mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix(structured-chat): prove a resumed Codex TUI off-screen
Returning a Codex session from structured chat to its terminal always failed with "did not prove the expected Codex rollout". Adoption stops the pane's Codex, so `launchAdoptedStructuredTui` typed a resume command into a bare shell and went straight to the proof, which pasted `/status` and scraped the tail for 15s. The probe landed before Codex was up, and it read kittyKeyboardFlags from the mode tracker before the new process negotiated, submitting a bare CR. A `tui-idle` wait would not fix it: that resolves on a shell prompt preview and on the pre-adoption `lastAgentStatus`. Nothing on screen separates a ready shell from a ready Codex. The resumed CLI can answer for itself. Orca already installs Codex SessionStart hooks, and the payload carries session_id, transcript_path and source=resume. The hook now also echoes back a one-shot nonce Orca stamps into the single resume invocation it types, so a stale SessionStart from an earlier process in the same pane cannot pass for the new one. The nonce is a new env var rather than `launchToken`, which is pane-lifetime identity that authority attestation matches against. Nothing is pasted at the pane any more, so the submit-key question disappears with the probe. An installed hook script predating this omits the nonce; pane + thread still bind, so that degrades instead of stranding the path. With agent status hooks off there is no proof to wait for, and the launch says so up front.
This commit is contained in:
@@ -177,7 +177,10 @@ export function buildWindowsAgentHookPostCommand(
|
||||
}
|
||||
|
||||
// Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both.
|
||||
export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string {
|
||||
export function buildWindowsAgentHookCurlPostCommand(
|
||||
source: AgentHookSource,
|
||||
extraFormLines: readonly string[] = []
|
||||
): string {
|
||||
return [
|
||||
'"%SystemRoot%\\System32\\curl.exe" -sS -X POST',
|
||||
`"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}"`,
|
||||
@@ -190,6 +193,7 @@ export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): s
|
||||
'--data-urlencode "worktreeId=%ORCA_WORKTREE_ID%"',
|
||||
'--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"',
|
||||
'--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"',
|
||||
...extraFormLines,
|
||||
'--data-urlencode "payload@-"',
|
||||
'>nul 2>&1'
|
||||
].join(' ')
|
||||
|
||||
@@ -16,6 +16,7 @@ export const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' }
|
||||
export type Body = {
|
||||
paneKey: string
|
||||
launchToken?: string
|
||||
sessionNonce?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
env?: string
|
||||
|
||||
@@ -103,7 +103,11 @@ type NormalizedLocalHook = {
|
||||
|
||||
type PersistedAgentHookEventPayload = Omit<
|
||||
EnrichedAgentHookEventPayload,
|
||||
'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed'
|
||||
| 'claudeRunningNonAgentTask'
|
||||
| 'launchToken'
|
||||
| 'promptInteractionKey'
|
||||
| 'restoredUnconfirmed'
|
||||
| 'sessionNonce'
|
||||
> & {
|
||||
launchTokenHash?: string
|
||||
}
|
||||
@@ -2824,6 +2828,8 @@ export class AgentHookServer {
|
||||
promptInteractionKey: _promptInteractionKey,
|
||||
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
|
||||
restoredUnconfirmed: _restoredUnconfirmed,
|
||||
// Why: a one-shot resume nonce is meaningless after the process it named exits.
|
||||
sessionNonce: _sessionNonce,
|
||||
launchToken,
|
||||
...persistedPayload
|
||||
} = payload as EnrichedAgentHookEventPayload
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Off-screen readiness proof for a Codex TUI resumed inside a pane Orca already
|
||||
// owns. Adoption stops the pane's Codex and types a `codex resume` line into the
|
||||
// bare shell that is left; nothing on screen can tell that shell apart from a
|
||||
// Codex that has finished starting. The CLI's own SessionStart hook can: it is
|
||||
// emitted by the resumed process itself, carries the conversation it reopened,
|
||||
// and reaches Orca over the hook endpoint instead of the terminal buffer.
|
||||
import type { AgentHookEventPayload } from '../../shared/agent-hook-listener'
|
||||
|
||||
export type AdoptedCodexReadinessEvent = Pick<
|
||||
AgentHookEventPayload,
|
||||
'paneKey' | 'source' | 'hookEventName' | 'isReplay' | 'sessionNonce' | 'providerSession'
|
||||
>
|
||||
|
||||
export type AdoptedCodexReadinessVerdict = 'ignore' | 'ready' | 'wrong-session'
|
||||
|
||||
export type AdoptedCodexReadinessTarget = {
|
||||
paneKey: string
|
||||
threadId: string
|
||||
sessionNonce: string
|
||||
}
|
||||
|
||||
export const ADOPTED_CODEX_READINESS_TIMEOUT_MS = 30_000
|
||||
|
||||
export function classifyAdoptedCodexReadinessEvent(
|
||||
event: AdoptedCodexReadinessEvent,
|
||||
target: AdoptedCodexReadinessTarget
|
||||
): AdoptedCodexReadinessVerdict {
|
||||
if (
|
||||
event.source !== 'codex' ||
|
||||
event.hookEventName !== 'SessionStart' ||
|
||||
event.isReplay === true ||
|
||||
event.paneKey !== target.paneKey
|
||||
) {
|
||||
return 'ignore'
|
||||
}
|
||||
// A hook script installed by an older Orca omits the nonce entirely. Pane + thread
|
||||
// still bind it, so degrade rather than strand the return path — but a nonce that
|
||||
// IS present and disagrees names a different invocation, which is the stale
|
||||
// SessionStart this gate exists to reject.
|
||||
if (event.sessionNonce !== undefined && event.sessionNonce !== target.sessionNonce) {
|
||||
return 'ignore'
|
||||
}
|
||||
const observedThreadId = event.providerSession?.id
|
||||
if (!observedThreadId) {
|
||||
return 'ignore'
|
||||
}
|
||||
if (observedThreadId === target.threadId) {
|
||||
return 'ready'
|
||||
}
|
||||
// Only our own nonce makes a mismatch conclusive; without one this could be any
|
||||
// Codex sharing the pane's env.
|
||||
return event.sessionNonce === target.sessionNonce ? 'wrong-session' : 'ignore'
|
||||
}
|
||||
|
||||
export type AdoptedCodexReadinessWatch = {
|
||||
/** Resolves when the resumed Codex proves itself; rejects on timeout or a wrong thread. */
|
||||
readonly settled: Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts listening BEFORE the resume command is written — a fast Codex can emit
|
||||
* SessionStart before the write call even returns.
|
||||
*/
|
||||
export function beginAdoptedCodexReadinessWatch(input: {
|
||||
subscribe: (listener: (event: AdoptedCodexReadinessEvent) => void) => () => void
|
||||
target: AdoptedCodexReadinessTarget
|
||||
timeoutMs?: number
|
||||
setTimer?: (callback: () => void, ms: number) => unknown
|
||||
clearTimer?: (timer: unknown) => void
|
||||
}): AdoptedCodexReadinessWatch {
|
||||
const setTimer = input.setTimer ?? ((callback, ms) => setTimeout(callback, ms))
|
||||
const clearTimer = input.clearTimer ?? ((timer) => clearTimeout(timer as never))
|
||||
let finish: ((error: Error | null) => void) | null = null
|
||||
const settled = new Promise<void>((resolve, reject) => {
|
||||
finish = (error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
})
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let timer: unknown = null
|
||||
const settle = (error: Error | null): void => {
|
||||
if (!finish) {
|
||||
return
|
||||
}
|
||||
const complete = finish
|
||||
finish = null
|
||||
if (timer !== null) {
|
||||
clearTimer(timer)
|
||||
timer = null
|
||||
}
|
||||
unsubscribe?.()
|
||||
unsubscribe = null
|
||||
complete(error)
|
||||
}
|
||||
unsubscribe = input.subscribe((event) => {
|
||||
const verdict = classifyAdoptedCodexReadinessEvent(event, input.target)
|
||||
if (verdict === 'ready') {
|
||||
settle(null)
|
||||
} else if (verdict === 'wrong-session') {
|
||||
settle(new Error('The agent terminal resumed a different Codex session.'))
|
||||
}
|
||||
})
|
||||
timer = setTimer(() => {
|
||||
settle(new Error('The agent terminal did not report the resumed Codex session.'))
|
||||
}, input.timeoutMs ?? ADOPTED_CODEX_READINESS_TIMEOUT_MS)
|
||||
return {
|
||||
settled,
|
||||
dispose: () => settle(new Error('The agent terminal stopped resuming Codex.'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce'
|
||||
import { AgentHookServer, _internals } from '../agent-hooks/server'
|
||||
import { buildBody, PANE } from '../agent-hooks/server.test-fixtures'
|
||||
import { classifyAdoptedCodexReadinessEvent } from './adopted-codex-tui-readiness'
|
||||
import { getCodexManagedHookInstallMaterial } from './hook-service'
|
||||
|
||||
describe('Codex session-nonce hook wire', () => {
|
||||
it('posts the nonce env var back as a form field', () => {
|
||||
const { script } = getCodexManagedHookInstallMaterial()
|
||||
expect(script).toContain(`sessionNonce=`)
|
||||
expect(script).toContain(AGENT_HOOK_SESSION_NONCE_ENV_VAR)
|
||||
})
|
||||
|
||||
it('keeps the nonce post fire-and-forget: no extra request, no extra wait', () => {
|
||||
const { script } = getCodexManagedHookInstallMaterial()
|
||||
// Why this is asserted at all: Codex blocks synchronously on hooks, so a second
|
||||
// POST or a longer budget for the nonce would stall every resume by seconds.
|
||||
expect(script.match(/-X POST/g)).toHaveLength(1)
|
||||
expect(script).not.toMatch(/--max-time "?[3-9]/)
|
||||
})
|
||||
|
||||
it('carries the nonce from the form body onto the normalized event', () => {
|
||||
const event = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody(
|
||||
{ hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' },
|
||||
{ sessionNonce: 'nonce-1' }
|
||||
),
|
||||
'production'
|
||||
)
|
||||
expect(event?.sessionNonce).toBe('nonce-1')
|
||||
expect(event?.hookEventName).toBe('SessionStart')
|
||||
expect(event?.providerSession?.id).toBe('thread-1')
|
||||
})
|
||||
|
||||
it('delivers the nonce over the real hook endpoint to enriched-status subscribers', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
const seen: unknown[] = []
|
||||
const unsubscribe = server.subscribeEnrichedStatus((event) => seen.push(event))
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildBody(
|
||||
{ hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' },
|
||||
{ sessionNonce: 'nonce-1' }
|
||||
)
|
||||
)
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
} finally {
|
||||
unsubscribe()
|
||||
server.stop()
|
||||
}
|
||||
// The seam the runtime's readiness watch actually sits on: a SessionStart that
|
||||
// travelled the wire end to end must classify as proof.
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(
|
||||
classifyAdoptedCodexReadinessEvent(seen[0] as never, {
|
||||
paneKey: PANE,
|
||||
threadId: 'thread-1',
|
||||
sessionNonce: 'nonce-1'
|
||||
})
|
||||
).toBe('ready')
|
||||
})
|
||||
|
||||
it('leaves the nonce undefined when an older installed script omits it', () => {
|
||||
const event = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({ hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' }),
|
||||
'production'
|
||||
)
|
||||
expect(event?.sessionNonce).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
snapshotCodexRuntimeHookTrustProvenance
|
||||
} from './hook-trust-promotion'
|
||||
import { grantManagedCodexHookTrust } from './codex-hook-trust-grant'
|
||||
import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce'
|
||||
import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host'
|
||||
import {
|
||||
getCodexLedgerTrustedHash,
|
||||
@@ -140,6 +141,8 @@ type MirroredRuntimeUserHookTrustEntry = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const CODEX_SESSION_NONCE_FORM_LINE_WINDOWS = `--data-urlencode "sessionNonce=%${AGENT_HOOK_SESSION_NONCE_ENV_VAR}%"`
|
||||
|
||||
function getManagedScriptPath(): string {
|
||||
return getSharedManagedScriptPath(getCodexManagedScriptFileName())
|
||||
}
|
||||
@@ -772,7 +775,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// Why: the endpoint file holds this install's live port/token; sourcing it lets a surviving PTY reach the current server (see claude/hook-service.ts).
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookCurlPostCommand('codex'),
|
||||
buildWindowsAgentHookCurlPostCommand('codex', [CODEX_SESSION_NONCE_FORM_LINE_WINDOWS]),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
@@ -824,6 +827,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
` --data-urlencode "sessionNonce=\${${AGENT_HOOK_SESSION_NONCE_ENV_VAR}}" \\`,
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
|
||||
@@ -2590,6 +2590,9 @@ void app.whenReady().then(async () => {
|
||||
agentHookServer.attestCompatibilityAuthority(candidate),
|
||||
retireAgentHookCompatibilityAuthority: (paneKey) =>
|
||||
agentHookServer.retirePaneAuthority(paneKey),
|
||||
// Why: the adopted-Codex return path proves readiness from the resumed CLI's own
|
||||
// SessionStart hook rather than the pane's screen, so it needs a live event tap.
|
||||
subscribeAgentHookEvents: (listener) => agentHookServer.subscribeEnrichedStatus(listener),
|
||||
canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null,
|
||||
// Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only.
|
||||
getAdditionalAiVaultCodexHomePaths: () =>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce'
|
||||
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
|
||||
import type { AdoptedCodexReadinessEvent } from '../codex/adopted-codex-tui-readiness'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const { proveCodexTuiRollout, readStructuredTuiProcessIdentity, resolvePinnedCodexRolloutProof } =
|
||||
vi.hoisted(() => ({
|
||||
proveCodexTuiRollout: vi.fn(),
|
||||
readStructuredTuiProcessIdentity: vi.fn(),
|
||||
resolvePinnedCodexRolloutProof: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity }))
|
||||
vi.mock('../codex/codex-tui-rollout-proof', () => ({
|
||||
proveCodexTuiRollout,
|
||||
resolveLiveCodexTuiRollout: vi.fn(),
|
||||
resolvePinnedCodexRolloutProof
|
||||
}))
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/tmp/adopted-readiness'
|
||||
const PANE_KEY = 'tab-adopt:leaf-adopt'
|
||||
const THREAD_ID = 'thread-adopt'
|
||||
const SESSION_ID = 'session-adopt'
|
||||
const TRANSCRIPT = '/tmp/codex-home/rollout.jsonl'
|
||||
|
||||
/** The tail a bare shell leaves behind once adoption has stopped the pane's Codex. */
|
||||
const READY_SHELL_TAIL = ['dev@host ~/repo %']
|
||||
|
||||
function returnToTerminalHarness(settingsOverrides: Record<string, unknown> = {}) {
|
||||
const listeners = new Set<(event: AdoptedCodexReadinessEvent) => void>()
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{
|
||||
getSettings: () => ({
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: {},
|
||||
agentDefaultEnv: {},
|
||||
...settingsOverrides
|
||||
})
|
||||
} as never,
|
||||
undefined,
|
||||
{
|
||||
subscribeAgentHookEvents: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
)
|
||||
const writeAgentSessionProof = vi.fn(() => true)
|
||||
runtime.setPtyController({
|
||||
writeAgentSessionProof,
|
||||
listProcesses: async () => [
|
||||
{ id: 'pty-adopt', incarnationId: 'inc-adopt', rootProcessId: 31337 }
|
||||
]
|
||||
} as never)
|
||||
|
||||
const internal = runtime as unknown as {
|
||||
ptysById: Map<string, unknown>
|
||||
adoptedStructuredTuiOwners: Map<string, unknown>
|
||||
resolveTerminalWorkspaceLaunchScope(): Promise<unknown>
|
||||
createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport
|
||||
}
|
||||
internal.ptysById.set('pty-adopt', {
|
||||
ptyId: 'pty-adopt',
|
||||
connected: true,
|
||||
connectionId: null,
|
||||
wslDistro: null,
|
||||
tabId: 'tab-adopt',
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: WORKTREE_ID,
|
||||
incarnationId: 'inc-adopt',
|
||||
// Decoys: exactly the two signals a `tui-idle` wait would settle on.
|
||||
lastAgentStatus: 'idle',
|
||||
tailBuffer: READY_SHELL_TAIL,
|
||||
tailPartialLine: '',
|
||||
preview: READY_SHELL_TAIL.join('\n'),
|
||||
lastOutputAt: 1_700_000_000_000
|
||||
})
|
||||
internal.adoptedStructuredTuiOwners.set(SESSION_ID, {
|
||||
terminal: {
|
||||
handle: 'term-adopt',
|
||||
tabId: 'tab-adopt',
|
||||
paneKey: PANE_KEY,
|
||||
ptyId: 'pty-adopt'
|
||||
},
|
||||
process: { hostId: 'local', pid: 4242, processStartTimeMs: 10, spawnToken: 'spawn-adopt' },
|
||||
link: {
|
||||
linkId: 'link-adopt',
|
||||
handle: { provider: 'codex', threadId: THREAD_ID },
|
||||
origin: 'adopted',
|
||||
mintedAtFence: 1,
|
||||
observedAt: 1
|
||||
},
|
||||
historySource: 'provider-resume',
|
||||
adoptedTerminal: true
|
||||
})
|
||||
internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({
|
||||
id: WORKTREE_ID,
|
||||
path: '/tmp/adopted-readiness',
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
}))
|
||||
|
||||
readStructuredTuiProcessIdentity.mockResolvedValue({
|
||||
hostId: 'local',
|
||||
pid: 5252,
|
||||
processStartTimeMs: 20,
|
||||
spawnToken: 'spawn-adopt'
|
||||
})
|
||||
resolvePinnedCodexRolloutProof.mockResolvedValue(TRANSCRIPT)
|
||||
|
||||
const launch = (): Promise<{ transcriptPath?: string }> =>
|
||||
internal.createStructuredAgentSessionHandoffTransport().launchTui({
|
||||
record: {
|
||||
sessionId: SESSION_ID,
|
||||
location: { workspaceId: WORKTREE_ID, executionHostId: 'local' },
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
|
||||
options: null,
|
||||
providerHandleChain: [{ handle: { provider: 'codex', threadId: THREAD_ID }, observedAt: 1 }]
|
||||
} as never,
|
||||
fence: 3,
|
||||
spawnToken: 'spawn-adopt'
|
||||
}) as Promise<{ transcriptPath?: string }>
|
||||
|
||||
const writtenNonce = (): string => {
|
||||
const written = writeAgentSessionProof.mock.calls
|
||||
.map((call) => (call as unknown as [string, string])[1])
|
||||
.join('')
|
||||
const match = new RegExp(`${AGENT_HOOK_SESSION_NONCE_ENV_VAR}[=:']*([0-9a-f-]{36})`).exec(
|
||||
written
|
||||
)
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`no session nonce in written command: ${written}`)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
const emit = (event: Partial<AdoptedCodexReadinessEvent>): void => {
|
||||
const full = {
|
||||
paneKey: PANE_KEY,
|
||||
source: 'codex',
|
||||
hookEventName: 'SessionStart',
|
||||
providerSession: { key: 'session_id', id: THREAD_ID },
|
||||
...event
|
||||
} as AdoptedCodexReadinessEvent
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener(full)
|
||||
}
|
||||
}
|
||||
|
||||
return { runtime, launch, emit, writtenNonce, writeAgentSessionProof }
|
||||
}
|
||||
|
||||
/** Resolves to `'pending'` unless `promise` settles first. */
|
||||
async function settlesBefore<T>(promise: Promise<T>): Promise<T | 'pending'> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<'pending'>((resolve) => {
|
||||
setTimeout(() => resolve('pending'), 0)
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
describe('returning an adopted Codex session to its terminal', () => {
|
||||
it('does not accept a ready shell as the resumed Codex, only its SessionStart hook', async () => {
|
||||
const rig = returnToTerminalHarness()
|
||||
const owner = rig.launch()
|
||||
const stillWaiting = owner.then(
|
||||
() => 'resolved' as const,
|
||||
() => 'rejected' as const
|
||||
)
|
||||
|
||||
// The pane now looks exactly like a settled agent terminal: an idle status
|
||||
// left over from the Codex adoption stopped, and a shell prompt on screen.
|
||||
expect(await settlesBefore(stillWaiting)).toBe('pending')
|
||||
expect(proveCodexTuiRollout).not.toHaveBeenCalled()
|
||||
|
||||
rig.emit({ sessionNonce: rig.writtenNonce() })
|
||||
await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT })
|
||||
})
|
||||
|
||||
it('stamps a one-shot nonce into the resume command and ignores a SessionStart without it', async () => {
|
||||
const rig = returnToTerminalHarness()
|
||||
const owner = rig.launch()
|
||||
const stillWaiting = owner.then(
|
||||
() => 'resolved' as const,
|
||||
() => 'rejected' as const
|
||||
)
|
||||
await settlesBefore(stillWaiting)
|
||||
const nonce = rig.writtenNonce()
|
||||
|
||||
// A SessionStart from an earlier Codex in this pane: same pane, same thread,
|
||||
// a nonce Orca minted for some other invocation.
|
||||
rig.emit({ sessionNonce: `stale-${nonce}` })
|
||||
expect(await settlesBefore(stillWaiting)).toBe('pending')
|
||||
|
||||
rig.emit({ sessionNonce: nonce })
|
||||
await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT })
|
||||
})
|
||||
|
||||
it('ignores non-SessionStart codex hooks and replayed ones', async () => {
|
||||
const rig = returnToTerminalHarness()
|
||||
const owner = rig.launch()
|
||||
const stillWaiting = owner.then(
|
||||
() => 'resolved' as const,
|
||||
() => 'rejected' as const
|
||||
)
|
||||
await settlesBefore(stillWaiting)
|
||||
const sessionNonce = rig.writtenNonce()
|
||||
|
||||
rig.emit({ sessionNonce, hookEventName: 'Stop' })
|
||||
rig.emit({ sessionNonce, hookEventName: 'UserPromptSubmit' })
|
||||
rig.emit({ sessionNonce, isReplay: true })
|
||||
rig.emit({ sessionNonce, source: 'claude' })
|
||||
rig.emit({ sessionNonce, paneKey: 'tab-other:leaf-other' })
|
||||
expect(await settlesBefore(stillWaiting)).toBe('pending')
|
||||
|
||||
rig.emit({ sessionNonce })
|
||||
await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT })
|
||||
})
|
||||
|
||||
it('rejects when the nonce Orca minted comes back bound to another conversation', async () => {
|
||||
const rig = returnToTerminalHarness()
|
||||
const owner = rig.launch()
|
||||
const settled = owner.catch((error: Error) => error.message)
|
||||
await settlesBefore(settled)
|
||||
|
||||
rig.emit({
|
||||
sessionNonce: rig.writtenNonce(),
|
||||
providerSession: { key: 'session_id', id: 'thread-other' }
|
||||
})
|
||||
await expect(settled).resolves.toContain('resumed a different Codex session')
|
||||
})
|
||||
|
||||
it('says why it cannot confirm the session when agent status hooks are off', async () => {
|
||||
const rig = returnToTerminalHarness({ agentStatusHooksEnabled: false })
|
||||
await expect(rig.launch()).rejects.toThrow('Agent status hooks are off')
|
||||
// Nothing may be typed at the pane when the proof can never arrive.
|
||||
expect(rig.writeAgentSessionProof).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never types a screen probe at the pane', async () => {
|
||||
const rig = returnToTerminalHarness()
|
||||
const owner = rig.launch()
|
||||
const stillWaiting = owner.then(
|
||||
() => 'resolved' as const,
|
||||
() => 'rejected' as const
|
||||
)
|
||||
await settlesBefore(stillWaiting)
|
||||
rig.emit({ sessionNonce: rig.writtenNonce() })
|
||||
await owner
|
||||
|
||||
const written = rig.writeAgentSessionProof.mock.calls.map(
|
||||
(call) => (call as unknown as [string, string])[1]
|
||||
)
|
||||
expect(written).toHaveLength(1)
|
||||
expect(written[0]).toContain('codex')
|
||||
expect(written[0]).not.toContain('/status')
|
||||
// Bracketed paste and the bare/kitty submit keys the old proof sent.
|
||||
expect(written[0]).not.toContain('[200~')
|
||||
expect(written[0]).not.toContain('[13u')
|
||||
expect(proveCodexTuiRollout).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -99,6 +99,13 @@ import {
|
||||
scopedAgentSessionClaimsEqual
|
||||
} from '../../shared/claimed-agent-pty-owner-snapshot'
|
||||
import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity'
|
||||
import {
|
||||
beginAdoptedCodexReadinessWatch,
|
||||
type AdoptedCodexReadinessEvent,
|
||||
type AdoptedCodexReadinessWatch
|
||||
} from '../codex/adopted-codex-tui-readiness'
|
||||
import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce'
|
||||
import { withInlineEnvAssignments } from '../../shared/inline-env-command-prefix'
|
||||
import {
|
||||
proveCodexTuiRollout,
|
||||
resolveLiveCodexTuiRollout,
|
||||
@@ -3523,6 +3530,9 @@ export class OrcaRuntimeService {
|
||||
}) => AgentHookAuthorityAttestation | null)
|
||||
| null
|
||||
private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null
|
||||
private readonly subscribeAgentHookEventsFn:
|
||||
| ((listener: (event: AdoptedCodexReadinessEvent) => void) => () => void)
|
||||
| null
|
||||
private readonly canRecoverPersistentLocalPtysFn: () => boolean
|
||||
private readonly buildAgentHookPtyEnv: (() => Record<string, string>) | null
|
||||
private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus
|
||||
@@ -3606,6 +3616,12 @@ export class OrcaRuntimeService {
|
||||
terminalProvenance: 'current_runtime' | 'restored'
|
||||
}) => AgentHookAuthorityAttestation | null
|
||||
retireAgentHookCompatibilityAuthority?: (paneKey: string) => void
|
||||
/** Live tap on accepted agent hook events. The adopted-Codex return path uses
|
||||
* it to take the resumed CLI's own SessionStart as its readiness proof
|
||||
* instead of scraping the pane. */
|
||||
subscribeAgentHookEvents?: (
|
||||
listener: (event: AdoptedCodexReadinessEvent) => void
|
||||
) => () => void
|
||||
canRecoverPersistentLocalPtys?: () => boolean
|
||||
// Why: codex-home paths for the Agent Session History scan must be sourced
|
||||
// here, not via the window-only registerCoreHandlers path — that path never
|
||||
@@ -3649,6 +3665,7 @@ export class OrcaRuntimeService {
|
||||
deps?.attestAgentHookCompatibilityAuthority ?? null
|
||||
this.retireAgentHookCompatibilityAuthorityFn =
|
||||
deps?.retireAgentHookCompatibilityAuthority ?? null
|
||||
this.subscribeAgentHookEventsFn = deps?.subscribeAgentHookEvents ?? null
|
||||
this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true)
|
||||
// Why: configure the shared AiVault scan cache from a serve-mode-reachable
|
||||
// seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions
|
||||
@@ -10771,84 +10788,110 @@ export class OrcaRuntimeService {
|
||||
shell,
|
||||
isRemote: false
|
||||
})
|
||||
if (
|
||||
!startup ||
|
||||
!this.ptyController?.writeAgentSessionProof?.(pty.ptyId, `${startup.launchCommand}\r`, {
|
||||
sessionId: input.record.sessionId,
|
||||
spawnToken: input.spawnToken
|
||||
})
|
||||
) {
|
||||
if (!startup || !pty.paneKey) {
|
||||
throw new Error('The adopted terminal could not resume Codex.')
|
||||
}
|
||||
const listing = (await this.ptyController.listProcesses?.())?.find(
|
||||
(candidate) =>
|
||||
candidate.id === pty.ptyId &&
|
||||
(!pty.incarnationId || candidate.incarnationId === pty.incarnationId)
|
||||
)
|
||||
if (!listing?.rootProcessId) {
|
||||
throw new Error('The adopted terminal did not publish a process identity.')
|
||||
// Why this is checked up front: the resumed Codex proves itself through its own
|
||||
// SessionStart hook, so without the hooks there is nothing to wait for — fail
|
||||
// now with the reason instead of after a 30s silence.
|
||||
const subscribeAgentHookEvents = this.subscribeAgentHookEventsFn
|
||||
if (
|
||||
!subscribeAgentHookEvents ||
|
||||
settings.agentStatusHooksEnabled === false ||
|
||||
settings.disabledTuiAgents?.includes('codex') === true
|
||||
) {
|
||||
throw new Error('Agent status hooks are off, so Codex cannot confirm the resumed session.')
|
||||
}
|
||||
const process = await readStructuredTuiProcessIdentity({
|
||||
hostId: input.record.location.executionHostId,
|
||||
rootPid: listing.rootProcessId,
|
||||
spawnToken: input.spawnToken,
|
||||
agent: 'codex'
|
||||
// Why the watch opens before the write: a warm Codex can post SessionStart
|
||||
// before `writeAgentSessionProof` even returns, and a proof that arrives
|
||||
// before its listener is a proof Orca never sees.
|
||||
const sessionNonce = randomUUID()
|
||||
const readiness = beginAdoptedCodexReadinessWatch({
|
||||
subscribe: subscribeAgentHookEvents,
|
||||
target: { paneKey: pty.paneKey, threadId: head.handle.threadId, sessionNonce }
|
||||
})
|
||||
const owner: StructuredTuiOwner = {
|
||||
...input.owner,
|
||||
process,
|
||||
link: codexProviderHandleLink({
|
||||
threadId: head.handle.threadId,
|
||||
resumed: true,
|
||||
fence: input.fence,
|
||||
observedAt: Date.now()
|
||||
// Why: a throw between here and the await would otherwise leave the watch's
|
||||
// timeout rejection unhandled.
|
||||
void readiness.settled.catch(() => {})
|
||||
let proved = false
|
||||
try {
|
||||
const resumeCommand = withInlineEnvAssignments({
|
||||
command: startup.launchCommand,
|
||||
env: { [AGENT_HOOK_SESSION_NONCE_ENV_VAR]: sessionNonce },
|
||||
platform,
|
||||
shell
|
||||
})
|
||||
if (
|
||||
!this.ptyController?.writeAgentSessionProof?.(pty.ptyId, `${resumeCommand}\r`, {
|
||||
sessionId: input.record.sessionId,
|
||||
spawnToken: input.spawnToken
|
||||
})
|
||||
) {
|
||||
throw new Error('The adopted terminal could not resume Codex.')
|
||||
}
|
||||
const listing = (await this.ptyController.listProcesses?.())?.find(
|
||||
(candidate) =>
|
||||
candidate.id === pty.ptyId &&
|
||||
(!pty.incarnationId || candidate.incarnationId === pty.incarnationId)
|
||||
)
|
||||
if (!listing?.rootProcessId) {
|
||||
throw new Error('The adopted terminal did not publish a process identity.')
|
||||
}
|
||||
const process = await readStructuredTuiProcessIdentity({
|
||||
hostId: input.record.location.executionHostId,
|
||||
rootPid: listing.rootProcessId,
|
||||
spawnToken: input.spawnToken,
|
||||
agent: 'codex'
|
||||
})
|
||||
const owner: StructuredTuiOwner = {
|
||||
...input.owner,
|
||||
process,
|
||||
link: codexProviderHandleLink({
|
||||
threadId: head.handle.threadId,
|
||||
resumed: true,
|
||||
fence: input.fence,
|
||||
observedAt: Date.now()
|
||||
})
|
||||
}
|
||||
await input.onSpawned?.(owner)
|
||||
const transcriptPath = await this.waitForAdoptedStructuredTuiProof({
|
||||
owner,
|
||||
readiness,
|
||||
threadId: head.handle.threadId,
|
||||
codexHome: input.record.accountHome.path
|
||||
})
|
||||
proved = true
|
||||
const provenOwner = { ...owner, transcriptPath }
|
||||
this.adoptedStructuredTuiOwners.set(input.record.sessionId, provenOwner)
|
||||
return provenOwner
|
||||
} finally {
|
||||
if (!proved) {
|
||||
readiness.dispose()
|
||||
}
|
||||
}
|
||||
await input.onSpawned?.(owner)
|
||||
const proof = await this.waitForAdoptedStructuredTuiProof({
|
||||
owner,
|
||||
threadId: head.handle.threadId,
|
||||
codexHome: input.record.accountHome.path,
|
||||
sessionId: input.record.sessionId,
|
||||
spawnToken: input.spawnToken
|
||||
})
|
||||
const proved = { ...owner, transcriptPath: proof.transcriptPath }
|
||||
this.adoptedStructuredTuiOwners.set(input.record.sessionId, proved)
|
||||
return proved
|
||||
}
|
||||
|
||||
// Why nothing is typed at the pane here: the old proof pasted `/status` and
|
||||
// scraped the tail for a session id, which cannot run until Codex is already up
|
||||
// and cannot tell a ready shell from a ready Codex. The CLI's own SessionStart
|
||||
// hook answers both questions off-screen, so this only has to wait for it and
|
||||
// then name the rollout the thread already owns on disk.
|
||||
private async waitForAdoptedStructuredTuiProof(input: {
|
||||
owner: StructuredTuiOwner
|
||||
readiness: AdoptedCodexReadinessWatch
|
||||
threadId: string
|
||||
codexHome: string
|
||||
sessionId: string
|
||||
spawnToken: string
|
||||
}): Promise<{ transcriptPath: string }> {
|
||||
const readPty = (): RuntimePtyWorktreeRecord => {
|
||||
const pty = this.ptysById.get(input.owner.terminal.ptyId)
|
||||
if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) {
|
||||
throw new Error('The adopted terminal lost its pane identity.')
|
||||
}
|
||||
return pty
|
||||
}): Promise<string> {
|
||||
await input.readiness.settled
|
||||
const pty = this.ptysById.get(input.owner.terminal.ptyId)
|
||||
if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) {
|
||||
throw new Error('The adopted terminal lost its pane identity.')
|
||||
}
|
||||
const pty = readPty()
|
||||
return proveCodexTuiRollout({
|
||||
codexHome: input.codexHome,
|
||||
threadId: input.threadId,
|
||||
kittyKeyboardFlags: this.providerModeTrackersByPtyId.get(pty.ptyId)?.flags ?? 0,
|
||||
readOutput: () => {
|
||||
const current = readPty()
|
||||
return {
|
||||
text: buildTerminalWaitText(current.tailBuffer, current.tailPartialLine, current.preview),
|
||||
lastOutputAt: current.lastOutputAt
|
||||
}
|
||||
},
|
||||
write: (data) =>
|
||||
this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, {
|
||||
sessionId: input.sessionId,
|
||||
spawnToken: input.spawnToken
|
||||
}) ?? false
|
||||
})
|
||||
const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId)
|
||||
if (!transcriptPath) {
|
||||
throw new Error('The agent terminal did not prove the expected Codex rollout.')
|
||||
}
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
private async closeAdoptedStructuredTuiOwner(owner: StructuredTuiOwner): Promise<void> {
|
||||
|
||||
@@ -331,6 +331,11 @@ export type AgentHookEventPayload = {
|
||||
source?: AgentHookSource
|
||||
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
|
||||
launchToken?: string
|
||||
/** One-shot nonce Orca minted for a single agent invocation, stamped into that
|
||||
* process's env only. Unlike `launchToken` (pane-lifetime identity that
|
||||
* authority attestation matches against) this is per-launch and disposable, so
|
||||
* it can bind a specific spawn without rewriting the pane's launch identity. */
|
||||
sessionNonce?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
/** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop
|
||||
@@ -4304,6 +4309,7 @@ export function normalizeHookPayload(
|
||||
}
|
||||
const worktreeId = readStringField(record, 'worktreeId')
|
||||
const launchToken = readStringField(record, 'launchToken')
|
||||
const sessionNonce = readStringField(record, 'sessionNonce')
|
||||
|
||||
const hookPayloadRecord = hookPayload as Record<string, unknown>
|
||||
if (source === 'claude') {
|
||||
@@ -4507,6 +4513,7 @@ export function normalizeHookPayload(
|
||||
paneKey,
|
||||
source,
|
||||
launchToken,
|
||||
...(sessionNonce ? { sessionNonce } : {}),
|
||||
tabId,
|
||||
worktreeId,
|
||||
// Why: normalization is transport-agnostic — only ingestRemote knows the mux identity to stamp here.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Why: a resumed Codex TUI must prove itself off-screen. Orca stamps this one-shot
|
||||
// nonce into the single `codex resume` invocation it types, and the managed hook
|
||||
// echoes it back as the `sessionNonce` form field, so a stale SessionStart from an
|
||||
// earlier process in the same pane cannot pass for the new one. Deliberately NOT
|
||||
// `launchToken`: that is pane-lifetime identity and authority attestation matches
|
||||
// against it, so rotating it per resume would break orchestration dispatch.
|
||||
export const AGENT_HOOK_SESSION_NONCE_ENV_VAR = 'ORCA_AGENT_SESSION_NONCE'
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { withInlineEnvAssignments } from './inline-env-command-prefix'
|
||||
|
||||
const ENV = { ORCA_AGENT_SESSION_NONCE: 'nonce-1' }
|
||||
|
||||
describe('inline env assignments for a typed command', () => {
|
||||
it('leaves the command untouched when there is nothing to assign', () => {
|
||||
expect(
|
||||
withInlineEnvAssignments({ command: 'codex resume x', env: {}, platform: 'darwin' })
|
||||
).toBe('codex resume x')
|
||||
})
|
||||
|
||||
it('uses env(1) on POSIX so fish parses it too', () => {
|
||||
for (const shell of ['posix', 'fish'] as const) {
|
||||
expect(
|
||||
withInlineEnvAssignments({
|
||||
command: "codex resume 'thread-1'",
|
||||
env: ENV,
|
||||
platform: 'linux',
|
||||
shell
|
||||
})
|
||||
// Why not `NAME=value cmd`: fish rejects that form outright.
|
||||
).toBe("env ORCA_AGENT_SESSION_NONCE='nonce-1' codex resume 'thread-1'")
|
||||
}
|
||||
})
|
||||
|
||||
it('uses PowerShell and cmd assignment syntax on Windows', () => {
|
||||
expect(
|
||||
withInlineEnvAssignments({
|
||||
command: 'codex resume thread-1',
|
||||
env: ENV,
|
||||
platform: 'win32',
|
||||
shell: 'powershell'
|
||||
})
|
||||
).toBe("$env:ORCA_AGENT_SESSION_NONCE='nonce-1'; codex resume thread-1")
|
||||
expect(
|
||||
withInlineEnvAssignments({
|
||||
command: 'codex resume thread-1',
|
||||
env: ENV,
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})
|
||||
).toBe('set "ORCA_AGENT_SESSION_NONCE=nonce-1" && codex resume thread-1')
|
||||
})
|
||||
|
||||
it('quotes values for the target dialect', () => {
|
||||
expect(
|
||||
withInlineEnvAssignments({
|
||||
command: 'codex',
|
||||
env: { N: "it's" },
|
||||
platform: 'darwin',
|
||||
shell: 'posix'
|
||||
})
|
||||
).toContain(`N='it'\\''s'`)
|
||||
expect(
|
||||
withInlineEnvAssignments({
|
||||
command: 'codex',
|
||||
env: { N: "it's" },
|
||||
platform: 'win32',
|
||||
shell: 'powershell'
|
||||
})
|
||||
).toContain(`$env:N='it''s'`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// Env assignments for a command Orca TYPES into a shell it already owns, rather
|
||||
// than one it spawns with an env map. The adopted-Codex return path has no spawn
|
||||
// to attach env to: the pane is a live shell and the resume command is keystrokes.
|
||||
import {
|
||||
quoteStartupArg,
|
||||
resolveStartupShell,
|
||||
type AgentStartupShell
|
||||
} from './tui-agent-startup-shell'
|
||||
|
||||
export function buildInlineEnvCommandPrefix(args: {
|
||||
env: Record<string, string>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
}): string {
|
||||
const entries = Object.entries(args.env)
|
||||
if (entries.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const shell = resolveStartupShell(args.platform, args.shell)
|
||||
const quote = (value: string): string => quoteStartupArg(value, shell)
|
||||
if (shell === 'powershell') {
|
||||
return `${entries.map(([name, value]) => `$env:${name}=${quote(value)}`).join('; ')}; `
|
||||
}
|
||||
if (shell === 'cmd') {
|
||||
// Why: `set` here outlives the command, unlike the POSIX one-shot form. Safe
|
||||
// because every caller overwrites the same names on its next invocation, so a
|
||||
// leftover value can only ever fail a match — never satisfy a fresh one.
|
||||
return `${entries.map(([name, value]) => `set ${quote(`${name}=${value}`)}`).join(' && ')} && `
|
||||
}
|
||||
// Why `env` rather than the bare `NAME=value cmd` prefix: fish rejects that form
|
||||
// outright ("Unsupported use of '='"), and the pane's POSIX dialect is not
|
||||
// recorded anywhere Orca can read at write time. `env` parses in sh, bash, zsh
|
||||
// and fish alike, and matches how every other Orca launch path reaches the agent
|
||||
// binary — through the executable, not a shell alias.
|
||||
return `env ${entries.map(([name, value]) => `${name}=${quote(value)}`).join(' ')} `
|
||||
}
|
||||
|
||||
/** Prefixes a typed command so the assignments apply to that invocation. */
|
||||
export function withInlineEnvAssignments(args: {
|
||||
command: string
|
||||
env: Record<string, string>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
}): string {
|
||||
return `${buildInlineEnvCommandPrefix(args)}${args.command}`
|
||||
}
|
||||
Reference in New Issue
Block a user