fix(opencode): submit admitted native startup briefs without overwriting input (#24762)

* fix: wait for OpenCode worker composer before first dispatch

Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* feat(opencode): probe execution-host CLI capabilities

* fix(opencode): select plugin default for execution host loader

* fix(opencode): limit prompt prefill capability to verified release

* feat(opencode): probe launch capabilities on the execution host

* fix(opencode): select plugin loader for the launched host binary

* fix(opencode): match WSL probe cwd and declared guest environment

* fix(opencode): preserve launch environment deletion boundaries

* wip(opencode): authorize native startup prompt intent at execution owner

* fix(opencode): atomically replace status plugin entrypoints

* fix(opencode): retain plugin permissions across restrictive umasks

* test(opencode): resolve permission fixture from primary cwd

* feat(opencode): install startup prompt plugin independently of status hooks

* fix(opencode): wait for admitted startup intent and preserve failed-launch briefs

* fix(opencode): unsubscribe hook settings during async host shutdown

* STRICT launch CI contract correction

* CAPS launch CI contract correction

* INTENT launch CI contract correction

* test: initialize Claude prompt state in output retention fixture

* Wait for OpenCode location hydration in intent startup

* Bind OpenCode startup readiness to the current location in intent startup

* Retry interrupted OpenCode startup prompt claims

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Ahmed Nagy <ahmednagy25t@gmail.com>
Co-authored-by: Orca startup hydration review <agents@stably.ai>
Co-authored-by: Orca <dev@stably.ai>
This commit is contained in:
Neil
2026-10-04 01:41:01 -07:00
committed by GitHub
co-authored by Brennan Benson Ahmed Nagy Orca startup hydration review Orca
parent 58bd15fa3f
commit e8310d5a4f
53 changed files with 3406 additions and 252 deletions
+55 -49
View File
@@ -2,6 +2,7 @@ import { getAppEnvironment } from '../../shared/app-environment'
import { join } from 'node:path'
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
@@ -10,22 +11,23 @@ import {
writeFileSync
} from 'node:fs'
import { createHash } from 'node:crypto'
import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror'
import { isSafeDescendCandidate, mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror'
import {
getOpenCode2PluginSource,
getOpenCodeFamilyPluginSource,
getOpenCodePluginSource
} from './status-plugin-module-source'
import {
readOpenCodeOverlayManifest,
OPENCODE_OVERLAY_MANIFEST_FILE,
type OpenCodeOverlayManifest
} from './opencode-overlay-manifest'
import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory'
import {
getOpenCodeLegacySharedConfigDir,
OPENCODE2_LEGACY_HOOKS_DIR,
OPENCODE_LEGACY_HOOKS_DIR
} from './legacy-shared-config-dir'
import {
isInstalledOpenCodePluginCurrent,
isOverlayOpenCodePluginCurrent
} from '../../shared/opencode-installed-plugin'
import {
openCodeTuiPluginDirName,
writeOpenCodeTuiPlugin
@@ -34,19 +36,11 @@ import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry
export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource }
import {
writeCanonicalOpenCodePluginAtomically,
writeOverlayOpenCodePluginAtomically
} from '../../shared/opencode-plugin-atomic-write'
import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write'
import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer'
const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays'
const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json'
type OpenCodeOverlayManifest = {
topLevelEntries: string[]
pluginEntries: string[]
}
type OpenCodeHookVariant = {
pluginFileName: string
@@ -55,6 +49,7 @@ type OpenCodeHookVariant = {
pluginSource: () => string
/** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */
installsTuiPlugin?: boolean
tuiOnlyDirectory?: string
}
// Why: session IDs may contain path separators and are hashed downstream; cap pathological input.
@@ -74,6 +69,7 @@ export class OpenCodeHookService {
private readonly legacyHooksDir: string
private readonly overlayDir: string
private readonly installsTuiPlugin: boolean
private readonly tuiOnlyDirectory: string | undefined
constructor(variant?: OpenCodeHookVariant | (() => string)) {
const config: OpenCodeHookVariant =
@@ -93,6 +89,7 @@ export class OpenCodeHookService {
})
this.pluginSource = config.pluginSource
this.installsTuiPlugin = config.installsTuiPlugin === true
this.tuiOnlyDirectory = config.tuiOnlyDirectory
this.pluginFileName = config.pluginFileName
this.legacyHooksDir = config.legacyHooksDir
this.overlayDir = config.overlayDir
@@ -102,6 +99,27 @@ export class OpenCodeHookService {
// Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows.
}
installIntoSourceOverlay(
directory: string,
sourceConfigDir: string,
owner: OpenCodeHookService
): 'unmatched' | 'installed' | 'failed' {
if (directory !== owner.getSourceOverlayDir(sourceConfigDir)) {
return 'unmatched'
}
try {
for (const path of [owner.getOverlayRoot(), directory, join(directory, 'plugins')]) {
if (!isSafeDescendCandidate(lstatSync(path))) {
return 'failed'
}
}
this.writePluginIntoOverlay(directory)
return 'installed'
} catch {
return 'failed'
}
}
buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record<string, string> {
if (!isUsableId(ptyId)) {
// Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited.
@@ -118,13 +136,15 @@ export class OpenCodeHookService {
return {}
}
}
if (!existsSync(existingConfigDir)) {
if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) {
return { OPENCODE_CONFIG_DIR: existingConfigDir }
}
const overlayDir = this.getSourceOverlayDir(existingConfigDir)
try {
mkdirSync(overlayDir, { recursive: true })
this.mirrorUserConfig(existingConfigDir, overlayDir)
if (existsSync(existingConfigDir)) {
this.mirrorUserConfig(existingConfigDir, overlayDir)
}
this.writePluginIntoOverlay(overlayDir)
return { OPENCODE_CONFIG_DIR: overlayDir }
} catch {
@@ -135,6 +155,9 @@ export class OpenCodeHookService {
// Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps
// processes that load it later; a running OpenCode 2 service keeps its cached module until restarted.
refreshLegacySharedPlugin(): void {
if (this.tuiOnlyDirectory) {
return
}
const pluginsDir = join(this.getSharedConfigDir(), 'plugins')
const pluginPath = join(pluginsDir, this.pluginFileName)
try {
@@ -198,20 +221,6 @@ export class OpenCodeHookService {
)
}
private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest {
try {
const parsed = JSON.parse(
readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8')
) as Partial<OpenCodeOverlayManifest>
return {
topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [],
pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : []
}
} catch {
return { topLevelEntries: [], pluginEntries: [] }
}
}
private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void {
writeFileSync(
join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE),
@@ -235,7 +244,7 @@ export class OpenCodeHookService {
// Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file.
private mirrorUserConfig(sourceDir: string, overlayDir: string): void {
const previousManifest = this.readOverlayManifest(overlayDir)
const previousManifest = readOpenCodeOverlayManifest(overlayDir)
// Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive.
this.clearManifestEntries(overlayDir, previousManifest)
@@ -266,6 +275,7 @@ export class OpenCodeHookService {
// Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file.
if (
pluginEntry.name === this.pluginFileName ||
pluginEntry.name === this.tuiOnlyDirectory ||
(this.installsTuiPlugin &&
pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName))
) {
@@ -288,27 +298,23 @@ export class OpenCodeHookService {
this.writeOverlayManifest(overlayDir, nextManifest)
}
// Atomic replacement detaches mirrored links without touching user plugins.
private writePluginIntoOverlay(overlayDir: string): void {
const pluginsDir = join(overlayDir, 'plugins')
mkdirSync(pluginsDir, { recursive: true })
const pluginPath = join(pluginsDir, this.pluginFileName)
const source = this.pluginSource()
this.writeTuiPlugin(pluginsDir, source, 'overlay')
if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) {
writeOverlayOpenCodePluginAtomically(pluginPath, source)
}
this.writePluginToDirectory(overlayDir, 'overlay')
}
private writePluginToConfigDir(configDir: string): void {
const pluginsDir = join(configDir, 'plugins')
mkdirSync(pluginsDir, { recursive: true })
const pluginPath = join(pluginsDir, this.pluginFileName)
const source = this.pluginSource()
this.writeTuiPlugin(pluginsDir, source)
if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) {
writeCanonicalOpenCodePluginAtomically(pluginPath, source)
}
this.writePluginToDirectory(configDir, 'canonical')
}
private writePluginToDirectory(configDir: string, ownership: 'canonical' | 'overlay'): void {
writeOpenCodePluginConfig({
configDir,
ownership,
pluginFileName: this.pluginFileName,
getSource: () => this.pluginSource(),
installsTuiPlugin: this.installsTuiPlugin,
tuiOnlyDirectory: this.tuiOnlyDirectory
})
}
private writeTuiPlugin(
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json'
export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] }
export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest {
const empty = { topLevelEntries: [], pluginEntries: [] }
try {
const parsed: unknown = JSON.parse(
readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8')
)
if (!parsed || typeof parsed !== 'object') {
return empty
}
return {
topLevelEntries:
'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries)
? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string')
: [],
pluginEntries:
'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries)
? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string')
: []
}
} catch {
return empty
}
}
@@ -0,0 +1,45 @@
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import {
isInstalledOpenCodePluginCurrent,
isOverlayOpenCodePluginCurrent
} from '../../shared/opencode-installed-plugin'
import {
writeCanonicalOpenCodePluginAtomically,
writeOverlayOpenCodePluginAtomically
} from '../../shared/opencode-plugin-atomic-write'
import {
writeOpenCodeTuiPlugin,
writeOpenCodeTuiPluginDirectory
} from '../../shared/opencode-tui-plugin-install'
export function writeOpenCodePluginConfig(options: {
configDir: string
pluginFileName: string
getSource: () => string
installsTuiPlugin: boolean
tuiOnlyDirectory: string | undefined
ownership: 'canonical' | 'overlay'
}): void {
const pluginsDir = join(options.configDir, 'plugins')
mkdirSync(pluginsDir, { recursive: true })
const pluginPath = join(pluginsDir, options.pluginFileName)
const source = options.getSource()
if (options.tuiOnlyDirectory) {
writeOpenCodeTuiPluginDirectory(pluginsDir, options.tuiOnlyDirectory, source, options.ownership)
return
}
if (options.installsTuiPlugin) {
writeOpenCodeTuiPlugin(pluginsDir, options.pluginFileName, source, options.ownership)
}
const overlay = options.ownership === 'overlay'
const current = overlay
? isOverlayOpenCodePluginCurrent(pluginPath, source)
: isInstalledOpenCodePluginCurrent(pluginPath, source)
if (!current) {
const write = overlay
? writeOverlayOpenCodePluginAtomically
: writeCanonicalOpenCodePluginAtomically
write(pluginPath, source)
}
}
+188 -9
View File
@@ -1,6 +1,12 @@
import { createHash } from 'node:crypto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version'
import { prepareOpenCodePtyLaunch } from './opencode-pty-launch'
import {
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
OPENCODE_STARTUP_PROMPT_BODY_ENV,
OPENCODE_STARTUP_PROMPT_SHELL_ENV
} from '../../shared/opencode-startup-prompt'
import {
buildLocalPtySpawnEnvironment,
enforceLocalPtySpawnEnvironmentOverrides
@@ -28,20 +34,195 @@ const plan: LocalPtyLaunchPlan = {
launchWslDistro: null
}
const hookServer = vi.hoisted(() => {
const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' }
return server
})
vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer }))
const reserve = vi.hoisted(() => vi.fn(() => true))
vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve }))
async function prepare(options: Parameters<typeof prepareOpenCodePtyLaunch>[0]) {
return (await prepareOpenCodePtyLaunch(options)).env
}
const probe = vi.hoisted(() => vi.fn())
const install = vi.hoisted(() => vi.fn())
vi.mock('./opencode-startup-prompt-installer', () => ({
installOpenCodeStartupPromptForLaunch: install
}))
vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe }))
beforeEach(() => probe.mockReset())
beforeEach(() => {
probe.mockReset()
reserve.mockReset().mockReturnValue(true)
install.mockReset()
hookServer.endpointFilePath = '/private/endpoint.env'
})
afterEach(() => vi.unstubAllEnvs())
describe('execution-host OpenCode launch preparation', () => {
it('retains fresh owned source provenance through the final provider deletion pass', async () => {
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
install.mockImplementation((env) => {
env.OPENCODE_CONFIG_DIR = '/private/owned-overlay'
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/private/real-source'
return true
})
const envToDelete = ['OPENCODE_CONFIG_DIR', 'ORCA_OPENCODE_SOURCE_CONFIG_DIR']
const env = await prepare({
command: 'opencode --prompt task',
isFreshLaunch: true,
envToDelete,
env: {
ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch',
ORCA_OPENCODE_STARTUP_PROMPT_SHA256: createHash('sha256').update('task').digest('hex'),
ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'task',
ORCA_OPENCODE_STARTUP_PROMPT_SHELL: 'posix'
}
})
const finalEnv = await buildLocalPtySpawnEnvironment({
id: 'source-proof',
spawn: { cols: 80, rows: 24, env, envToDelete },
getOptions: () => ({}),
plan
})
enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv)
expect(finalEnv.OPENCODE_CONFIG_DIR).toBe('/private/owned-overlay')
expect(finalEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/private/real-source')
})
it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => {
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
const env = {
ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch',
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'),
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task',
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix'
}
const options = { env, envToDelete: [], isFreshLaunch: true }
expect(
(
await prepareOpenCodePtyLaunch({
...options,
command: "opencode --standalone --prompt 'task'"
})
).command
).toBe('opencode --standalone')
expect(
(await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" }))
.command
).toBe("opencode run --prompt 'task'")
expect(
(await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" }))
.command
).toBe("opencode --prompt 'task'")
})
it.each(['inherited', 'explicit', 'deleted'] as const)(
'passes the %s config environment used by the execution-host version probe to the prompt installer',
async (selection) => {
vi.stubEnv('XDG_CONFIG_HOME', '/ambient/config')
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
await prepareOpenCodePtyLaunch({
command: 'opencode --prompt task',
envToDelete: selection === 'deleted' ? ['XDG_CONFIG_HOME'] : [],
isFreshLaunch: true,
env: {
ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch',
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'),
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task',
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix',
...(selection === 'explicit' ? { XDG_CONFIG_HOME: '/selected/config' } : {})
}
})
expect(install).toHaveBeenCalledTimes(1)
const resolved = install.mock.calls[0][2]
expect(resolved).toEqual(probe.mock.calls[0][0].env)
expect(resolved.XDG_CONFIG_HOME).toBe(
selection === 'deleted'
? undefined
: selection === 'explicit'
? '/selected/config'
: '/ambient/config'
)
}
)
it.each(['endpoint', 'installer', 'capacity', 'identity'])(
'keeps the editable brief when automatic preparation lacks %s',
async (failure) => {
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
if (failure === 'endpoint') {
hookServer.endpointFilePath = null
}
if (failure === 'installer') {
install.mockImplementation((env) => {
delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE
})
}
if (failure === 'capacity') {
reserve.mockReturnValue(false)
}
const original = "opencode --standalone --prompt 'task'"
const result = await prepareOpenCodePtyLaunch({
command: original,
envToDelete: [],
isFreshLaunch: true,
env: {
...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }),
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'),
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task',
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix'
}
})
expect(result.command).toBe(original)
expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE')
}
)
it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])(
'gates native intent against the executing %s capability',
async (version) => {
probe.mockResolvedValue(getOpenCodeCliCapabilities(version))
const envToDelete: string[] = []
const fingerprint = createHash('sha256').update('task').digest('hex')
const env = await prepare({
command: 'opencode --prompt task',
env: {
ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch',
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint,
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task',
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix'
},
envToDelete,
isFreshLaunch: true
})
expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe(
version === '2.0.16' ? fingerprint : undefined
)
expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16')
}
)
it('refuses remote automatic intent without execution-owned driving input', async () => {
const fingerprint = 'b'.repeat(64)
const envToDelete: string[] = []
const env = await prepare({
command: 'opencode --prompt task',
connectionId: 'remote',
isFreshLaunch: true,
env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint },
envToDelete
})
expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined()
expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV)
expect(probe).not.toHaveBeenCalled()
})
it('keeps deleted credentials and config absent from the probe and final provider environment', async () => {
vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key')
vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config')
vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1')
probe.mockResolvedValue(getOpenCodeCliCapabilities(null))
const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR']
const env = await prepareOpenCodePtyLaunch({
const env = await prepare({
command: 'opencode',
env: {},
envToDelete,
@@ -67,7 +248,7 @@ describe('execution-host OpenCode launch preparation', () => {
vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1')
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API']
const env = await prepareOpenCodePtyLaunch({
const env = await prepare({
command: 'opencode',
env: {},
envToDelete,
@@ -94,7 +275,7 @@ describe('execution-host OpenCode launch preparation', () => {
KEEP: '1',
ORCA_OPENCODE_PLUGIN_API: 'stale'
}
const result = await prepareOpenCodePtyLaunch({
const result = await prepare({
command: 'opencode --prompt test',
agent: 'opencode',
env,
@@ -117,7 +298,7 @@ describe('execution-host OpenCode launch preparation', () => {
it('creates a launch environment for a known binary without caller env', async () => {
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
expect(
await prepareOpenCodePtyLaunch({
await prepare({
command: 'opencode',
env: undefined,
envToDelete: [],
@@ -129,7 +310,7 @@ describe('execution-host OpenCode launch preparation', () => {
it('forwards WSL plugin selection through WSLENV after a guest probe', async () => {
probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23'))
const env = { KEEP: '1' }
const result = await prepareOpenCodePtyLaunch({
const result = await prepare({
command: 'opencode',
agent: 'opencode',
env,
@@ -148,9 +329,7 @@ describe('execution-host OpenCode launch preparation', () => {
'never probes the client for an attach or SSH launch',
async (route) => {
const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' }
expect(
await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route })
).toEqual({})
expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({})
expect(probe).not.toHaveBeenCalled()
expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' })
}
+97 -5
View File
@@ -1,7 +1,29 @@
import { addWslEnvKeys } from '../../shared/wsl-env'
import type { TuiAgent } from '../../shared/tui-agent'
import { randomUUID, createHash } from 'node:crypto'
import { agentHookServer } from '../agent-hooks/server'
import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell'
import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command'
import {
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV,
OPENCODE_STARTUP_PROMPT_BODY_ENV,
OPENCODE_STARTUP_PROMPT_SHELL_ENV
} from '../../shared/opencode-startup-prompt'
const intentKeys = [
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV,
OPENCODE_STARTUP_PROMPT_BODY_ENV,
OPENCODE_STARTUP_PROMPT_SHELL_ENV
]
import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path'
import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities'
import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner'
import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer'
export async function prepareOpenCodePtyLaunch(options: {
command: string | undefined
@@ -12,17 +34,29 @@ export async function prepareOpenCodePtyLaunch(options: {
connectionId?: string | null
isFreshLaunch: boolean
wsl?: { distro?: string }
}): Promise<Record<string, string> | undefined> {
}): Promise<{ env: Record<string, string> | undefined; command: string | undefined }> {
let command = options.command
const env = options.env ? { ...options.env } : undefined
const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]
const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV]
const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV]
if (env) {
delete env.ORCA_OPENCODE_PLUGIN_API
for (const key of intentKeys) {
delete env[key]
}
}
// Providers merge their own ambient environment after this preparation.
if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) {
options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API')
}
for (const key of intentKeys) {
if (!options.envToDelete.includes(key)) {
options.envToDelete.push(key)
}
}
if (options.connectionId || !options.isFreshLaunch) {
return env
return { env, command }
}
const probeEnv: Record<string, string> = {}
for (const [key, value] of Object.entries({ ...process.env, ...env })) {
@@ -36,12 +70,70 @@ export async function prepareOpenCodePtyLaunch(options: {
env: probeEnv
})
if (!capabilities || capabilities.pluginApi === 'unknown') {
return env
return { env, command }
}
const launchEnv: Record<string, string> = {
...env,
ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi
}
const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }
options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1)
if (
capabilities.promptMode === 'prefill' &&
!options.wsl &&
launchEnv.ORCA_AGENT_LAUNCH_TOKEN &&
requestedPrompt &&
body &&
command &&
(shell === 'posix' || shell === 'powershell' || shell === 'cmd') &&
createHash('sha256').update(body).digest('hex') === requestedPrompt
) {
const parsed = tokenizeStartupCommand(command, shell)
const last = parsed.ok ? parsed.tokens.length - 1 : -1
if (
parsed.ok &&
!isOpenCodeRunCommand(parsed.tokens, shell) &&
parsed.tokens.filter((token) => token === '--prompt').length === 1 &&
parsed.tokens[last - 1] === '--prompt' &&
parsed.tokens[last] === body &&
!parsed.spans[last].divergesFromShell &&
!parsed.spans[last - 1].divergesFromShell
) {
const endpoint = agentHookServer.endpointFilePath
if (endpoint) {
launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt
launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body
launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID()
launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint
if (installOpenCodeStartupPromptForLaunch(launchEnv, false, probeEnv)) {
for (const key of [
'OPENCODE_CONFIG_DIR',
'ORCA_OPENCODE_CONFIG_DIR',
'ORCA_OPENCODE_SOURCE_CONFIG_DIR'
]) {
const deletion = options.envToDelete.indexOf(key)
if (launchEnv[key] && deletion !== -1) {
options.envToDelete.splice(deletion, 1)
}
}
}
const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV]
if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) {
command = command.slice(0, parsed.spans[last - 1].start).trimEnd()
} else {
for (const key of intentKeys) {
delete launchEnv[key]
}
}
for (const key of intentKeys) {
if (launchEnv[key]) {
options.envToDelete.splice(options.envToDelete.indexOf(key), 1)
}
}
}
}
}
if (options.wsl) {
addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API'])
}
return launchEnv
return { env: launchEnv, command }
}
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest'
import type { TerminalRunFacts } from '../runtime/terminal-run-facts'
import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims'
describe('execution-owned startup prompt claims', () => {
it('consumes each nonce once and checks owner facts at claim time', () => {
const claims = new OpenCodeStartupPromptClaims()
let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null }
claims.register('first', 'hash', () => facts)
facts.firstUserInputAt = 1
expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false)
facts.firstUserInputAt = null
expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false)
claims.register('second', 'hash', () => facts)
expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true)
expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false)
claims.register('missing', 'hash', () => facts)
facts = null
expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false)
})
it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => {
let now = 0
const claims = new OpenCodeStartupPromptClaims(() => now)
claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null }))
expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false)
claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false)
expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false)
claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
now = 20000
expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false)
for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) {
expect(claims.claim(body)).toBe(false)
}
})
it('keeps pending admission bounded and never recreates canceled or consumed claims', () => {
let now = 0
const claims = new OpenCodeStartupPromptClaims(() => now)
claims.register('waiting', 'hash', () => 'pending')
expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending')
expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending')
expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true)
expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true)
expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(
false
)
claims.register('canceled', 'hash', () => 'pending')
claims.cancel('canceled')
expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(
false
)
claims.register('expired', 'hash', () => 'pending')
now = 20000
expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false)
expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(
false
)
claims.clear()
})
it('bounds pending claims and reclaims expired capacity without timers', () => {
let now = 0
const claims = new OpenCodeStartupPromptClaims(() => now)
for (let index = 0; index < 129; index++) {
claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
}
expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false)
now = 20000
claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true)
})
it('starts a fresh bounded claim window after delayed spawn admission', () => {
vi.useFakeTimers()
try {
const claims = new OpenCodeStartupPromptClaims()
claims.register('slow-spawn', 'hash', () => 'pending')
vi.advanceTimersByTime(18000)
const cleanup = vi.fn()
expect(
claims.admit('slow-spawn', () => ({ freshSpawn: true, firstUserInputAt: null }), cleanup)
).toBe(true)
vi.advanceTimersByTime(8000)
expect(claims.claim({ nonce: 'slow-spawn', digest: 'hash' })).toBe(true)
expect(cleanup).toHaveBeenCalledTimes(1)
claims.clear()
} finally {
vi.useRealTimers()
}
})
it('replays only the same operation while execution facts remain authorized', () => {
let now = 0
const claims = new OpenCodeStartupPromptClaims(() => now)
let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null }
const cleanup = vi.fn()
claims.register('retry', 'hash', () => facts, cleanup)
const body = { nonce: 'retry', digest: 'hash', requestId: 'stable-operation' }
expect(claims.claim(body)).toBe(true)
expect(claims.claim(body)).toBe(true)
expect(claims.claim({ ...body, requestId: 'another-operation' })).toBe(false)
expect(claims.claim({ nonce: 'retry', digest: 'hash' })).toBe(false)
expect(cleanup).not.toHaveBeenCalled()
expect(claims.claim(body)).toBe(true)
facts.firstUserInputAt = 1
expect(claims.claim(body)).toBe(false)
expect(cleanup).toHaveBeenCalledTimes(1)
facts = { freshSpawn: true, firstUserInputAt: null }
expect(claims.claim(body)).toBe(false)
claims.register('expires', 'hash', () => facts, cleanup)
expect(claims.claim({ ...body, nonce: 'expires' })).toBe(true)
now = 20000
expect(claims.claim({ ...body, nonce: 'expires' })).toBe(false)
expect(cleanup).toHaveBeenCalledTimes(2)
})
it('does not renew admission, retain unbounded IDs or replay retired owners', () => {
let now = 0
const claims = new OpenCodeStartupPromptClaims(() => now)
let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null }
claims.register('bound', 'hash', () => 'pending')
now = 18000
expect(claims.admit('bound', () => facts)).toBe(true)
now = 37000
expect(claims.admit('bound', () => facts)).toBe(false)
expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'x'.repeat(129) })).toBe(false)
expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(true)
facts = null
expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(false)
claims.register('clear', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(true)
claims.clear()
expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(false)
})
it.each([null, 1, '', 'with spaces', 'x'.repeat(129)])(
'rejects malformed operation ID %j without consuming valid authorization',
(requestId) => {
const claims = new OpenCodeStartupPromptClaims()
claims.register('valid', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId })).toBe(false)
expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId: 'valid-operation' })).toBe(
true
)
claims.clear()
}
)
it('expires the renewed window without permitting repeated admission to extend it', () => {
vi.useFakeTimers()
try {
const claims = new OpenCodeStartupPromptClaims()
claims.register('bounded', 'hash', () => 'pending')
vi.advanceTimersByTime(18000)
const owner = () => ({ freshSpawn: true, firstUserInputAt: null })
const cleanup = vi.fn()
expect(claims.admit('bounded', owner, cleanup)).toBe(true)
expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true)
vi.advanceTimersByTime(19999)
expect(claims.admit('bounded', owner)).toBe(false)
expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true)
vi.advanceTimersByTime(1)
expect(cleanup).toHaveBeenCalledTimes(1)
expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(false)
claims.clear()
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,120 @@
import type { TerminalRunFacts } from '../runtime/terminal-run-facts'
type PromptClaim = {
digest: string
expiresAt: number
admitted: boolean
grantedRequestId?: string
readOwner: () => TerminalRunFacts | 'pending' | null
cleanup: () => void
expiry: ReturnType<typeof setTimeout>
}
/** Authorizes one startup operation against current execution-owner facts. */
export class OpenCodeStartupPromptClaims {
private readonly pending = new Map<string, PromptClaim>()
constructor(private readonly now: () => number = Date.now) {}
register(
nonce: string,
digest: string,
readOwner: PromptClaim['readOwner'],
cleanup = () => {}
): boolean {
const now = this.now()
for (const [key, claim] of this.pending) {
if (claim.expiresAt <= now) {
this.cancel(key)
}
}
if (this.pending.size >= 128 || this.pending.has(nonce)) {
return false
}
const expiry = setTimeout(() => this.cancel(nonce), 20000)
expiry.unref?.()
this.pending.set(nonce, {
digest,
readOwner,
expiresAt: now + 20000,
admitted: false,
cleanup,
expiry
})
return true
}
admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean {
const pending = this.pending.get(nonce)
if (!pending || pending.expiresAt <= this.now()) {
this.cancel(nonce)
return false
}
if (pending.admitted || pending.grantedRequestId !== undefined) {
return false
}
clearTimeout(pending.expiry)
pending.expiresAt = this.now() + 20000
pending.expiry = setTimeout(() => this.cancel(nonce), 20000)
pending.expiry.unref?.()
pending.admitted = true
pending.readOwner = readOwner
pending.cleanup = cleanup
return true
}
cancel(nonce: string): void {
const pending = this.pending.get(nonce)
this.pending.delete(nonce)
if (pending) {
clearTimeout(pending.expiry)
}
pending?.cleanup()
}
clear(): void {
for (const nonce of this.pending.keys()) {
this.cancel(nonce)
}
}
claim(body: unknown): boolean | 'pending' {
if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') {
return false
}
const pending = this.pending.get(body.nonce)
if (
!pending ||
pending.expiresAt <= this.now() ||
!('digest' in body) ||
body.digest !== pending.digest
) {
this.cancel(body.nonce)
return false
}
const requestId = 'requestId' in body ? body.requestId : undefined
if (
requestId !== undefined &&
(typeof requestId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(requestId))
) {
return false
}
if (pending.grantedRequestId !== undefined && pending.grantedRequestId !== requestId) {
return false
}
const owner = pending.readOwner()
if (owner === 'pending') {
return 'pending'
}
if (owner?.freshSpawn !== true || owner.firstUserInputAt !== null) {
this.cancel(body.nonce)
return false
}
if (requestId === undefined) {
this.cancel(body.nonce)
} else {
pending.grantedRequestId = requestId
}
return true
}
}
@@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setAppEnvironment } from '../../shared/app-environment'
import {
createOpenCodeStartupPromptInstaller,
installOpenCodeStartupPromptForLaunch
} from './opencode-startup-prompt-installer'
import {
captureOpenCodeSourceConfig,
applyOpenCodeStatusPluginEnv
} from '../ipc/pty/host-env/opencode-config'
let root: string
let originalXdg: string | undefined
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-'))
originalXdg = process.env.XDG_CONFIG_HOME
process.env.XDG_CONFIG_HOME = join(root, 'config')
setAppEnvironment({
getPath: () => join(root, 'profile'),
getAppPath: () => root,
getVersion: () => 'test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: () => []
})
})
afterEach(() => {
vi.unstubAllEnvs()
if (originalXdg === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = originalXdg
}
rmSync(root, { recursive: true, force: true })
})
describe('OpenCode startup prompt installer', () => {
it('refuses a replaced status overlay instead of writing through its symlink or losing status hooks', () => {
const source = join(root, 'safe-source')
const foreign = join(root, 'foreign')
mkdirSync(source)
mkdirSync(foreign)
writeFileSync(join(foreign, 'untouched.txt'), 'foreign bytes')
const env: Record<string, string> = {
OPENCODE_CONFIG_DIR: source,
ORCA_OPENCODE_PLUGIN_API: 'v2',
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'replaced-overlay'
}
const config = captureOpenCodeSourceConfig(env, join(root, 'profile'))
applyOpenCodeStatusPluginEnv(
'pane',
env,
config,
{
launchAgent: 'opencode',
agentStatusHooksEnabled: true
},
'opencode'
)
rmSync(env.OPENCODE_CONFIG_DIR, { recursive: true })
symlinkSync(foreign, env.OPENCODE_CONFIG_DIR, 'junction')
expect(installOpenCodeStartupPromptForLaunch(env)).toBe(false)
expect(env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE).toBeUndefined()
expect(readFileSync(join(foreign, 'untouched.txt'), 'utf8')).toBe('foreign bytes')
expect(existsSync(join(foreign, 'plugins'))).toBe(false)
})
it.each(['opencode', 'opencode2'] as const)(
'keeps real source provenance and composes the %s status and prompt in one final overlay',
(agent) => {
const source = join(root, 'real-source')
mkdirSync(join(source, 'plugins'), { recursive: true })
writeFileSync(join(source, 'plugins', 'user.js'), 'user bytes')
writeFileSync(join(source, 'opencode.json'), '{"model":"selected/model"}')
const env: Record<string, string> = {
OPENCODE_CONFIG_DIR: source,
ORCA_OPENCODE_PLUGIN_API: 'v2',
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'source-provenance'
}
expect(installOpenCodeStartupPromptForLaunch(env, false)).toBe(true)
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source)
const captured = captureOpenCodeSourceConfig(env, join(root, 'profile'))
expect(captured.directory).toBe(source)
applyOpenCodeStatusPluginEnv(
'pane',
env,
captured,
{
launchAgent: agent,
agentStatusHooksEnabled: true
},
`${agent} --standalone`
)
const statusOverlay = env.OPENCODE_CONFIG_DIR
expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true)
expect(env.OPENCODE_CONFIG_DIR).toBe(statusOverlay)
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source)
expect(
readFileSync(join(statusOverlay, 'plugins', `orca-${agent}-status.js`), 'utf8')
).toContain('ORCA_STATUS_AGENT')
expect(
existsSync(join(statusOverlay, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'))
).toBe(true)
expect(readFileSync(join(statusOverlay, 'plugins', 'user.js'), 'utf8')).toBe('user bytes')
const nested: Record<string, string> = {
...env,
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nested-source-provenance'
}
expect(installOpenCodeStartupPromptForLaunch(nested)).toBe(true)
expect(nested.OPENCODE_CONFIG_DIR).toBe(statusOverlay)
expect(nested.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source)
expect(readFileSync(join(source, 'plugins', 'user.js'), 'utf8')).toBe('user bytes')
expect(existsSync(join(source, 'plugins', `orca-${agent}-status.js`))).toBe(false)
}
)
it.each(['inherited', 'explicit'] as const)(
'preserves status and user plugins from the %s XDG config when launch env is sparse',
(selection) => {
const configHome = join(root, selection === 'explicit' ? 'selected-config' : 'config')
const config = join(configHome, 'opencode')
mkdirSync(join(config, 'plugins'), { recursive: true })
writeFileSync(join(config, 'plugins', 'orca-opencode-status.js'), 'status entry')
writeFileSync(join(config, 'plugins', 'user.js'), 'user entry')
writeFileSync(join(config, 'opencode.json'), '{"model":"selected/model"}')
const env: Record<string, string> = {
ORCA_OPENCODE_PLUGIN_API: 'v2',
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'sparse-launch',
...(selection === 'explicit' ? { XDG_CONFIG_HOME: configHome } : {})
}
expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true)
expect(
readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'), 'utf8')
).toBe('status entry')
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe(
'user entry'
)
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toContain(
'selected/model'
)
}
)
it("uses the execution owner's resolved environment instead of reintroducing a deleted ambient XDG home", () => {
const selected = join(root, 'resolved-config')
mkdirSync(join(selected, 'opencode', 'plugins'), { recursive: true })
writeFileSync(join(selected, 'opencode', 'plugins', 'user.js'), 'resolved entry')
const env: Record<string, string> = {
ORCA_OPENCODE_PLUGIN_API: 'v2',
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'resolved-launch'
}
expect(installOpenCodeStartupPromptForLaunch(env, false, { XDG_CONFIG_HOME: selected })).toBe(
true
)
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe(
'resolved entry'
)
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
})
it.each(['inherited', 'explicit'] as const)(
'preserves the %s OPENCODE_CONFIG_DIR ahead of the XDG default',
(selection) => {
const ambient = join(root, 'ambient-source')
const selected = join(root, 'selected-source')
for (const config of [ambient, selected]) {
mkdirSync(join(config, 'plugins'), { recursive: true })
writeFileSync(join(config, 'plugins', 'user.js'), config)
}
vi.stubEnv('OPENCODE_CONFIG_DIR', ambient)
const env: Record<string, string> = {
ORCA_OPENCODE_PLUGIN_API: 'v2',
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'custom-config-launch',
...(selection === 'explicit' ? { OPENCODE_CONFIG_DIR: selected } : {})
}
expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true)
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe(
selection === 'explicit' ? selected : ambient
)
}
)
it('keeps a missing user config untouched and installs the launch into an owned overlay', () => {
const source = join(root, 'missing-user-config')
const env: Record<string, string> = {
ORCA_OPENCODE_PLUGIN_API: 'v2',
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch',
OPENCODE_CONFIG_DIR: source,
OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}'
}
installOpenCodeStartupPromptForLaunch(env)
expect(existsSync(source)).toBe(false)
expect(env.OPENCODE_CONFIG_DIR).toContain(
join(root, 'profile', 'opencode-startup-prompt-overlays')
)
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR)
expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}')
expect(
existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'))
).toBe(true)
expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe(
false
)
})
it('installs only a TUI entry without installing status hooks or a v1/server entry', () => {
const service = createOpenCodeStartupPromptInstaller(() => 'prompt source')
expect(service.buildPtyEnv('pane')).toEqual({})
const plugins = join(root, 'config', 'opencode', 'plugins')
expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe(
'prompt source'
)
expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false)
expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false)
expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false)
})
it('preserves user config and plugins across source-scoped overlay refreshes', () => {
const config = join(root, 'custom')
mkdirSync(join(config, 'plugins'), { recursive: true })
writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}')
writeFileSync(join(config, 'plugins', 'user.js'), 'user source')
mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'))
writeFileSync(
join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'),
'user collision'
)
let source = 'first prompt source'
const service = createOpenCodeStartupPromptInstaller(() => source)
const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR
expect(first).toBeDefined()
source = 'next prompt source'
expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first)
if (!first) {
throw new Error('Missing overlay')
}
expect(
readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')
).toBe(source)
expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model')
expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source')
expect(
readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')
).toBe('user collision')
})
})
@@ -0,0 +1,82 @@
import { OpenCodeHookService, openCodeHookService, openCode2HookService } from './hook-service'
import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install'
import { join } from 'node:path'
import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory'
import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin'
import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source'
import { resolveOpenCodeSourceConfigDir } from '../ipc/pty/host-env/pi-agent'
import {
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
OPENCODE_STARTUP_PROMPT_BODY_ENV,
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV
} from '../../shared/opencode-startup-prompt'
export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService {
return new OpenCodeHookService({
pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`,
legacyHooksDir: 'opencode-startup-prompt-hooks',
overlayDir: 'opencode-startup-prompt-overlays',
pluginSource: source,
tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY
})
}
const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource)
export function installOpenCodeStartupPromptForLaunch(
env: Record<string, string>,
restoreAfterShellStartup = true,
sourceEnvironment: NodeJS.ProcessEnv = { ...process.env, ...env }
): boolean {
const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV]
if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') {
return false
}
const source =
resolveOpenCodeSourceConfigDir(env, sourceEnvironment) ||
resolveOpenCodeConfigDirectory(sourceEnvironment)
const statusOwner =
env.ORCA_OPENCODE_AGENT === 'opencode2' ? openCode2HookService : openCodeHookService
const existing =
env.OPENCODE_CONFIG_DIR && env.OPENCODE_CONFIG_DIR === env.ORCA_OPENCODE_CONFIG_DIR
? installer.installIntoSourceOverlay(env.OPENCODE_CONFIG_DIR, source, statusOwner)
: 'unmatched'
const overlay =
existing === 'installed'
? env.OPENCODE_CONFIG_DIR
: existing === 'failed'
? undefined
: installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR
if (
!overlay ||
!isOverlayOpenCodePluginCurrent(
join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'),
getOpenCodeStartupPromptSource()
)
) {
for (const key of [
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
OPENCODE_STARTUP_PROMPT_BODY_ENV,
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV
]) {
delete env[key]
}
return false
}
env.OPENCODE_CONFIG_DIR = overlay
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = source
if (restoreAfterShellStartup || existing === 'installed') {
env.ORCA_OPENCODE_CONFIG_DIR = overlay
} else {
delete env.ORCA_OPENCODE_CONFIG_DIR
}
return true
}
export function ensureOpenCodeStartupPromptForLaunch(env: Record<string, string>): void {
if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) {
throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.')
}
}
@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts'
import {
reserveOpenCodeStartupPrompt,
commitPtyWithOpenCodePromptIntent
} from './opencode-startup-prompt-owner'
const control = vi.hoisted(() => ({
claim: (_body: unknown): boolean | 'pending' => false,
clear: () => {}
}))
const ownership = vi.hoisted(() => ({
ptyOwnership: new Map<string, null>(),
ptyIncarnationById: new Map<string, string>()
}))
vi.mock('../agent-hooks/server', () => ({
agentHookServer: {
setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => {
control.claim = claim
control.clear = clear
}
}
}))
vi.mock('../ipc/pty/provider/ownership-state', () => ownership)
beforeEach(() => {
control.clear()
ownership.ptyOwnership.clear()
ownership.ptyIncarnationById.clear()
})
function fixture() {
const facts = new TerminalRunFactsRegister()
const result = { id: 'owned', incarnationId: 'incarnation' }
let identityReady = false
let release = () => {}
const waiting = new Promise<void>((resolve) => {
release = resolve
})
const runtime = {
terminalRunFacts: facts,
readOpenCodeStartupPromptOwner: () =>
identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const),
isPtyStopRequested: () => false,
subscribeToPtyExit: (_ptyId: string, _listener: () => void) => () => {}
}
ownership.ptyOwnership.set(result.id, null)
ownership.ptyIncarnationById.set(result.id, result.incarnationId)
const context = {
env: {
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce',
ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest',
ORCA_AGENT_LAUNCH_TOKEN: 'launch'
},
deps: { runtime },
result,
provider: { hasPty: () => true },
args: {}
}
reserveOpenCodeStartupPrompt('nonce', 'digest')
const body = { nonce: 'nonce', digest: 'digest' }
return {
facts,
result,
context,
waiting,
release,
body,
admit: () => {
identityReady = true
}
}
}
describe('native prompt admission after spawn commit', () => {
it('waits through delayed persistence and later runtime identity admission', async () => {
const f = fixture()
const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => {
await f.waiting
f.facts.recordSpawnCommit(f.result)
return f.result
})
expect(control.claim(f.body)).toBe('pending')
f.release()
await committed
expect(control.claim(f.body)).toBe('pending')
f.admit()
expect(control.claim(f.body)).toBe(true)
expect(control.claim(f.body)).toBe(false)
})
it('keeps pre-commit driving input sticky even if the draft is erased', async () => {
const f = fixture()
const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => {
await f.waiting
f.facts.recordSpawnCommit(f.result)
return f.result
})
f.facts.recordInput(f.result.id, 'driving', 'x')
f.facts.recordInput(f.result.id, 'driving', '\u007f')
expect(control.claim(f.body)).toBe('pending')
f.release()
await committed
f.admit()
expect(control.claim(f.body)).toBe(false)
})
it('cancels a failed commit without allowing later admission', async () => {
const f = fixture()
await expect(
commitPtyWithOpenCodePromptIntent(f.context, async () => {
throw new Error('persistence rejected')
})
).rejects.toThrow('persistence rejected')
f.admit()
expect(control.claim(f.body)).toBe(false)
})
it.each(['incarnation', 'provider', 'stop', 'exit', 'clear'])(
'invalidates granted replay after %s',
async (reason) => {
const f = fixture()
let exited = () => {}
const unsubscribe = vi.fn()
vi.spyOn(f.context.deps.runtime, 'subscribeToPtyExit').mockImplementation(
(_id, listener: () => void) => {
exited = listener
return unsubscribe
}
)
await commitPtyWithOpenCodePromptIntent(f.context, async () => {
f.facts.recordSpawnCommit(f.result)
return f.result
})
f.admit()
const body = { ...f.body, requestId: 'stable-operation' }
expect(control.claim(body)).toBe(true)
expect(control.claim(body)).toBe(true)
if (reason === 'incarnation') {
ownership.ptyIncarnationById.set(f.result.id, 'replacement')
}
if (reason === 'provider') {
vi.spyOn(f.context.provider, 'hasPty').mockReturnValue(false)
}
if (reason === 'stop') {
vi.spyOn(f.context.deps.runtime, 'isPtyStopRequested').mockReturnValue(true)
}
if (reason === 'exit') {
exited()
}
if (reason === 'clear') {
control.clear()
}
expect(control.claim(body)).toBe(false)
expect(unsubscribe).toHaveBeenCalledTimes(1)
f.facts.recordSpawnCommit(f.result)
expect(control.claim(body)).toBe(false)
}
)
it('retains sticky input cancellation after a lost grant response', async () => {
const f = fixture()
await commitPtyWithOpenCodePromptIntent(f.context, async () => {
f.facts.recordSpawnCommit(f.result)
return f.result
})
f.admit()
const body = { ...f.body, requestId: 'stable-operation' }
expect(control.claim(body)).toBe(true)
f.facts.recordInput(f.result.id, 'driving', 'x')
f.facts.recordInput(f.result.id, 'driving', '\u007f')
expect(control.claim(body)).toBe(false)
})
})
@@ -0,0 +1,111 @@
import { agentHookServer } from '../agent-hooks/server'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { IPtyProvider, PtySpawnResult } from '../providers/types'
import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import {
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
OPENCODE_STARTUP_PROMPT_SHA256_ENV
} from '../../shared/opencode-startup-prompt'
import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims'
type OpenCodePromptRuntime = Pick<
OrcaRuntimeService,
| 'terminalRunFacts'
| 'readOpenCodeStartupPromptOwner'
| 'isPtyStopRequested'
| 'subscribeToPtyExit'
>
const claims = new OpenCodeStartupPromptClaims()
export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean {
agentHookServer.setStartupPromptClaimListener(
(body) => claims.claim(body),
() => claims.clear()
)
return claims.register(nonce, digest, () => 'pending')
}
export async function commitPtyWithOpenCodePromptIntent<Result extends PtySpawnResult>(
context: {
env?: Record<string, string>
spawnEnv?: Record<string, string>
deps: { runtime?: OpenCodePromptRuntime }
result: PtySpawnResult
provider: Pick<IPtyProvider, 'hasPty'>
args: { connectionId?: string | null }
},
commit: () => Promise<Result>
): Promise<Result> {
const options = {
env: context.spawnEnv ?? context.env,
runtime: context.deps.runtime,
result: context.result,
provider: context.provider,
connectionId: context.args.connectionId
}
const facts = options.runtime?.terminalRunFacts
facts?.reserveSpawnCommit(options.result)
try {
const result = await commit()
bindOpenCodeStartupPromptOwner({ ...options, result })
return result
} catch (error) {
const nonce = options.env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV]
if (nonce) {
claims.cancel(nonce)
}
throw error
} finally {
facts?.discardSpawnCommit(options.result)
}
}
export function bindOpenCodeStartupPromptOwner(options: {
env: Record<string, string> | undefined
result: PtySpawnResult
runtime: OpenCodePromptRuntime | undefined
provider: Pick<IPtyProvider, 'hasPty'>
connectionId?: string | null
}): void {
const { env, result, runtime, provider } = options
const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV]
const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]
const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN
const incarnation = result.incarnationId
if (
options.connectionId ||
!runtime ||
result.isReattach ||
result.agentSessionEnsure?.disposition === 'adopted' ||
!isPtyIncarnationId(incarnation) ||
!nonce ||
!digest ||
!launchToken
) {
if (nonce) {
claims.cancel(nonce)
}
return
}
let unsubscribe = () => {}
if (
claims.admit(
nonce,
() => {
if (
ptyOwnership.get(result.id) !== null ||
ptyIncarnationById.get(result.id) !== incarnation ||
provider.hasPty?.(result.id) !== true ||
runtime.isPtyStopRequested(result.id)
) {
return null
}
return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken)
},
() => unsubscribe()
)
) {
unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce))
}
}
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims'
import {
createTranscriptPane,
TRANSCRIPT_PANE_PTY_ID
} from '../runtime/agent-transcript-pane-test-harness'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
async function fixture(launchAgent?: 'opencode' | 'opencode2' | 'zcode') {
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: null,
data: '',
...(launchAgent ? { launchAgent } : {})
})
runtime.terminalRunFacts.recordSpawnCommit({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' })
const read = (
ptyId = TRANSCRIPT_PANE_PTY_ID,
incarnation = 'inc-1',
token = 'transcript-launch'
) => runtime.readOpenCodeStartupPromptOwner(ptyId, incarnation, token)
return { runtime, read }
}
describe('runtime-owned startup prompt identity', () => {
it('keeps launch admission pending before runtime identity is assigned', async () => {
const f = await fixture()
expect(f.read()).toBe('pending')
expect(f.read('missing')).toBeNull()
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull()
})
it.each(['opencode', 'opencode2'] as const)(
'reads only the admitted %s launch',
async (agent) => {
const f = await fixture(agent)
expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: null })
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-1', 'another-launch')).toBeNull()
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull()
f.runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', 'x', 123)
expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: 123 })
}
)
it('denies another agent even with the exact incarnation and launch token', async () => {
expect((await fixture('zcode')).read()).toBeNull()
})
it('refuses same-ID replay after the execution owner or launch token changes', async () => {
const f = await fixture('opencode2')
const claims = new OpenCodeStartupPromptClaims()
claims.register('owned-operation', 'digest', () => f.read())
const body = { nonce: 'owned-operation', digest: 'digest', requestId: 'stable-operation' }
expect(claims.claim(body)).toBe(true)
expect(claims.claim(body)).toBe(true)
await f.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0, 'inc-1')
f.runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, 'wt-1', null, {
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
incarnationId: 'inc-2',
agentLaunchAuthority: { launchToken: 'replacement-launch', launchAgent: 'opencode2' }
})
f.runtime.terminalRunFacts.recordSpawnCommit({
id: TRANSCRIPT_PANE_PTY_ID,
incarnationId: 'inc-2'
})
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'replacement-launch')).toEqual({
freshSpawn: true,
firstUserInputAt: null
})
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'transcript-launch')).toBeNull()
expect(claims.claim(body)).toBe(false)
claims.clear()
})
})
@@ -0,0 +1,761 @@
import { EventEmitter } from 'node:events'
import { createServer } from 'node:http'
import { createHash } from 'node:crypto'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
OPENCODE_STARTUP_PROMPT_BODY_ENV,
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV
} from '../../shared/opencode-startup-prompt'
import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source'
import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims'
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise<void>> } }
const prompt = 'exact startup brief\nwith unicode é'
const digest = createHash('sha256').update(prompt).digest('hex')
let dir: string
let setup: PluginModule['default']['setup']
let claim: ReturnType<typeof vi.fn>
class Editor extends EventEmitter {
traits: { owner: string; role: string; capture: string[]; status?: string } = {
owner: 'opencode',
role: 'prompt',
capture: ['tab']
}
plainText = ''
focused = true
insertText(text: string) {
this.replace(this.plainText + text)
}
replace(text: string) {
this.plainText = text
this.emit('line-info-change')
}
}
type FixtureLocation = { directory: string; workspaceID?: string }
function fixture() {
const editor = new Editor()
const input = new EventEmitter()
const memory = { settled: false, expiresAt: Date.now() + 20000 }
const route = { type: 'home' }
const agent = vi.fn((): unknown[] | undefined => [{}])
const model = vi.fn((): unknown[] | undefined => [{}])
const sync = vi.fn(async (_location: FixtureLocation) => {})
const dispatch = vi.fn(() => editor.replace(''))
const ctx = {
app: { version: '2.0.16' },
renderer: { keyInput: input, currentFocusedEditor: editor },
storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] },
keymap: { dispatch },
ui: { router: { current: () => route } },
location: { directory: '/private' },
data: { location: { sync, agent: { list: agent }, model: { list: model } } }
}
return { ctx, editor, input, memory, route, agent, model, sync, dispatch }
}
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-'))
const path = join(dir, 'prompt.mjs')
writeFileSync(path, getOpenCodeStartupPromptSource())
const module: PluginModule = await import(pathToFileURL(path).href)
setup = module.default.setup
vi.useFakeTimers()
vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest)
vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt)
vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce')
const endpoint = join(dir, 'endpoint.cmd')
writeFileSync(
endpoint,
'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n'
)
vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint)
claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) }))
vi.stubGlobal('fetch', claim)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
rmSync(dir, { recursive: true, force: true })
})
describe('installed-version native prompt intent plugin', () => {
it('waits for the current home location after the startup directory changes', async () => {
const f = fixture()
let location: FixtureLocation = { directory: '/private/home/private-folder' }
Object.defineProperty(f.ctx, 'location', { get: () => location })
let releaseStartup = () => {}
let releaseHome = () => {}
let selectedModel = 'opencode/mimo-v2.6-flash-free'
const selections: string[] = []
f.sync.mockImplementation(
(target) =>
new Promise<void>((resolve) => {
if (target.directory.endsWith('/private-folder')) {
releaseStartup = resolve
} else {
releaseHome = () => {
selectedModel = 'private-proof/model-a'
resolve()
}
}
})
)
f.dispatch.mockImplementation(() => {
selections.push(selectedModel)
f.editor.replace('')
})
const dispose = await setup(f.ctx)
try {
await vi.advanceTimersByTimeAsync(100)
location = { directory: '/private/home' }
releaseStartup()
await vi.advanceTimersByTimeAsync(300)
expect(claim).not.toHaveBeenCalled()
expect(selections).toEqual([])
expect(f.sync).toHaveBeenCalledTimes(2)
expect(f.sync).toHaveBeenLastCalledWith(location)
releaseHome()
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
expect(selections).toEqual(['private-proof/model-a'])
expect(claim).toHaveBeenCalledTimes(1)
} finally {
await dispose()
}
})
it('waits for a concrete location before starting authoritative hydration', async () => {
const f = fixture()
let location: FixtureLocation | undefined
Object.defineProperty(f.ctx, 'location', { get: () => location })
const dispose = await setup(f.ctx)
try {
await vi.advanceTimersByTimeAsync(300)
expect(f.sync).not.toHaveBeenCalled()
expect(claim).not.toHaveBeenCalled()
location = { directory: '/private/home' }
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
expect(f.sync).toHaveBeenCalledExactlyOnceWith(location)
} finally {
await dispose()
}
})
it('keeps readiness scoped to the workspace as well as the directory', async () => {
const f = fixture()
let location: FixtureLocation = { directory: '/private', workspaceID: 'first' }
Object.defineProperty(f.ctx, 'location', { get: () => location })
let release = () => {}
f.sync.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
release = resolve
})
)
const dispose = await setup(f.ctx)
try {
await vi.advanceTimersByTimeAsync(100)
location = { directory: '/private', workspaceID: 'second' }
release()
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
expect(f.sync).toHaveBeenCalledTimes(2)
expect(f.sync).toHaveBeenLastCalledWith(location)
expect(claim).toHaveBeenCalledTimes(1)
} finally {
await dispose()
}
})
it('refuses a granted claim after the composer location changes', async () => {
const f = fixture()
let location: FixtureLocation = { directory: '/private' }
Object.defineProperty(f.ctx, 'location', { get: () => location })
let grant = () => {}
claim.mockImplementation(
() =>
new Promise((resolve) => {
grant = () => resolve({ ok: true, json: async () => ({ allowed: true }) })
})
)
const insert = vi.spyOn(f.editor, 'insertText')
const dispose = await setup(f.ctx)
try {
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
location = { directory: '/private/other' }
grant()
await vi.advanceTimersByTimeAsync(500)
expect(insert).not.toHaveBeenCalled()
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.memory.settled).toBe(true)
} finally {
await dispose()
}
})
it('waits for authoritative location config before claiming or selecting a model', async () => {
const f = fixture()
let configuredModel = 'unavailable-fallback'
let release = () => {}
f.sync.mockImplementation(
() =>
new Promise<void>((resolve) => {
release = () => {
configuredModel = 'configured-model'
resolve()
}
})
)
const selections: string[] = []
f.dispatch.mockImplementation(() => {
selections.push(configuredModel)
f.editor.replace('')
})
const insert = vi.spyOn(f.editor, 'insertText')
const dispose = await setup(f.ctx)
try {
await vi.advanceTimersByTimeAsync(500)
expect(claim).not.toHaveBeenCalled()
expect(insert).not.toHaveBeenCalled()
expect(selections).toEqual([])
expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location)
release()
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit'))
expect(selections).toEqual(['configured-model'])
expect(insert).toHaveBeenCalledExactlyOnceWith(prompt)
expect(claim).toHaveBeenCalledTimes(1)
expect(f.sync).toHaveBeenCalledTimes(1)
} finally {
await dispose()
}
})
it.each(['keypress', 'paste', 'edit', 'route', 'expiry', 'dispose', 'editor', 'focus'])(
'cancels delayed location hydration on %s before any claim',
async (reason) => {
const f = fixture()
let release = () => {}
f.sync.mockImplementation(
() =>
new Promise<void>((resolve) => {
release = resolve
})
)
const dispose = await setup(f.ctx)
try {
await vi.advanceTimersByTimeAsync(100)
expect(claim).not.toHaveBeenCalled()
if (reason === 'keypress' || reason === 'paste') {
f.input.emit(reason)
}
if (reason === 'edit') {
f.editor.replace('typed')
f.editor.replace('')
}
if (reason === 'route') {
f.route.type = 'session'
}
if (reason === 'expiry') {
f.memory.expiresAt = Date.now()
}
if (reason === 'dispose') {
await dispose()
}
if (reason === 'editor') {
f.ctx.renderer.currentFocusedEditor = new Editor()
}
if (reason === 'focus') {
f.editor.focused = false
}
await vi.advanceTimersByTimeAsync(100)
release()
await vi.advanceTimersByTimeAsync(500)
expect(claim).not.toHaveBeenCalled()
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.editor.plainText).toBe('')
if (reason !== 'focus') {
expect(f.memory.settled).toBe(true)
}
} finally {
await dispose()
}
expect(f.input.listenerCount('keypress')).toBe(0)
expect(f.editor.listenerCount('line-info-change')).toBe(0)
}
)
it('fails closed when authoritative location sync rejects', async () => {
const f = fixture()
f.sync.mockRejectedValue(new Error('location unavailable'))
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.memory.settled).toBe(true)
expect(claim).not.toHaveBeenCalled()
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.input.listenerCount('keypress')).toBe(0)
await dispose()
})
it('fails closed when authoritative location sync is missing', async () => {
const f = fixture()
const { sync: _sync, ...location } = f.ctx.data.location
const dispose = await setup({ ...f.ctx, data: { location } })
await vi.advanceTimersByTimeAsync(500)
expect(claim).not.toHaveBeenCalled()
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.input.listenerCount('keypress')).toBe(0)
await dispose()
})
it.each(['non-ok', 'json-rejected'])('cancels unread %s claim bodies', async (reason) => {
const cancelled = vi.fn()
const response = cancelTrackingResponse(reason === 'non-ok' ? 503 : 200, cancelled)
if (reason === 'json-rejected') {
vi.spyOn(response, 'json').mockRejectedValue(new Error('decoder rejected'))
}
claim.mockResolvedValue(response)
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(cancelled).toHaveBeenCalled())
expect(f.memory.settled).toBe(false)
expect(f.dispatch).not.toHaveBeenCalled()
await dispose()
expect(f.memory.settled).toBe(true)
})
it('consumes an allowed claim body before dispatching the prompt', async () => {
const response = Response.json({ allowed: true })
claim.mockResolvedValue(response)
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit'))
expect(response.bodyUsed).toBe(true)
await dispose()
})
it('consumes malformed JSON and retries without delivering until expiry', async () => {
const response = new Response('{invalid', { status: 200 })
claim.mockResolvedValue(response)
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => {
expect(response.bodyUsed).toBe(true)
expect(claim.mock.calls.length).toBeGreaterThanOrEqual(2)
})
expect(f.memory.settled).toBe(false)
f.memory.expiresAt = Date.now()
await vi.advanceTimersByTimeAsync(100)
expect(response.bodyUsed).toBe(true)
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.memory.settled).toBe(true)
await dispose()
})
it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => {
const f = fixture()
if (kind === 'dialog') {
f.editor.traits.role = 'dialog'
}
if (kind === 'shell') {
f.editor.traits.status = 'SHELL'
}
if (kind === 'autocomplete') {
f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab']
}
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.editor.plainText).toBe('')
expect(claim).not.toHaveBeenCalled()
expect(f.dispatch).not.toHaveBeenCalled()
await dispose()
})
it('retries pending admission, then submits once', async () => {
claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) })
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(100)
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
expect(f.dispatch).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(300)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
expect(claim).toHaveBeenCalledTimes(2)
await dispose()
})
it('cancels pending admission on input without retrying a consumed denial', async () => {
claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) })
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(100)
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
f.input.emit('keypress', { name: 'x' })
await vi.advanceTimersByTimeAsync(1000)
expect(claim).toHaveBeenCalledTimes(1)
expect(f.dispatch).not.toHaveBeenCalled()
await dispose()
})
it('preserves typing that predates plugin setup without requesting owner permission', async () => {
const f = fixture()
f.editor.replace('early typing')
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.editor.plainText).toBe('early typing')
expect(claim).not.toHaveBeenCalled()
expect(f.dispatch).not.toHaveBeenCalled()
await dispose()
})
it('waits for catalogs and settles before a single dispatch', async () => {
const f = fixture()
f.model.mockReturnValue(undefined)
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).not.toHaveBeenCalled()
f.model.mockReturnValue([{}])
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit'))
expect(claim).toHaveBeenCalledTimes(1)
expect(f.memory.settled).toBe(true)
f.editor.replace(prompt)
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).toHaveBeenCalledTimes(1)
await dispose()
const reloadDispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).toHaveBeenCalledTimes(1)
await reloadDispose()
})
it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => {
const f = fixture()
f.agent.mockReturnValue(undefined)
const dispose = await setup(f.ctx)
f.input.emit(event)
f.agent.mockReturnValue([{}])
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.memory.settled).toBe(true)
await dispose()
})
it('cancels a changed draft even when it is restored before the next tick', async () => {
const f = fixture()
f.model.mockReturnValue(undefined)
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(100)
f.editor.replace('edited')
f.editor.replace('')
f.model.mockReturnValue([{}])
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).not.toHaveBeenCalled()
await dispose()
})
it('cancels pending intent on route changes, expiration and disposal', async () => {
for (const reason of ['route', 'expiration', 'dispose']) {
const f = fixture()
f.model.mockReturnValue(undefined)
const dispose = await setup(f.ctx)
if (reason === 'route') {
f.route.type = 'session'
}
if (reason === 'expiration') {
f.memory.expiresAt = Date.now()
}
if (reason === 'dispose') {
await dispose()
}
await vi.advanceTimersByTimeAsync(100)
f.model.mockReturnValue([{}])
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.memory.settled).toBe(true)
await dispose()
expect(f.input.listenerCount('keypress')).toBe(0)
}
})
it('leaves unverified versions and mismatched drafts unsubmitted', async () => {
const f = fixture()
f.ctx.app.version = '2.0.17'
await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).not.toHaveBeenCalled()
f.ctx.app.version = '2.0.16'
f.editor.replace('another brief')
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).not.toHaveBeenCalled()
await dispose()
})
it.each(['canceled', 'unavailable'])(
'fails closed when the execution owner is %s',
async (reason) => {
claim.mockImplementation(async () => {
if (reason === 'unavailable') {
throw new Error('contact lost')
}
return { ok: true, json: async () => ({ allowed: false }) }
})
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).not.toHaveBeenCalled()
if (reason === 'unavailable') {
await vi.waitFor(() => expect(claim).toHaveBeenCalled())
expect(f.memory.settled).toBe(false)
f.memory.expiresAt = Date.now()
await vi.advanceTimersByTimeAsync(100)
}
await vi.waitFor(() => expect(f.memory.settled).toBe(true))
await dispose()
}
)
it('rechecks physical cancellation after the owner response', async () => {
const f = fixture()
claim.mockImplementation(async () => {
f.input.emit('keypress')
return { ok: true, json: async () => ({ allowed: true }) }
})
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(f.memory.settled).toBe(true))
expect(f.dispatch).not.toHaveBeenCalled()
await dispose()
})
it('settles before insertion and never repeats dispatch when the draft is retained', async () => {
const f = fixture()
f.dispatch.mockImplementation(() => {})
const insert = vi.spyOn(f.editor, 'insertText')
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(1500)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
expect(insert).toHaveBeenCalledExactlyOnceWith(prompt)
expect(f.memory.settled).toBe(true)
expect(f.editor.plainText).toBe(prompt)
await dispose()
const reloadDispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).toHaveBeenCalledTimes(1)
await reloadDispose()
})
it.each(['before-grant', 'after-grant', 'timeout-after-grant'])(
'recovers actual HTTP response loss %s exactly once',
async (phase) => {
vi.useRealTimers()
vi.unstubAllGlobals()
const claims = new OpenCodeStartupPromptClaims()
claims.register('single-use-nonce', digest, () => ({
freshSpawn: true,
firstUserInputAt: null
}))
let requests = 0
const bodies: unknown[] = []
const grants: (boolean | 'pending')[] = []
let heldResponse: ReturnType<typeof setTimeout> | undefined
const server = createServer(async (request, response) => {
let text = ''
for await (const chunk of request) {
text += chunk.toString()
}
const body: unknown = JSON.parse(text)
bodies.push(body)
requests++
if (requests === 1 && phase === 'before-grant') {
response.writeHead(503).end('temporarily unavailable')
return
}
const allowed = claims.claim(body)
grants.push(allowed)
if (requests === 1 && phase === 'after-grant') {
response.destroy()
return
}
response.writeHead(200, { 'content-type': 'application/json' })
if (requests === 1 && phase === 'timeout-after-grant') {
response.write('{"allowed":')
heldResponse = setTimeout(() => response.end('true}'), 1300)
return
}
response.end(JSON.stringify({ allowed: allowed === true, pending: allowed === 'pending' }))
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('missing loopback address')
}
writeFileSync(
join(dir, 'endpoint.cmd'),
`set ORCA_AGENT_HOOK_PORT=${address.port}\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n`
)
const f = fixture()
const dispose = await setup(f.ctx)
try {
await vi.waitFor(
() =>
expect(
f.dispatch,
JSON.stringify({ phase, requests, bodies, grants })
).toHaveBeenCalledTimes(1),
{ timeout: 3000 }
)
await new Promise<void>((resolve) => setTimeout(resolve, 300))
expect(requests).toBe(2)
expect(grants).toEqual(phase === 'before-grant' ? [true] : [true, true])
expect(bodies[1]).toEqual(bodies[0])
expect(bodies[0]).toHaveProperty('requestId', expect.any(String))
expect(f.memory.settled).toBe(true)
expect(f.editor.plainText).toBe('')
} finally {
await dispose()
claims.clear()
clearTimeout(heldResponse)
server.closeAllConnections()
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}
)
it.each([408, 429, 503])(
'retries transient HTTP %s with one stable operation ID',
async (status) => {
claim.mockResolvedValueOnce({ ok: false, status })
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
const firstBody = JSON.parse(claim.mock.calls[0][1].body)
expect(firstBody.requestId).toMatch(/^[a-f0-9-]{36}$/)
expect(JSON.parse(claim.mock.calls[1][1].body)).toEqual(firstBody)
expect(claim).toHaveBeenCalledTimes(2)
await dispose()
}
)
it.each([401, 403, 404])('settles HTTP %s denial without retrying', async (status) => {
claim.mockResolvedValue({ ok: false, status })
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.memory.settled).toBe(true))
expect(claim).toHaveBeenCalledTimes(1)
expect(f.dispatch).not.toHaveBeenCalled()
await dispose()
})
it.each(['input', 'route', 'dispose', 'expiry', 'editor'])(
'rejects late grants after %s changes',
async (reason) => {
const f = fixture()
let release = (_response: unknown) => {}
claim.mockImplementation(
() =>
new Promise((resolve) => {
release = resolve
})
)
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(100)
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
if (reason === 'input') {
f.input.emit('paste')
}
if (reason === 'route') {
f.route.type = 'session'
}
if (reason === 'dispose') {
await dispose()
}
if (reason === 'expiry') {
f.memory.expiresAt = Date.now()
}
if (reason === 'editor') {
f.ctx.renderer.currentFocusedEditor = new Editor()
}
release({ ok: true, json: async () => ({ allowed: true }) })
await vi.advanceTimersByTimeAsync(500)
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.editor.plainText).toBe('')
await dispose()
}
)
it.each(['input', 'route', 'dispose'])(
'ends delivery when %s changes during insertion',
async (reason) => {
const f = fixture()
let dispose = async () => {}
const insert = f.editor.insertText.bind(f.editor)
vi.spyOn(f.editor, 'insertText').mockImplementation((text) => {
expect(f.memory.settled).toBe(true)
insert(text)
if (reason === 'input') {
f.input.emit('keypress')
}
if (reason === 'route') {
f.route.type = 'session'
}
if (reason === 'dispose') {
void dispose()
}
})
dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
expect(f.dispatch).not.toHaveBeenCalled()
expect(f.memory.settled).toBe(true)
await dispose()
}
)
it.each(['allow', 'lost-response'])(
'degrades safely against a legacy host with %s',
async (reason) => {
const claims = new OpenCodeStartupPromptClaims()
claims.register('single-use-nonce', digest, () => ({
freshSpawn: true,
firstUserInputAt: null
}))
let lost = false
claim.mockImplementation(async (_url, init) => {
const body = JSON.parse(init.body)
const allowed = claims.claim({ nonce: body.nonce, digest: body.digest })
if (reason === 'lost-response' && !lost) {
lost = true
throw new Error('legacy grant response lost')
}
return { ok: true, json: async () => ({ allowed }) }
})
const f = fixture()
const dispose = await setup(f.ctx)
await vi.advanceTimersByTimeAsync(500)
await vi.waitFor(() => expect(f.memory.settled).toBe(true))
expect(f.dispatch).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 0)
expect(claim).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 2)
expect(f.editor.plainText).toBe('')
claims.clear()
await dispose()
}
)
})
@@ -0,0 +1,152 @@
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
import {
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV,
OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
OPENCODE_STARTUP_PROMPT_BODY_ENV
} from '../../shared/opencode-startup-prompt'
export function getOpenCodeStartupPromptSource(): string {
return String.raw`
const parseEndpoint = ${parseAgentHookEndpointFile.toString()};
const cancelUnreadResponseBody = ${cancelUnreadResponseBody.toString()};
async function claimStartupPrompt(nonce, digest, endpoint, requestId) {
let response;
try {
const { readFile, stat } = await import("node:fs/promises");
if ((await stat(endpoint)).size > 4096) return false;
const coords = parseEndpoint(await readFile(endpoint, "utf8"));
const port = Number(coords.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", {
method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token },
body: JSON.stringify({ nonce, digest, requestId }), signal: AbortSignal.timeout(1000)
});
if (!response.ok) return response.status === 408 || response.status === 429 || response.status >= 500 ? "pending" : false;
const result = await response.json();
return result.allowed === true ? true : result.pending === true ? "pending" : false;
} catch {
// A lost grant response can be replayed with the same operation ID until expiry.
return "pending";
} finally {
if (response) await cancelUnreadResponseBody(response);
}
}
async function submitStartupPrompt(ctx) {
const noop = async () => {};
const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV};
const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV};
const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV};
const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV};
if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop;
const input = ctx.renderer?.keyInput;
if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" ||
typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" ||
typeof ctx.ui?.router?.current !== "function" ||
typeof ctx.data?.location?.sync !== "function" ||
typeof ctx.data?.location?.agent?.list !== "function" ||
typeof ctx.data?.location?.model?.list !== "function") return noop;
const [memory, setMemory] = ctx.storage.memory("startup-prompt", {
initial: { settled: false, expiresAt: Date.now() + 20000 }
});
if (memory.settled) return noop;
let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, hydrating = false, readyLocation;
// Home can change location after plugin setup.
const locationKey = (location) => typeof location?.directory === "string" && location.directory ?
JSON.stringify([location.directory, location.workspaceID]) : undefined;
const isComposer = (candidate) => candidate?.traits?.owner === "opencode" &&
candidate.traits.role === "prompt" && !candidate.traits.status &&
candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab";
const matches = (candidate) => typeof candidate?.plainText === "string" &&
createHash("sha256").update(candidate.plainText).digest("hex") === digest;
const cleanup = () => {
clearInterval(timer);
input.off("keypress", cancel);
input.off("paste", cancel);
editor?.off("line-info-change", changed);
};
const settle = () => {
setMemory((draft) => { draft.settled = true; });
cleanup();
};
const cancel = () => { canceled = true; settle(); };
// Any edit before delivery ends this startup operation.
const changed = () => { if (seen && editor.plainText !== "") settle(); };
input.on("keypress", cancel);
input.on("paste", cancel);
const dispose = async () => { disposed = true; settle(); };
try {
const crypto = await import("node:crypto");
createHash = crypto.createHash;
setMemory((draft) => { draft.requestId ??= crypto.randomUUID(); });
requestId = memory.requestId;
if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; }
if (memory.settled) return dispose;
timer = setInterval(async () => {
if (disposed || memory.settled) return;
try {
if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle();
const current = ctx.renderer.currentFocusedEditor;
if (!isComposer(current)) { if (seen) settle(); return; }
if (editor !== current) {
if (seen) return settle();
editor?.off("line-info-change", changed);
editor = current;
editor?.on("line-info-change", changed);
}
if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return;
if (editor.plainText !== "") return settle();
seen = true;
const location = ctx.location;
const key = locationKey(location);
if (!key) return;
if (readyLocation !== key) {
readyLocation = undefined;
if (!hydrating) {
hydrating = true;
const ref = { directory: location.directory, workspaceID: location.workspaceID };
void ctx.data.location.sync(ref).then(() => {
hydrating = false;
if (!disposed && !memory.settled && locationKey(ctx.location) === key) readyLocation = key;
}, settle);
}
return;
}
const agents = ctx.data.location.agent.list(location);
const models = ctx.data.location.model.list(location);
if (!editor.focused || !agents?.length || !models?.length) return;
if (claiming) return;
claiming = true;
const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId);
claiming = false;
if (allowed === "pending") return;
if (!allowed) return settle();
if (disposed || memory.settled || Date.now() >= memory.expiresAt ||
locationKey(ctx.location) !== key ||
ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor ||
!editor.focused || !isComposer(editor) || editor.plainText !== "") return settle();
setMemory((draft) => { draft.settled = true; });
clearInterval(timer);
editor.off("line-info-change", changed);
try {
editor.insertText(prompt);
if (disposed || canceled || Date.now() >= memory.expiresAt ||
locationKey(ctx.location) !== key ||
ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor ||
!editor.focused || !isComposer(editor) || !matches(editor)) return;
ctx.keymap.dispatch("prompt.submit");
} finally { cleanup(); }
} catch { settle(); }
}, 100);
timer.unref?.();
return dispose;
} catch {
settle();
return noop;
}
}
export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt };
`.trimStart()
}