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
@@ -0,0 +1,146 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
import { PANE } from './server.test-fixtures'
describe('startup prompt control with status hooks disabled', () => {
it.each([false, true])(
'persists a pending terminal status at shutdown after starting with hooks %s',
async (statusHooksEnabled) => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-'))
const server = new AgentHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled })
server.setStatusHooksEnabled(false)
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'folder-1',
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
})
expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done')
const statusPath = server.lastStatusPath
if (!statusPath) {
throw new Error('missing status persistence path')
}
server.stop()
expect(existsSync(statusPath)).toBe(true)
expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({
paneKey: PANE,
worktreeId: 'folder-1',
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
})
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
}
)
it('enables and disables status without restarting the control listener', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-'))
class IsolatedHookServer extends AgentHookServer {
constructor() {
super()
this._setOpenCodeBinderDepsForTests({
dbPath: () => join(dir, 'no-user-db'),
listSessions: async () => [],
listPanes: () => [],
sweep: async () => []
})
}
}
const server = new IsolatedHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled: false })
const endpoint = server.endpointFilePath
if (!endpoint) {
throw new Error('missing control endpoint')
}
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
const post = (path: string) =>
fetch(`http://127.0.0.1:${coords.port}${path}`, {
method: 'POST',
headers: { 'x-orca-agent-hook-token': coords.token },
body: '{}'
})
expect((await post('/hook/opencode')).status).toBe(404)
await server.start({ statusHooksEnabled: true })
expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port)
expect((await post('/hook/opencode')).status).toBe(204)
server.setStatusHooksEnabled(false)
expect(server.buildPtyEnv()).toEqual({})
expect((await post('/hook/opencode')).status).toBe(404)
await server.start()
expect((await post('/hook/opencode')).status).toBe(404)
server.setStartupPromptClaimListener(
() => 'pending',
() => {}
)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({
allowed: false,
pending: true
})
server.setStatusHooksEnabled(true)
expect((await post('/hook/opencode')).status).toBe(204)
expect(server.endpointFilePath).toBe(endpoint)
expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords)
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
})
it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-'))
const server = new AgentHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled: false })
expect(server.buildPtyEnv()).toEqual({})
const statusPath = server.lastStatusPath
if (!statusPath) {
throw new Error('missing status persistence path')
}
writeFileSync(statusPath, 'existing status must survive control-only shutdown')
const endpoint = server.endpointFilePath
if (!endpoint) {
throw new Error('missing control endpoint')
}
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
const post = (path: string, body: string, token = coords.token) =>
fetch(`http://127.0.0.1:${coords.port}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token },
body
})
expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
allowed: false
})
const clear = vi.fn()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, clear)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
allowed: true
})
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({
allowed: false
})
expect(claim).toHaveBeenCalledTimes(1)
expect((await post('/hook/opencode', '{}')).status).toBe(404)
expect((await post('/statusline/claude', '{}')).status).toBe(404)
server.stop()
expect(clear).toHaveBeenCalledTimes(1)
expect(readFileSync(statusPath, 'utf8')).toBe(
'existing status must survive control-only shutdown'
)
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
})
})
@@ -2,9 +2,11 @@
// short of its own Content-Length. The listener fails open on every request error, so the only
// way this stays diagnosable is if the truncation is classified before it is swallowed.
import { connect } from 'node:net'
import { ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference'
import { AgentHookServer } from './server'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
async function postTruncatedHook(
port: number,
@@ -40,11 +42,15 @@ async function postTruncatedHook(
}
/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */
async function postStalledHook(port: number, token: string): Promise<void> {
async function postStalledHook(
port: number,
token: string,
pathname = '/hook/claude'
): Promise<void> {
const socket = connect({ port, host: '127.0.0.1' })
await new Promise<void>((resolve) => socket.on('connect', () => resolve()))
socket.write(
`POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
`POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
)
await new Promise<void>((resolve) => {
socket.on('close', () => resolve())
@@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => {
expect(reports).toHaveLength(1)
}, 20_000)
it('classifies an interrupted startup claim as retryable without consuming it', async () => {
const { server, port, token, reports } = await startServer()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, () => {})
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
try {
await postTruncatedHook(port, token, {
pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
sentBytes: '{"nonce":',
announcedLength: 1000
})
// The reset peer cannot receive this response; observe the real handler's classification.
expect(writeHead.mock.calls).toEqual([[503]])
expect(claim).not.toHaveBeenCalled()
expect(reports).toEqual([])
} finally {
writeHead.mockRestore()
}
})
it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => {
const { server, port, token, reports } = await startServer()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, () => {})
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
try {
await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH)
expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]])
expect(claim).not.toHaveBeenCalled()
expect(reports).toEqual([])
} finally {
writeHead.mockRestore()
}
}, 30_000)
it('never reports for POSTs that deliver their whole body', async () => {
const { port, token, reports } = await startServer()
+46 -30
View File
@@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state'
import { trackEmptyPaneKeyHook } from './server-transport-rules'
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt'
export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv {
export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle {
/** Start the loopback listener after hydration and spool replay have settled. */
async start(options?: {
env?: string
userDataPath?: string
endpointNamespace?: string
statusHooksEnabled?: boolean
}): Promise<void> {
if (this.server) {
if (options?.statusHooksEnabled !== undefined) {
this.setStatusHooksEnabled(options.statusHooksEnabled)
}
return
}
this.statusHooksEnabled = options?.statusHooksEnabled !== false
if (options?.env) {
this.env = options.env
@@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.token = randomUUID()
this.endpointFileWritten = false
this.lastWrittenJson = null
if (!this.ownerStateInitialized) {
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
// Drain before binding the listener so replay cannot race a live hook during startup.
if (this.endpointDir) {
const replayedPaneKeys = new Set<string>()
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: (paneKey) =>
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
ingest: (record: SpoolRecord) => {
this.ingestSpoolRecord(record)
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
}
})
// Why: the owner may have died while Orca was down; check each replayed pane once.
for (const paneKey of replayedPaneKeys) {
void this.checkAgentPresence(paneKey)
}
}
this.ownerStateInitialized = true
if (this.statusHooksEnabled) {
this.initializeStatusHookOwner()
}
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'POST') {
@@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
try {
const body = await readRequestBody(req)
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
res.writeHead(200, { 'content-type': 'application/json' })
const claim = this.onStartupPromptClaim?.(body)
res.end(
JSON.stringify({
allowed: claim === true,
...(claim === 'pending' ? { pending: true } : {})
})
)
return
}
if (!this.statusHooksEnabled) {
res.writeHead(404)
res.end()
return
}
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
const statusLineEvent = parseClaudeStatusLineBody(body)
if (statusLineEvent) {
@@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
res.writeHead(204)
res.end()
} catch (error) {
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
res.writeHead(503)
res.end()
return
}
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"allowed":false}')
return
}
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
// this is the one failure mode that silently stops status for every runtime at once.
@@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.rollbackTransportStart()
throw error
}
this.startOpenCodeBinderLoop()
if (this.statusHooksEnabled) {
this.startOpenCodeBinderLoop()
}
}
private rollbackTransportStart(): void {
@@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
}
stop(): void {
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
this.flushStatusPersistSync()
// Terminal status may still have a pending write while hook ingress is disabled.
if (this.statusHooksEnabled || this.statusPersistTimer) {
this.flushStatusPersistSync()
}
this.stopOpenCodeBinderLoop()
this.stopTmuxStatus()
this.rollbackTransportStart()
this.env = 'production'
this.onAgentStatus = null
this.onClaudeStatusLine = null
this.clearStartupPromptClaims?.()
this.clearStartupPromptClaims = null
this.onStartupPromptClaim = null
this.onPaneStatusCleared = null
this.onTransportInterference = null
this.transportInterference.reset()
@@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row'
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
export abstract class AgentHookServerListeners extends AgentHookServerState {
setStartupPromptClaimListener(
listener: (body: unknown) => boolean | 'pending',
clear: () => void
): void {
this.onStartupPromptClaim = listener
this.clearStartupPromptClaims = clear
}
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
this.onAgentStatus?.(enriched)
for (const listener of this.enrichedStatusListeners) {
@@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote'
export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote {
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
if (!this.statusHooksEnabled || this.port <= 0 || !this.token) {
return {}
}
const env: Record<string, string> = {
@@ -89,6 +89,9 @@ export abstract class AgentHookServerState {
protected env = 'production'
protected onAgentStatus: ServerAgentStatusListener = null
protected onClaudeStatusLine: ServerStatusLineListener = null
protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null
protected clearStartupPromptClaims: (() => void) | null = null
protected statusHooksEnabled = true
protected onPaneStatusCleared: PaneStatusClearListener | null = null
protected paneStatusClearListeners = new Set<PaneStatusClearListener>()
protected statusDropListeners = new Set<StatusDropListener>()
@@ -0,0 +1,52 @@
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv {
setStatusHooksEnabled(enabled: boolean): void {
if (enabled === this.statusHooksEnabled) {
return
}
if (!enabled) {
this.flushStatusPersistSync()
this.stopOpenCodeBinderLoop()
for (const timer of this.assistantMessageRetryTimers.values()) {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
this.clearAllTranscriptPolls()
}
this.statusHooksEnabled = enabled
if (enabled && this.server) {
this.initializeStatusHookOwner()
this.startOpenCodeBinderLoop()
}
}
protected initializeStatusHookOwner(): void {
if (!this.ownerStateInitialized) {
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
// Drain before binding the listener so replay cannot race a live hook during startup.
if (this.endpointDir) {
const replayedPaneKeys = new Set<string>()
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: (paneKey) =>
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
ingest: (record: SpoolRecord) => {
this.ingestSpoolRecord(record)
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
}
})
// Why: the owner may have died while Orca was down; check each replayed pane once.
for (const paneKey of replayedPaneKeys) {
void this.checkAgentPresence(paneKey)
}
}
this.ownerStateInitialized = true
}
}
}
@@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['main/bitbucket/client.ts', 1],
['main/bitbucket/user-request.ts', 1],
['main/gitea/client.ts', 1],
// Generated OpenCode claim source consumes JSON or cancels its body in finally.
['main/opencode/opencode-startup-prompt-source.ts', 1],
['main/orca-profiles/profile-cloud-client.ts', 1],
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
@@ -1,3 +1,4 @@
import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({
@@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({
})
}))
vi.mock('../opencode/hook-service', () => ({
openCodeHookService: {
buildPtyEnv: () => ({}),
refreshLegacySharedPlugin: vi.fn(),
clearPty: vi.fn()
},
openCode2HookService: {
buildPtyEnv: () => ({}),
refreshLegacySharedPlugin: vi.fn(),
clearPty: vi.fn()
}
}))
vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock())
vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
+3
View File
@@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record<string, unknown>) => ({
})
export const openCodeHookServiceModuleMock = () => ({
OpenCodeHookService: class {
buildPtyEnv = vi.fn(() => ({}))
},
openCodeHookService: {
buildPtyEnv: openCodeBuildPtyEnvMock,
refreshLegacySharedPlugin: vi.fn<() => void>(),
+11 -60
View File
@@ -1,15 +1,10 @@
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
import {
detectExplicitPiAgentKindFromCommand,
isPiCompatibleAgentType
} from '../../../../shared/pi-agent-kind'
import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard'
import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
import {
OPENCODE_CONFIG_DIR_ENV_KEYS,
isOpenCodeLegacySharedConfigDir
} from '../../../opencode/legacy-shared-config-dir'
import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer'
import { mimoCodeHookService } from '../../../mimo/hook-service'
import { agentHookServer } from '../../../agent-hooks/server'
import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager'
@@ -28,13 +23,13 @@ import {
exposePiManagedExtensionEnv,
isMimoLaunchCommand,
resolveMimocodeSourceHome,
resolveOpenCodeSourceConfigDir,
resolvePiAgentSourceDir,
resolveScopedPiAgentSourceDir,
restoreOrStripOverlayEnv
} from './pi-agent'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys'
import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment'
import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config'
/**
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
@@ -50,32 +45,9 @@ export function buildPtyHostEnv(
mergePersistedWindowsPath(baseEnv)
Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings))
// Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config.
const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean =>
isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath)
const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {}
for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
if (isLegacyOpenCodeHooksDir(baseEnv[key])) {
delete baseEnv[key]
}
if (!isLegacyOpenCodeHooksDir(process.env[key])) {
inheritedOpenCodeEnv[key] = process.env[key]
}
}
// A daemon or sibling shell can retain a retired path that main no longer sees.
openCodeHookService.refreshLegacySharedPlugin()
openCode2HookService.refreshLegacySharedPlugin()
const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv)
const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir)
? undefined
: resolvedOpenCodeConfigDir
const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint })
const openCodeAgent = selectOpenCodeHookAgent(
opts.launchAgent,
launchCommandHint,
(agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents)
)
const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent)
? opts.launchAgent
: opts.launchAgent === undefined
@@ -110,37 +82,13 @@ export function buildPtyHostEnv(
? resolvePiAgentSourceDir(baseEnv, 'prime-agent')
: resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent')
restoreOrStripOverlayEnv(
const openCodeAgent = applyOpenCodeStatusPluginEnv(
id,
baseEnv,
{
primary: 'OPENCODE_CONFIG_DIR',
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
preserveExplicitPrimary: true
},
inheritedOpenCodeEnv
openCodeConfig,
opts,
launchCommandHint
)
delete baseEnv.ORCA_OPENCODE_AGENT
if (openCodeAgent) {
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
const openCodeStatusService =
openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
if (!opts.isWsl) {
Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
}
if (baseEnv.OPENCODE_CONFIG_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
if (preexistingOpenCodeConfigDir) {
// Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays.
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir
} else {
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
}
if (opts.agentStatusHooksEnabled) {
if (isMimoLaunchCommand(launchCommandHint)) {
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
@@ -343,5 +291,8 @@ export function buildPtyHostEnv(
// process.env when baseEnv carries none, which is the daemon path's normal shape.
stripLegacyTerminalShimEnv(baseEnv, process.platform)
if (!opts.isWsl) {
ensureOpenCodeStartupPromptForLaunch(baseEnv)
}
return baseEnv
}
@@ -0,0 +1,84 @@
import {
OPENCODE_CONFIG_DIR_ENV_KEYS,
isOpenCodeLegacySharedConfigDir
} from '../../../opencode/legacy-shared-config-dir'
import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent'
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { BuildPtyHostEnvOptions } from './types'
type OpenCodeSourceConfig = {
inheritedEnv: NodeJS.ProcessEnv
directory: string | undefined
}
export function captureOpenCodeSourceConfig(
env: Record<string, string>,
userDataPath: string
): OpenCodeSourceConfig {
const isLegacyDirectory = (dir: string | undefined): boolean =>
isOpenCodeLegacySharedConfigDir(dir, userDataPath)
const inheritedEnv: NodeJS.ProcessEnv = {}
for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
if (isLegacyDirectory(env[key])) {
delete env[key]
}
if (!isLegacyDirectory(process.env[key])) {
inheritedEnv[key] = process.env[key]
}
}
// A daemon or sibling shell can retain a retired path that main no longer sees.
openCodeHookService.refreshLegacySharedPlugin()
openCode2HookService.refreshLegacySharedPlugin()
const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv)
return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory }
}
export function applyOpenCodeStatusPluginEnv(
id: string,
env: Record<string, string>,
config: OpenCodeSourceConfig,
options: Pick<
BuildPtyHostEnvOptions,
'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl'
>,
command: string | undefined
): 'opencode' | 'opencode2' | null {
const agent = selectOpenCodeHookAgent(
options.launchAgent,
command,
(candidate) =>
options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents)
)
restoreOrStripOverlayEnv(
env,
{
primary: 'OPENCODE_CONFIG_DIR',
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
preserveExplicitPrimary: true
},
config.inheritedEnv
)
delete env.ORCA_OPENCODE_AGENT
if (!agent) {
return null
}
const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService
env.ORCA_OPENCODE_AGENT = agent
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
if (!options.isWsl) {
Object.assign(env, service.buildPtyEnv(id, config.directory))
}
if (env.OPENCODE_CONFIG_DIR) {
// Shell startup can re-export the default; preserve this pane's overlay and original source.
env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR
if (config.directory) {
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory
} else {
delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
return agent
}
@@ -77,6 +77,28 @@ afterEach(() => {
})
describe('OpenCode installation uses the current enabled agents', () => {
it('refuses spawn if a prepared startup intent loses its owned installer', () => {
mkdirSync(fixture.userData, { recursive: true })
writeFileSync(
join(fixture.userData, 'opencode-startup-prompt-overlays'),
'blocked fixture root'
)
expect(() =>
buildPtyHostEnv(
'owned-launch',
{
OPENCODE_CONFIG_DIR: custom,
ORCA_OPENCODE_PLUGIN_API: 'v2',
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce',
ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief'
},
{ ...options, agentStatusHooksEnabled: false }
)
).toThrow('launch was canceled')
expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}')
expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin')
})
const combinations = [
{ disabled: [], fallback: 'opencode' },
{ disabled: ['opencode'], fallback: 'opencode2' },
+6
View File
@@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp
import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority'
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx))
}
async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
const args = ctx.args
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
if (ctx.nativeWindowsConptySpawn) {
@@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
ctx.pendingRegistrationPtyId = null
}
publishPtyIpcSpawnCommit(ctx, committedSize)
// Admission must precede reflow: a replaced spawn cannot resize its successor's model.
reflowHeadlessTerminalToCommittedGrid({
result: ctx.result,
+3 -1
View File
@@ -65,7 +65,7 @@ export async function buildPtyIpcSpawnOptions(
ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete)
}
deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete)
ctx.spawnEnv = await prepareOpenCodePtyLaunch({
const openCodeLaunch = await prepareOpenCodePtyLaunch({
command: ctx.launchCommand,
agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
env: ctx.spawnEnv,
@@ -77,6 +77,8 @@ export async function buildPtyIpcSpawnOptions(
? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
: {})
})
ctx.spawnEnv = openCodeLaunch.env
ctx.launchCommand = openCodeLaunch.command
promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath)
ctx.spawnOptions = {
cols: args.cols,
+6
View File
@@ -30,6 +30,7 @@ import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
import type { RuntimePtySpawnState } from './spawn-state'
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
import {
admitPtyReattachOwnership,
discardUnpersistedPtySpawn,
@@ -37,6 +38,10 @@ import {
} from '../pane/spawn-registration'
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedRuntimePtySpawn(ctx))
}
async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) {
const args = ctx.args
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
@@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
claimSshPaneLease({
store: ctx.deps.store,
connectionId: args.connectionId,
+3 -1
View File
@@ -104,7 +104,7 @@ export async function buildRuntimePtySpawnOptions(
env: ctx.env,
envToDelete: ctx.spawnOptions.envToDelete
})
ctx.env = await prepareOpenCodePtyLaunch({
const openCodeLaunch = await prepareOpenCodePtyLaunch({
command: ctx.launchCommand,
agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
env: ctx.env,
@@ -116,6 +116,8 @@ export async function buildRuntimePtySpawnOptions(
? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
: {})
})
ctx.env = openCodeLaunch.env
ctx.launchCommand = openCodeLaunch.command
ctx.spawnOptions.env = ctx.env
promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath)
const noDaemonLaunch = planCodexNoDaemonLaunch({
+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()
}
+13 -3
View File
@@ -151,6 +151,7 @@ async function startOrcadRuntime(
| undefined
let uninstallHookStatusRepublish = (): void => {}
let uninstallObservedStatusIdentity = (): void => {}
let removeStatusHookSettingsListener = (): void => {}
registerCleanup(async () => {
try {
await rpc?.stop()
@@ -167,6 +168,7 @@ async function startOrcadRuntime(
// orcad restart goes back to killing every running terminal.
await stopOrcadDaemon()
} finally {
removeStatusHookSettingsListener()
uninstallObservedStatusIdentity()
uninstallHookStatusRepublish()
agentHookServer.stop()
@@ -194,9 +196,17 @@ async function startOrcadRuntime(
uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) =>
observedStatusCapture.observe(enriched)
)
if (isAgentStatusHooksEnabled(profileStore.getSettings())) {
await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath })
}
await agentHookServer.start({
env: 'production',
userDataPath: runtimeUserDataPath,
statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings())
})
removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => {
if ('agentStatusHooksEnabled' in updates) {
agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings))
}
})
// Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon
// adapter as THE local provider, and the registry's contract is that it lands before
+27 -2
View File
@@ -1,7 +1,8 @@
import { mkdtempSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences'
import { DeviceRegistry } from '../runtime/device-registry'
import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller'
import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox'
@@ -14,6 +15,9 @@ const state = vi.hoisted(() => ({
controller: null as RuntimeMobileNotificationController | null,
registry: null as DeviceRegistry | null,
rpcStarted: false,
onSettingsChanged: vi.fn<ProfilePreferences['onSettingsChanged']>(),
removeSettingsListener: vi.fn(),
startDaemon: vi.fn(async () => {}),
browserProvider: vi.fn(async () => null),
register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })),
send: vi.fn(async () => ({ ok: true, results: [] }))
@@ -26,7 +30,7 @@ vi.mock('./orcad-app-paths', () => ({
vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider }))
vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) }))
vi.mock('./orcad-daemon-supervision', () => ({
startOrcadDaemon: async () => {},
startOrcadDaemon: state.startDaemon,
stopOrcadDaemon: async () => {}
}))
vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) }))
@@ -44,6 +48,7 @@ vi.mock('./orcad-profile-state-startup', () => ({
createOrcadProfileStateStartup: async () => ({
store: {
getSettings: () => ({}),
onSettingsChanged: state.onSettingsChanged,
flushFinalOrThrowAsync: async () => {},
freezeWritesAsync: async () => {}
},
@@ -124,6 +129,10 @@ vi.mock('../runtime/push/push-gateway-client', () => ({
}
}))
beforeEach(() => {
state.onSettingsChanged.mockReturnValue(state.removeSettingsListener)
})
afterEach(() => {
rmSync(state.root, { recursive: true, force: true })
vi.clearAllMocks()
@@ -176,6 +185,11 @@ it('starts push after RPC identity is available and stops dispatch on shutdown',
expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([])
acquireProfileStateMaintenance(state.root).release()
expect(state.controller.getListenerCount()).toBe(0)
expect(state.rpcStarted).toBe(false)
expect(state.onSettingsChanged).toHaveBeenCalledOnce()
expect(state.removeSettingsListener).toHaveBeenCalledOnce()
await host.stop()
expect(state.removeSettingsListener).toHaveBeenCalledOnce()
expect(await state.controller.registerPushDevice({} as never)).toMatchObject({
registered: false
})
@@ -189,3 +203,14 @@ it('releases admission when host setup fails before a runtime exists', async ()
expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([])
acquireProfileStateMaintenance(state.root).release()
})
it('unsubscribes settings when daemon startup fails after hook setup', async () => {
state.root = mkdtempSync(join(tmpdir(), 'orca-headless-daemon-failure-'))
state.startDaemon.mockRejectedValueOnce(new Error('daemon setup failed'))
const { startOrcad } = await import('./orcad-entry')
await expect(startOrcad()).rejects.toThrow('daemon setup failed')
expect(state.onSettingsChanged).toHaveBeenCalledOnce()
expect(state.removeSettingsListener).toHaveBeenCalledOnce()
expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([])
acquireProfileStateMaintenance(state.root).release()
})
+7 -12
View File
@@ -1,3 +1,4 @@
import { openCodeHookServiceModuleMock } from '../ipc/pty-ipc-mock-registry'
import { settledWriteStub } from './settled-pty-write-stub'
import { describe, expect, it, vi } from 'vitest'
import { setPtyHostBindings } from '../ipc/pty-host-bindings'
@@ -48,18 +49,7 @@ vi.mock('node-pty', () => ({
})
}))
vi.mock('../opencode/hook-service', () => ({
openCodeHookService: {
buildPtyEnv: () => ({}),
refreshLegacySharedPlugin: vi.fn(),
clearPty: vi.fn()
},
openCode2HookService: {
buildPtyEnv: () => ({}),
refreshLegacySharedPlugin: vi.fn(),
clearPty: vi.fn()
}
}))
vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock())
vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
@@ -176,6 +166,11 @@ describe('PTY provider dispatch', () => {
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_BRIDGE_SESSION_ID',
'ORCA_OPENCODE_PLUGIN_API',
'ORCA_OPENCODE_STARTUP_PROMPT_BODY',
'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT',
'ORCA_OPENCODE_STARTUP_PROMPT_NONCE',
'ORCA_OPENCODE_STARTUP_PROMPT_SHA256',
'ORCA_OPENCODE_STARTUP_PROMPT_SHELL',
'ORCA_PI_STATUS_OWNED',
'ORCA_PRIME_AGENT_STATUS_OWNED',
'ORCA_PI_TITLE_MARKER_OWNED',
@@ -19,6 +19,25 @@ import type { AgentPromptActivity } from './agent-prompt-submission-verification
import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane'
export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation {
readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) {
const pty = this.ptysById.get(ptyId)
if (!pty || pty.incarnationId !== incarnationId) {
return null
}
// The runtime launch route admits identity immediately after low-level spawn returns.
if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) {
return 'pending' as const
}
if (
pty.launchIncarnationId !== incarnationId ||
pty.launchToken !== launchToken ||
(pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2')
) {
return null
}
return this.terminalRunFacts.read(ptyId, incarnationId)
}
protected resolveAuthoritativeTerminalWaitPermission(
terminal: RuntimeTerminalAgentStatusSnapshot,
explicitStatus: { status: AgentStatus; updatedAt: number } | null,
@@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest'
import { TerminalRunFactsRegister } from './terminal-run-facts'
describe('terminal run facts', () => {
it('keeps driving input before publication across the exact reserved commit', () => {
const facts = new TerminalRunFactsRegister()
facts.recordInput('pending', 'driving', 'x', 100)
facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' })
facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' })
expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100)
facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' })
facts.recordInput('pending', 'driving', 'x', 200)
facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' })
expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200)
facts.delete('pending')
facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' })
facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' })
expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull()
})
it('reads a run main never saw committed as not fresh', () => {
expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({
freshSpawn: false,
+40 -1
View File
@@ -42,12 +42,38 @@ export class TerminalRunFactsRegister {
private readonly runsByPtyId = new Map<string, TerminalRunRecord>()
// Why apart from the run record: input must count on a PTY main adopted without a commit.
private readonly lastInputAtByPtyId = new Map<string, number>()
private readonly pendingByPtyId = new Map<
string,
{ incarnationId: string | null; firstInputAt: number | null }
>()
reserveSpawnCommit(commit: TerminalSpawnCommit): void {
if (!commit.incarnationId) {
return
}
const prior = this.pendingByPtyId.get(commit.id)
this.pendingByPtyId.set(commit.id, {
incarnationId: commit.incarnationId,
firstInputAt:
prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId
? prior.firstInputAt
: null
})
}
discardSpawnCommit(commit: TerminalSpawnCommit): void {
if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) {
this.pendingByPtyId.delete(commit.id)
}
}
/** Once per process: a re-registration of the same incarnation keeps its facts, and so does a
* reattach or adoption of the running process unless its incarnation shows another process. */
recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void {
const incarnationId = commit.incarnationId ?? null
const previous = this.runsByPtyId.get(commit.id)
const pending = this.pendingByPtyId.get(commit.id)
this.pendingByPtyId.delete(commit.id)
if (incarnationId !== null && previous?.incarnationId === incarnationId) {
return
}
@@ -60,7 +86,11 @@ export class TerminalRunFactsRegister {
this.runsByPtyId.set(commit.id, {
incarnationId,
spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin,
firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null
firstUserInputAt: sameProcess
? (previous?.firstUserInputAt ?? null)
: pending?.incarnationId === incarnationId
? pending.firstInputAt
: null
})
}
@@ -73,6 +103,14 @@ export class TerminalRunFactsRegister {
}
this.lastInputAtByPtyId.set(ptyId, now)
const run = this.runsByPtyId.get(ptyId)
if (inputKind === 'driving') {
const pending = this.pendingByPtyId.get(ptyId)
if (pending) {
pending.firstInputAt ??= now
} else if (!run) {
this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now })
}
}
if (run && inputKind === 'driving') {
run.firstUserInputAt ??= now
}
@@ -99,5 +137,6 @@ export class TerminalRunFactsRegister {
delete(ptyId: string): void {
this.runsByPtyId.delete(ptyId)
this.lastInputAtByPtyId.delete(ptyId)
this.pendingByPtyId.delete(ptyId)
}
}
@@ -78,14 +78,28 @@ describe('headless PTY registry hydration ordering', () => {
const runtime = source.indexOf('const runtime = new OrcaRuntimeService(')
const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime)
const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(')
const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription)
const hookStart = source.indexOf('await agentHookServer.start(', identitySubscription)
const settingsListener = source.indexOf('profileStore.onSettingsChanged(', hookStart)
const daemon = source.indexOf('await startOrcadDaemon()', hookStart)
const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime)
expect(runtime).toBeGreaterThanOrEqual(0)
expect(identityReader).toBeGreaterThan(runtime)
expect(identitySubscription).toBeGreaterThanOrEqual(0)
expect(identitySubscription).toBeLessThan(runtime)
expect(hooksEnabled).toBeGreaterThan(identitySubscription)
expect(hookStart).toBeGreaterThan(identitySubscription)
expect(settingsListener).toBeGreaterThan(hookStart)
expect(daemon).toBeGreaterThan(settingsListener)
expect(runtime).toBeGreaterThan(daemon)
expect(source.slice(identitySubscription, hookStart)).not.toContain(
'if (isAgentStatusHooksEnabled('
)
expect(source.slice(hookStart, settingsListener)).toContain(
'statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings())'
)
expect(source.slice(settingsListener, daemon)).toContain(
'agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings))'
)
expect(identityFlush).toBeGreaterThan(runtime)
expect(source.slice(identitySubscription, runtime)).toContain(
'observedStatusCapture.observe(enriched)'
+1 -3
View File
@@ -171,9 +171,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect.
startAgentHookServer: async () => {
const settings = state.store?.getSettings()
if (!isAgentStatusHooksEnabled(settings)) {
return
}
logStartupMilestone('startup-service-start', { service: 'agent-hook-server' })
// Why (#11217): the hook listener fails open on every request error, so an IDS resetting
// loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness.
@@ -182,6 +179,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ
track('agent_hook_transport_blocked', { count: report.count })
})
await agentHookServer.start({
statusHooksEnabled: isAgentStatusHooksEnabled(settings),
env: app.isPackaged ? 'production' : 'development',
// Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`).
userDataPath: app.getPath('userData'),
@@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions'
import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle'
import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation'
import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier'
import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { reportProfileStateWriteFailure } from './profile-state-write-failure'
@@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise<void> {
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)
}
if ('agentStatusHooksEnabled' in updates) {
agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings))
// Why both directions: the ensure gate only blocks NEW relays, so off must stop the running
// guest process and timers, and on must restart them — otherwise open WSL panes report no
// status until their next spawn.
+40
View File
@@ -0,0 +1,40 @@
import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { mirrorEntry } from '../main/pty/overlay-mirror'
export function mirrorOpenCodeConfig(
sourceDir: string,
overlayDir: string,
excludedPluginEntries: ReadonlySet<string>
): void {
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = join(sourceDir, entry.name)
if (entry.name === 'plugins') {
const isSymlink = entry.isSymbolicLink()
let isLinkPointingToDir = false
if (isSymlink) {
try {
isLinkPointingToDir = statSync(sourcePath).isDirectory()
} catch {
isLinkPointingToDir = false
}
}
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
const overlayPluginsDir = join(overlayDir, 'plugins')
mkdirSync(overlayPluginsDir, { recursive: true })
for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
if (excludedPluginEntries.has(pluginEntry.name)) {
continue
}
mirrorEntry(
join(resolvedSource, pluginEntry.name),
join(overlayPluginsDir, pluginEntry.name)
)
}
continue
}
}
mirrorEntry(sourcePath, join(overlayDir, entry.name))
}
}
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PluginOverlayManager } from './plugin-overlay'
import { createInstallPluginsHandler } from './wsl-install-plugins-handler'
let root: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-'))
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
const promptPath = (config: string) =>
join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')
describe('execution-host startup prompt installation', () => {
it('caches prompt source independently and revokes future installs with an empty source', () => {
const manager = new PluginOverlayManager({ homeDir: root })
const config = join(root, 'custom')
manager.setSources({ opencodeStartupPromptSource: 'prompt source' })
expect(manager.hasOpenCodeSource()).toBe(false)
expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true)
manager.setSources({ opencodePluginSource: '' })
expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true)
expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source')
manager.setSources({ opencodeStartupPromptSource: '' })
expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false)
expect(existsSync(join(root, 'not-installed'))).toBe(false)
})
it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => {
const manager = new PluginOverlayManager({ homeDir: root })
const config = join(root, 'custom')
mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true })
writeFileSync(promptPath(config), 'user collision')
writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}')
manager.setSources({ opencodeStartupPromptSource: 'prompt source' })
const overlay = manager.materializeOpenCode('pane', config)
if (!overlay) {
throw new Error('Missing prompt overlay')
}
expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source')
expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision')
expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model')
expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false)
})
it('installs through WSL with both status sources disabled and preserves explicit config', () => {
const config = join(root, 'custom')
const manager = new PluginOverlayManager({ homeDir: root })
const install = createInstallPluginsHandler(manager, {
HOME: root,
OPENCODE_CONFIG_DIR: config
})
const result = install({
opencodeStartupPromptSource: 'first',
opencodePluginSource: '',
opencode2PluginSource: ''
})
expect(result.installed).toMatchObject({
opencodeStartupPrompt: true,
opencode: false,
opencode2: false
})
expect(result.overlayDirs).toEqual({})
expect(readFileSync(promptPath(config), 'utf8')).toBe('first')
install({ opencodeStartupPromptSource: 'second' })
expect(readFileSync(promptPath(config), 'utf8')).toBe('second')
})
it('refreshes prompt source in both cached status overlays without rebuilding them', () => {
const manager = new PluginOverlayManager({ homeDir: root })
const install = createInstallPluginsHandler(manager, { HOME: root })
const first = install({
opencodeStartupPromptSource: 'first',
opencodePluginSource: 'status v1',
opencode2PluginSource: 'status v2'
})
const second = install({ opencodeStartupPromptSource: 'second' })
expect(second.overlayDirs).toEqual(first.overlayDirs)
for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) {
if (!config) {
throw new Error('Missing status overlay')
}
expect(readFileSync(promptPath(config), 'utf8')).toBe('second')
}
})
})
+48 -48
View File
@@ -1,3 +1,9 @@
import { mirrorOpenCodeConfig } from './opencode-overlay-mirror'
import {
writeOpenCodeStartupPromptPlugin,
OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY
} from '../shared/opencode-startup-prompt-install'
import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory'
import { materializeOmpFreshConfig } from '../shared/omp-fresh-config'
// Why: relay-side equivalent of Orca's local agent integration installers.
// OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed
@@ -17,19 +23,11 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config'
// implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote
// Pi/OMP homes for those agents.
import { createHash } from 'node:crypto'
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
statSync,
writeFileSync
} from 'node:fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror'
import { safeRemoveOverlay } from '../main/pty/overlay-mirror'
import type { PiAgentKind } from '../shared/pi-agent-kind'
import {
installOpenCodePluginInCanonicalConfig,
@@ -52,9 +50,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record<LegacyOverlayAgentKind, string> = {
const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js'
// Orca's own entries (either major, file and TUI copy) are never mirrored from user config.
const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set(
[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)])
)
const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([
OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY,
...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)])
])
const PI_EXTENSION_FILE = 'orca-agent-status.ts'
const PI_AGENT_SUBDIR = 'agent'
const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension'
@@ -87,6 +86,7 @@ function isUsableId(id: string): boolean {
return typeof id === 'string' && id.length > 0 && id.length <= 1024
}
export type PluginSources = {
opencodeStartupPromptSource?: string
/** Empty string revokes future installs; omission preserves the cached source. */
opencodePluginSource?: string
/** Source body of OpenCode 2's status plugin. */
@@ -118,6 +118,7 @@ export function getRelayOpenCodePluginPath(
)
}
export class PluginOverlayManager {
private opencodeStartupPromptSource: string | null = null
private opencodePluginSource: string | null = null
private opencode2PluginSource: string | null = null
private piExtensionSources: Record<PiAgentKind, string | null> = {
@@ -147,6 +148,9 @@ export class PluginOverlayManager {
* process start. Future PTYs pick up the refreshed source when the relay
* writes plugin/extension files before spawn. */
setSources(sources: PluginSources): void {
if (typeof sources.opencodeStartupPromptSource === 'string') {
this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource
}
if (typeof sources.opencodePluginSource === 'string') {
this.opencodePluginSource = sources.opencodePluginSource
}
@@ -178,38 +182,6 @@ export class PluginOverlayManager {
const source = this.piExtensionSources[kind]
return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null)
}
private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void {
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = join(sourceDir, entry.name)
if (entry.name === 'plugins') {
const isSymlink = entry.isSymbolicLink()
let isLinkPointingToDir = false
if (isSymlink) {
try {
isLinkPointingToDir = statSync(sourcePath).isDirectory()
} catch {
isLinkPointingToDir = false
}
}
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
const overlayPluginsDir = join(overlayDir, 'plugins')
mkdirSync(overlayPluginsDir, { recursive: true })
for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) {
continue
}
mirrorEntry(
join(resolvedSource, pluginEntry.name),
join(overlayPluginsDir, pluginEntry.name)
)
}
continue
}
}
mirrorEntry(sourcePath, join(overlayDir, entry.name))
}
}
private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void {
const pluginsDir = join(overlayDir, 'plugins')
mkdirSync(pluginsDir, { recursive: true })
@@ -230,7 +202,7 @@ export class PluginOverlayManager {
agent: 'opencode' | 'opencode2' = 'opencode'
): string | null {
const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource
if (!source || !isUsableId(id)) {
if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) {
return null
}
const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE
@@ -246,9 +218,14 @@ export class PluginOverlayManager {
// Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's
// remote root into the overlay before adding Orca's plugin so status
// reporting does not hide their auth, models, keybinds, or plugins.
this.mirrorOpenCodeConfig(existingConfigDir, dir)
mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES)
}
if (source) {
this.writeOpenCodePlugin(dir, pluginFileName, source)
}
if (this.opencodeStartupPromptSource) {
writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay')
}
this.writeOpenCodePlugin(dir, pluginFileName, source)
return dir
} catch (err) {
process.stderr.write(
@@ -258,6 +235,29 @@ export class PluginOverlayManager {
}
}
installOpenCodeStartupPromptPlugin(
environment: NodeJS.ProcessEnv | Record<string, string>,
configDir?: string
): boolean {
if (!this.opencodeStartupPromptSource) {
return false
}
try {
writeOpenCodeStartupPromptPlugin(
configDir ??
environment.OPENCODE_CONFIG_DIR ??
resolveOpenCodeConfigDirectory(environment, this.homeDir),
this.opencodeStartupPromptSource
)
return true
} catch (error) {
process.stderr.write(
`[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n`
)
return false
}
}
hasOpenCode2Source(): boolean {
return this.hasOpenCodeSource('opencode2')
}
+17
View File
@@ -34,6 +34,13 @@ import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch'
import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces'
import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env'
import { addWslEnvKeys } from '../shared/wsl-env'
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'
import {
ORCA_IMAGE_PROTOCOL_ENV,
ORCA_IMAGE_PROTOCOL_VALUE
@@ -2043,6 +2050,16 @@ export class PtyHandler {
envToDelete
)
delete spawnEnv.ORCA_OPENCODE_PLUGIN_API
// Relay input streams lack driving-input provenance, so native intent is unavailable.
for (const key of [
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
]) {
delete spawnEnv[key]
}
const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({
command,
agent: launchAgent,
+7
View File
@@ -186,17 +186,20 @@ export class RelayAgentHookRuntime {
}))
registerManagedHookInstaller(this.dispatcher)
this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => {
const startupPrompt = params.opencodeStartupPromptSource
const opencode = params.opencodePluginSource
const opencode2 = params.opencode2PluginSource
const pi = params.piExtensionSource
const omp = params.ompExtensionSource
const primeAgent = params.primeAgentExtensionSource
assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt)
assertPluginSourceUnderByteCap('opencodePluginSource', opencode)
assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2)
assertPluginSourceUnderByteCap('piExtensionSource', pi)
assertPluginSourceUnderByteCap('ompExtensionSource', omp)
assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent)
this.pluginOverlay.setSources({
opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined,
opencodePluginSource: typeof opencode === 'string' ? opencode : undefined,
opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined,
piExtensionSource: typeof pi === 'string' ? pi : undefined,
@@ -213,8 +216,12 @@ export class RelayAgentHookRuntime {
installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true)
}
}
const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin(
process.env
)
return {
installed: {
opencodeStartupPrompt: startupPromptInstalled,
opencode: this.pluginOverlay.hasOpenCodeSource(),
opencode2: this.pluginOverlay.hasOpenCode2Source(),
pi: this.pluginOverlay.hasPiSource('pi'),
+24
View File
@@ -16,6 +16,7 @@ import {
export type InstallPluginsResult = {
installed: {
opencodeStartupPrompt?: boolean
opencode: boolean
opencode2?: boolean
pi: boolean
@@ -43,18 +44,21 @@ export function createInstallPluginsHandler(
let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null
return (params) => {
const startupPrompt = params.opencodeStartupPromptSource
const opencode = params.opencodePluginSource
const opencode2 = params.opencode2PluginSource
const pi = params.piExtensionSource
const omp = params.ompExtensionSource
const primeAgent = params.primeAgentExtensionSource
// Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay.
assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt)
assertPluginSourceUnderByteCap('opencodePluginSource', opencode)
assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2)
assertPluginSourceUnderByteCap('piExtensionSource', pi)
assertPluginSourceUnderByteCap('ompExtensionSource', omp)
assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent)
pluginOverlay.setSources({
opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined,
opencodePluginSource: typeof opencode === 'string' ? opencode : undefined,
opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined,
piExtensionSource: typeof pi === 'string' ? pi : undefined,
@@ -135,8 +139,28 @@ export function createInstallPluginsHandler(
}
}
}
const promptConfigDirs = [opencodeDir, opencode2Dir].filter(
(dir): dir is string => typeof dir === 'string'
)
if (promptConfigDirs.length === 0) {
promptConfigDirs.push(
resolveOpenCodeSourceConfigDir(
Object.fromEntries(
Object.entries(env).flatMap(([key, value]) =>
typeof value === 'string' ? [[key, value]] : []
)
),
env.SHELL
) ?? resolveOpenCodeConfigDirectory(env, env.HOME)
)
}
const promptInstallResults = promptConfigDirs.map((dir) =>
pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir)
)
const startupPromptInstalled = promptInstallResults.every(Boolean)
return {
installed: {
opencodeStartupPrompt: startupPromptInstalled,
opencode: pluginOverlay.hasOpenCodeSource(),
opencode2: pluginOverlay.hasOpenCode2Source(),
pi: pluginOverlay.hasPiSource('pi'),
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
import { tokenizeCommandLine } from './agent-command-line-entrypoint'
import { isOpenCodeRunCommand } from './opencode-headless-command'
@@ -24,3 +25,66 @@ describe('isOpenCodeRunCommand', () => {
expect(matches('opencode --log-level run')).toBe(false)
})
})
describe('wrapped OpenCode run command position', () => {
it.each([
'CUSTOM_CONFIG=private opencode --log-level debug run --standalone',
'env CUSTOM_CONFIG=private opencode run --standalone',
'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone'
])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => {
expect(matches(command)).toBe(true)
})
it('recognizes a PowerShell call operator before a quoted executable', () => {
const parsed = tokenizeStartupCommand(
'& "C:\\Program Files\\opencode\\opencode.exe" run --standalone',
'powershell'
)
expect(parsed.ok).toBe(true)
if (!parsed.ok) {
throw new Error(parsed.error)
}
expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true)
expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false)
})
it.each([
'env -u FOO opencode run',
'env -uFOO opencode run',
'env --unset FOO opencode run',
'env --unset=FOO opencode run',
'env -i PRIVATE_CONFIG=kept opencode run',
'env --ignore-environment PRIVATE_CONFIG=kept opencode run',
'env - PRIVATE_CONFIG=kept opencode run',
'env -C /workspace opencode run',
'env -C/workspace opencode run',
'env --chdir /workspace opencode run',
'env --chdir=/workspace opencode run',
'env -P /private/bin opencode run',
'env -P/private/bin opencode run',
'CONFIG=kept /usr/bin/env -i -u FOO -C /workspace -- OTHER=kept opencode run'
])('recognizes an executable after env options: %s', (command) => {
expect(matches(command)).toBe(true)
})
it.each([
'env -u',
'env -C',
'env -u opencode run',
'env -C opencode run',
'env -u FOO echo opencode run',
'env --unset=FOO echo run',
'env -S "opencode run"',
'env --unknown opencode run'
])('does not mistake env option arguments for the executable: %s', (command) => {
expect(matches(command)).toBe(false)
})
it('does not find executable names inside other commands or prompt arguments', () => {
expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false)
expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false)
expect(matches('CUSTOM_CONFIG=private opencode --prompt "opencode run"')).toBe(false)
expect(matches('env -- opencode serve --title run')).toBe(false)
expect(matches('opencode attach http://host/run')).toBe(false)
})
})
+97 -8
View File
@@ -1,16 +1,105 @@
// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn.
// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity,
// whose in-process plugin reports it. Only `--log-level` takes a separate value before the
// subcommand; any other valued option makes the value the first positional, which fails safe.
export function isOpenCodeRunCommand(tokens: readonly string[]): boolean {
for (let index = 1; index < tokens.length; index += 1) {
import { extractLeadingEnvAssignments } from './command-environment'
import { getCommandTokenPathBasename } from './command-token-scanner'
import type { AgentStartupShell } from './tui-agent-startup-shell'
const RUN_VALUE_FLAGS = new Set([
'--log-level',
'--completions',
'--server',
'--session',
'-s',
'--model',
'-m',
'--agent',
'--format',
'--file',
'-f',
'--title'
])
const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-P'])
const ENV_EMPTY_FLAGS = new Set(['-i', '--ignore-environment', '-'])
function envCommandPosition(tokens: readonly string[], offset: number): number {
let index = offset
while (index < tokens.length) {
const token = tokens[index]
if (token === '--') {
index += 1
break
}
if (ENV_EMPTY_FLAGS.has(token)) {
index += 1
} else if (ENV_VALUE_FLAGS.has(token)) {
index += 2
} else if (/^(?:-[uCP].+|--(?:unset|chdir)=)/.test(token)) {
index += 1
} else if (token.startsWith('-')) {
// Split-string options need another parse before their executable is known.
return tokens.length
} else {
break
}
}
return tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length
}
function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number {
if (shell === 'powershell' && tokens[0] === '&') {
return 1
}
if (shell !== 'posix') {
return 0
}
let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length
if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') {
index = envCommandPosition(tokens, index + 1)
}
return index
}
export function findOpenCodeRunCommand(
tokens: readonly string[],
shell: AgentStartupShell = 'posix'
): { runIndex: number; messageSeparatorIndex: number | null } | null {
const commandPosition = openCodeCommandPosition(tokens, shell)
if (commandPosition > 0) {
const binary = getCommandTokenPathBasename(tokens[commandPosition] ?? '')
.toLowerCase()
.replace(/\.(?:exe|cmd)$/, '')
if (binary !== 'opencode' && binary !== 'opencode2') {
return null
}
}
for (let index = commandPosition + 1; index < tokens.length; index += 1) {
const token = tokens[index]
if (!token.startsWith('-')) {
return token === 'run'
if (token !== 'run') {
return null
}
for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex += 1) {
const argument = tokens[argumentIndex]
if (argument === '--') {
return { runIndex: index, messageSeparatorIndex: argumentIndex }
}
if (RUN_VALUE_FLAGS.has(argument)) {
argumentIndex += 1
}
}
return { runIndex: index, messageSeparatorIndex: null }
}
// Other valued global options fail closed at their first positional value.
if (token === '--log-level') {
index += 1
}
}
return false
return null
}
// OpenCode run's process lifetime is its turn; v1 still reports through its plugin.
export function isOpenCodeRunCommand(
tokens: readonly string[],
shell: AgentStartupShell = 'posix'
): boolean {
return findOpenCodeRunCommand(tokens, shell) !== null
}
@@ -0,0 +1,17 @@
import { join } from 'node:path'
import { writeOpenCodeTuiPluginDirectory } from './opencode-tui-plugin-install'
export const OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY = 'orca-opencode-startup-prompt'
export function writeOpenCodeStartupPromptPlugin(
configDir: string,
source: string,
ownership: 'canonical' | 'overlay' = 'canonical'
): void {
writeOpenCodeTuiPluginDirectory(
join(configDir, 'plugins'),
OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY,
source,
ownership
)
}
@@ -0,0 +1,52 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup'
import {
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
OPENCODE_STARTUP_PROMPT_BODY_ENV,
OPENCODE_STARTUP_PROMPT_SHELL_ENV
} from './opencode-startup-prompt'
describe('native OpenCode startup submission intent', () => {
it('binds the exact trimmed native prompt to host-selectable transport', () => {
const plan = buildAgentStartupPlan({
agent: 'opencode',
prompt: ' task\nwith unicode é ',
cmdOverrides: {},
platform: 'linux'
})
expect(plan?.env).toEqual({
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256')
.update('task\nwith unicode é')
.digest('hex'),
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task\nwith unicode é',
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix'
})
expect(plan?.launchCommand).toContain('--prompt')
})
it('preserves explicit run commands without a submission intent', () => {
const plan = buildAgentStartupPlan({
agent: 'opencode',
prompt: 'task',
cmdOverrides: { opencode: 'opencode --log-level debug run' },
platform: 'linux'
})
expect(plan?.env).toBeUndefined()
expect(plan?.launchCommand).toContain('run --prompt')
})
it('never gives an editable draft or empty launch an automatic submission intent', () => {
const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const }
expect(
buildAgentDraftLaunchPlan({ ...args, draft: 'draft' })?.env?.[
OPENCODE_STARTUP_PROMPT_SHA256_ENV
]
).toBeUndefined()
expect(
buildAgentStartupPlan({ ...args, prompt: '', allowEmptyPromptLaunch: true })?.env?.[
OPENCODE_STARTUP_PROMPT_SHA256_ENV
]
).toBeUndefined()
})
})
+39
View File
@@ -0,0 +1,39 @@
import { isOpenCodeRunCommand } from './opencode-headless-command'
import { sha256 } from './sha256'
import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell'
import type { TuiAgent } from './tui-agent'
export const OPENCODE_STARTUP_PROMPT_SHA256_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256'
export const OPENCODE_STARTUP_PROMPT_NONCE_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE'
export const OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT'
export const OPENCODE_STARTUP_PROMPT_CLAIM_PATH = '/opencode/startup-prompt/claim'
export const OPENCODE_STARTUP_PROMPT_BODY_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_BODY'
export const OPENCODE_STARTUP_PROMPT_SHELL_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL'
export function openCodeStartupPromptEnv(
agent: TuiAgent,
command: string,
shell: AgentStartupShell,
prompt: string,
env: Record<string, string> | null | undefined
): { env?: Record<string, string> } {
const parsed = tokenizeStartupCommand(command, shell)
if (
(agent !== 'opencode' && agent !== 'opencode2') ||
!parsed.ok ||
isOpenCodeRunCommand(parsed.tokens)
) {
return env ? { env: { ...env } } : {}
}
const digest = Array.from(sha256(new TextEncoder().encode(prompt)), (byte) =>
byte.toString(16).padStart(2, '0')
).join('')
return {
env: {
...env,
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: digest,
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: prompt,
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: shell
}
}
}
+16 -2
View File
@@ -32,13 +32,27 @@ export function writeOpenCodeTuiPlugin(
source: string,
ownership: 'canonical' | 'overlay' = 'canonical'
): void {
const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName))
writeOpenCodeTuiPluginDirectory(
pluginsDir,
openCodeTuiPluginDirName(pluginFileName),
source,
ownership
)
}
export function writeOpenCodeTuiPluginDirectory(
pluginsDir: string,
directoryName: string,
source: string,
ownership: 'canonical' | 'overlay' = 'canonical'
): void {
const dir = join(pluginsDir, directoryName)
const entry = join(dir, 'tui.js')
// The 1.x TUI loader rejects a default object that also exposes server().
const tuiSource =
source.includes('const ORCA_STATUS_AGENT = "opencode";') &&
source.includes('async function setupLegacyOpenCodeTui(')
? `${source.replace(/^export default /m, 'const orcaServerPlugin = ')}\nconst { server: _orcaServerOnly, ...orcaTuiPlugin } = orcaServerPlugin;\nexport default { id: ${JSON.stringify(pluginFileName.replace(/\.js$/, ''))}, setup: setupOpenCode2Status, ...orcaTuiPlugin, tui: setupLegacyOpenCodeTui };\n`
? `${source.replace(/^export default /m, 'const orcaServerPlugin = ')}\nconst { server: _orcaServerOnly, ...orcaTuiPlugin } = orcaServerPlugin;\nexport default { id: ${JSON.stringify(directoryName.replace(/-tui$/, ''))}, setup: setupOpenCode2Status, ...orcaTuiPlugin, tui: setupLegacyOpenCodeTui };\n`
: source
const isCurrent =
ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent
+2 -1
View File
@@ -17,6 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit'
import type { TuiAgent } from './tui-agent'
import type { SessionOptionValue } from './native-chat-session-options'
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
import { openCodeStartupPromptEnv } from './opencode-startup-prompt'
export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup'
@@ -123,7 +124,7 @@ export function buildAgentStartupPlan(args: {
followupPrompt: null,
launchConfig,
...appliedSessionOptionProps(baseCommand.appliedSessionOptions),
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv)
}
}