mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
co-authored by
Brennan Benson
Ahmed Nagy
Orca startup hydration review
Orca
parent
58bd15fa3f
commit
e8310d5a4f
@@ -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()
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user