From f86ab83ffcd9e049500d363000d41b189feb0252 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:11:09 -0700 Subject: [PATCH 01/24] feat(orchestration): inject the Orca session id into structured children and let the CLI act as it Every structured session's child (native Claude, native Codex, and the terminal view) carries ORCA_AGENT_SESSION_ID and reaches the Orca CLI. The CLI sends the id in the orchestration envelope; when present it is the caller, and a caller flag naming anyone else is refused before any request. The id is stripped from inherited PTY env and from the SSH host-CLI passthrough, and crosses into WSL so the host can refuse the cross-host claim. --- .../vitest-caller-identity-env-setup.ts | 8 +++ config/tsconfig.node.json | 1 + config/vitest.config.ts | 3 +- .../orchestration/message-check-handler.ts | 12 ++-- .../orchestration/message-send-handler.ts | 7 ++- .../orchestration/question-handler.ts | 4 +- .../handlers/orchestration/run-handlers.ts | 4 +- .../orchestration/terminal-identity.ts | 55 +++++++++++++++++- .../orchestration/worker-list-run-scope.ts | 8 ++- .../orchestration-compatibility-envelope.ts | 7 ++- .../claude-structured-launch-resolution.ts | 7 +-- .../codex-structured-child-environment.ts | 7 +-- src/main/ipc/pty/host-env/pi-agent.ts | 14 +++-- src/main/ipc/pty/host-env/spawn-env-keys.ts | 9 +++ src/main/ipc/pty/ipc/spawn-options.ts | 4 +- src/main/ipc/pty/runtime/spawn-options.ts | 4 +- src/main/pty/wsl-orca-env.ts | 4 ++ ...stration-structured-worker-session.test.ts | 6 +- .../rpc/orchestration-session-caller.test.ts | 2 +- .../rpc/orchestration-session-caller.ts | 2 + ...ctured-session-child-identity-env.test.ts} | 18 +++--- ... structured-session-child-identity-env.ts} | 57 ++++++++++--------- .../structured-worker-identity.test.ts | 4 +- .../runtime/structured-worker-identity.ts | 15 ++--- .../ssh/ssh-remote-cli-host-passthrough.ts | 6 ++ src/shared/agent-session-caller-env.ts | 17 ++++++ src/shared/orca-session-address.ts | 13 +++-- src/shared/structured-session-marker.ts | 19 +++++-- src/shared/structured-worker-handle.ts | 7 +++ 29 files changed, 234 insertions(+), 90 deletions(-) create mode 100644 config/scripts/vitest-caller-identity-env-setup.ts rename src/main/runtime/{structured-worker-child-identity-env.test.ts => structured-session-child-identity-env.test.ts} (88%) rename src/main/runtime/{structured-worker-child-identity-env.ts => structured-session-child-identity-env.ts} (50%) create mode 100644 src/shared/agent-session-caller-env.ts create mode 100644 src/shared/structured-worker-handle.ts diff --git a/config/scripts/vitest-caller-identity-env-setup.ts b/config/scripts/vitest-caller-identity-env-setup.ts new file mode 100644 index 000000000000..1d14998e46c6 --- /dev/null +++ b/config/scripts/vitest-caller-identity-env-setup.ts @@ -0,0 +1,8 @@ +/** + * Why: a structured chat exports its own orchestration caller identity to every child, including a + * test runner it launches. Inherited, it would decide which CLI identity branch a test exercises + * depending on who ran the suite; suites that need one set it themselves. + */ +for (const name of ['ORCA_AGENT_SESSION_ID', 'ORCA_STRUCTURED_SESSION']) { + delete process.env[name] +} diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 4f7cc41bcc1e..0d3077d8f0e1 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -4,6 +4,7 @@ "../electron.vite.config.*", "./build-plugins/**/*", "./scripts/vitest-host-ports-setup.ts", + "./scripts/vitest-caller-identity-env-setup.ts", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index b81bbc2b0f3a..d2a586169340 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -32,7 +32,8 @@ export default defineConfig({ setupFiles: [ resolve('config/scripts/happy-dom-offscreen-canvas.ts'), resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), - resolve('config/scripts/vitest-host-ports-setup.ts') + resolve('config/scripts/vitest-host-ports-setup.ts'), + resolve('config/scripts/vitest-caller-identity-env-setup.ts') ], include: UNIT_INCLUDE, ...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1' ? { exclude: UNIT_EXCLUDE } : {}), diff --git a/src/cli/handlers/orchestration/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts index f2af827ee048..26cefcc06ea9 100644 --- a/src/cli/handlers/orchestration/message-check-handler.ts +++ b/src/cli/handlers/orchestration/message-check-handler.ts @@ -13,7 +13,7 @@ import { startCheckKeepalive } from './check-keepalive' import { callOrchestrationMutation } from './mutation-request' import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' import { flushOrchestrationStdout, resolveCompatibilityCliCommand } from './runtime-compatibility' -import { resolveOrchestrationTerminalHandle } from './terminal-identity' +import { orchestrationCallerLabel, resolveOrchestrationTerminalHandle } from './terminal-identity' type CheckResult = { messages: MessageSummary[] @@ -41,12 +41,16 @@ export const ORCHESTRATION_CHECK_HANDLER: Record = { const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') const explicitTerminal = getOptionalStringFlag(flags, 'terminal') const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') + // Why: a session names itself by its id alone; a terminal view's pane is not its identity. + const paneKey = + explicitTerminal || terminal === undefined ? undefined : process.env.ORCA_PANE_KEY + const callerLabel = orchestrationCallerLabel(terminal) const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null let result: Awaited>> try { result = await callOrchestrationMutation(client, flags, 'orchestration.check', { terminal, - terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, + terminalPaneKey: paneKey || undefined, // Why: old runtimes degrade peek to non-consuming all mode instead of destructive mark-read. unread: flags.has('unread') ? true : peek ? false : undefined, peek: peek ? true : undefined, @@ -68,9 +72,9 @@ export const ORCHESTRATION_CHECK_HANDLER: Record = { } result = { ...result, - result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) + result: prepareOrchestrationCheckOutput(result.result, callerLabel, flags.has('format')) } - printResult(result, json, (value) => formatOrchestrationCheckText(value, terminal)) + printResult(result, json, (value) => formatOrchestrationCheckText(value, callerLabel)) const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds if (compatibilityAck && compatibilityAck.length > 0) { await flushOrchestrationStdout() diff --git a/src/cli/handlers/orchestration/message-send-handler.ts b/src/cli/handlers/orchestration/message-send-handler.ts index 088d1361e69e..f79a846328b8 100644 --- a/src/cli/handlers/orchestration/message-send-handler.ts +++ b/src/cli/handlers/orchestration/message-send-handler.ts @@ -2,6 +2,7 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' +import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' import { requireWorkerDoneSettlement } from '../orchestration-worker-settlement' import { getOptionalStructuredMessagePayload } from './message-payload' import { callOrchestrationMutation } from './mutation-request' @@ -75,7 +76,8 @@ export const ORCHESTRATION_SEND_HANDLER: Record = { if ( (type === 'worker_done' || type === 'heartbeat') && !getOptionalStringFlag(flags, 'from') && - !process.env.ORCA_TERMINAL_HANDLE + !process.env.ORCA_TERMINAL_HANDLE && + !readInjectedAgentSessionId() ) { // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. throwNoActiveSenderTerminal() @@ -94,7 +96,8 @@ export const ORCHESTRATION_SEND_HANDLER: Record = { threadId: getOptionalStringFlag(flags, 'thread-id'), payload: getOptionalStructuredMessagePayload(flags), // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. - senderPaneKey: process.env.ORCA_PANE_KEY || undefined, + // A session names itself by its id alone. + senderPaneKey: from === undefined ? undefined : process.env.ORCA_PANE_KEY || undefined, waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, devMode: isDevCliInvocation() } diff --git a/src/cli/handlers/orchestration/question-handler.ts b/src/cli/handlers/orchestration/question-handler.ts index bd9e239b6d59..67aaeb16e4d8 100644 --- a/src/cli/handlers/orchestration/question-handler.ts +++ b/src/cli/handlers/orchestration/question-handler.ts @@ -103,8 +103,8 @@ export const ORCHESTRATION_QUESTION_HANDLER: Record = { resolveOrchestrationCliExecutable(), 'orchestration', 'ask', - '--from', - from, + // A session's resume is flagless: its injected id names it again. + ...(from ? ['--from', from] : []), ...(dispatchCapability ? ['--dispatch-capability', dispatchCapability] : []), '--resume', messageId, diff --git a/src/cli/handlers/orchestration/run-handlers.ts b/src/cli/handlers/orchestration/run-handlers.ts index 7c77913df158..d8abe3533c2c 100644 --- a/src/cli/handlers/orchestration/run-handlers.ts +++ b/src/cli/handlers/orchestration/run-handlers.ts @@ -39,7 +39,9 @@ export const ORCHESTRATION_RUN_HANDLERS: Record = { run: { id: string; objective: string } | null }>('orchestration.runCurrent', { from }) printResult(result, json, (r) => - r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' + r.run + ? `${r.run.id} ${r.run.objective}` + : `No Run is bound to this ${from === undefined ? 'session' : 'terminal'}.` ) }, diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index e693d99079df..f9d72d76e3af 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -3,14 +3,25 @@ import { getOptionalStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' import { isStructuredSessionWithoutIdentity } from '../../../shared/structured-session-marker' +import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' +import { normalizeOrchestrationActor } from '../../../shared/orchestration-actor' +import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' +/** + * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: + * the orchestration envelope carries that id and the host binds the caller param to it, so nothing + * is resolved or guessed here. + */ export async function resolveOrchestrationTerminalHandle( flags: Map, cwd: string, client: RuntimeClient, flagName: 'from' | 'terminal', options: { validateEnvHandle?: boolean } = {} -): Promise { +): Promise { + if (resolveInjectedSessionCaller(flags, flagName)) { + return undefined + } const explicit = getOptionalStringFlag(flags, flagName) if (explicit) { return explicit @@ -157,11 +168,51 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } +/** + * The injected session id when this command runs as an agent session. The id wins over every other + * identity this process carries; a caller flag may restate that same session but never name + * another, and a conflicting one is refused here, before any request is sent. + */ +export function resolveInjectedSessionCaller( + flags: Map, + flagName: 'from' | 'terminal' +): string | undefined { + const sessionId = readInjectedAgentSessionId() + if (!sessionId) { + return undefined + } + const declared = getOptionalStringFlag(flags, flagName) + if (declared !== undefined && !namesInjectedSession(declared, sessionId)) { + throw new RuntimeClientError( + 'consumer_fenced', + `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + + `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + + `session:${sessionId}. No request was sent.` + ) + } + return sessionId +} + +/** The session's own spellings, plus the handle a structured worker session was minted. */ +function namesInjectedSession(value: string, sessionId: string): boolean { + if (normalizeOrchestrationActor(value)?.id === sessionId) { + return true + } + const ownHandle = process.env.ORCA_TERMINAL_HANDLE + return isStructuredWorkerHandle(ownHandle) && value === ownHandle +} + +/** How check output names its caller: the handle, or the session's address. */ +export function orchestrationCallerLabel(handle: string | undefined): string { + const sessionId = handle === undefined ? readInjectedAgentSessionId() : undefined + return handle ?? (sessionId ? `session:${sessionId}` : 'unknown') +} + export async function resolveCoordinatorTerminalHandle( flags: Map, cwd: string, client: RuntimeClient -): Promise { +): Promise { return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { validateEnvHandle: true }) diff --git a/src/cli/handlers/orchestration/worker-list-run-scope.ts b/src/cli/handlers/orchestration/worker-list-run-scope.ts index 130376161531..83bfcd5b9e0b 100644 --- a/src/cli/handlers/orchestration/worker-list-run-scope.ts +++ b/src/cli/handlers/orchestration/worker-list-run-scope.ts @@ -1,6 +1,9 @@ import { getOptionalStringFlag } from '../../flags' import type { RuntimeClient } from '../../runtime-client' -import { resolveOrchestrationTerminalHandle } from './terminal-identity' +import { + resolveInjectedSessionCaller, + resolveOrchestrationTerminalHandle +} from './terminal-identity' /** Which Run `worker-list` enumerated, and why. Additive: old readers ignore it. */ export type WorkerListRunScope = { run?: string; source: 'flag' | 'bound' | 'all' } @@ -20,6 +23,9 @@ export async function resolveWorkerListRunScope( if (explicit) { return { run: explicit, source: 'flag' } } + // Outside the catch: a --terminal naming someone other than this session is refused, never + // widened into an unscoped listing. + resolveInjectedSessionCaller(flags, 'terminal') try { const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') const current = await client.call<{ run: { id: string } | null }>('orchestration.runCurrent', { diff --git a/src/cli/runtime/orchestration-compatibility-envelope.ts b/src/cli/runtime/orchestration-compatibility-envelope.ts index 8172e556dc1e..7d6c0dfda80b 100644 --- a/src/cli/runtime/orchestration-compatibility-envelope.ts +++ b/src/cli/runtime/orchestration-compatibility-envelope.ts @@ -1,12 +1,17 @@ import { randomUUID } from 'node:crypto' +import { readInjectedAgentSessionId } from '../../shared/agent-session-caller-env' import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' export function createOrchestrationCompatibilityEnvelope( env: NodeJS.ProcessEnv ): RuntimeOrchestrationEnvelope { + const evidence = readOrchestrationCompatibilityEvidence(env) + // Read here, from this CLI's own environment only: the SSH paths build evidence from a remote + // shell's environment, where a session id could never name a session on this host. + const agentSessionId = readInjectedAgentSessionId(env) return { compatibilityInvocationId: randomUUID(), - orchestrationCompatibilityEvidence: readOrchestrationCompatibilityEvidence(env) + orchestrationCompatibilityEvidence: agentSessionId ? { ...evidence, agentSessionId } : evidence } } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index d5d0542cb9da..29703dfd8eb7 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -8,7 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-jou import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, @@ -288,9 +288,8 @@ export function createClaudeStructuredLaunchResolver( (await deps.resolvePermissionMode?.()) ?? 'default' ) const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => - // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on - // PATH; an ordinary chat session's env passes through untouched. - structuredWorkerChildIdentityEnv(record.sessionId, { + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + structuredSessionChildIdentityEnv(record.sessionId, { ...base, // The turn translator relies on Claude's authoritative idle frame when no result arrives. [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts index 72bf17a1bcef..38ea91abe268 100644 --- a/src/main/codex/codex-structured-child-environment.ts +++ b/src/main/codex/codex-structured-child-environment.ts @@ -1,6 +1,6 @@ import type { CodexStructuredLaunch } from './codex-structured-session-state' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' -import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' export function buildCodexStructuredChildEnvironment( launch: CodexStructuredLaunch, @@ -8,9 +8,8 @@ export function buildCodexStructuredChildEnvironment( sessionId: string ): Record { return { - // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on - // PATH; an ordinary chat session's env passes through untouched. - ...structuredWorkerChildIdentityEnv(sessionId, { + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + ...structuredSessionChildIdentityEnv(sessionId, { ...launch.env, ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}) }), diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index 05258f0bdc6c..4123f35a0718 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -8,7 +8,11 @@ import { type PiAgentKind } from '../../../../shared/pi-agent-kind' import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env' -import { AGENT_HOOK_RUNTIME_ENV_KEYS, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS } from './spawn-env-keys' +import { + AGENT_HOOK_RUNTIME_ENV_KEYS, + CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, + ORCA_AGENT_SESSION_CALLER_ENV_KEYS +} from './spawn-env-keys' export function readEnvWithProcessFallback( baseEnv: Record, @@ -138,13 +142,15 @@ export function getInheritedAgentHookEnvKeysToDelete( ].filter((key) => env[key] === undefined) } -export function getInheritedClaudeSessionStampEnvKeysToDelete( +export function getInheritedAgentSessionStampEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { const env = spawnEnv ?? {} // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (deliberately spawning a nested Claude child) keeps it. - return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) + // provides a stamp (a nested Claude child, a structured session's terminal view) keeps it. + return [...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS].filter( + (key) => env[key] === undefined + ) } export { restoreOrStripOverlayEnv } from '../../../../shared/agent-overlay-env' diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts index cabbde7b0ecb..ed070c7d043d 100644 --- a/src/main/ipc/pty/host-env/spawn-env-keys.ts +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -1,3 +1,6 @@ +import { ORCA_AGENT_SESSION_ID_ENV } from '../../../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../../../shared/structured-session-marker' + export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', @@ -15,3 +18,9 @@ export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID' ] as const + +// Why: Orca writes these only into a structured session's own spawn, so an inherited value means a pty host launched from inside one — every pane would claim that session as its orchestration caller. +export const ORCA_AGENT_SESSION_CALLER_ENV_KEYS = [ + ORCA_AGENT_SESSION_ID_ENV, + ORCA_STRUCTURED_SESSION_ENV +] as const diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 42d2aedf0084..d46f210fcd38 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -9,7 +9,7 @@ import { mergePtyEnvDeletions, removeCodexHomeDeletionRequests, getInheritedAgentHookEnvKeysToDelete, - getInheritedClaudeSessionStampEnvKeysToDelete + getInheritedAgentSessionStampEnvKeysToDelete } from '../host-env/pi-agent' import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' @@ -49,7 +49,7 @@ export async function buildPtyIpcSpawnOptions( !args.connectionId && !ctx.isDaemonHostSpawn ? getLegacyOpenCodeEnvKeysToDelete(ctx.spawnEnv, getAppEnvironment().getPath('userData')) : [], - getInheritedClaudeSessionStampEnvKeysToDelete(ctx.spawnEnv), + getInheritedAgentSessionStampEnvKeysToDelete(ctx.spawnEnv), ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the persistent daemon compares its own merged CODEX_HOME pair; // main cannot safely decide ownership for a process it may not parent. diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index c544b9ac7c90..933624e6dd56 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -11,7 +11,7 @@ import { mergePtyEnvDeletions, removeCodexHomeDeletionRequests, getInheritedAgentHookEnvKeysToDelete, - getInheritedClaudeSessionStampEnvKeysToDelete + getInheritedAgentSessionStampEnvKeysToDelete } from '../host-env/pi-agent' import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' import { @@ -79,7 +79,7 @@ export async function buildRuntimePtySpawnOptions( ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) + getInheritedAgentSessionStampEnvKeysToDelete(ctx.env) ) if (ctx.skipCodexHomeEnv) { ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index b3047fd8a27f..5611c2fd6332 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -9,6 +9,7 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../shared/setup-agent-sequencing' import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_IMAGE_PROTOCOL_ENV } from '../../shared/terminal-image-protocol' const WSLENV_ENTRY_SEPARATOR = ':' @@ -74,6 +75,9 @@ export function addOrcaWslInteropEnv(env: Record): void { // Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start. const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', + // Why: a structured session's terminal view in a WSL shell must still present its id, so the + // host refuses the cross-host claim instead of the pane handle silently becoming its caller. + `${ORCA_AGENT_SESSION_ID_ENV}/u`, 'ORCA_USER_DATA_PATH/p', // Why /p: the guest reads the content-addressed wrapper tree through /mnt/c, // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 0d1f04cb83bd..8c71d2eae176 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -19,8 +19,8 @@ const { } = await import('./orchestration-structured-worker-session') const { isUnknownWorkerStartOutcome } = await import('./orchestration/worker/worker-topology') const { structuredWorkerIdentities } = await import('../../structured-worker-identity') -const { structuredWorkerChildIdentityEnv } = - await import('../../structured-worker-child-identity-env') +const { structuredSessionChildIdentityEnv } = + await import('../../structured-session-child-identity-env') // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a host stub carrying only the members the worker start reaches. function installHost(location = { executionHostId: 'local', wslDistro: null as string | null }) { @@ -82,7 +82,7 @@ describe('structured worker session', () => { createSpy.mockImplementation(async (args: { envelope: { sessionId: string } }) => { // `attach` is what spawns the provider child, and the child's env is read from the registry // at spawn time. Registering afterwards ships a worker with no ORCA_TERMINAL_HANDLE. - envAtSpawn = structuredWorkerChildIdentityEnv(args.envelope.sessionId, {}) + envAtSpawn = structuredSessionChildIdentityEnv(args.envelope.sessionId, {}) return { ok: true, value: { sessionId: args.envelope.sessionId } } }) const created = await createStructuredWorkerSession({ diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index d96e214d4421..c91ad0ff1692 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -43,7 +43,6 @@ const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle'] a const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', 'orchestration.runShow from': 'reads a Run by id; `from` is unused', - 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal' @@ -59,6 +58,7 @@ const MINIMAL_PARAMS: Readonly>> = { 'orchestration.reply': { id: 'msg_missing', body: 'b' }, 'orchestration.ask': { question: 'q', to: 'term_worker' }, 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, + 'orchestration.dispatchShow': { task: 'task_missing', preamble: true }, 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, 'orchestration.gateList': {}, diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index e4bd09f2619f..deeeb397ca30 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -50,6 +50,8 @@ export const ORCHESTRATION_CALLER_PARAM: Readonly> = 'orchestration.reply': 'from', 'orchestration.ask': 'from', 'orchestration.dispatch': 'from', + // The preview names its caller as the coordinator, exactly as the dispatch it previews does. + 'orchestration.dispatchShow': 'from', 'orchestration.gateCreate': 'from', 'orchestration.gateResolve': 'from', 'orchestration.gateList': 'from', diff --git a/src/main/runtime/structured-worker-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts similarity index 88% rename from src/main/runtime/structured-worker-child-identity-env.test.ts rename to src/main/runtime/structured-session-child-identity-env.test.ts index e966dd558240..7fe19c491e55 100644 --- a/src/main/runtime/structured-worker-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -5,7 +5,7 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) -import { structuredWorkerChildIdentityEnv } from './structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -56,7 +56,7 @@ afterEach(() => { } }) -describe('structuredWorkerChildIdentityEnv', () => { +describe('structuredSessionChildIdentityEnv', () => { it('marks an ordinary chat session as having NO identity, and grants it nothing', () => { // The marker names nothing — no handle, no pane key, no session id, no token — so it cannot be // replayed or impersonated, and it does not reach the hook, agent-row or mobile-projection @@ -66,7 +66,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const childEnv = { PATH: '/usr/bin' } - const env = structuredWorkerChildIdentityEnv(SESSION_ID, childEnv) + const env = structuredSessionChildIdentityEnv(SESSION_ID, childEnv) expect(env).toEqual({ PATH: '/usr/bin', ORCA_STRUCTURED_SESSION: '1' }) expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() expect(env.ORCA_PANE_KEY).toBeUndefined() @@ -82,7 +82,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const handle = registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) expect(env.ORCA_CLI_COMMAND).toBe('orca') expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) @@ -92,7 +92,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('darwin') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) }) @@ -100,7 +100,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('win32') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) expect(env.PATH).toBeUndefined() }) @@ -109,7 +109,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('darwin') installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) }) @@ -119,7 +119,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.ORCA_PANE_KEY).toBeUndefined() expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) }) @@ -140,7 +140,7 @@ describe('structuredWorkerChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() expect( - structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND + structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND ).not.toBe('orca-ide') }) }) diff --git a/src/main/runtime/structured-worker-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts similarity index 50% rename from src/main/runtime/structured-worker-child-identity-env.ts rename to src/main/runtime/structured-session-child-identity-env.ts index 6cf9f46e910c..f2ab92b3d664 100644 --- a/src/main/runtime/structured-worker-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -1,62 +1,67 @@ /** - * The orchestration identity — and the CLI reachability — a structured worker's own child needs - * to speak for itself. + * The orchestration identity — and the CLI reachability — a structured session's own child needs to + * speak for itself. Both providers' native launches (Claude, Codex) build their child env here. * - * Without `ORCA_TERMINAL_HANDLE` the worker's Bash tool has nothing to pass as `--from`, and - * `resolveOrchestrationTerminalHandle` falls back to a cwd lookup that returns whichever leaf in - * the worktree comes first. Two attacks follow from that: a bare `check` reads and consumes a - * SIBLING's dispatch mailbox, and a bare `send --type worker_done` can settle a sibling's - * context-only dispatch, a tier that has no capability token to reject on. + * Every structured session carries `ORCA_AGENT_SESSION_ID`, the id Orca minted for it — never the + * provider's, which rotates on `/clear`. The CLI sends it as the caller, so a bare + * `orca orchestration check` acts as this session instead of guessing a terminal: with no pane of + * its own, every guess landed on a sibling, and a destructive `check` consumed that sibling's mail. + * Identity by session id assumes one machine and one user; crossing a host boundary (SSH, a paired + * peer) re-opens that decision, and the host refuses a session claim from across one. + * + * A dispatched structured worker also keeps the `structworker_` handle it was minted, for the + * handle-based surfaces outside orchestration; for orchestration the id wins and the host maps it + * back to that handle, so the worker keeps one identity. * * `ORCA_CLI_COMMAND: 'orca'` is honest ONLY because of the PATH prepend below. Orca's Linux CLI * installs as `orca-ide` so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and * on packaged macOS/Windows the bundled launcher is reachable only from the app's own resources - * dir. A PTY worker gets that treatment from `buildPtyHostEnv`; a structured worker has no PTY, - * so it applies the SAME function here rather than a second, drifting copy of the rule. + * dir. A PTY agent gets that treatment from `buildPtyHostEnv`; a structured session has no PTY, so + * it applies the SAME function here rather than a second, drifting copy of the rule. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, * agent-row and mobile-projection pipelines, every one of which assumes a pane key names a live * PTY leaf. It would also open `selectExactWorkerProviderSession`, which is fail-closed today - * precisely because a structured session emits no hook agent status. The CLI needs none of it once - * the handle is present. + * precisely because a structured session emits no hook agent status. * - * A session that is not a dispatched worker gets ONE variable, `ORCA_STRUCTURED_SESSION`, and it - * names nothing: no handle, no pane key, no session id, no token. Its only meaning is "this child - * is a structured session with no orchestration identity", which is what a verb needs in order to - * REFUSE rather than guess one. Because it names nothing it cannot be replayed, cannot impersonate, - * and cannot flow into the hook, agent-row or mobile-projection pipelines the way a pane key would - * — which is why it is a different decision from withholding `ORCA_PANE_KEY`, not a reversal of it. - * Without it, `check` fell through to the active-terminal guess and destructively consumed a - * SIBLING pane's oldest unread batch; `requireUnambiguous` only narrows that, because with exactly - * one terminal pane in the worktree the guess still resolves — to a sibling. + * The identity-less marker (`ORCA_STRUCTURED_SESSION`) is no longer written: a child with an id is + * never identity-less, so a marker inherited from an Orca launched inside an older session is inert. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. */ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { structuredWorkerIdentities } from './structured-worker-identity' -export function structuredWorkerChildIdentityEnv( +export function structuredSessionChildIdentityEnv( sessionId: string, childEnv: Record ): Record { const identity = structuredWorkerIdentities.getBySessionId(sessionId) - if (!identity) { - return { ...childEnv, [ORCA_STRUCTURED_SESSION_ENV]: '1' } - } const env: Record = { ...childEnv, - ORCA_TERMINAL_HANDLE: identity.handle, + ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), + [ORCA_AGENT_SESSION_ID_ENV]: sessionId, ORCA_CLI_COMMAND: 'orca' } applyOrcaCliPath(env) return env } +/** + * The same session id for its terminal view, so switching views never changes who the session is. + * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. + */ +export function structuredSessionTerminalViewEnv( + sessionId: string | undefined +): Record { + return sessionId ? { [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : {} +} + /** * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index d20cadf5c770..1737a96036c8 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -5,7 +5,7 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' -import { structuredWorkerChildIdentityEnv } from './structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' import { StructuredWorkerIdentityRegistry, isStructuredWorkerHandle, @@ -300,7 +300,7 @@ describe('structured workers stay outside the PTY-only fail-closed paths', () => hostScope: { kind: 'local', hostId: 'local' } }) try { - const env = structuredWorkerChildIdentityEnv(SESSION_ID, {}) + const env = structuredSessionChildIdentityEnv(SESSION_ID, {}) // Registered, so this is a populated env — not the empty one an unregistered session gets, // which would satisfy the pane-key assertion for the wrong reason. expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index e09adee7c1e2..22a49e11abb2 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -26,14 +26,19 @@ import { } from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isOrcaSessionId, type OrcaSessionId } from '../../shared/orca-session-address' +import { + STRUCTURED_WORKER_HANDLE_PREFIX, + isStructuredWorkerHandle +} from '../../shared/structured-worker-handle' import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' -// Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the -// renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. -export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' +export { + STRUCTURED_WORKER_HANDLE_PREFIX, + isStructuredWorkerHandle +} from '../../shared/structured-worker-handle' export const STRUCTURED_WORKER_INCARNATION_PREFIX = 'structured:' export type StructuredWorkerIdentity = { @@ -47,10 +52,6 @@ export type StructuredWorkerIdentity = { hostScope: WorkerTerminalHostScope } -export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { - return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) -} - export function mintStructuredWorkerHandle(): string { return `${STRUCTURED_WORKER_HANDLE_PREFIX}${randomUUID()}` } diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index d62a23d8e304..4490343504ff 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -13,6 +13,8 @@ import { ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV, ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV } from '../../shared/orchestration-compatibility-evidence' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { REMOTE_ARTIFACT_INPUT_ENV, sshArtifactSourceKey, @@ -131,6 +133,10 @@ export function buildHostCliEnv(args: { delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] delete env[REMOTE_ARTIFACT_INPUT_ENV] + // Why: a remote command must never claim a local agent session. The host's env carries one only + // when Orca was launched inside a session, and identity by session id is same-host only. + delete env[ORCA_AGENT_SESSION_ID_ENV] + delete env[ORCA_STRUCTURED_SESSION_ENV] if (args.runtimeAuthority) { env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh' env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts new file mode 100644 index 000000000000..d0f5e8605596 --- /dev/null +++ b/src/shared/agent-session-caller-env.ts @@ -0,0 +1,17 @@ +/** + * The Orca-minted agent session id, injected into a structured session's own child processes, in + * native chat and in terminal view alike. When it is present it IS the orchestration caller: the + * CLI sends it in the orchestration envelope and the host resolves it to the session's actor, so no + * terminal is resolved or guessed on its behalf. + * + * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, + * WSL) re-opens that decision, which is why nothing forwards the id across one as a caller. + */ +export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' + +export function readInjectedAgentSessionId( + env: Readonly> = process.env +): string | undefined { + const value = env[ORCA_AGENT_SESSION_ID_ENV]?.trim() + return value ? value : undefined +} diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index 339ccb01f435..9762b95d63c6 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -1,11 +1,12 @@ import { isAgentSessionId } from './agent-session-record' +import { STRUCTURED_WORKER_HANDLE_PREFIX } from './structured-worker-handle' /** - * The Orca session id is the id Orca minted for a structured session (its session record id), never - * the provider's own session id. Orchestration stores, bare, the one the agent is addressed by: for - * a `/clear`ed chat, its lineage root's, not the live session's. Mail addresses the session as - * `session:`, beside `run:` and `dispatch:`, and derives that spelling here rather than - * storing it. + * The Orca session id is the id Orca minted for a structured session (its session record id, the + * value of `ORCA_AGENT_SESSION_ID`), never the provider's own session id. Orchestration stores, + * bare, the one the agent is addressed by: for a `/clear`ed chat, its lineage root's, not the live + * session's. Mail addresses the session as `session:`, beside `run:` and `dispatch:`, + * and derives that spelling here rather than storing it. * * Where the session runs is not part of the id; it is read from the session record when needed. PTY * agents have none today, and never a pane-keyed one: a pane outlives the agent in it, so such an id @@ -24,7 +25,7 @@ export type OrcaSessionAddress = string & { readonly [orcaSessionAddressBrand]: // Terminal handles (`term_` from the PTY runtime, `structworker_` from structured-worker-identity) // share the session-id charset. A handle is never a session, so one handed over by mistake must not // become a durable Orca session id. -const TERMINAL_HANDLE_PREFIXES = ['term_', 'structworker_'] as const +const TERMINAL_HANDLE_PREFIXES = ['term_', STRUCTURED_WORKER_HANDLE_PREFIX] as const export function isOrcaSessionId(id: string): id is OrcaSessionId { return isAgentSessionId(id) && !TERMINAL_HANDLE_PREFIXES.some((prefix) => id.startsWith(prefix)) diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 36c90f432ee8..1725a6ef337e 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,13 +1,20 @@ +import { readInjectedAgentSessionId } from './agent-session-caller-env' + /** - * The marker a structured chat session's child carries when it has NO orchestration identity. + * The marker a structured chat session's child carried when it had NO orchestration identity. * - * It names nothing on purpose — no handle, no pane key, no session id, no token — so it grants no - * authority and cannot be replayed or impersonated. Its only job is to let a CLI verb that would - * otherwise GUESS an implicit terminal refuse instead: a structured session has no pane, so every - * guess resolves to a sibling, and `orchestration check` is destructive by default. + * Current hosts no longer write it: every structured child carries its injected session id + * instead. The reader stays for a child spawned by an Orca that predates injection, which can still + * reach a newer CLI through a global install — it must refuse, not guess, because a structured + * session has no pane, so every implicit-terminal guess resolves to a sibling and + * `orchestration check` is destructive by default. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' +/** An injected session id is an identity, so a child carrying one is never identity-less. */ export function isStructuredSessionWithoutIdentity(env: NodeJS.ProcessEnv = process.env): boolean { - return (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 + return ( + (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 && + readInjectedAgentSessionId(env) === undefined + ) } diff --git a/src/shared/structured-worker-handle.ts b/src/shared/structured-worker-handle.ts new file mode 100644 index 000000000000..e36df8135706 --- /dev/null +++ b/src/shared/structured-worker-handle.ts @@ -0,0 +1,7 @@ +// Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the +// renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. +export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' + +export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { + return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) +} From 370402d8d5bc54398771a1ab6969e984b86de154 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:21:40 -0700 Subject: [PATCH 02/24] test(orchestration): pin session id injection for native Claude, native Codex, the terminal view, WSL, PTY inheritance and SSH --- .../claude-stream-json-connection.test.ts | 5 + ...laude-structured-launch-resolution.test.ts | 13 ++ ...codex-structured-child-environment.test.ts | 79 +++++++++++- .../codex-structured-session-adapter.test.ts | 6 +- .../pty-daemon-spawn-agent-home-env.test.ts | 19 +++ .../ipc/pty-spawn-env-terminal-basics.test.ts | 17 +++ src/main/providers/provider-dispatch.test.ts | 8 +- src/main/pty/wsl-orca-env.test.ts | 20 +++ ...ca-runtime-agent-session-operation.test.ts | 34 +++++ ...uctured-session-child-identity-env.test.ts | 118 ++++++++++++------ .../structured-session-child-identity-env.ts | 8 +- .../ssh-remote-cli-host-passthrough.test.ts | 22 ++++ 12 files changed, 295 insertions(+), 54 deletions(-) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 946cf08e1c41..6d3f0d89f305 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -164,9 +164,12 @@ describe('Claude stream-json connection', () => { // An inherited value wins over the SDK's default, so clear it to pin the default. vi.stubEnv('CLAUDE_CODE_ENTRYPOINT', undefined) vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited') + // An Orca launched inside another structured session inherits that session's id. + vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') const scenario = scriptScenario([HOLD_OPEN]) const connection = await open( launchFor(scenario, { + ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', CLAUDE_CONFIG_DIR: '/accounts/managed/home', ANTHROPIC_AUTH_TOKEN: 'configured-token', ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9', @@ -185,6 +188,8 @@ describe('Claude stream-json connection', () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe('configured-token') expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-9') expect(env.ORCA_CONNECTION_MARKER).toBe('inherited') + // The session's own id reaches the spawned child over the inherited one. + expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') expect(env.ANTHROPIC_API_KEY).toBeUndefined() expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 3d7592c8406a..d34a296cfa24 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -161,6 +161,19 @@ describe('claude structured launch resolution', () => { expect(launch.options.sessionId).toBeUndefined() }) + it('names the child by the Orca session id, over any id the configured overlay carries', async () => { + // The Orca-minted id, never the provider's: the provider id rotates on /clear. + const launch = await resolverFor(record(), () => ({ + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' + }))({ identity: IDENTITY }) + + expect(launch.env).toMatchObject({ + ORCA_AGENT_SESSION_ID: SESSION_ID, + ORCA_CLI_COMMAND: 'orca' + }) + expect(launch.env?.ORCA_AGENT_SESSION_ID).not.toBe(launch.providerSessionId) + }) + it('forces session-state events on when the inherited overlay disables them', async () => { const launch = await resolverFor(record(), () => ({ [CLAUDE_SESSION_STATE_EVENTS_ENV]: '0' diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 201efc0b0c5b..bddb4c69aeb3 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { openCodexAppServerConnection } from './codex-app-server-connection' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -9,6 +9,8 @@ import { structuredWorkerProcessIncarnation } from '../runtime/structured-worker-identity' +const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ + describe('buildCodexStructuredChildEnvironment', () => { it('keeps shell exports while pinned launch values win', () => { expect( @@ -28,11 +30,14 @@ describe('buildCodexStructuredChildEnvironment', () => { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/pinned/home', [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', - [ORCA_STRUCTURED_SESSION_ENV]: '1' + ORCA_AGENT_SESSION_ID: 'session-not-a-worker', + ORCA_CLI_COMMAND: 'orca', + // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. + PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) }) - it('adds the orchestration handle only for a registered structured worker', () => { + it('names every session by its id, and adds the handle only for a registered worker', () => { const launch = { command: 'codex', args: ['app-server'], @@ -44,8 +49,10 @@ describe('buildCodexStructuredChildEnvironment', () => { const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' expect(buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId)).toEqual({ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', - // No identity yet, so the child carries only the refuse-rather-than-guess marker. - [ORCA_STRUCTURED_SESSION_ENV]: '1' + // Not a worker, so no handle: the id alone names this chat as a caller. + ORCA_AGENT_SESSION_ID: sessionId, + ORCA_CLI_COMMAND: 'orca', + PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) const handle = mintStructuredWorkerHandle() @@ -61,6 +68,7 @@ describe('buildCodexStructuredChildEnvironment', () => { try { const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) + expect(env.ORCA_AGENT_SESSION_ID).toBe(sessionId) expect(env.ORCA_CLI_COMMAND).toBe('orca') // A pane key here would leak into hook-emitted agent statuses, which assume a PTY leaf. expect(env.ORCA_PANE_KEY).toBeUndefined() @@ -69,3 +77,62 @@ describe('buildCodexStructuredChildEnvironment', () => { } }) }) + +/** A real child speaking Codex's JSONL framing, answering with the environment it was spawned with. */ +const ENV_REPORTING_APP_SERVER = String.raw` + const readline = require('node:readline') + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'test/env') { + return send({ + id: message.id, + result: { + sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, + cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + path: process.env.PATH ?? process.env.Path ?? null + } + }) + } + }) +` + +describe('the spawned Codex child', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("runs with its own session id and this app's CLI, over an id inherited by Orca itself", async () => { + // The builder's output is an overlay on process.env, so only the spawned child proves the id + // survives the merge — an Orca launched inside another session inherits that session's id. + vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') + const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + const env = buildCodexStructuredChildEnvironment( + { + command: process.execPath, + args: ['-e', ENV_REPORTING_APP_SERVER], + cwd: process.cwd(), + codexHome: null, + resumeThreadId: null, + env: {} + }, + 'spawn-token', + sessionId + ) + const connection = await openCodexAppServerConnection({ + command: process.execPath, + args: ['-e', ENV_REPORTING_APP_SERVER], + env + }) + try { + await expect(connection.request('test/env')).resolves.toEqual({ + sessionId, + cliCommand: 'orca', + path: expect.stringMatching(DEV_CLI_BIN_FIRST) + }) + } finally { + await connection.close() + } + }) +}) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 43b76536c64e..b83941ae9f84 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -6,7 +6,6 @@ import { } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, @@ -35,7 +34,10 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].launch.env).toEqual({ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-9', CODEX_HOME: '/codex/home', - [ORCA_STRUCTURED_SESSION_ENV]: '1' + ORCA_AGENT_SESSION_ID: 'session-1', + ORCA_CLI_COMMAND: 'orca', + // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. + PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) }) expect(codex.connections[0].launch.cwd).toBe('/work/repo') expect(codex.connections[0].calls[0]).toEqual({ diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index f73f11e7f932..8895666706ec 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -413,6 +413,25 @@ describe('registerPtyHandlers', () => { ]) ) }) + it("strips an inherited agent session id and keeps a terminal view's own", async () => { + // Why: a daemon forked by an Orca launched inside a structured session inherits its id, + // and every daemon pane would present that session as its orchestration caller. + const inherited = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + ORCA_STRUCTURED_SESSION: '1' + }) + expect(inherited.envToDelete).toEqual( + expect.arrayContaining(['ORCA_AGENT_SESSION_ID', 'ORCA_STRUCTURED_SESSION']) + ) + const own = await daemonSpawnAndGetOptions( + { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }, + undefined, + undefined, + { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' } + ) + expect(own.envToDelete ?? []).not.toContain('ORCA_AGENT_SESSION_ID') + expect(own.env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') + }) it('preserves an explicitly requested Claude child-session stamp', async () => { // Why: only inherited values are poison; a caller deliberately spawning a // nested Claude child passes the stamp in args.env and must keep it. diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index c36fa3e40f5e..e269bc7f6df8 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -351,6 +351,23 @@ describe('registerPtyHandlers', () => { expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() }) + it('strips an inherited agent session id so a pane never claims that session', async () => { + // Why: an Orca launched inside a structured session inherits its id; every pane would then + // present that session as its orchestration caller instead of its own terminal. + const env = await spawnAndGetEnv(undefined, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + ORCA_STRUCTURED_SESSION: '1' + }) + expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() + expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() + }) + it('keeps the session id a terminal view is spawned with', async () => { + const env = await spawnAndGetEnv( + { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }, + { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' } + ) + expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') + }) it('keeps an explicitly requested Claude child-session stamp on a local spawn', async () => { const env = await spawnAndGetEnv( { CLAUDE_CODE_CHILD_SESSION: '1' }, diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index c74358b89b0e..7ac956b70fde 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -165,8 +165,8 @@ describe('PTY provider dispatch', () => { })) as { id: string } expect(result.id).toBe('ssh-pty-1') - // Why: the relay host can be launched from a Claude session too, so the stamps are - // stripped on the SSH path as well. Compared as a set — envToDelete is consumed by + // Why: the relay host can be launched from a Claude or structured session too, so the + // stamps are stripped on the SSH path as well; a remote pane never names a local session. Compared as a set — envToDelete is consumed by // membership only, so a reordering of the merge sources must not fail this. const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0] expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual( @@ -177,7 +177,9 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', - 'ORCA_PI_TITLE_MARKER_OWNED' + 'ORCA_PI_TITLE_MARKER_OWNED', + 'ORCA_AGENT_SESSION_ID', + 'ORCA_STRUCTURED_SESSION' ].sort() ) expect(mockSshProvider.spawn).toHaveBeenCalledWith( diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 1df30613076a..7134ebf3855a 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -16,6 +16,26 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') }) + it("carries a terminal view's session id into the guest untranslated, beside the WSL stamp", () => { + // Crossing is what makes the claim refusable: without it the pane handle would silently become + // the caller inside WSL, and the host could not tell the session was asking at all. + const env: Record = { + ORCA_TERMINAL_HANDLE: 'term_wsl', + ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' + } + stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu') + + addOrcaWslInteropEnv(env) + + expect(env.WSLENV?.split(':')).toEqual( + expect.arrayContaining([ + 'ORCA_AGENT_SESSION_ID/u', + 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u', + 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u' + ]) + ) + }) + // Why this is published at all: the wrapper tree is content-addressed, so the // in-guest login script cannot rebuild its path from ORCA_USER_DATA_PATH -- it // cannot derive the hash segment. Without this the guest finds no wrapper and diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 8d7d6fa68126..e22d6de28e86 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -266,6 +266,40 @@ describe('agent-session create operation ledger', () => { ) }) + it("spawns a structured session's terminal view with its session id, never persisting it", async () => { + // Switching a chat to terminal view must not change who it is as an orchestration caller. The + // id rides the spawn env only: the launch config persists, and a relaunch from it would replay + // the id without the handoff that binds the terminal to the session. + const sessionId = 'f7a1c0de-1111-4222-8333-444455556666' + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const resume = { + kind: 'explicit' as const, + worktree: 'id:worktree-1', + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: 'provider-session-1' } + } + + await runtime.ensureAgentSession( + resume, + {}, + { + spawnToken: 'spawn-9', + providerRoot: '/accounts/claude', + sessionId + } + ) + await runtime.ensureAgentSession(resume) + + const [handoff, plain] = createTerminal.mock.calls.map(([, opts]) => opts) + expect(handoff).toMatchObject({ + structuredAgentSessionId: sessionId, + env: expect.objectContaining({ ORCA_AGENT_SESSION_ID: sessionId }) + }) + expect(handoff?.launchConfig?.agentEnv).not.toHaveProperty('ORCA_AGENT_SESSION_ID') + expect(plain?.env ?? {}).not.toHaveProperty('ORCA_AGENT_SESSION_ID') + }) + it('selects nested SSH legacy fallback before reading a Pi transcript path locally', async () => { const runtime = createRuntime() const internal = runtime as unknown as { diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 7fe19c491e55..0b0581496c43 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -5,7 +5,10 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) -import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' +import { + structuredSessionChildIdentityEnv, + withStructuredSessionTerminalViewEnv +} from './structured-session-child-identity-env' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -57,60 +60,95 @@ afterEach(() => { }) describe('structuredSessionChildIdentityEnv', () => { - it('marks an ordinary chat session as having NO identity, and grants it nothing', () => { - // The marker names nothing — no handle, no pane key, no session id, no token — so it cannot be - // replayed or impersonated, and it does not reach the hook, agent-row or mobile-projection - // pipelines a pane key would. Its only job is to let the CLI REFUSE instead of guessing: this - // session has no pane, so every implicit-terminal guess resolved to a sibling, and a - // destructive `check` then consumed that sibling's mail. + it("gives an ordinary chat session its own id and this app's CLI, and no terminal identity", () => { + // The id names the caller, so a bare `orca orchestration check` acts as this session instead of + // guessing a terminal — every guess landed on a sibling pane, and `check` consumed its mail. pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const childEnv = { PATH: '/usr/bin' } const env = structuredSessionChildIdentityEnv(SESSION_ID, childEnv) - expect(env).toEqual({ PATH: '/usr/bin', ORCA_STRUCTURED_SESSION: '1' }) + expect(env).toEqual({ + PATH: `${SHIM_DIR}:/usr/bin`, + ORCA_AGENT_SESSION_ID: SESSION_ID, + ORCA_CLI_COMMAND: 'orca' + }) + // A chat names itself by its id alone: no handle, no pane key, no identity-less marker. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(env.ORCA_CLI_COMMAND).toBeUndefined() - // Still no CLI reachability granted, so packaged builds keep today's exposure. - expect(childEnv.PATH).toBe('/usr/bin') - expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() + expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() + expect(childEnv).toEqual({ PATH: '/usr/bin' }) }) - it('gives a packaged-Linux worker the bare-orca shim its ORCA_CLI_COMMAND assumes', () => { - // Without this the child's first `orca orchestration check` execs GNOME Orca — the CLI - // installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - const handle = registerWorker() - const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) - expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) - expect(env.ORCA_CLI_COMMAND).toBe('orca') - expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) + it('replaces an id inherited from an Orca launched inside another session', () => { + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + PATH: '/usr/bin' + }) + expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) }) - it('gives a packaged-macOS worker the bundled CLI dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() + it('gives a structured worker its id and keeps the handle it was minted', () => { + // For orchestration the id wins and the host maps it back to this handle, so the worker keeps + // one identity; the handle stays for the handle-based surfaces outside orchestration. + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const handle = registerWorker() const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) + expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) }) - it('gives a packaged-Windows worker the bundled CLI dir under the env block spelling', () => { - pinPlatform('win32') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) - expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) - expect(env.PATH).toBeUndefined() + describe.each(['chat', 'worker'] as const)("reaches this app's CLI as a %s", (kind) => { + beforeEach(() => { + if (kind === 'worker') { + registerWorker() + } + }) + + it('on packaged Linux, through the bare-orca shim its ORCA_CLI_COMMAND assumes', () => { + // Without this the child's first `orca orchestration check` execs GNOME Orca — the CLI + // installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) + expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) + }) + + it('on packaged macOS, through the bundled CLI dir', () => { + pinPlatform('darwin') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + }) + + it('on packaged Windows, through the bundled CLI dir under the env block spelling', () => { + pinPlatform('win32') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) + expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) + expect(env.PATH).toBeUndefined() + }) + + it('unpackaged, through the dev launcher dir', () => { + pinPlatform('darwin') + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) + }) }) - it('gives an unpackaged worker the dev launcher dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) - registerWorker() - const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) + it("gives the terminal view the same id, and leaves any other terminal's env as given", () => { + expect(withStructuredSessionTerminalViewEnv({ CLAUDE_CONFIG_DIR: '/c' }, SESSION_ID)).toEqual({ + CLAUDE_CONFIG_DIR: '/c', + ORCA_AGENT_SESSION_ID: SESSION_ID + }) + expect(withStructuredSessionTerminalViewEnv(undefined, SESSION_ID)).toEqual({ + ORCA_AGENT_SESSION_ID: SESSION_ID + }) + const plain = { CLAUDE_CONFIG_DIR: '/c' } + expect(withStructuredSessionTerminalViewEnv(plain, undefined)).toBe(plain) + expect(withStructuredSessionTerminalViewEnv(undefined, undefined)).toBeUndefined() }) it('never puts a pane key in the child environment', () => { diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index f2ab92b3d664..36ea752e7930 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -55,11 +55,13 @@ export function structuredSessionChildIdentityEnv( /** * The same session id for its terminal view, so switching views never changes who the session is. * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. + * A terminal that is not a session's view keeps its env exactly as given. */ -export function structuredSessionTerminalViewEnv( +export function withStructuredSessionTerminalViewEnv( + env: Record | undefined, sessionId: string | undefined -): Record { - return sessionId ? { [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : {} +): Record | undefined { + return sessionId ? { ...env, [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : env } /** diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index 53334e3cd125..811917cf7152 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -129,6 +129,28 @@ describe('buildHostCliEnv', () => { expect(env.ORCA_CLI_COMMAND).toBe('orca') }) + it('never lets a remote command claim a local agent session', () => { + // The host's env carries a session id when Orca was launched inside a structured session; the + // remote shell's own is from another machine. Session identity is same-host only. + const env = buildHostCliEnv({ + hostEnv: { + ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', + ORCA_STRUCTURED_SESSION: '1' + }, + remoteEnv: { + ORCA_TERMINAL_HANDLE: 'term_remote', + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' + }, + userDataPath: '/host/user-data', + remoteCwd: '/srv/repo' + }) + + expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() + expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() + // The remote command still speaks as its own terminal. + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') + }) + it('namespaces identical remote artifact paths by stable SSH target', () => { const artifactInput = { sourceKey: '/srv/repo/report.html', From 720fd277b84ce8804b363571cbd97200b43c4173 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:26:45 -0700 Subject: [PATCH 03/24] test(orchestration): pin one caller precedence rule across every CLI verb that names its caller Adds the per-verb table (flagless acts as the session; a conflicting --from or --terminal is refused before any request; the session's own spellings are accepted), the enumerated guess population with its positive control, the structured worker's own handle, the identity-less refusal for an older child, the unchanged terminal agent, and the envelope. dispatch-show's --from only fills preview text, so it passes through unfenced and a session's flagless preview names the address the real dispatch writes. --- .../orchestration/dispatch-handlers.ts | 9 +- .../orchestration/terminal-identity.ts | 20 +- .../orchestration/worker-list-run-scope.ts | 8 +- .../orchestration-session-caller-cli.test.ts | 476 ++++++++++++++++++ .../rpc/orchestration-session-caller.test.ts | 2 +- .../rpc/orchestration-session-caller.ts | 2 - src/shared/structured-worker-handle.ts | 2 +- 7 files changed, 499 insertions(+), 20 deletions(-) create mode 100644 src/cli/orchestration-session-caller-cli.test.ts diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index afe79ab8e2f3..5162ca6e291c 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -5,7 +5,7 @@ import { RuntimeClientError } from '../../runtime-client' import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' import { callOrchestrationMutation } from './mutation-request' import { isDevCliInvocation } from './runtime-compatibility' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { injectedSessionAddress, resolveCoordinatorTerminalHandle } from './terminal-identity' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { @@ -42,9 +42,12 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record = { export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record = { 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { const showPreamble = flags.has('preamble') ? true : undefined - // Why: a preview must embed the same real coordinator handle as an actual dispatch. + // Why: a preview must embed the same real coordinator handle as an actual dispatch. Its --from + // only fills preview text and names no caller, so it passes through unfenced. const from = showPreamble - ? await resolveCoordinatorTerminalHandle(flags, cwd, client) + ? (getOptionalStringFlag(flags, 'from') ?? + injectedSessionAddress() ?? + (await resolveCoordinatorTerminalHandle(flags, cwd, client))) : undefined const result = await client.call<{ dispatch: { id: string; task_id: string; status: string } | null diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index f9d72d76e3af..732fb13a77f0 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -173,7 +173,7 @@ function getClientErrorMessage(err: unknown): string | undefined { * identity this process carries; a caller flag may restate that same session but never name * another, and a conflicting one is refused here, before any request is sent. */ -export function resolveInjectedSessionCaller( +function resolveInjectedSessionCaller( flags: Map, flagName: 'from' | 'terminal' ): string | undefined { @@ -195,17 +195,25 @@ export function resolveInjectedSessionCaller( /** The session's own spellings, plus the handle a structured worker session was minted. */ function namesInjectedSession(value: string, sessionId: string): boolean { - if (normalizeOrchestrationActor(value)?.id === sessionId) { - return true + return normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress() +} + +/** + * The address the host gives this session: a structured worker keeps the handle it was minted, any + * other session is `session:`. Only for text that must match what the host writes. + */ +export function injectedSessionAddress(): string | undefined { + const sessionId = readInjectedAgentSessionId() + if (!sessionId) { + return undefined } const ownHandle = process.env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) && value === ownHandle + return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` } /** How check output names its caller: the handle, or the session's address. */ export function orchestrationCallerLabel(handle: string | undefined): string { - const sessionId = handle === undefined ? readInjectedAgentSessionId() : undefined - return handle ?? (sessionId ? `session:${sessionId}` : 'unknown') + return handle ?? injectedSessionAddress() ?? 'unknown' } export async function resolveCoordinatorTerminalHandle( diff --git a/src/cli/handlers/orchestration/worker-list-run-scope.ts b/src/cli/handlers/orchestration/worker-list-run-scope.ts index 83bfcd5b9e0b..130376161531 100644 --- a/src/cli/handlers/orchestration/worker-list-run-scope.ts +++ b/src/cli/handlers/orchestration/worker-list-run-scope.ts @@ -1,9 +1,6 @@ import { getOptionalStringFlag } from '../../flags' import type { RuntimeClient } from '../../runtime-client' -import { - resolveInjectedSessionCaller, - resolveOrchestrationTerminalHandle -} from './terminal-identity' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' /** Which Run `worker-list` enumerated, and why. Additive: old readers ignore it. */ export type WorkerListRunScope = { run?: string; source: 'flag' | 'bound' | 'all' } @@ -23,9 +20,6 @@ export async function resolveWorkerListRunScope( if (explicit) { return { run: explicit, source: 'flag' } } - // Outside the catch: a --terminal naming someone other than this session is refused, never - // widened into an unscoped listing. - resolveInjectedSessionCaller(flags, 'terminal') try { const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') const current = await client.call<{ run: { id: string } | null }>('orchestration.runCurrent', { diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts new file mode 100644 index 000000000000..93a76645a305 --- /dev/null +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -0,0 +1,476 @@ +/** + * A command that runs inside a structured agent session is that session: the injected + * `ORCA_AGENT_SESSION_ID` names the caller, and nothing resolves or guesses a terminal for it. + * + * One rule for every verb that names a caller: a caller flag may restate the session, but a flag + * naming anyone else is refused before any request — never silently dropped, never allowed to win. + * The #21097 accident was a chat that named a sibling's terminal and consumed that sibling's mail. + * + * The session env here is the hardest case, a chat in terminal view: it also carries its pane's + * `ORCA_TERMINAL_HANDLE` and `ORCA_PANE_KEY`, and the implicit-terminal guess has a sibling to find. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.hoisted(() => vi.fn()) +const getTerminalHandleMock = vi.hoisted(() => vi.fn()) + +vi.mock('./format', () => ({ printResult: vi.fn() })) +vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) + +import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' +import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' + +const SESSION = 'f7a1c0de-1111-4222-8333-444455556666' +const IDENTITY_ENV = [ + 'ORCA_AGENT_SESSION_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_PANE_KEY', + 'ORCA_STRUCTURED_SESSION' +] as const +const originalEnv = Object.fromEntries(IDENTITY_ENV.map((name) => [name, process.env[name]])) + +/** Enough of every receipt shape that each handler finishes after its RPC. */ +const RESULT = { + result: { + run: { id: 'run_1', objective: 'o', consumer_generation: 1 }, + runs: [], + nextCursor: null, + messages: [], + count: 0, + message: { id: 'msg_1' }, + lifecycle: { action: 'completed' }, + dispatch: { id: 'dispatch_1', task_id: 'task_1', status: 'dispatched' }, + gate: { id: 'gate_1', task_id: 'task_1', status: 'pending', resolution: 'r' }, + gates: [], + task: { id: 'task_1', status: 'pending' }, + tasks: [], + answer: 'yes', + messageId: 'msg_1', + threadId: 'thread_1', + timedOut: false, + state: 'ready', + runId: 'run_1', + taskId: 'task_1', + dispatchId: 'dispatch_1', + effects: [], + residualResources: [], + workers: [], + counts: {} + } +} + +type Verb = { + command: string + flags: Record + /** The flag that names the caller, when the verb has one. */ + callerFlag?: 'from' | 'terminal' + method: string + callerParam: 'from' | 'terminal' | 'callerTerminalHandle' +} + +/** Every verb whose request names its caller: the host's caller-param map, from the CLI side. + * `dispatch-show` is not one: its --from only fills preview text, so it is pinned on its own. */ +const CALLER_VERBS: Verb[] = [ + { + command: 'run-create', + flags: { objective: 'o' }, + callerFlag: 'from', + method: 'runCreate', + callerParam: 'from' + }, + { + command: 'run-use', + flags: { id: 'run_1' }, + callerFlag: 'from', + method: 'runUse', + callerParam: 'from' + }, + { + command: 'run-current', + flags: {}, + callerFlag: 'from', + method: 'runCurrent', + callerParam: 'from' + }, + { command: 'check', flags: {}, callerFlag: 'terminal', method: 'check', callerParam: 'terminal' }, + { + command: 'send', + flags: { to: 'term_worker', subject: 's', body: 'b' }, + callerFlag: 'from', + method: 'send', + callerParam: 'from' + }, + { + command: 'reply', + flags: { id: 'msg_1', body: 'b' }, + callerFlag: 'from', + method: 'reply', + callerParam: 'from' + }, + { + command: 'ask', + flags: { to: 'term_worker', question: 'q' }, + callerFlag: 'from', + method: 'ask', + callerParam: 'from' + }, + { + command: 'dispatch', + flags: { task: 'task_1', to: 'term_worker' }, + callerFlag: 'from', + method: 'dispatch', + callerParam: 'from' + }, + { + command: 'gate-create', + flags: { task: 'task_1', question: 'q' }, + callerFlag: 'from', + method: 'gateCreate', + callerParam: 'from' + }, + { + command: 'gate-resolve', + flags: { id: 'gate_1', resolution: 'r' }, + callerFlag: 'from', + method: 'gateResolve', + callerParam: 'from' + }, + { command: 'gate-list', flags: {}, callerFlag: 'from', method: 'gateList', callerParam: 'from' }, + { + command: 'task-create', + flags: { spec: 's' }, + callerFlag: 'from', + method: 'taskCreate', + callerParam: 'callerTerminalHandle' + }, + { + command: 'task-list', + flags: {}, + callerFlag: 'from', + method: 'taskList', + callerParam: 'callerTerminalHandle' + }, + { + command: 'task-update', + flags: { id: 'task_1', status: 'completed' }, + callerFlag: 'from', + method: 'taskUpdate', + callerParam: 'callerTerminalHandle' + }, + { + command: 'worker-start', + flags: { spec: 's' }, + callerFlag: 'from', + method: 'workerStart', + callerParam: 'from' + }, + // No caller flag: its spec takes none. It asks runCurrent for the caller's Run. + { command: 'worker-list', flags: {}, method: 'runCurrent', callerParam: 'from' } +] + +function flagMap(flags: Record): Map { + return new Map(Object.entries(flags)) +} + +async function invoke( + command: string, + flags: Map, + json = true +): Promise { + const handler = ORCHESTRATION_HANDLERS[`orchestration ${command}`] + if (!handler) { + throw new Error(`no handler for ${command}`) + } + await handler({ + flags, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only `call`; RuntimeClient is a class, so a structural double cannot satisfy it without the cast. + client: { call: callMock } as never, + cwd: '/tmp/repo', + json + }) +} + +function isParams(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function callsTo(method: string): Record[] { + return callMock.mock.calls + .filter(([name]) => name === `orchestration.${method}`) + .map(([, params]) => (isParams(params) ? params : {})) +} + +function setEnv(env: Partial>): void { + for (const name of IDENTITY_ENV) { + const value = env[name] + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } +} + +/** A chat in terminal view: its id, plus the pane identity that view inherits. */ +function asSessionInTerminalView(): void { + setEnv({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_TERMINAL_HANDLE: 'term_view_pane', + ORCA_PANE_KEY: 'tab_view:11111111-1111-4111-8111-111111111111' + }) +} + +beforeEach(() => { + callMock.mockReset().mockResolvedValue(RESULT) + getTerminalHandleMock.mockReset().mockResolvedValue('term_sibling') + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + setEnv(originalEnv) + process.exitCode = undefined +}) + +describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (verb) => { + beforeEach(asSessionInTerminalView) + + it('acts as the session: no terminal is resolved, guessed or sent', async () => { + await invoke(verb.command, flagMap(verb.flags)) + + const [params] = callsTo(verb.method) + expect(params, 'the verb reached its method').toBeDefined() + expect(params?.[verb.callerParam]).toBeUndefined() + // A terminal view's pane is not the session's identity. + expect(params?.terminalPaneKey).toBeUndefined() + expect(params?.senderPaneKey).toBeUndefined() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock.mock.calls.map(([name]) => name)).not.toEqual( + expect.arrayContaining([expect.stringMatching(/^terminal\./)]) + ) + }) + + it.runIf(verb.callerFlag !== undefined)( + 'refuses a caller flag naming another actor, before any request', + async () => { + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_sibling' }) + + await expect(invoke(verb.command, flags)).rejects.toMatchObject({ + code: 'consumer_fenced', + message: expect.stringContaining(`agent session ${SESSION}`) + }) + expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + } + ) + + it.runIf(verb.callerFlag !== undefined)( + "refuses its own terminal view's pane handle too: the session, not the pane, is the caller", + async () => { + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_view_pane' }) + + await expect(invoke(verb.command, flags)).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + } + ) + + it.runIf(verb.callerFlag !== undefined).each([`session:${SESSION}`, SESSION])( + 'accepts a caller flag that restates the session (%s)', + async (restated) => { + await invoke(verb.command, flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: restated })) + + const [params] = callsTo(verb.method) + expect(params, 'the verb reached its method').toBeDefined() + expect(params?.[verb.callerParam]).toBeUndefined() + } + ) +}) + +describe('the identity a session presents', () => { + it("lets a structured worker restate its own minted handle, and nobody else's", async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) + + await invoke('send', flagMap({ from: 'structworker_self', to: 'run:run_1', subject: 's' })) + expect(callsTo('send')[0]?.from).toBeUndefined() + + callMock.mockClear() + await expect( + invoke('send', flagMap({ from: 'structworker_other', to: 'run:run_1', subject: 's' })) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + }) + + it("sends a structured worker's lifecycle report as the session, not refused as identity-less", async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION }) + + await invoke( + 'send', + flagMap({ to: 'run:run_1', subject: 'done', type: 'worker_done', outcome: 'succeeded' }) + ) + + expect(callsTo('send')[0]).toMatchObject({ type: 'worker_done' }) + expect(callsTo('send')[0]?.from).toBeUndefined() + }) + + it('never treats a session that has an id as identity-less, even beside the old marker', async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_STRUCTURED_SESSION: '1' }) + + await invoke('check', flagMap({})) + + expect(callsTo('check')).toHaveLength(1) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + + it('keeps the identity-less refusal, without --from advice, for a child that has no id', async () => { + setEnv({ ORCA_STRUCTURED_SESSION: '1' }) + + await expect(invoke('reply', flagMap({ id: 'msg_1', body: 'b' }))).rejects.toMatchObject({ + code: 'no_active_sender_terminal', + message: expect.not.stringContaining('Pass --from') + }) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + }) + + it('leaves a terminal agent exactly as it was: its own handle is the caller', async () => { + setEnv({ ORCA_TERMINAL_HANDLE: 'term_pty', ORCA_PANE_KEY: 'tab_pty:1:2' }) + callMock.mockImplementation(async (name: string) => + name === 'terminal.resolveIdentity' ? { result: { identity: { live: true } } } : RESULT + ) + + await invoke('run-create', flagMap({ objective: 'o' })) + await invoke('check', flagMap({})) + + expect(callsTo('runCreate')[0]?.from).toBe('term_pty') + expect(callsTo('check')[0]).toMatchObject({ + terminal: 'term_pty', + terminalPaneKey: 'tab_pty:1:2' + }) + }) + + it('previews a dispatch with the coordinator address the real dispatch would write', async () => { + const preview = async (flags: Record) => { + callMock.mockClear() + await invoke('dispatch-show', flagMap({ task: 'task_1', preamble: true, ...flags })) + return callsTo('dispatchShow')[0]?.from + } + asSessionInTerminalView() + expect(await preview({})).toBe(`session:${SESSION}`) + // Not a caller flag: it names the text to preview, so it is never fenced. + expect(await preview({ from: 'term_sibling' })).toBe('term_sibling') + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) + expect(await preview({})).toBe('structworker_self') + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + + it('resumes a timed-out ask as the session, without naming a terminal', async () => { + asSessionInTerminalView() + callMock.mockResolvedValue({ result: { ...RESULT.result, answer: null, timedOut: true } }) + const errors = vi.mocked(console.error) + + await invoke('ask', flagMap({ to: 'term_worker', question: 'q' }), false) + + const advice = errors.mock.calls.map(([line]) => String(line)).join('\n') + expect(advice).toContain('--resume msg_1') + expect(advice).not.toContain('--from') + }) +}) + +describe('the orchestration envelope', () => { + it('carries the injected id beside whatever terminal evidence the process also has', () => { + const envelope = createOrchestrationCompatibilityEnvelope({ + ORCA_AGENT_SESSION_ID: ` ${SESSION} `, + ORCA_TERMINAL_HANDLE: 'term_view_pane' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ + terminalHandle: 'term_view_pane', + agentSessionId: SESSION + }) + }) + + it('claims no session without an injected id', () => { + expect( + createOrchestrationCompatibilityEnvelope({ ORCA_AGENT_SESSION_ID: ' ' }) + .orchestrationCompatibilityEvidence + ).toBeUndefined() + }) + + it('keeps a WSL stamp beside the id, so the host can refuse the cross-host claim', () => { + const envelope = createOrchestrationCompatibilityEnvelope({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ + agentSessionId: SESSION, + host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' } + }) + }) +}) + +describe('every orchestration verb, enumerated', () => { + /** Enough flags for any verb to get past its own validation to identity resolution. */ + const EVERY_REQUIRED_FLAG = { + objective: 'o', + id: 'id_1', + task: 'task_1', + spec: 's', + question: 'q', + resolution: 'r', + subject: 's', + body: 'b', + to: 'term_worker', + status: 'completed', + preamble: true, + request: 'req_1' + } as const + + /** Runs every verb once; returns the ones that guessed an implicit terminal. */ + async function verbsThatGuess(): Promise { + const guessed: string[] = [] + for (const command of Object.keys(ORCHESTRATION_HANDLERS)) { + const verb = command.replace('orchestration ', '') + getTerminalHandleMock.mockClear() + callMock.mockClear() + await invoke(verb, flagMap(EVERY_REQUIRED_FLAG)).catch(() => undefined) + if (getTerminalHandleMock.mock.calls.length > 0) { + guessed.push(verb) + } + } + return guessed.sort() + } + + it('guesses a terminal for no verb when the session id is present', async () => { + // Positive control first: with no identity at all the same harness sees the guess, so an empty + // result below is about the id, not a harness that cannot observe a guess. + setEnv({}) + const population = await verbsThatGuess() + expect(population).toEqual([ + 'ask', + 'check', + 'dispatch', + 'dispatch-show', + 'gate-create', + 'gate-list', + 'gate-resolve', + 'reply', + 'run-create', + 'run-current', + 'run-use', + 'send', + 'task-create', + 'task-list', + 'task-update', + 'worker-list', + 'worker-start' + ]) + + asSessionInTerminalView() + expect(await verbsThatGuess()).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index c91ad0ff1692..d96e214d4421 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -43,6 +43,7 @@ const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle'] a const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', 'orchestration.runShow from': 'reads a Run by id; `from` is unused', + 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal' @@ -58,7 +59,6 @@ const MINIMAL_PARAMS: Readonly>> = { 'orchestration.reply': { id: 'msg_missing', body: 'b' }, 'orchestration.ask': { question: 'q', to: 'term_worker' }, 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, - 'orchestration.dispatchShow': { task: 'task_missing', preamble: true }, 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, 'orchestration.gateList': {}, diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index deeeb397ca30..e4bd09f2619f 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -50,8 +50,6 @@ export const ORCHESTRATION_CALLER_PARAM: Readonly> = 'orchestration.reply': 'from', 'orchestration.ask': 'from', 'orchestration.dispatch': 'from', - // The preview names its caller as the coordinator, exactly as the dispatch it previews does. - 'orchestration.dispatchShow': 'from', 'orchestration.gateCreate': 'from', 'orchestration.gateResolve': 'from', 'orchestration.gateList': 'from', diff --git a/src/shared/structured-worker-handle.ts b/src/shared/structured-worker-handle.ts index e36df8135706..c9fd3927c02c 100644 --- a/src/shared/structured-worker-handle.ts +++ b/src/shared/structured-worker-handle.ts @@ -2,6 +2,6 @@ // renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' -export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { +export function isStructuredWorkerHandle(handle: string | null | undefined): handle is string { return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) } From 8224b04a614a099fb66ad3aecdc488cc1dda8c13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:27:36 -0700 Subject: [PATCH 04/24] refactor(orchestration): keep the identity-less marker reader to the marker; the id is checked first --- src/shared/structured-session-marker.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 1725a6ef337e..923f75624a1b 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,20 +1,14 @@ -import { readInjectedAgentSessionId } from './agent-session-caller-env' - /** * The marker a structured chat session's child carried when it had NO orchestration identity. * - * Current hosts no longer write it: every structured child carries its injected session id - * instead. The reader stays for a child spawned by an Orca that predates injection, which can still - * reach a newer CLI through a global install — it must refuse, not guess, because a structured - * session has no pane, so every implicit-terminal guess resolves to a sibling and + * Current hosts no longer write it: every structured child carries its injected session id, which + * the CLI checks first. The reader stays for a child spawned by an Orca that predates injection, + * which can still reach a newer CLI through a global install — it must refuse, not guess, because a + * structured session has no pane, so every implicit-terminal guess resolves to a sibling and * `orchestration check` is destructive by default. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' -/** An injected session id is an identity, so a child carrying one is never identity-less. */ export function isStructuredSessionWithoutIdentity(env: NodeJS.ProcessEnv = process.env): boolean { - return ( - (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 && - readInjectedAgentSessionId(env) === undefined - ) + return (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 } From 992f3b1132063ad9c3630509a72362a6dfcbb813 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:59:50 -0700 Subject: [PATCH 05/24] test(orchestration): pin that a host refusal of the session surfaces verbatim from the CLI --- .../orchestration-session-caller-cli.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 93a76645a305..501eb3188c54 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -20,6 +20,8 @@ vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' +import { formatCliError, reportCliError } from './cli-error' +import { RuntimeRpcFailureError } from './runtime/types' const SESSION = 'f7a1c0de-1111-4222-8333-444455556666' const IDENTITY_ENV = [ @@ -378,6 +380,55 @@ describe('the identity a session presents', () => { }) }) +describe('a host refusal of the session', () => { + const WORKER_GONE = new RuntimeRpcFailureError({ + id: 'rpc_1', + ok: false, + error: { + code: 'session_caller_not_live', + message: `Agent session ${SESSION} is a structured worker whose worker identity this host no longer has, so it cannot act in orchestration. No effects were applied.`, + data: { effectsApplied: false } + }, + _meta: { runtimeId: 'runtime_1' } + }) + + it.each(['check', 'run-current', 'worker-list'])( + 'surfaces from %s verbatim, never widened, retried or turned into a terminal guess', + async (command) => { + asSessionInTerminalView() + callMock.mockRejectedValue(WORKER_GONE) + + await expect(invoke(command, flagMap({}))).rejects.toBe(WORKER_GONE) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(formatCliError(WORKER_GONE)).toBe(WORKER_GONE.message) + } + ) + + it('keeps the Orca id a provider-id refusal names, for a JSON reader to branch on', () => { + const providerId = new RuntimeRpcFailureError({ + id: 'rpc_1', + ok: false, + error: { + code: 'session_caller_provider_id', + message: 'provider id', + data: { effectsApplied: false, orcaSessionId: SESSION } + }, + _meta: { runtimeId: 'runtime_1' } + }) + const printed: string[] = [] + vi.mocked(console.log).mockImplementation((line: string) => { + printed.push(line) + }) + + reportCliError(providerId, true) + + expect(JSON.parse(printed.join('\n'))).toMatchObject({ + ok: false, + error: { code: 'session_caller_provider_id', data: { orcaSessionId: SESSION } } + }) + }) +}) + describe('the orchestration envelope', () => { it('carries the injected id beside whatever terminal evidence the process also has', () => { const envelope = createOrchestrationCompatibilityEnvelope({ From d1672af7e3eb6643fd12ca489fd6c3c3efffc3b8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:15:52 -0700 Subject: [PATCH 06/24] fix(orchestration): keep the identity-less marker beside the id for CLIs that predate it A CLI older than the id, reached through a global install when a shell rc resets PATH, would otherwise guess a sibling's terminal in a chat that no longer carries the marker. It refuses on the marker instead; a current CLI checks the id first, so the marker never makes a session with an id identity-less. --- .../codex-structured-child-environment.test.ts | 2 ++ .../codex/codex-structured-session-adapter.test.ts | 1 + .../structured-session-child-identity-env.test.ts | 5 +++-- .../structured-session-child-identity-env.ts | 8 ++++++-- src/shared/agent-session-caller-env.ts | 3 ++- src/shared/structured-session-marker.ts | 13 +++++++------ 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index bddb4c69aeb3..b674fd72ef5f 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -31,6 +31,7 @@ describe('buildCodexStructuredChildEnvironment', () => { CODEX_HOME: '/pinned/home', [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', ORCA_AGENT_SESSION_ID: 'session-not-a-worker', + ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: 'orca', // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -51,6 +52,7 @@ describe('buildCodexStructuredChildEnvironment', () => { [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', // Not a worker, so no handle: the id alone names this chat as a caller. ORCA_AGENT_SESSION_ID: sessionId, + ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: 'orca', PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index b83941ae9f84..0754ad9b0cd7 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -35,6 +35,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { [CODEX_SPAWN_TOKEN_ENV]: 'spawn-9', CODEX_HOME: '/codex/home', ORCA_AGENT_SESSION_ID: 'session-1', + ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: 'orca', // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 0b0581496c43..6087c1b5356c 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -70,12 +70,13 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env).toEqual({ PATH: `${SHIM_DIR}:/usr/bin`, ORCA_AGENT_SESSION_ID: SESSION_ID, + // For a CLI that predates the id, which refuses on it instead of guessing a sibling. + ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: 'orca' }) - // A chat names itself by its id alone: no handle, no pane key, no identity-less marker. + // A chat names itself by its id alone: no handle, no pane key. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() expect(childEnv).toEqual({ PATH: '/usr/bin' }) }) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index 36ea752e7930..28f15fa24df4 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -25,8 +25,10 @@ * PTY leaf. It would also open `selectExactWorkerProviderSession`, which is fail-closed today * precisely because a structured session emits no hook agent status. * - * The identity-less marker (`ORCA_STRUCTURED_SESSION`) is no longer written: a child with an id is - * never identity-less, so a marker inherited from an Orca launched inside an older session is inert. + * `ORCA_STRUCTURED_SESSION` stays beside the id for a CLI that predates it — one reached through a + * global install when a shell rc resets PATH — which would otherwise guess a sibling's terminal; + * such a CLI refuses on the marker. A current CLI checks the id first, so the marker never makes a + * session with an id identity-less. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. @@ -34,6 +36,7 @@ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { structuredWorkerIdentities } from './structured-worker-identity' @@ -46,6 +49,7 @@ export function structuredSessionChildIdentityEnv( ...childEnv, ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), [ORCA_AGENT_SESSION_ID_ENV]: sessionId, + [ORCA_STRUCTURED_SESSION_ENV]: '1', ORCA_CLI_COMMAND: 'orca' } applyOrcaCliPath(env) diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index d0f5e8605596..7968dff7e13b 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -5,7 +5,8 @@ * terminal is resolved or guessed on its behalf. * * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, - * WSL) re-opens that decision, which is why nothing forwards the id across one as a caller. + * WSL) re-opens that decision: the host refuses a claim that arrives across one, and the SSH + * passthrough never carries the id. */ export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 923f75624a1b..3c849db8f9a8 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,11 +1,12 @@ /** - * The marker a structured chat session's child carried when it had NO orchestration identity. + * Marks a structured session's child. It names nothing — no handle, no pane key, no token. * - * Current hosts no longer write it: every structured child carries its injected session id, which - * the CLI checks first. The reader stays for a child spawned by an Orca that predates injection, - * which can still reach a newer CLI through a global install — it must refuse, not guess, because a - * structured session has no pane, so every implicit-terminal guess resolves to a sibling and - * `orchestration check` is destructive by default. + * Every structured child now also carries its injected session id, and a current CLI checks the id + * first, so for it the marker only matters when the id is absent: a child spawned by an Orca that + * predates injection. The marker is still written for the opposite case, a CLI that predates the + * id, which refuses on it. Either way the answer is refuse, never guess: a structured session has + * no pane, so every implicit-terminal guess resolves to a sibling, and `orchestration check` is + * destructive by default. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' From 2156beac0687c1c5d94fc843cc3d1a980d98ddc5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:52:23 -0700 Subject: [PATCH 07/24] fix(orchestration): refuse a conflicting --from on gate-list and task-list scoped by --run A --run listing needs no caller, so both handlers skipped the resolver and a --from naming another actor was dropped silently under a session. The conflict check now runs on that branch too; terminal callers are unchanged. --- .../handlers/orchestration/gate-handlers.ts | 9 +++++-- .../handlers/orchestration/task-handlers.ts | 7 ++++-- .../orchestration/terminal-identity.ts | 12 ++++++++++ .../orchestration-session-caller-cli.test.ts | 24 +++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts index f9ec4c6583fb..9af0faa1be9e 100644 --- a/src/cli/handlers/orchestration/gate-handlers.ts +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -2,7 +2,10 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalJsonFlag, getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { callOrchestrationMutation } from './mutation-request' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { + refuseConflictingSessionCaller, + resolveCoordinatorTerminalHandle +} from './terminal-identity' export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-create': async ({ flags, client, cwd, json }) => { @@ -37,7 +40,9 @@ export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const from = run + ? refuseConflictingSessionCaller(flags, 'from') + : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] count: number diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts index c4c943261e8c..9533b1e5f8aa 100644 --- a/src/cli/handlers/orchestration/task-handlers.ts +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -4,7 +4,10 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' import { callOrchestrationMutation } from './mutation-request' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { + refuseConflictingSessionCaller, + resolveCoordinatorTerminalHandle +} from './terminal-identity' const TASK_STATUS_VALUES = [ 'pending', @@ -39,7 +42,7 @@ export const ORCHESTRATION_TASK_HANDLERS: Record = { const brief = flags.has('brief') const run = getOptionalStringFlag(flags, 'run') const callerTerminalHandle = run - ? undefined + ? refuseConflictingSessionCaller(flags, 'from') : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ tasks: { diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 732fb13a77f0..9766bd372144 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -193,6 +193,18 @@ function resolveInjectedSessionCaller( return sessionId } +/** + * For a listing scoped by `--run`, which needs no caller: a session still refuses a caller flag + * naming someone else rather than dropping it. + */ +export function refuseConflictingSessionCaller( + flags: Map, + flagName: 'from' | 'terminal' +): undefined { + resolveInjectedSessionCaller(flags, flagName) + return undefined +} + /** The session's own spellings, plus the handle a structured worker session was minted. */ function namesInjectedSession(value: string, sessionId: string): boolean { return normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress() diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 501eb3188c54..7118dcac4ab2 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -290,6 +290,30 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v ) }) +describe.each([ + { command: 'gate-list', method: 'gateList', callerParam: 'from' }, + { command: 'task-list', method: 'taskList', callerParam: 'callerTerminalHandle' } +])('orchestration $command --run run as an agent session', ({ command, method, callerParam }) => { + beforeEach(asSessionInTerminalView) + + it('needs no caller, but refuses a --from naming another actor, before any request', async () => { + await invoke(command, flagMap({ run: 'run_1' })) + expect(callsTo(method)[0]).toMatchObject({ run: 'run_1' }) + expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() + + callMock.mockClear() + await expect( + invoke(command, flagMap({ run: 'run_1', from: 'term_sibling' })) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + }) + + it('accepts a --from that restates the session', async () => { + await invoke(command, flagMap({ run: 'run_1', from: `session:${SESSION}` })) + expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() + }) +}) + describe('the identity a session presents', () => { it("lets a structured worker restate its own minted handle, and nobody else's", async () => { setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) From 95325027f75fc2025ce6f795dd86b2e88ff3bd89 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:45:15 -0700 Subject: [PATCH 08/24] fix(orchestration): name this app's CLI by absolute path for a structured session's login shells A provider can run each command in a login shell: Codex runs zsh -lc, and the profile rebuilds PATH, putting a global install (possibly an older Orca) ahead of the directory Orca prepended. ORCA_CLI_COMMAND, which an agent resolves the CLI from first, is now the absolute launcher in that directory (the native launcher on Windows), so no shell's startup files can swap it. The PATH prepend stays for shells that read no profile. Found by the live coordinator run of the next PR. --- ...laude-structured-launch-resolution.test.ts | 2 +- src/main/cli/orca-cli-child-path.ts | 16 ++++- ...codex-structured-child-environment.test.ts | 10 +-- .../codex-structured-session-adapter.test.ts | 2 +- ...uctured-session-child-identity-env.test.ts | 10 ++- .../structured-session-child-identity-env.ts | 25 ++++--- ...structured-session-cli-login-shell.test.ts | 70 +++++++++++++++++++ 7 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 src/main/runtime/structured-session-cli-login-shell.test.ts diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index d34a296cfa24..66fed64c3230 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -169,7 +169,7 @@ describe('claude structured launch resolution', () => { expect(launch.env).toMatchObject({ ORCA_AGENT_SESSION_ID: SESSION_ID, - ORCA_CLI_COMMAND: 'orca' + ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/) }) expect(launch.env?.ORCA_AGENT_SESSION_ID).not.toBe(launch.providerSessionId) }) diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index f053e54c3d53..2a8136c367fc 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -17,6 +17,8 @@ import { delimiter, join } from 'node:path' import { readInheritedPath } from '../ipc/pty/host-env/path' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { DEV_COMMAND_NAME } from './cli-install-constants' export type OrcaCliChildPathOptions = { isPackaged: boolean @@ -26,11 +28,16 @@ export type OrcaCliChildPathOptions = { platform?: NodeJS.Platform } -/** Mutates `env` in place, prepending the directory that makes bare `orca` this app's CLI. */ +/** + * Mutates `env` in place, prepending the directory that makes bare `orca` this app's CLI. Returns + * the absolute launcher in that directory, or null when none was prepended: a child whose shell + * rebuilds PATH (a login shell reordering it behind a global install) can still name this app's + * CLI by path. + */ export function prependOrcaCliDirToChildPath( env: Record, opts: OrcaCliChildPathOptions -): void { +): string | null { const platform = opts.platform ?? process.platform // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. @@ -43,6 +50,7 @@ export function prependOrcaCliDirToChildPath( env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${devCliBin}${pathDelimiter}${inheritedPath}` : devCliBin + return join(devCliBin, platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME) } else if (platform === 'linux') { // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) @@ -51,6 +59,7 @@ export function prependOrcaCliDirToChildPath( .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) env.PATH = [shimDir, ...inheritedEntries].join(pathDelimiter) + return join(shimDir, 'orca') } } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. @@ -59,5 +68,8 @@ export function prependOrcaCliDirToChildPath( env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${bundledCliBin}${pathDelimiter}${inheritedPath}` : bundledCliBin + // Why the native launcher on Windows: `orca.cmd` refuses message bodies cmd.exe would mangle. + return getBundledLauncherPath(platform, opts.resourcesPath) } + return null } diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index b674fd72ef5f..dfbe00d7021d 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -10,6 +10,8 @@ import { } from '../runtime/structured-worker-identity' const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ +// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global. +const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ describe('buildCodexStructuredChildEnvironment', () => { it('keeps shell exports while pinned launch values win', () => { @@ -32,7 +34,7 @@ describe('buildCodexStructuredChildEnvironment', () => { [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: 'orca', + ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -53,7 +55,7 @@ describe('buildCodexStructuredChildEnvironment', () => { // Not a worker, so no handle: the id alone names this chat as a caller. ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: 'orca', + ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -71,7 +73,7 @@ describe('buildCodexStructuredChildEnvironment', () => { const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) expect(env.ORCA_AGENT_SESSION_ID).toBe(sessionId) - expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.ORCA_CLI_COMMAND).toMatch(DEV_CLI_LAUNCHER) // A pane key here would leak into hook-emitted agent statuses, which assume a PTY leaf. expect(env.ORCA_PANE_KEY).toBeUndefined() } finally { @@ -130,7 +132,7 @@ describe('the spawned Codex child', () => { try { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, - cliCommand: 'orca', + cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) } finally { diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 0754ad9b0cd7..12eb0755f95d 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -36,7 +36,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { CODEX_HOME: '/codex/home', ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: 'orca', + ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) }) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 6087c1b5356c..3456f8af7237 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -72,7 +72,7 @@ describe('structuredSessionChildIdentityEnv', () => { ORCA_AGENT_SESSION_ID: SESSION_ID, // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: 'orca' + ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca') }) // A chat names itself by its id alone: no handle, no pane key. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() @@ -106,13 +106,13 @@ describe('structuredSessionChildIdentityEnv', () => { } }) - it('on packaged Linux, through the bare-orca shim its ORCA_CLI_COMMAND assumes', () => { + it('on packaged Linux, through the bare-orca shim, named by absolute path', () => { // Without this the child's first `orca orchestration check` execs GNOME Orca — the CLI // installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout. pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) - expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.ORCA_CLI_COMMAND).toBe(join(SHIM_DIR, 'orca')) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) }) @@ -121,6 +121,7 @@ describe('structuredSessionChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca')) }) it('on packaged Windows, through the bundled CLI dir under the env block spelling', () => { @@ -129,6 +130,8 @@ describe('structuredSessionChildIdentityEnv', () => { const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) expect(env.PATH).toBeUndefined() + // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. + expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) }) it('unpackaged, through the dev launcher dir', () => { @@ -136,6 +139,7 @@ describe('structuredSessionChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_COMMAND).toBe(join(USER_DATA, 'cli', 'bin', 'orca-dev')) }) }) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index 28f15fa24df4..287491863f9b 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -13,11 +13,15 @@ * handle-based surfaces outside orchestration; for orchestration the id wins and the host maps it * back to that handle, so the worker keeps one identity. * - * `ORCA_CLI_COMMAND: 'orca'` is honest ONLY because of the PATH prepend below. Orca's Linux CLI - * installs as `orca-ide` so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and - * on packaged macOS/Windows the bundled launcher is reachable only from the app's own resources - * dir. A PTY agent gets that treatment from `buildPtyHostEnv`; a structured session has no PTY, so - * it applies the SAME function here rather than a second, drifting copy of the rule. + * The PATH prepend below makes bare `orca` this app's CLI, the SAME function `buildPtyHostEnv` + * applies, rather than a second, drifting copy of the rule. Orca's Linux CLI installs as `orca-ide` + * so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and on packaged macOS/Windows + * the bundled launcher is reachable only from the app's own resources dir. + * + * `ORCA_CLI_COMMAND` is the absolute launcher in that directory, because a provider can run each + * command in a login shell (Codex runs `zsh -lc`), whose profile rebuilds PATH and puts a global + * install — possibly an older Orca — ahead of this app's. The absolute path is what an agent resolves + * the CLI from, so it survives any shell's startup files, on every platform. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, @@ -49,10 +53,9 @@ export function structuredSessionChildIdentityEnv( ...childEnv, ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), [ORCA_AGENT_SESSION_ID_ENV]: sessionId, - [ORCA_STRUCTURED_SESSION_ENV]: '1', - ORCA_CLI_COMMAND: 'orca' + [ORCA_STRUCTURED_SESSION_ENV]: '1' } - applyOrcaCliPath(env) + env.ORCA_CLI_COMMAND = applyOrcaCliPath(env) ?? 'orca' return env } @@ -72,12 +75,12 @@ export function withStructuredSessionTerminalViewEnv( * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. */ -function applyOrcaCliPath(env: Record): void { +function applyOrcaCliPath(env: Record): string | null { if (!hasAppEnvironment()) { - return + return null } const app = getAppEnvironment() - prependOrcaCliDirToChildPath(env, { + return prependOrcaCliDirToChildPath(env, { isPackaged: app.isPackaged(), userDataPath: app.getPath('userData'), resourcesPath: process.resourcesPath ?? null diff --git a/src/main/runtime/structured-session-cli-login-shell.test.ts b/src/main/runtime/structured-session-cli-login-shell.test.ts new file mode 100644 index 000000000000..9d2d7ceab239 --- /dev/null +++ b/src/main/runtime/structured-session-cli-login-shell.test.ts @@ -0,0 +1,70 @@ +/** + * A provider can run every command in a login shell: Codex runs `/bin/zsh -lc `. The + * login profile rebuilds PATH — macOS's path_helper, a user's `.zprofile` — so the directory Orca + * prepended ends up behind a global install, and bare `orca` becomes that install, possibly an + * older Orca. `ORCA_CLI_COMMAND` names this app's launcher by absolute path, which no startup file + * can reorder. Real shells, with a profile that puts a stand-in global `orca` first. + */ + +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { runProcess } from '../../shared/child-process/run-process' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' + +const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' + +function writeStub(path: string, says: string): void { + writeFileSync(path, `#!/bin/sh\nprintf '%s' '${says}'\n`) + chmodSync(path, 0o755) +} + +describe.runIf(process.platform !== 'win32')('a structured session in a login shell', () => { + let root: string + let env: Record + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-login-shell-cli-')) + const home = join(root, 'home') + const globalBin = join(root, 'global-bin') + const userData = join(root, 'user-data') + const appCliBin = join(userData, 'cli', 'bin') + for (const dir of [home, globalBin, appCliBin]) { + mkdirSync(dir, { recursive: true }) + } + // A global install that the user's profile puts first, as `/usr/local/bin` often is. + writeStub(join(globalBin, 'orca'), 'global') + writeStub(join(appCliBin, 'orca'), 'app') + writeStub(join(appCliBin, 'orca-dev'), 'app') + const prependGlobal = `export PATH="${globalBin}:$PATH"\n` + writeFileSync(join(home, '.zprofile'), prependGlobal) + writeFileSync(join(home, '.bash_profile'), prependGlobal) + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => userData }) + env = structuredSessionChildIdentityEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' }) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + async function run(shell: string, script: string): Promise { + const result = await runProcess({ program: shell, args: ['-lc', script], env }) + return result.stdout + } + + it.each(['/bin/zsh', '/bin/bash'])( + "resolves this app's CLI through ORCA_CLI_COMMAND in `%s -lc`", + async (shell) => { + // Positive control: the profile really does put the global install first for a bare name. + expect(await run(shell, 'orca')).toBe('global') + expect(await run(shell, '"$ORCA_CLI_COMMAND"')).toBe('app') + } + ) + + it("keeps bare `orca` this app's CLI in a shell that reads no login profile", async () => { + const result = await runProcess({ program: '/bin/zsh', args: ['-c', 'orca'], env }) + expect(result.stdout).toBe('app') + }) +}) From 0842014b5b5efca5014ea67304f7a7b17b1d1260 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:59:04 -0700 Subject: [PATCH 09/24] test(orchestration): pin a structured worker's CLI command as this app's absolute launcher --- .../orchestration-structured-worker-session.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 8c71d2eae176..c3c69dc15fd2 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,5 +1,7 @@ +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { getAppEnvironment } from '../../../../shared/app-environment' import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' const hostRef: { current: unknown } = { current: null } @@ -93,7 +95,15 @@ describe('structured worker session', () => { onJournalActivity: () => {} }) expect(envAtSpawn?.ORCA_TERMINAL_HANDLE).toBe(created.identity.handle) - expect(envAtSpawn?.ORCA_CLI_COMMAND).toBe('orca') + // This app's own launcher by absolute path, so a login shell's profile cannot swap in a global. + expect(envAtSpawn?.ORCA_CLI_COMMAND).toBe( + join( + getAppEnvironment().getPath('userData'), + 'cli', + 'bin', + process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev' + ) + ) expect(envAtSpawn?.ORCA_PANE_KEY).toBeUndefined() releaseStructuredWorkerSession('d_spawn') }) From 29c279e525aba8b3cb39433daa66c6f11b9fe595 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:56:58 -0700 Subject: [PATCH 10/24] test(orchestration): run the zsh login-shell arm in the real-shell lane that installs zsh The ordinary Linux unit lane has no /bin/zsh, so the zsh arm failed there with ENOENT. It moves to a live-shell file registered in the shell-contracts lane; the bash arm keeps running in every lane. The lane guard's detector now also sees a zsh spawned through the ProcessSpec program field, which is how this test escaped it. --- .github/workflows/pr.yml | 1 + config/scripts/ci-unit-files.mjs | 1 + .../scripts/pr-workflow-parallelism.test.mjs | 3 +- ...session-cli-login-shell.live-shell.test.ts | 34 +++++++++ ...structured-session-cli-login-shell.test.ts | 76 ++++++------------- ...ctured-session-login-shell-test-harness.ts | 48 ++++++++++++ 6 files changed, 108 insertions(+), 55 deletions(-) create mode 100644 src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts create mode 100644 src/main/runtime/structured-session-login-shell-test-harness.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4c6f68147c40..ba3ba7556ed1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -614,6 +614,7 @@ jobs: src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ + src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ src/shared/pty-reply-echo-shapes.node-pty.test.ts \ diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index ec7101b6ee30..ab8d5038aed9 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -24,6 +24,7 @@ export const UNIT_EXCLUDE = [ 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', + 'src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts', 'src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts', 'src/shared/fish-query-reply-child-stdin.node-pty.test.ts', 'src/shared/pty-reply-echo-shapes.node-pty.test.ts', diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index b08365f5baf2..6804527fe3cc 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -28,6 +28,7 @@ const shellContractFiles = [ 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', + 'src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] const patchedNodePtyContractFiles = [ @@ -46,7 +47,7 @@ const testFilePatterns = [ // rather than calling spawnSync('zsh') themselves. Without this branch the rule // silently stops noticing the very tests that need the lane's zsh install. const realZshUsage = - /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ + /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|program:\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ describe('PR workflow parallelism', () => { it('keeps lightweight orchestration jobs on the free slim runner', () => { diff --git a/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts b/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts new file mode 100644 index 000000000000..c5a9eb769f1e --- /dev/null +++ b/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts @@ -0,0 +1,34 @@ +/** + * The zsh arm of `structured-session-cli-login-shell.test.ts`: Codex's own shell on macOS. Runs in + * the real-shell lane, which installs zsh; the ordinary unit lane has none. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + createLoginShellHarness, + type LoginShellHarness +} from './structured-session-login-shell-test-harness' + +describe.runIf(process.platform !== 'win32')('a structured session in a zsh login shell', () => { + let harness: LoginShellHarness + + beforeEach(() => { + harness = createLoginShellHarness() + }) + + afterEach(() => { + harness.dispose() + }) + + it("resolves this app's CLI through ORCA_CLI_COMMAND in `zsh -lc`", async () => { + // Positive control: the profile really does put the global install first for a bare name. + expect(await harness.run({ program: '/bin/zsh', args: ['-lc', 'orca'] })).toBe('global') + expect(await harness.run({ program: '/bin/zsh', args: ['-lc', '"$ORCA_CLI_COMMAND"'] })).toBe( + 'app' + ) + }) + + it("keeps bare `orca` this app's CLI in a zsh that reads no login profile", async () => { + expect(await harness.run({ program: '/bin/zsh', args: ['-c', 'orca'] })).toBe('app') + }) +}) diff --git a/src/main/runtime/structured-session-cli-login-shell.test.ts b/src/main/runtime/structured-session-cli-login-shell.test.ts index 9d2d7ceab239..2079dc44eb66 100644 --- a/src/main/runtime/structured-session-cli-login-shell.test.ts +++ b/src/main/runtime/structured-session-cli-login-shell.test.ts @@ -1,70 +1,38 @@ /** - * A provider can run every command in a login shell: Codex runs `/bin/zsh -lc `. The - * login profile rebuilds PATH — macOS's path_helper, a user's `.zprofile` — so the directory Orca - * prepended ends up behind a global install, and bare `orca` becomes that install, possibly an - * older Orca. `ORCA_CLI_COMMAND` names this app's launcher by absolute path, which no startup file - * can reorder. Real shells, with a profile that puts a stand-in global `orca` first. + * A provider can run every command in a login shell: Codex runs ` -lc `. The login + * profile rebuilds PATH — macOS's path_helper, a user's profile — so the directory Orca prepended + * ends up behind a global install, and bare `orca` becomes that install, possibly an older Orca. + * `ORCA_CLI_COMMAND` names this app's launcher by absolute path, which no startup file can reorder. + * The zsh arm lives in `structured-session-cli-login-shell.live-shell.test.ts`, in the real-shell + * lane that installs zsh. */ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -import { runProcess } from '../../shared/child-process/run-process' -import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' +import { + createLoginShellHarness, + type LoginShellHarness +} from './structured-session-login-shell-test-harness' -const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' - -function writeStub(path: string, says: string): void { - writeFileSync(path, `#!/bin/sh\nprintf '%s' '${says}'\n`) - chmodSync(path, 0o755) -} - -describe.runIf(process.platform !== 'win32')('a structured session in a login shell', () => { - let root: string - let env: Record +describe.runIf(process.platform !== 'win32')('a structured session in a bash login shell', () => { + let harness: LoginShellHarness beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-login-shell-cli-')) - const home = join(root, 'home') - const globalBin = join(root, 'global-bin') - const userData = join(root, 'user-data') - const appCliBin = join(userData, 'cli', 'bin') - for (const dir of [home, globalBin, appCliBin]) { - mkdirSync(dir, { recursive: true }) - } - // A global install that the user's profile puts first, as `/usr/local/bin` often is. - writeStub(join(globalBin, 'orca'), 'global') - writeStub(join(appCliBin, 'orca'), 'app') - writeStub(join(appCliBin, 'orca-dev'), 'app') - const prependGlobal = `export PATH="${globalBin}:$PATH"\n` - writeFileSync(join(home, '.zprofile'), prependGlobal) - writeFileSync(join(home, '.bash_profile'), prependGlobal) - installFakeAppEnvironment({ isPackaged: () => false, getPath: () => userData }) - env = structuredSessionChildIdentityEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' }) + harness = createLoginShellHarness() }) afterEach(() => { - rmSync(root, { recursive: true, force: true }) + harness.dispose() }) - async function run(shell: string, script: string): Promise { - const result = await runProcess({ program: shell, args: ['-lc', script], env }) - return result.stdout - } - - it.each(['/bin/zsh', '/bin/bash'])( - "resolves this app's CLI through ORCA_CLI_COMMAND in `%s -lc`", - async (shell) => { - // Positive control: the profile really does put the global install first for a bare name. - expect(await run(shell, 'orca')).toBe('global') - expect(await run(shell, '"$ORCA_CLI_COMMAND"')).toBe('app') - } - ) + it("resolves this app's CLI through ORCA_CLI_COMMAND in `bash -lc`", async () => { + // Positive control: the profile really does put the global install first for a bare name. + expect(await harness.run({ program: '/bin/bash', args: ['-lc', 'orca'] })).toBe('global') + expect(await harness.run({ program: '/bin/bash', args: ['-lc', '"$ORCA_CLI_COMMAND"'] })).toBe( + 'app' + ) + }) it("keeps bare `orca` this app's CLI in a shell that reads no login profile", async () => { - const result = await runProcess({ program: '/bin/zsh', args: ['-c', 'orca'], env }) - expect(result.stdout).toBe('app') + expect(await harness.run({ program: '/bin/bash', args: ['-c', 'orca'] })).toBe('app') }) }) diff --git a/src/main/runtime/structured-session-login-shell-test-harness.ts b/src/main/runtime/structured-session-login-shell-test-harness.ts new file mode 100644 index 000000000000..943dfe0f4d3d --- /dev/null +++ b/src/main/runtime/structured-session-login-shell-test-harness.ts @@ -0,0 +1,48 @@ +/** + * A structured session's child env beside a HOME whose login profiles put a stand-in global `orca` + * first, as `/usr/local/bin` often is. Shared by the bash suite (every lane) and the zsh suite + * (the real-shell lane, which installs zsh). + */ + +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { runProcess } from '../../shared/child-process/run-process' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' + +const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' + +export type LoginShellHarness = { + /** Runs a shell with the structured session's env and returns its stdout. */ + run: (spec: { program: string; args: [flag: '-lc' | '-c', script: string] }) => Promise + dispose: () => void +} + +function writeStub(path: string, says: string): void { + writeFileSync(path, `#!/bin/sh\nprintf '%s' '${says}'\n`) + chmodSync(path, 0o755) +} + +export function createLoginShellHarness(): LoginShellHarness { + const root = mkdtempSync(join(tmpdir(), 'orca-login-shell-cli-')) + const home = join(root, 'home') + const globalBin = join(root, 'global-bin') + const userData = join(root, 'user-data') + const appCliBin = join(userData, 'cli', 'bin') + for (const dir of [home, globalBin, appCliBin]) { + mkdirSync(dir, { recursive: true }) + } + writeStub(join(globalBin, 'orca'), 'global') + writeStub(join(appCliBin, 'orca'), 'app') + writeStub(join(appCliBin, 'orca-dev'), 'app') + const prependGlobal = `export PATH="${globalBin}:$PATH"\n` + writeFileSync(join(home, '.zprofile'), prependGlobal) + writeFileSync(join(home, '.bash_profile'), prependGlobal) + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => userData }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' }) + return { + run: async (spec) => (await runProcess({ ...spec, env })).stdout, + dispose: () => rmSync(root, { recursive: true, force: true }) + } +} From 5ba192e6b67dfac87db99114d6c4a5a1d0bc99c0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:57:26 -0700 Subject: [PATCH 11/24] fix(orchestration): omit a structured child's CLI command when no launcher resolves, and pin its instance A bare `orca` fallback named GNOME's screen reader on packaged Linux, and an inherited value named another app's CLI. The builder now deletes any inherited value, sets the absolute launcher only when one resolved, and pins ORCA_USER_DATA_PATH so a current CLI dials the instance that minted the id. Renames the marker reader to hasStructuredSessionMarker and records why the terminal view carries the id without the marker. --- .../orchestration/terminal-identity.ts | 6 +-- ...codex-structured-child-environment.test.ts | 2 + .../codex-structured-session-adapter.test.ts | 1 + ...uctured-session-child-identity-env.test.ts | 22 ++++++++++- .../structured-session-child-identity-env.ts | 37 ++++++++++++++----- src/shared/structured-session-marker.ts | 5 ++- 6 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 9766bd372144..fce1696b0369 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -2,7 +2,7 @@ import type { RuntimeClient } from '../../runtime-client' import { getOptionalStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' -import { isStructuredSessionWithoutIdentity } from '../../../shared/structured-session-marker' +import { hasStructuredSessionMarker } from '../../../shared/structured-session-marker' import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' import { normalizeOrchestrationActor } from '../../../shared/orchestration-actor' import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' @@ -46,7 +46,7 @@ export async function resolveOrchestrationTerminalHandle( // default, so that guess consumed another pane's oldest unread batch and marked it read, and the // rightful worker never saw its mail. Refusing is the only honest answer: this child genuinely // cannot infer its own identity. - if (isStructuredSessionWithoutIdentity()) { + if (hasStructuredSessionMarker()) { throw structuredSessionRefusal(flagName) } if (flagName === 'from') { @@ -275,7 +275,7 @@ export function throwNoActiveSenderTerminal(): never { // place left that would tell an identity-less session to pass a handle it does not have. A stale // ORCA_TERMINAL_HANDLE is a different case — that caller HAS an identity, so it keeps the advice // to re-run under a live one. - if (isStructuredSessionWithoutIdentity() && !process.env.ORCA_TERMINAL_HANDLE) { + if (hasStructuredSessionMarker() && !process.env.ORCA_TERMINAL_HANDLE) { throw structuredSessionRefusal('from') } throw new RuntimeClientError( diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index dfbe00d7021d..4dcd49e96459 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -35,6 +35,7 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -56,6 +57,7 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 12eb0755f95d..2fa337b60011 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -37,6 +37,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) }) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 3456f8af7237..34f746979a46 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -72,7 +72,9 @@ describe('structuredSessionChildIdentityEnv', () => { ORCA_AGENT_SESSION_ID: SESSION_ID, // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca') + ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + // The instance that minted the id, so any current CLI dials it rather than the default. + ORCA_USER_DATA_PATH: USER_DATA }) // A chat names itself by its id alone: no handle, no pane key. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() @@ -143,6 +145,24 @@ describe('structuredSessionChildIdentityEnv', () => { }) }) + it('omits the CLI command when no launcher resolves, never naming a bare `orca`', () => { + // On packaged Linux the shim can fail to resolve (no bundled launcher, an unverified AppImage); + // a bare `orca` there is GNOME's screen reader, and an inherited value names another app's CLI. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { + PATH: '/usr/bin', + ORCA_CLI_COMMAND: '/Applications/Other Orca.app/Contents/Resources/bin/orca', + ORCA_USER_DATA_PATH: '/data/other-orca' + }) + expect(env).not.toHaveProperty('ORCA_CLI_COMMAND') + expect(env.PATH).toBe('/usr/bin') + expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA) + expect(console.warn).toHaveBeenCalledOnce() + }) + it("gives the terminal view the same id, and leaves any other terminal's env as given", () => { expect(withStructuredSessionTerminalViewEnv({ CLAUDE_CONFIG_DIR: '/c' }, SESSION_ID)).toEqual({ CLAUDE_CONFIG_DIR: '/c', diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index 287491863f9b..af17056b23d1 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -20,8 +20,13 @@ * * `ORCA_CLI_COMMAND` is the absolute launcher in that directory, because a provider can run each * command in a login shell (Codex runs `zsh -lc`), whose profile rebuilds PATH and puts a global - * install — possibly an older Orca — ahead of this app's. The absolute path is what an agent resolves - * the CLI from, so it survives any shell's startup files, on every platform. + * install — possibly an older Orca — ahead of this app's. A current CLI reached that way re-runs + * itself as this launcher (`src/cli/session-cli-reexec.ts`), so an agent that types bare `orca` still acts + * through this app's CLI. When no launcher resolves the key is omitted rather than set to a bare + * name: on Linux a bare `orca` is GNOME's screen reader, and an inherited value names another app. + * + * `ORCA_USER_DATA_PATH` pins this instance beside the identity, so any current CLI — the session's + * own or a global one — dials the Orca that minted the id instead of the production default. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, @@ -32,7 +37,9 @@ * `ORCA_STRUCTURED_SESSION` stays beside the id for a CLI that predates it — one reached through a * global install when a shell rc resets PATH — which would otherwise guess a sibling's terminal; * such a CLI refuses on the marker. A current CLI checks the id first, so the marker never makes a - * session with an id identity-less. + * session with an id identity-less. The terminal view deliberately gets the id WITHOUT the marker: + * there an older CLI has the view's own pane handle and legitimately acts as that pane, and the + * marker would make it refuse its own pane. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. @@ -55,14 +62,16 @@ export function structuredSessionChildIdentityEnv( [ORCA_AGENT_SESSION_ID_ENV]: sessionId, [ORCA_STRUCTURED_SESSION_ENV]: '1' } - env.ORCA_CLI_COMMAND = applyOrcaCliPath(env) ?? 'orca' + applyThisAppCli(env) return env } /** * The same session id for its terminal view, so switching views never changes who the session is. * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. - * A terminal that is not a session's view keeps its env exactly as given. + * A terminal that is not a session's view keeps its env exactly as given. No marker (see above), and + * no CLI command: the PTY lane names this app's launcher for every local terminal, and this env also + * crosses to SSH hosts, where a local path means nothing. */ export function withStructuredSessionTerminalViewEnv( env: Record | undefined, @@ -75,14 +84,24 @@ export function withStructuredSessionTerminalViewEnv( * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. */ -function applyOrcaCliPath(env: Record): string | null { +function applyThisAppCli(env: Record): void { + delete env.ORCA_CLI_COMMAND if (!hasAppEnvironment()) { - return null + return } const app = getAppEnvironment() - return prependOrcaCliDirToChildPath(env, { + const userDataPath = app.getPath('userData') + env.ORCA_USER_DATA_PATH = userDataPath + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: app.isPackaged(), - userDataPath: app.getPath('userData'), + userDataPath, resourcesPath: process.resourcesPath ?? null }) + if (launcher) { + env.ORCA_CLI_COMMAND = launcher + } else { + console.warn( + "[structured-session] This app's CLI launcher did not resolve; the session's child has no ORCA_CLI_COMMAND." + ) + } } diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 3c849db8f9a8..c30dc5a6139d 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -7,9 +7,12 @@ * id, which refuses on it. Either way the answer is refuse, never guess: a structured session has * no pane, so every implicit-terminal guess resolves to a sibling, and `orchestration check` is * destructive by default. + * + * The reader answers only "does this process carry the marker": a current CLI reaches it after the + * id check has already returned, which is what makes a marked child with an id act as its session. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' -export function isStructuredSessionWithoutIdentity(env: NodeJS.ProcessEnv = process.env): boolean { +export function hasStructuredSessionMarker(env: NodeJS.ProcessEnv = process.env): boolean { return (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 } From 9c8f1801206db8b09c3e89d8b7b7993b60dab533 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:59:27 -0700 Subject: [PATCH 12/24] fix(terminal): name this app's CLI launcher by absolute path in every local terminal ORCA_CLI_COMMAND meant three things by lane: an absolute launcher for a structured session, a bare name for WSL, and nothing for any other terminal, so a structured session's terminal view lost it. Local terminals now get the same absolute launcher the structured lane gets; WSL keeps its guest command name, and a terminal whose launcher does not resolve still gets none. --- .../pty-daemon-spawn-agent-home-env.test.ts | 4 +++- src/main/ipc/pty/host-env/assembly.ts | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 8895666706ec..273f08580f3e 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -462,7 +462,8 @@ describe('registerPtyHandlers', () => { // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904). expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) - expect(env.ORCA_CLI_COMMAND).toBeUndefined() + // The same absolute spelling a structured session gets, so a terminal view keeps it too. + expect(env.ORCA_CLI_COMMAND).toBe(join(shimDir, 'orca')) } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -479,6 +480,7 @@ describe('registerPtyHandlers', () => { try { const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }) expect(env.PATH.split(delimiter)[0]).toBe(join('/tmp/orca-resources', 'bin')) + expect(env.ORCA_CLI_COMMAND?.startsWith(join('/tmp/orca-resources', 'bin'))).toBe(true) } finally { if (resourcesPathDescriptor) { Object.defineProperty(process, 'resourcesPath', resourcesPathDescriptor) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index de61800c7eff..e70a7388e331 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -310,23 +310,28 @@ export function buildPtyHostEnv( // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. if (opts.isWsl) { baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath - // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. - baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged) const managedCliDir = getManagedWslCliDir(opts) if (managedCliDir) { baseEnv.ORCA_WSL_CLI_DIR = managedCliDir } - } else { - if (!opts.isPackaged) { - baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath - } - delete baseEnv.ORCA_CLI_COMMAND + } else if (!opts.isPackaged) { + baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath } - prependOrcaCliDirToChildPath(baseEnv, { + const launcher = prependOrcaCliDirToChildPath(baseEnv, { isPackaged: opts.isPackaged, userDataPath: opts.userDataPath, resourcesPath: opts.resourcesPath }) + if (opts.isWsl) { + // Why: managed WSL registration uses `orca-ide`; a guest cannot run the host launcher's path. + baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged) + } else if (launcher) { + // Why the absolute launcher, the same spelling a structured session gets: a login shell can + // reorder PATH behind a global install, and a current CLI re-runs itself as this one. + baseEnv.ORCA_CLI_COMMAND = launcher + } else { + delete baseEnv.ORCA_CLI_COMMAND + } if ( opts.routeBrowserOpensToClient === true && From 92bf593d642593d07746b11f9810309e4e56e68a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:06:33 -0700 Subject: [PATCH 13/24] feat(cli): hand a command to the session's own CLI when another Orca CLI was invoked A login shell can reorder PATH behind a global install, and an agent or its helper script can run bare `orca`, so the binary that answered depended on the agent following instructions. Orca's packaged launchers and bare-orca shims now export ORCA_CLI_SELF (outermost wins). At the CLI entry, when it names a different launcher than ORCA_CLI_COMMAND, the command re-runs once through the named launcher with ORCA_CLI_REEXEC=1 and exits with its status; both variables are consumed so no child inherits them. Dev launchers export no self on purpose, WSL and SSH names never qualify, and a launcher that cannot start leaves the command to run here. The Windows launcher no longer rewrites ORCA_CLI_COMMAND; the legacy ask protocol normalizes its resume command itself. --- .../windows-cli-launcher/OrcaCliLauncher.cs | 14 +- resources/darwin/bin/orca | 2 + resources/linux/bin/orca-ide | 2 + .../orchestration-windows-ask-cli.test.ts | 21 ++ .../orchestration/runtime-compatibility.ts | 12 +- src/cli/index.ts | 5 +- src/cli/session-cli-reexec.test.ts | 182 ++++++++++++++++++ src/cli/session-cli-reexec.ts | 146 ++++++++++++++ src/main/cli/cli-self-export.test.ts | 50 +++++ src/main/cli/cli-self-export.ts | 7 + src/main/cli/linux-bare-orca-dispatcher.ts | 8 +- .../cli/linux-terminal-orca-cli-shim.test.ts | 4 + src/main/cli/linux-terminal-orca-cli-shim.ts | 3 +- src/main/cli/windows-launcher-asset.test.ts | 9 +- 14 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 src/cli/session-cli-reexec.test.ts create mode 100644 src/cli/session-cli-reexec.ts create mode 100644 src/main/cli/cli-self-export.test.ts create mode 100644 src/main/cli/cli-self-export.ts diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs index 3357c7b15963..067b4c1ee108 100644 --- a/native/windows-cli-launcher/OrcaCliLauncher.cs +++ b/native/windows-cli-launcher/OrcaCliLauncher.cs @@ -57,11 +57,15 @@ private static int Main(string[] args) MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE"); Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1"); Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); - string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND"); - Environment.SetEnvironmentVariable( - "ORCA_CLI_COMMAND", - requestedCliCommand == "orca-ide" ? "orca-ide" : "orca" - ); + // Why: names this launcher as the entry the caller ran, and leaves ORCA_CLI_COMMAND as + // the session set it, so the CLI can hand off to the session's own launcher. + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ORCA_CLI_SELF"))) + { + Environment.SetEnvironmentVariable( + "ORCA_CLI_SELF", + typeof(OrcaCliLauncher).Assembly.Location + ); + } using (Process child = Process.Start(startInfo)) { diff --git a/resources/darwin/bin/orca b/resources/darwin/bin/orca index 4d01b9928cad..ab0b0edb2263 100755 --- a/resources/darwin/bin/orca +++ b/resources/darwin/bin/orca @@ -25,6 +25,8 @@ ELECTRON="$CONTENTS/MacOS/Orca" # launcher model instead of requiring a separate npm-distributed binary. CLI="$CONTENTS/Resources/app.asar.unpacked/out/cli/index.js" +# Why: names the entry the caller ran, so the CLI can hand off to a session's own launcher. +export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}" export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}" export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS diff --git a/resources/linux/bin/orca-ide b/resources/linux/bin/orca-ide index f191f27c770e..8cbcaf35970d 100755 --- a/resources/linux/bin/orca-ide +++ b/resources/linux/bin/orca-ide @@ -33,6 +33,8 @@ fi # launcher model used on macOS instead of maintaining a separate Node binary. CLI="$RESOURCES_DIR/app.asar.unpacked/out/cli/index.js" +# Why: names the entry the caller ran, so the CLI can hand off to a session's own launcher. +export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}" export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}" export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS diff --git a/src/cli/handlers/orchestration-windows-ask-cli.test.ts b/src/cli/handlers/orchestration-windows-ask-cli.test.ts index ec7f346c0bdc..c29300a70749 100644 --- a/src/cli/handlers/orchestration-windows-ask-cli.test.ts +++ b/src/cli/handlers/orchestration-windows-ask-cli.test.ts @@ -64,6 +64,27 @@ describe('packaged Windows legacy ask protocol', () => { } ) + it("names `orca` when a session's ORCA_CLI_COMMAND is the launcher's absolute path", async () => { + process.env.ORCA_CLI_COMMAND = 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe' + callMock.mockResolvedValue({ + result: { + answer: 'yes', + messageId: 'msg_question', + threadId: 'msg_question', + timedOut: false + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk(new Map([['resume', 'msg_question']])) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + expect.objectContaining({ compatibilityWindowsCommand: 'orca' }), + expect.any(Object) + ) + }) + it('resumes the committed question without another exit-75 handoff', async () => { process.env.ORCA_CLI_COMMAND = 'orca' callMock.mockResolvedValue({ diff --git a/src/cli/handlers/orchestration/runtime-compatibility.ts b/src/cli/handlers/orchestration/runtime-compatibility.ts index e4076534daa8..8335f0c34e53 100644 --- a/src/cli/handlers/orchestration/runtime-compatibility.ts +++ b/src/cli/handlers/orchestration/runtime-compatibility.ts @@ -1,5 +1,3 @@ -import { RuntimeClientError } from '../../runtime-client' - export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { const configured = process.env.ORCA_CLI_COMMAND if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { @@ -8,18 +6,12 @@ export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-de return process.platform === 'linux' ? 'orca-ide' : 'orca' } +/** The resume command a legacy host prints: `orca-ide` only when WSL asked for it, else `orca`. */ export function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { return undefined } - const command = process.env.ORCA_CLI_COMMAND - if (command === 'orca' || command === 'orca-ide') { - return command - } - throw new RuntimeClientError( - 'invalid_argument', - 'The packaged Orca launcher did not provide a valid resume command. No question was created.' - ) + return process.env.ORCA_CLI_COMMAND === 'orca-ide' ? 'orca-ide' : 'orca' } export async function flushOrchestrationStdout(): Promise { diff --git a/src/cli/index.ts b/src/cli/index.ts index b2e01c04e56f..616876a636e4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,7 @@ import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' +import { runAsSessionCli } from './session-cli-reexec' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -233,5 +234,7 @@ async function runAgentTeamsTmuxShim(argv: string[]): Promise { } if (require.main === module) { - void main() + // Why here and not in main(): main() is also called in-process by tests and by wrappers that + // require this module, where exiting or consuming process.env would hit the caller's process. + void runAsSessionCli(() => main()) } diff --git a/src/cli/session-cli-reexec.test.ts b/src/cli/session-cli-reexec.test.ts new file mode 100644 index 000000000000..383895fe283b --- /dev/null +++ b/src/cli/session-cli-reexec.test.ts @@ -0,0 +1,182 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ORCA_CLI_REEXEC_ENV, + ORCA_CLI_SELF_ENV, + runAsSessionCli, + takeSessionCliReexec +} from './session-cli-reexec' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-session-cli-reexec-')) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(dir, { recursive: true, force: true }) +}) + +function writeScript(name: string, body: string): string { + const path = join(dir, name) + writeFileSync(path, `#!/usr/bin/env bash\n${body}`) + chmodSync(path, 0o755) + return path +} + +class Exited extends Error { + constructor(readonly code: number) { + super(`exit ${code}`) + } +} + +function exitSpy(): (code: number) => never { + return (code: number) => { + throw new Exited(code) + } +} + +describe('takeSessionCliReexec', () => { + it('hands off when the invoked CLI is not the launcher the session named', () => { + const invoked = writeScript('global-orca', 'exit 0\n') + const named = writeScript('session-orca', 'exit 0\n') + const env: NodeJS.ProcessEnv = { + ORCA_CLI_COMMAND: named, + [ORCA_CLI_SELF_ENV]: invoked, + ORCA_AGENT_SESSION_ID: 'session-1', + ELECTRON_RUN_AS_NODE: '1', + ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1', + ORCA_NODE_OPTIONS: '--max-old-space-size=4096', + ORCA_NODE_REPL_EXTERNAL_MODULE: '' + } + + const reexec = takeSessionCliReexec({ env, argv: ['orchestration', 'check'] }) + + expect(reexec).toEqual({ + target: named, + argv: ['orchestration', 'check'], + // What the invoked launcher was handed, so the named one sees the caller's own environment. + env: { + ORCA_CLI_COMMAND: named, + ORCA_AGENT_SESSION_ID: 'session-1', + NODE_OPTIONS: '--max-old-space-size=4096', + [ORCA_CLI_REEXEC_ENV]: '1' + } + }) + // Consumed: nothing this CLI starts inherits the identity of the launcher that ran it. + expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) + }) + + it('stays when the invoked launcher is the named one, through a symlink', () => { + const named = writeScript('session-orca', 'exit 0\n') + const link = join(dir, 'usr-local-bin-orca') + symlinkSync(named, link) + + expect( + takeSessionCliReexec({ env: { ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: link } }) + ).toBeNull() + }) + + it('makes at most one hop, and consumes the guard so no child inherits it', () => { + const env: NodeJS.ProcessEnv = { + ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n'), + [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n'), + [ORCA_CLI_REEXEC_ENV]: '1' + } + + expect(takeSessionCliReexec({ env })).toBeNull() + expect(env).not.toHaveProperty(ORCA_CLI_REEXEC_ENV) + expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) + }) + + it('stays when no Orca launcher named itself: a dev launcher, or an older one', () => { + expect( + takeSessionCliReexec({ env: { ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n') } }) + ).toBeNull() + }) + + it.each([ + ['a WSL guest command name', 'orca-ide'], + ["the SSH host's relay command", 'orca'] + ])('never resolves %s against the working directory', (_label, command) => { + writeScript(command, 'exit 0\n') + vi.spyOn(process, 'cwd').mockReturnValue(dir) + + expect( + takeSessionCliReexec({ + env: { ORCA_CLI_COMMAND: command, [ORCA_CLI_SELF_ENV]: writeScript('global', 'exit 0\n') } + }) + ).toBeNull() + }) + + it('stays when the named launcher no longer exists', () => { + expect( + takeSessionCliReexec({ + env: { + ORCA_CLI_COMMAND: join(dir, 'gone', 'orca'), + [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n') + } + }) + ).toBeNull() + }) +}) + +describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { + it("runs the command through the session's launcher and exits with its status", async () => { + const report = join(dir, 'report') + const named = writeScript( + 'session-orca', + `printf '%s|%s|%s' "$*" "$ORCA_CLI_REEXEC" "\${NODE_OPTIONS-}" > '${report}'\nexit 7\n` + ) + const run = vi.fn(async () => {}) + + await expect( + runAsSessionCli(run, { + env: { + ...process.env, + ORCA_CLI_COMMAND: named, + [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n'), + ORCA_NODE_OPTIONS: '--no-warnings' + }, + argv: ['orchestration', 'check', '--wait'], + exit: exitSpy() + }) + ).rejects.toEqual(new Exited(7)) + + expect(readFileSync(report, 'utf8')).toBe('orchestration check --wait|1|--no-warnings') + expect(run).not.toHaveBeenCalled() + }) + + it('runs the command here when it is already the named CLI', async () => { + const named = writeScript('session-orca', 'exit 0\n') + const run = vi.fn(async () => {}) + + await runAsSessionCli(run, { + env: { ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: named }, + exit: exitSpy() + }) + + expect(run).toHaveBeenCalledOnce() + }) + + it('runs the command here, and says so, when the named CLI cannot start', async () => { + const named = join(dir, 'not-executable') + writeFileSync(named, 'not a program') + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const run = vi.fn(async () => {}) + + await runAsSessionCli(run, { + env: { + ORCA_CLI_COMMAND: named, + [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n') + }, + exit: exitSpy() + }) + + expect(run).toHaveBeenCalledOnce() + expect(String(stderr.mock.calls[0]?.[0])).toContain("could not run this session's CLI") + }) +}) diff --git a/src/cli/session-cli-reexec.ts b/src/cli/session-cli-reexec.ts new file mode 100644 index 000000000000..5b31646e322c --- /dev/null +++ b/src/cli/session-cli-reexec.ts @@ -0,0 +1,146 @@ +/** + * Hands a command to the CLI the session named, when a different Orca CLI was the one invoked. + * + * Orca puts the absolute launcher of its own CLI in `ORCA_CLI_COMMAND` for every local terminal and + * structured session. An agent may still reach another install — a login shell reorders PATH behind + * a global `orca`, a helper script hardcodes `orca`, a user types `/usr/local/bin/orca` — and that + * CLI can be older than the session's identity or dial a different instance. So a current CLI that + * is not the named launcher re-runs the command through it, once, and exits with its status. Which + * binary answers stops depending on the agent following instructions. + * + * Identity comes from `ORCA_CLI_SELF`, which Orca's packaged launchers and bare-`orca` shims export + * (the outermost one wins); this entry's own argv names the JS file, never a launcher. A dev launcher + * exports none on purpose: it pins its own instance, so running one is a deliberate choice of + * instance, often from another instance's terminal. `ORCA_CLI_REEXEC=1` bounds the handoff to one + * hop and is also the escape hatch. Both variables are consumed here, so no child of the CLI — an + * Orca app it starts, a terminal that app opens — inherits a stale identity or a disabled handoff. + * + * WSL and SSH never qualify: they carry a guest command name or `orca`, not a host path, and a + * relative command is never resolved against the working directory. + */ + +import { realpathSync } from 'node:fs' +import { constants as osConstants } from 'node:os' +import { posix, resolve, win32 } from 'node:path' + +export const ORCA_CLI_SELF_ENV = 'ORCA_CLI_SELF' +export const ORCA_CLI_REEXEC_ENV = 'ORCA_CLI_REEXEC' + +/** Set by the launcher that started this process; the next launcher sets them again itself. */ +const LAUNCHER_OWNED_ENV = ['ELECTRON_RUN_AS_NODE', 'ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER'] as const +/** Stashed by every launcher so Electron's node bootstrap never sees them; the next one re-stashes. */ +const LAUNCHER_STASHED_ENV = [ + ['ORCA_NODE_OPTIONS', 'NODE_OPTIONS'], + ['ORCA_NODE_REPL_EXTERNAL_MODULE', 'NODE_REPL_EXTERNAL_MODULE'] +] as const + +export type SessionCliReexec = { + target: string + argv: readonly string[] + env: NodeJS.ProcessEnv +} + +type ReexecOptions = { + env?: NodeJS.ProcessEnv + argv?: readonly string[] + platform?: NodeJS.Platform +} + +/** The CLI entry: hand off to the session's own CLI when this is a different one, else `run`. */ +export async function runAsSessionCli( + run: () => Promise, + options: ReexecOptions & { exit?: (code: number) => never } = {} +): Promise { + const reexec = takeSessionCliReexec(options) + if (reexec) { + await runSessionCliReexec(reexec, options.exit) + } + await run() +} + +/** + * Removes the launcher handoff variables from `env` and returns the re-exec this process owes, or + * null when it is already the named CLI, cannot tell, or is itself the one hop. + */ +export function takeSessionCliReexec(options: ReexecOptions = {}): SessionCliReexec | null { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const self = env[ORCA_CLI_SELF_ENV]?.trim() + const alreadyHandedOff = env[ORCA_CLI_REEXEC_ENV] === '1' + delete env[ORCA_CLI_SELF_ENV] + delete env[ORCA_CLI_REEXEC_ENV] + if (alreadyHandedOff || !self) { + return null + } + const named = env.ORCA_CLI_COMMAND?.trim() + if (!named || !(platform === 'win32' ? win32 : posix).isAbsolute(named)) { + return null + } + const target = tryRealpath(named) + const current = tryRealpath(self) + if (target === null || current === null || samePath(target, current, platform)) { + return null + } + return { + target: named, + argv: [...(options.argv ?? process.argv.slice(2))], + env: buildHandoffEnv(env) + } +} + +/** The environment the invoked launcher was given, plus the one-hop guard. */ +function buildHandoffEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const handoff: NodeJS.ProcessEnv = { ...env } + for (const key of LAUNCHER_OWNED_ENV) { + delete handoff[key] + } + for (const [stash, original] of LAUNCHER_STASHED_ENV) { + const value = handoff[stash] + delete handoff[stash] + if (value) { + handoff[original] = value + } + } + handoff[ORCA_CLI_REEXEC_ENV] = '1' + return handoff +} + +function tryRealpath(path: string): string | null { + try { + return realpathSync(resolve(path)) + } catch { + return null + } +} + +function samePath(left: string, right: string, platform: NodeJS.Platform): boolean { + return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right +} + +/** + * Runs the handoff and exits with its status. Returns only when the named CLI could not be started, + * so the command still runs here — the behavior before the handoff existed — rather than failing. + */ +export async function runSessionCliReexec( + reexec: SessionCliReexec, + exit: (code: number) => never = process.exit +): Promise { + const { runProcessSync } = await import('../shared/child-process/run-process.js') + let result: { code: number | null; signal: NodeJS.Signals | null } + try { + result = runProcessSync({ + program: reexec.target, + args: reexec.argv, + env: reexec.env, + stdio: 'inherit', + timeoutMs: null + }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + process.stderr.write( + `orca: could not run this session's CLI (${reexec.target}): ${reason}. Running this one.\n` + ) + return + } + exit(result.code ?? (result.signal ? 128 + (osConstants.signals[result.signal] ?? 0) : 1)) +} diff --git a/src/main/cli/cli-self-export.test.ts b/src/main/cli/cli-self-export.test.ts new file mode 100644 index 000000000000..af24a3cf9253 --- /dev/null +++ b/src/main/cli/cli-self-export.test.ts @@ -0,0 +1,50 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' + +const ORCA_CLI_SELF_ENV = 'ORCA_CLI_SELF' +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-cli-self-export-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function writeScript(name: string, body: string): string { + const path = join(dir, name) + writeFileSync(path, `#!/usr/bin/env bash\n${body}`) + chmodSync(path, 0o755) + return path +} + +describe.skipIf(process.platform === 'win32')('the launcher self export', () => { + it('names the outermost Orca script, so a shim that execs a launcher stays the entry', () => { + const report = join(dir, 'report') + const launcher = writeScript( + 'orca-ide', + `${ORCA_CLI_SELF_EXPORT}printf '%s' "$ORCA_CLI_SELF" > '${report}'\n` + ) + const shim = writeScript('orca', `${ORCA_CLI_SELF_EXPORT}exec '${launcher}' "$@"\n`) + const env = { ...process.env } + delete env[ORCA_CLI_SELF_ENV] + + expect(spawnSync(shim, [], { env }).status).toBe(0) + expect(readFileSync(report, 'utf8')).toBe(shim) + + expect(spawnSync(launcher, [], { env }).status).toBe(0) + expect(readFileSync(report, 'utf8')).toBe(launcher) + }) + + it.each(['resources/darwin/bin/orca', 'resources/linux/bin/orca-ide'])( + 'is the line the packaged %s launcher runs', + (path) => { + expect(readFileSync(join(process.cwd(), path), 'utf8')).toContain(ORCA_CLI_SELF_EXPORT) + } + ) +}) diff --git a/src/main/cli/cli-self-export.ts b/src/main/cli/cli-self-export.ts new file mode 100644 index 000000000000..499da55677b0 --- /dev/null +++ b/src/main/cli/cli-self-export.ts @@ -0,0 +1,7 @@ +/** + * The bash line an Orca CLI launcher or shim runs to name itself as the entry the caller invoked. + * The outermost Orca script wins, so a shim that execs a launcher keeps the shim's own path; the CLI + * compares it with the session's `ORCA_CLI_COMMAND` and consumes it (src/cli/session-cli-reexec.ts). + * Kept identical to the line in the packaged launchers under resources/. + */ +export const ORCA_CLI_SELF_EXPORT = 'export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}"\n' diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 6c4b273fcd8a..d7993590de63 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -15,6 +15,7 @@ import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' import { withAppImageRegistrationLock } from './appimage-registration-lock' import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { quoteShell } from './cli-install-path-format' +import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. @@ -74,9 +75,12 @@ export async function installLinuxBareOrcaDispatcher( : { state: 'skipped-foreign', dispatcherPath, target: null } } -/** Bare-`orca` script that execs the one Linux CLI launcher. */ +/** + * Bare-`orca` script that execs the one Linux CLI launcher. It names itself as the CLI entry, so a + * session that names this script as its CLI does not hand off to the launcher behind it. + */ export function buildBareOrcaCliScript(launcherPath: string): string { - return `#!/usr/bin/env bash\nexec ${quoteShell(launcherPath)} "$@"\n` + return `#!/usr/bin/env bash\n${ORCA_CLI_SELF_EXPORT}exec ${quoteShell(launcherPath)} "$@"\n` } /** diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 905d48073684..1820668e3848 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -11,6 +11,7 @@ vi.mock('electron', () => ({ import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' +import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' const created: string[] = [] const canFenceAppImageRuntime = process.platform === 'linux' && existsSync('/proc/self/stat') @@ -57,6 +58,8 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const content = readFileSync(join(shimDir!, 'orca'), 'utf8') // Single-quoted so a resources path with shell metacharacters can't break out. expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`) + // A session names this shim as its CLI, so the shim, not the launcher behind it, is the entry. + expect(content).toContain(ORCA_CLI_SELF_EXPORT) const mode = statSync(join(shimDir!, 'orca')).mode & 0o777 expect(mode & 0o111).not.toBe(0) }) @@ -115,6 +118,7 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(content).toContain(liveLauncherPath) expect(content).toContain('runtime_pid=') expect(content).toContain('/proc/$runtime_pid/stat') + expect(content).toContain(ORCA_CLI_SELF_EXPORT) expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) await expect( runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index 7697bac01acb..7e4848366733 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -23,6 +23,7 @@ import { import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' import { quoteShell } from './cli-install-path-format' +import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' const SHIM_DIR_NAME = 'linux-orca-cli-shim' @@ -203,7 +204,7 @@ runtime_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$runtime_root" 2>/dev/null)" | launcher_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$launcher" 2>/dev/null)" || fail [[ "$launcher_identity" == "$expected_launcher_identity" ]] || fail [[ -f "$launcher" && -x "$launcher" ]] || fail -exec "$launcher" "$@" +${ORCA_CLI_SELF_EXPORT}exec "$launcher" "$@" ` } diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index 2eec4fee5773..91986d4e43d3 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -21,10 +21,11 @@ describe('packaged Windows CLI launcher asset', () => { expect(source).toContain( 'Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");' ) - expect(source).toContain( - 'string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND");' - ) - expect(source).toContain('requestedCliCommand == "orca-ide" ? "orca-ide" : "orca"') + // It names itself as the CLI entry and leaves the session's ORCA_CLI_COMMAND untouched, so the + // CLI can compare the two and hand off to the session's own launcher. + expect(source).toContain('"ORCA_CLI_SELF",') + expect(source).toContain('typeof(OrcaCliLauncher).Assembly.Location') + expect(source).not.toMatch(/SetEnvironmentVariable\(\s*"ORCA_CLI_COMMAND"/) expect(source).toContain('child.WaitForExit();') expect(source).toContain('return child.ExitCode;') }) From e9bd6369fa587aa3f930c356673a3da55618bfef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:10:44 -0700 Subject: [PATCH 14/24] refactor(orchestration): declare which flag names the caller on each spec and refuse at the CLI entry Each handler hand-classified its --from/--terminal as the caller or a target, and the refusal of a conflicting caller flag ran inside the caller resolver plus two standalone calls for --run listings, so a new verb that read its flag raw would pass a sibling's handle to a pre-session host. Specs now declare identityFlagRoles, the CLI entry refuses a conflicting caller flag once from the spec, the resolver only applies the id-wins rule, and a test fails any orchestration verb that accepts --from or --terminal without classifying it. --- src/cli/command-spec.ts | 5 + .../orchestration/dispatch-handlers.ts | 3 +- .../handlers/orchestration/gate-handlers.ts | 9 +- .../handlers/orchestration/task-handlers.ts | 7 +- .../orchestration/terminal-identity.ts | 61 +----------- src/cli/index-orchestration.test.ts | 19 ++++ src/cli/index.ts | 5 + .../orchestration-session-caller-cli.test.ts | 95 +++++++++++++++---- src/cli/session-caller-flags.ts | 63 ++++++++++++ src/cli/specs/orchestration-worker-specs.ts | 1 + src/cli/specs/orchestration.ts | 33 +++++-- 11 files changed, 206 insertions(+), 95 deletions(-) create mode 100644 src/cli/session-caller-flags.ts diff --git a/src/cli/command-spec.ts b/src/cli/command-spec.ts index 1922888a2925..dbb28a66d71c 100644 --- a/src/cli/command-spec.ts +++ b/src/cli/command-spec.ts @@ -15,8 +15,13 @@ export type CommandSpec = { positionalArgs?: string[] examples?: string[] notes?: string[] + // Why: `--from`/`--terminal` names either the acting caller or a target, and only the spec can + // say which. An agent session refuses a caller flag naming anyone else before dispatch. + identityFlagRoles?: Partial> } +export type IdentityFlag = 'from' | 'terminal' + export function specPaths(spec: CommandSpec): string[][] { return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path] } diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index 5162ca6e291c..5f28d4bbcd31 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -5,7 +5,8 @@ import { RuntimeClientError } from '../../runtime-client' import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' import { callOrchestrationMutation } from './mutation-request' import { isDevCliInvocation } from './runtime-compatibility' -import { injectedSessionAddress, resolveCoordinatorTerminalHandle } from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { injectedSessionAddress } from '../../session-caller-flags' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts index 9af0faa1be9e..f9ec4c6583fb 100644 --- a/src/cli/handlers/orchestration/gate-handlers.ts +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -2,10 +2,7 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalJsonFlag, getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { callOrchestrationMutation } from './mutation-request' -import { - refuseConflictingSessionCaller, - resolveCoordinatorTerminalHandle -} from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-create': async ({ flags, client, cwd, json }) => { @@ -40,9 +37,7 @@ export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run - ? refuseConflictingSessionCaller(flags, 'from') - : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] count: number diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts index 9533b1e5f8aa..c4c943261e8c 100644 --- a/src/cli/handlers/orchestration/task-handlers.ts +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -4,10 +4,7 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' import { callOrchestrationMutation } from './mutation-request' -import { - refuseConflictingSessionCaller, - resolveCoordinatorTerminalHandle -} from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' const TASK_STATUS_VALUES = [ 'pending', @@ -42,7 +39,7 @@ export const ORCHESTRATION_TASK_HANDLERS: Record = { const brief = flags.has('brief') const run = getOptionalStringFlag(flags, 'run') const callerTerminalHandle = run - ? refuseConflictingSessionCaller(flags, 'from') + ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ tasks: { diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index fce1696b0369..572b9096bced 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -4,8 +4,7 @@ import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' import { hasStructuredSessionMarker } from '../../../shared/structured-session-marker' import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' -import { normalizeOrchestrationActor } from '../../../shared/orchestration-actor' -import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' +import { injectedSessionAddress } from '../../session-caller-flags' /** * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: @@ -19,7 +18,8 @@ export async function resolveOrchestrationTerminalHandle( flagName: 'from' | 'terminal', options: { validateEnvHandle?: boolean } = {} ): Promise { - if (resolveInjectedSessionCaller(flags, flagName)) { + // A caller flag naming anyone else was already refused at the CLI entry, from the command's spec. + if (readInjectedAgentSessionId()) { return undefined } const explicit = getOptionalStringFlag(flags, flagName) @@ -168,61 +168,6 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } -/** - * The injected session id when this command runs as an agent session. The id wins over every other - * identity this process carries; a caller flag may restate that same session but never name - * another, and a conflicting one is refused here, before any request is sent. - */ -function resolveInjectedSessionCaller( - flags: Map, - flagName: 'from' | 'terminal' -): string | undefined { - const sessionId = readInjectedAgentSessionId() - if (!sessionId) { - return undefined - } - const declared = getOptionalStringFlag(flags, flagName) - if (declared !== undefined && !namesInjectedSession(declared, sessionId)) { - throw new RuntimeClientError( - 'consumer_fenced', - `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + - `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + - `session:${sessionId}. No request was sent.` - ) - } - return sessionId -} - -/** - * For a listing scoped by `--run`, which needs no caller: a session still refuses a caller flag - * naming someone else rather than dropping it. - */ -export function refuseConflictingSessionCaller( - flags: Map, - flagName: 'from' | 'terminal' -): undefined { - resolveInjectedSessionCaller(flags, flagName) - return undefined -} - -/** The session's own spellings, plus the handle a structured worker session was minted. */ -function namesInjectedSession(value: string, sessionId: string): boolean { - return normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress() -} - -/** - * The address the host gives this session: a structured worker keeps the handle it was minted, any - * other session is `session:`. Only for text that must match what the host writes. - */ -export function injectedSessionAddress(): string | undefined { - const sessionId = readInjectedAgentSessionId() - if (!sessionId) { - return undefined - } - const ownHandle = process.env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` -} - /** How check output names its caller: the handle, or the session's address. */ export function orchestrationCallerLabel(handle: string | undefined): string { return handle ?? injectedSessionAddress() ?? 'unknown' diff --git a/src/cli/index-orchestration.test.ts b/src/cli/index-orchestration.test.ts index c4e4438f2e9e..52fc7222e468 100644 --- a/src/cli/index-orchestration.test.ts +++ b/src/cli/index-orchestration.test.ts @@ -85,6 +85,25 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) + it("refuses an agent session's caller flag naming another actor before any request", async () => { + // One chokepoint for every verb: the spec says which flag names the caller. + process.env.ORCA_AGENT_SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await main(['orchestration', 'check', '--terminal', 'term_sibling', '--json'], '/tmp/repo') + } finally { + delete process.env.ORCA_AGENT_SESSION_ID + } + + expect(callMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { code: 'consumer_fenced' } + }) + process.exitCode = undefined + }) + it('rejects no-flag orchestration reset before calling the runtime', async () => { await main(['orchestration', 'reset'], '/tmp/repo') diff --git a/src/cli/index.ts b/src/cli/index.ts index 616876a636e4..a538a2723505 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,7 @@ import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' import { runAsSessionCli } from './session-cli-reexec' +import { refuseConflictingSessionCallerFlags } from './session-caller-flags' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -112,6 +113,10 @@ export async function main( // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) + refuseConflictingSessionCallerFlags( + findCommandSpec(COMMAND_SPECS, parsed.commandPath), + parsed.flags + ) const RuntimeClientClass = await loadRuntimeClientClass() const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 7118dcac4ab2..9ca398e35b2e 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -19,6 +19,9 @@ vi.mock('./format', () => ({ printResult: vi.fn() })) vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' +import { findCommandSpec } from './args' +import { COMMAND_SPECS } from './specs' +import { refuseConflictingSessionCallerFlags } from './session-caller-flags' import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' import { formatCliError, reportCliError } from './cli-error' import { RuntimeRpcFailureError } from './runtime/types' @@ -171,10 +174,27 @@ const CALLER_VERBS: Verb[] = [ { command: 'worker-list', flags: {}, method: 'runCurrent', callerParam: 'from' } ] +/** Enough flags for any verb to get past its own validation to identity resolution. */ +const EVERY_REQUIRED_FLAG = { + objective: 'o', + id: 'id_1', + task: 'task_1', + spec: 's', + question: 'q', + resolution: 'r', + subject: 's', + body: 'b', + to: 'term_worker', + status: 'completed', + preamble: true, + request: 'req_1' +} as const + function flagMap(flags: Record): Map { return new Map(Object.entries(flags)) } +/** What `main()` does between parsing and dispatch: the spec-driven caller check, then the handler. */ async function invoke( command: string, flags: Map, @@ -184,6 +204,10 @@ async function invoke( if (!handler) { throw new Error(`no handler for ${command}`) } + refuseConflictingSessionCallerFlags( + findCommandSpec(COMMAND_SPECS, ['orchestration', command]), + flags + ) await handler({ flags, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only `call`; RuntimeClient is a class, so a structural double cannot satisfy it without the cast. @@ -488,23 +512,62 @@ describe('the orchestration envelope', () => { }) }) -describe('every orchestration verb, enumerated', () => { - /** Enough flags for any verb to get past its own validation to identity resolution. */ - const EVERY_REQUIRED_FLAG = { - objective: 'o', - id: 'id_1', - task: 'task_1', - spec: 's', - question: 'q', - resolution: 'r', - subject: 's', - body: 'b', - to: 'term_worker', - status: 'completed', - preamble: true, - request: 'req_1' - } as const +describe('which flag names the caller, declared on every spec', () => { + const ORCHESTRATION_SPECS = COMMAND_SPECS.filter((spec) => spec.path[0] === 'orchestration') + it('classifies every --from and --terminal an orchestration verb accepts', () => { + // A new verb cannot take either flag without saying whether it names the caller, so the entry + // check covers it by construction instead of each handler remembering to refuse. + const unclassified = ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.allowedFlags.includes(flag) && !spec.identityFlagRoles?.[flag]) + .map((flag) => `${spec.path.join(' ')} --${flag}`) + ) + expect(unclassified).toEqual([]) + }) + + const callerFlagVerbs = ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.identityFlagRoles?.[flag] === 'caller') + .map((flag) => ({ command: spec.path[1] ?? '', flag })) + ) + + it('covers the verbs whose requests name a caller', () => { + expect(callerFlagVerbs.length).toBeGreaterThanOrEqual(CALLER_VERBS.length) + }) + + it.each(callerFlagVerbs)( + '$command refuses --$flag naming another actor, before any request', + async ({ command, flag }) => { + asSessionInTerminalView() + await expect( + invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + } + ) + + it.each( + ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.identityFlagRoles?.[flag] === 'target') + .map((flag) => ({ command: spec.path[1] ?? '', flag })) + ) + )('$command passes a --$flag target through unfenced', async ({ command, flag }) => { + asSessionInTerminalView() + await invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })).catch( + (error: unknown) => { + expect(error).not.toMatchObject({ code: 'consumer_fenced' }) + } + ) + expect(callMock.mock.calls.flatMap(([, params]) => Object.values(params ?? {}))).toContain( + 'term_sibling' + ) + }) +}) + +describe('every orchestration verb, enumerated', () => { /** Runs every verb once; returns the ones that guessed an implicit terminal. */ async function verbsThatGuess(): Promise { const guessed: string[] = [] diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts new file mode 100644 index 000000000000..b136bccb4bce --- /dev/null +++ b/src/cli/session-caller-flags.ts @@ -0,0 +1,63 @@ +/** + * An agent session is its own orchestration caller. A flag that names the caller may restate that + * session, but one naming anyone else is refused before any handler runs — never dropped, never + * allowed to win. Against a host that predates session callers this is the only guard: such a host + * would honor the flag as the caller, which is how a chat consumed a sibling's mail (#21097). + * + * Whether `--from`/`--terminal` names the caller or a target is declared on the command's spec + * (`identityFlagRoles`) and checked once, here, at the CLI entry, so a handler cannot forget it. + */ + +import type { CommandSpec, IdentityFlag } from './command-spec' +import { RuntimeClientError } from './runtime/types' +import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' +import { normalizeOrchestrationActor } from '../shared/orchestration-actor' +import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' + +export function refuseConflictingSessionCallerFlags( + spec: CommandSpec | undefined, + flags: ReadonlyMap, + env: NodeJS.ProcessEnv = process.env +): void { + const sessionId = readInjectedAgentSessionId(env) + if (!sessionId || !spec?.identityFlagRoles) { + return + } + for (const flagName of IDENTITY_FLAGS) { + const declared = flags.get(flagName) + if ( + spec.identityFlagRoles[flagName] === 'caller' && + typeof declared === 'string' && + !namesInjectedSession(declared, sessionId, env) + ) { + throw new RuntimeClientError( + 'consumer_fenced', + `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + + `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + + `session:${sessionId}. No request was sent.` + ) + } + } +} + +const IDENTITY_FLAGS: readonly IdentityFlag[] = ['from', 'terminal'] + +/** The session's own spellings, plus the handle a structured worker session was minted. */ +function namesInjectedSession(value: string, sessionId: string, env: NodeJS.ProcessEnv): boolean { + return ( + normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress(env) + ) +} + +/** + * The address the host gives this session: a structured worker keeps the handle it was minted, any + * other session is `session:`. Only for text that must match what the host writes. + */ +export function injectedSessionAddress(env: NodeJS.ProcessEnv = process.env): string | undefined { + const sessionId = readInjectedAgentSessionId(env) + if (!sessionId) { + return undefined + } + const ownHandle = env.ORCA_TERMINAL_HANDLE + return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` +} diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 1448667e54ae..04423ffc6fb0 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -31,6 +31,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'from', 'retry-request' ], + identityFlagRoles: { from: 'caller', terminal: 'target' }, notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 458afa2e5257..8c4418f38ba5 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -9,6 +9,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration run-create --objective [--from ] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'objective', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: [ 'A Run is a namespace and home inbox. It never schedules or places workers.', '--retry-request is only for exact recovery after an unknown mutation result.' @@ -20,6 +21,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration run-use --id [--from ] [--takeover-legacy] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: [ '--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.' ] @@ -28,7 +30,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'run-current'], summary: 'Show the Run bound to this coordinator terminal', usage: 'orca orchestration run-current [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'from'] + allowedFlags: [...GLOBAL_FLAGS, 'from'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'run-list'], @@ -67,6 +70,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'report-path', 'phase' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.', 'To answer a worker question, use orchestration reply --id --body with the same Orca CLI executable.', @@ -109,6 +113,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'timeout-ms', 'retry-request' ], + identityFlagRoles: { terminal: 'caller' }, notes: [ 'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".', '--types is the wake condition for --wait; a returned Delivery is always the whole FIFO batch, so it is never filtered by type. Without --wait it has no effect on consuming checks. Only --peek and --all filter their rows.', @@ -121,13 +126,15 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Reply to a message', usage: 'orca orchestration reply --id --body [--run ] [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'inbox'], summary: 'Show messages across (or for) recipients', usage: 'orca orchestration inbox [--limit ] [--terminal ] [--full] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit', 'terminal', 'full'] + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'terminal', 'full'], + identityFlagRoles: { terminal: 'target' } }, { path: ['orchestration', 'task-create'], @@ -144,7 +151,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'run', 'from', 'retry-request' - ] + ], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'task-list'], @@ -152,6 +160,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration task-list [--status ] [--ready] [--brief] [--run ] [--from ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief', 'run', 'from'], + identityFlagRoles: { from: 'caller' }, notes: ['--brief collapses whitespace and caps each spec at 160 characters.'] }, { @@ -160,6 +169,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration task-update --id --status [--result ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.'] }, ...ORCHESTRATION_WORKER_COMMAND_SPECS, @@ -178,7 +188,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'dry-run', 'return-preamble', 'retry-request' - ] + ], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'request-show'], @@ -196,7 +207,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Show dispatch context for a task', usage: 'orca orchestration dispatch-show --task [--preamble] [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'] + allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'], + identityFlagRoles: { from: 'target' } }, { path: ['orchestration', 'ask'], @@ -215,6 +227,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'from', 'retry-request' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'From an active Dispatch, a new question defaults to its owning Run mailbox.', 'Timeout leaves the question pending; resume with the original message ID.' @@ -234,6 +247,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'max-concurrent', 'worktree' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'This command performs no effects and returns the exact `skills get orchestration --full` recovery action.', 'Use the lightweight Run, Task, and worker-start primitives described by the current skill.' @@ -254,14 +268,16 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Create a decision gate blocking a task', usage: 'orca orchestration gate-create --task --question [--options ] [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'gate-resolve'], summary: 'Resolve a pending decision gate', usage: 'orca orchestration gate-resolve --id --resolution [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'gate-list'], @@ -269,6 +285,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration gate-list [--task ] [--status ] [--run ] [--from ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'task', 'status', 'run', 'from'], + identityFlagRoles: { from: 'caller' }, notes: ['--run inspects a named Run without binding; otherwise gates are scoped to the caller.'] }, { From b200aae4c974ed263700a5f87021b59db924c2ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:24:16 -0700 Subject: [PATCH 15/24] perf(cli): keep the session caller check off the actor codec's module graph The check runs at the CLI entry for every command, and the actor codec pulls zod through the session record. Compare the session's own spellings as plain strings instead. --- src/cli/session-caller-flags.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts index b136bccb4bce..1ef820d7fee1 100644 --- a/src/cli/session-caller-flags.ts +++ b/src/cli/session-caller-flags.ts @@ -11,7 +11,6 @@ import type { CommandSpec, IdentityFlag } from './command-spec' import { RuntimeClientError } from './runtime/types' import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' -import { normalizeOrchestrationActor } from '../shared/orchestration-actor' import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' export function refuseConflictingSessionCallerFlags( @@ -42,10 +41,13 @@ export function refuseConflictingSessionCallerFlags( const IDENTITY_FLAGS: readonly IdentityFlag[] = ['from', 'terminal'] -/** The session's own spellings, plus the handle a structured worker session was minted. */ +/** + * The session's own spellings, plus the handle a structured worker session was minted. Plain + * strings: this runs at the CLI entry for every command, before the actor codec's module graph. + */ function namesInjectedSession(value: string, sessionId: string, env: NodeJS.ProcessEnv): boolean { return ( - normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress(env) + value === sessionId || value === `session:${sessionId}` || value === injectedSessionAddress(env) ) } From d49294b36ec3118db255d8bc7243e5df84989a56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:39:58 -0700 Subject: [PATCH 16/24] refactor(cli): spell a session's address from the one prefix constant, off the codec's module graph The Orca session address prefix moves to a leaf module with no imports, re-exported by the address codec, so the CLI entry check derives `session:` from that constant instead of re-typing it and still stays off the codec's zod graph. Prose and test names say caller or Orca session id, not actor. --- src/cli/index-orchestration.test.ts | 2 +- src/cli/orchestration-session-caller-cli.test.ts | 6 +++--- src/cli/session-caller-flags.ts | 13 +++++++++---- src/shared/agent-session-caller-env.ts | 2 +- src/shared/orca-session-address-prefix.ts | 2 ++ src/shared/orca-session-address.ts | 3 ++- 6 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/shared/orca-session-address-prefix.ts diff --git a/src/cli/index-orchestration.test.ts b/src/cli/index-orchestration.test.ts index 52fc7222e468..bc7366f5b11c 100644 --- a/src/cli/index-orchestration.test.ts +++ b/src/cli/index-orchestration.test.ts @@ -85,7 +85,7 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) - it("refuses an agent session's caller flag naming another actor before any request", async () => { + it("refuses an agent session's caller flag naming another caller before any request", async () => { // One chokepoint for every verb: the spec says which flag names the caller. process.env.ORCA_AGENT_SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 9ca398e35b2e..f0588dc80d70 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -279,7 +279,7 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v }) it.runIf(verb.callerFlag !== undefined)( - 'refuses a caller flag naming another actor, before any request', + 'refuses a caller flag naming another caller, before any request', async () => { const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_sibling' }) @@ -320,7 +320,7 @@ describe.each([ ])('orchestration $command --run run as an agent session', ({ command, method, callerParam }) => { beforeEach(asSessionInTerminalView) - it('needs no caller, but refuses a --from naming another actor, before any request', async () => { + it('needs no caller, but refuses a --from naming another caller, before any request', async () => { await invoke(command, flagMap({ run: 'run_1' })) expect(callsTo(method)[0]).toMatchObject({ run: 'run_1' }) expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() @@ -537,7 +537,7 @@ describe('which flag names the caller, declared on every spec', () => { }) it.each(callerFlagVerbs)( - '$command refuses --$flag naming another actor, before any request', + '$command refuses --$flag naming another caller, before any request', async ({ command, flag }) => { asSessionInTerminalView() await expect( diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts index 1ef820d7fee1..df3f893dd293 100644 --- a/src/cli/session-caller-flags.ts +++ b/src/cli/session-caller-flags.ts @@ -12,6 +12,7 @@ import type { CommandSpec, IdentityFlag } from './command-spec' import { RuntimeClientError } from './runtime/types' import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../shared/orca-session-address-prefix' export function refuseConflictingSessionCallerFlags( spec: CommandSpec | undefined, @@ -33,7 +34,7 @@ export function refuseConflictingSessionCallerFlags( 'consumer_fenced', `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + - `session:${sessionId}. No request was sent.` + `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}. No request was sent.` ) } } @@ -43,11 +44,13 @@ const IDENTITY_FLAGS: readonly IdentityFlag[] = ['from', 'terminal'] /** * The session's own spellings, plus the handle a structured worker session was minted. Plain - * strings: this runs at the CLI entry for every command, before the actor codec's module graph. + * strings: this runs at the CLI entry for every command, before the address codec's module graph. */ function namesInjectedSession(value: string, sessionId: string, env: NodeJS.ProcessEnv): boolean { return ( - value === sessionId || value === `session:${sessionId}` || value === injectedSessionAddress(env) + value === sessionId || + value === `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` || + value === injectedSessionAddress(env) ) } @@ -61,5 +64,7 @@ export function injectedSessionAddress(env: NodeJS.ProcessEnv = process.env): st return undefined } const ownHandle = env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` + return isStructuredWorkerHandle(ownHandle) + ? ownHandle + : `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` } diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index 7968dff7e13b..e13da348b5aa 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -1,7 +1,7 @@ /** * The Orca-minted agent session id, injected into a structured session's own child processes, in * native chat and in terminal view alike. When it is present it IS the orchestration caller: the - * CLI sends it in the orchestration envelope and the host resolves it to the session's actor, so no + * CLI sends it in the orchestration envelope and the host resolves the session it names, so no * terminal is resolved or guessed on its behalf. * * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, diff --git a/src/shared/orca-session-address-prefix.ts b/src/shared/orca-session-address-prefix.ts new file mode 100644 index 000000000000..984aec9d7890 --- /dev/null +++ b/src/shared/orca-session-address-prefix.ts @@ -0,0 +1,2 @@ +// A leaf with no imports, so the CLI entry can spell a session address without the codec's zod graph. +export const ORCA_SESSION_ADDRESS_PREFIX = 'session:' diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index 9762b95d63c6..d0098ea2ef9d 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -1,5 +1,6 @@ import { isAgentSessionId } from './agent-session-record' import { STRUCTURED_WORKER_HANDLE_PREFIX } from './structured-worker-handle' +import { ORCA_SESSION_ADDRESS_PREFIX } from './orca-session-address-prefix' /** * The Orca session id is the id Orca minted for a structured session (its session record id, the @@ -12,7 +13,7 @@ import { STRUCTURED_WORKER_HANDLE_PREFIX } from './structured-worker-handle' * agents have none today, and never a pane-keyed one: a pane outlives the agent in it, so such an id * would be inherited by the pane's next occupant. */ -export const ORCA_SESSION_ADDRESS_PREFIX = 'session:' +export { ORCA_SESSION_ADDRESS_PREFIX } declare const orcaSessionIdBrand: unique symbol declare const orcaSessionAddressBrand: unique symbol From f0c788611ed77459883d0ea4cb60e1ec5581c6a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:53:19 -0700 Subject: [PATCH 17/24] refactor(orchestration): drop the session id's terminal-view spawn now that the handoff is gone The terminal handoff was removed, so no terminal is ever a structured session: - delete the terminal-view identity env and its WSL passthrough, and their tests; - strip the session caller keys from every terminal's env unconditionally; - the CLI's own-address spelling moves beside the injected id in src/shared, with a test pinning it to the address the host's party resolver gives that session. --- .../orchestration/dispatch-handlers.ts | 2 +- .../orchestration/message-check-handler.ts | 2 +- .../orchestration/terminal-identity.ts | 6 ++- .../orchestration-session-caller-cli.test.ts | 39 ++++++++------- src/cli/session-caller-flags.ts | 21 ++------ .../pty-daemon-spawn-agent-home-env.test.ts | 12 +---- .../ipc/pty-spawn-env-terminal-basics.test.ts | 7 --- src/main/ipc/pty/host-env/pi-agent.ts | 11 ++-- src/main/pty/wsl-orca-env.test.ts | 20 -------- src/main/pty/wsl-orca-env.ts | 4 -- ...ca-runtime-agent-session-operation.test.ts | 34 ------------- .../orchestration-party-cli-address.test.ts | 50 +++++++++++++++++++ ...uctured-session-child-identity-env.test.ts | 18 +------ .../structured-session-child-identity-env.ts | 18 +------ src/shared/agent-session-caller-env.ts | 28 +++++++++-- 15 files changed, 114 insertions(+), 158 deletions(-) create mode 100644 src/main/runtime/orchestration/orchestration-party-cli-address.test.ts diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index 5f28d4bbcd31..c8ade53e907a 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -6,7 +6,7 @@ import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-co import { callOrchestrationMutation } from './mutation-request' import { isDevCliInvocation } from './runtime-compatibility' import { resolveCoordinatorTerminalHandle } from './terminal-identity' -import { injectedSessionAddress } from '../../session-caller-flags' +import { injectedSessionAddress } from '../../../shared/agent-session-caller-env' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/handlers/orchestration/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts index 26cefcc06ea9..6ace43eac31a 100644 --- a/src/cli/handlers/orchestration/message-check-handler.ts +++ b/src/cli/handlers/orchestration/message-check-handler.ts @@ -41,7 +41,7 @@ export const ORCHESTRATION_CHECK_HANDLER: Record = { const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') const explicitTerminal = getOptionalStringFlag(flags, 'terminal') const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') - // Why: a session names itself by its id alone; a terminal view's pane is not its identity. + // Why: a session names itself by its id alone; a pane key it inherited is not its identity. const paneKey = explicitTerminal || terminal === undefined ? undefined : process.env.ORCA_PANE_KEY const callerLabel = orchestrationCallerLabel(terminal) diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 572b9096bced..b97500db89b2 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -3,8 +3,10 @@ import { getOptionalStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' import { hasStructuredSessionMarker } from '../../../shared/structured-session-marker' -import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' -import { injectedSessionAddress } from '../../session-caller-flags' +import { + injectedSessionAddress, + readInjectedAgentSessionId +} from '../../../shared/agent-session-caller-env' /** * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index f0588dc80d70..59bcae5b845b 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -6,8 +6,9 @@ * naming anyone else is refused before any request — never silently dropped, never allowed to win. * The #21097 accident was a chat that named a sibling's terminal and consumed that sibling's mail. * - * The session env here is the hardest case, a chat in terminal view: it also carries its pane's - * `ORCA_TERMINAL_HANDLE` and `ORCA_PANE_KEY`, and the implicit-terminal guess has a sibling to find. + * The session env here is the hardest case, a chat that inherited a pane's `ORCA_TERMINAL_HANDLE` + * and `ORCA_PANE_KEY` (an Orca launched from an Orca terminal), and the implicit-terminal guess has + * a sibling to find. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -238,12 +239,12 @@ function setEnv(env: Partial>): vo } } -/** A chat in terminal view: its id, plus the pane identity that view inherits. */ -function asSessionInTerminalView(): void { +/** A chat with its id, plus a pane identity it inherited from the Orca that launched it. */ +function asSessionWithInheritedPane(): void { setEnv({ ORCA_AGENT_SESSION_ID: SESSION, - ORCA_TERMINAL_HANDLE: 'term_view_pane', - ORCA_PANE_KEY: 'tab_view:11111111-1111-4111-8111-111111111111' + ORCA_TERMINAL_HANDLE: 'term_inherited_pane', + ORCA_PANE_KEY: 'tab_inherited:11111111-1111-4111-8111-111111111111' }) } @@ -261,7 +262,7 @@ afterEach(() => { }) describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (verb) => { - beforeEach(asSessionInTerminalView) + beforeEach(asSessionWithInheritedPane) it('acts as the session: no terminal is resolved, guessed or sent', async () => { await invoke(verb.command, flagMap(verb.flags)) @@ -269,7 +270,7 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v const [params] = callsTo(verb.method) expect(params, 'the verb reached its method').toBeDefined() expect(params?.[verb.callerParam]).toBeUndefined() - // A terminal view's pane is not the session's identity. + // An inherited pane is not the session's identity. expect(params?.terminalPaneKey).toBeUndefined() expect(params?.senderPaneKey).toBeUndefined() expect(getTerminalHandleMock).not.toHaveBeenCalled() @@ -293,9 +294,9 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v ) it.runIf(verb.callerFlag !== undefined)( - "refuses its own terminal view's pane handle too: the session, not the pane, is the caller", + 'refuses an inherited pane handle too: the session, not the pane, is the caller', async () => { - const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_view_pane' }) + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_inherited_pane' }) await expect(invoke(verb.command, flags)).rejects.toMatchObject({ code: 'consumer_fenced' }) expect(callMock).not.toHaveBeenCalled() @@ -318,7 +319,7 @@ describe.each([ { command: 'gate-list', method: 'gateList', callerParam: 'from' }, { command: 'task-list', method: 'taskList', callerParam: 'callerTerminalHandle' } ])('orchestration $command --run run as an agent session', ({ command, method, callerParam }) => { - beforeEach(asSessionInTerminalView) + beforeEach(asSessionWithInheritedPane) it('needs no caller, but refuses a --from naming another caller, before any request', async () => { await invoke(command, flagMap({ run: 'run_1' })) @@ -406,7 +407,7 @@ describe('the identity a session presents', () => { await invoke('dispatch-show', flagMap({ task: 'task_1', preamble: true, ...flags })) return callsTo('dispatchShow')[0]?.from } - asSessionInTerminalView() + asSessionWithInheritedPane() expect(await preview({})).toBe(`session:${SESSION}`) // Not a caller flag: it names the text to preview, so it is never fenced. expect(await preview({ from: 'term_sibling' })).toBe('term_sibling') @@ -416,7 +417,7 @@ describe('the identity a session presents', () => { }) it('resumes a timed-out ask as the session, without naming a terminal', async () => { - asSessionInTerminalView() + asSessionWithInheritedPane() callMock.mockResolvedValue({ result: { ...RESULT.result, answer: null, timedOut: true } }) const errors = vi.mocked(console.error) @@ -443,7 +444,7 @@ describe('a host refusal of the session', () => { it.each(['check', 'run-current', 'worker-list'])( 'surfaces from %s verbatim, never widened, retried or turned into a terminal guess', async (command) => { - asSessionInTerminalView() + asSessionWithInheritedPane() callMock.mockRejectedValue(WORKER_GONE) await expect(invoke(command, flagMap({}))).rejects.toBe(WORKER_GONE) @@ -481,11 +482,11 @@ describe('the orchestration envelope', () => { it('carries the injected id beside whatever terminal evidence the process also has', () => { const envelope = createOrchestrationCompatibilityEnvelope({ ORCA_AGENT_SESSION_ID: ` ${SESSION} `, - ORCA_TERMINAL_HANDLE: 'term_view_pane' + ORCA_TERMINAL_HANDLE: 'term_inherited_pane' }) expect(envelope.orchestrationCompatibilityEvidence).toEqual({ - terminalHandle: 'term_view_pane', + terminalHandle: 'term_inherited_pane', agentSessionId: SESSION }) }) @@ -539,7 +540,7 @@ describe('which flag names the caller, declared on every spec', () => { it.each(callerFlagVerbs)( '$command refuses --$flag naming another caller, before any request', async ({ command, flag }) => { - asSessionInTerminalView() + asSessionWithInheritedPane() await expect( invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })) ).rejects.toMatchObject({ code: 'consumer_fenced' }) @@ -555,7 +556,7 @@ describe('which flag names the caller, declared on every spec', () => { .map((flag) => ({ command: spec.path[1] ?? '', flag })) ) )('$command passes a --$flag target through unfenced', async ({ command, flag }) => { - asSessionInTerminalView() + asSessionWithInheritedPane() await invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })).catch( (error: unknown) => { expect(error).not.toMatchObject({ code: 'consumer_fenced' }) @@ -608,7 +609,7 @@ describe('every orchestration verb, enumerated', () => { 'worker-start' ]) - asSessionInTerminalView() + asSessionWithInheritedPane() expect(await verbsThatGuess()).toEqual([]) }) }) diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts index df3f893dd293..cbefa9ee2b8d 100644 --- a/src/cli/session-caller-flags.ts +++ b/src/cli/session-caller-flags.ts @@ -10,8 +10,10 @@ import type { CommandSpec, IdentityFlag } from './command-spec' import { RuntimeClientError } from './runtime/types' -import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' -import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' +import { + injectedSessionAddress, + readInjectedAgentSessionId +} from '../shared/agent-session-caller-env' import { ORCA_SESSION_ADDRESS_PREFIX } from '../shared/orca-session-address-prefix' export function refuseConflictingSessionCallerFlags( @@ -53,18 +55,3 @@ function namesInjectedSession(value: string, sessionId: string, env: NodeJS.Proc value === injectedSessionAddress(env) ) } - -/** - * The address the host gives this session: a structured worker keeps the handle it was minted, any - * other session is `session:`. Only for text that must match what the host writes. - */ -export function injectedSessionAddress(env: NodeJS.ProcessEnv = process.env): string | undefined { - const sessionId = readInjectedAgentSessionId(env) - if (!sessionId) { - return undefined - } - const ownHandle = env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) - ? ownHandle - : `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` -} diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 273f08580f3e..f6ffae641d3c 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -413,7 +413,7 @@ describe('registerPtyHandlers', () => { ]) ) }) - it("strips an inherited agent session id and keeps a terminal view's own", async () => { + it('strips an inherited agent session id', async () => { // Why: a daemon forked by an Orca launched inside a structured session inherits its id, // and every daemon pane would present that session as its orchestration caller. const inherited = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { @@ -423,14 +423,6 @@ describe('registerPtyHandlers', () => { expect(inherited.envToDelete).toEqual( expect.arrayContaining(['ORCA_AGENT_SESSION_ID', 'ORCA_STRUCTURED_SESSION']) ) - const own = await daemonSpawnAndGetOptions( - { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }, - undefined, - undefined, - { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' } - ) - expect(own.envToDelete ?? []).not.toContain('ORCA_AGENT_SESSION_ID') - expect(own.env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') }) it('preserves an explicitly requested Claude child-session stamp', async () => { // Why: only inherited values are poison; a caller deliberately spawning a @@ -462,7 +454,7 @@ describe('registerPtyHandlers', () => { // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904). expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) - // The same absolute spelling a structured session gets, so a terminal view keeps it too. + // The same absolute spelling a structured session gets. expect(env.ORCA_CLI_COMMAND).toBe(join(shimDir, 'orca')) } finally { Object.defineProperty(process, 'platform', { diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index e269bc7f6df8..bed6953e2b12 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -361,13 +361,6 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() }) - it('keeps the session id a terminal view is spawned with', async () => { - const env = await spawnAndGetEnv( - { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }, - { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' } - ) - expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') - }) it('keeps an explicitly requested Claude child-session stamp on a local spawn', async () => { const env = await spawnAndGetEnv( { CLAUDE_CODE_CHILD_SESSION: '1' }, diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index 4123f35a0718..caf18e3e1aa7 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -146,11 +146,12 @@ export function getInheritedAgentSessionStampEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { const env = spawnEnv ?? {} - // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (a nested Claude child, a structured session's terminal view) keeps it. - return [...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS].filter( - (key) => env[key] === undefined - ) + // Why: a caller that explicitly provides a Claude stamp (a nested Claude child) keeps it; no + // terminal is a structured session, so the session caller keys always go. + return [ + ...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined), + ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS + ] } export { restoreOrStripOverlayEnv } from '../../../../shared/agent-overlay-env' diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 7134ebf3855a..1df30613076a 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -16,26 +16,6 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') }) - it("carries a terminal view's session id into the guest untranslated, beside the WSL stamp", () => { - // Crossing is what makes the claim refusable: without it the pane handle would silently become - // the caller inside WSL, and the host could not tell the session was asking at all. - const env: Record = { - ORCA_TERMINAL_HANDLE: 'term_wsl', - ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' - } - stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu') - - addOrcaWslInteropEnv(env) - - expect(env.WSLENV?.split(':')).toEqual( - expect.arrayContaining([ - 'ORCA_AGENT_SESSION_ID/u', - 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u', - 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u' - ]) - ) - }) - // Why this is published at all: the wrapper tree is content-addressed, so the // in-guest login script cannot rebuild its path from ORCA_USER_DATA_PATH -- it // cannot derive the hash segment. Without this the guest finds no wrapper and diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 5611c2fd6332..b3047fd8a27f 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -9,7 +9,6 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../shared/setup-agent-sequencing' import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' -import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_IMAGE_PROTOCOL_ENV } from '../../shared/terminal-image-protocol' const WSLENV_ENTRY_SEPARATOR = ':' @@ -75,9 +74,6 @@ export function addOrcaWslInteropEnv(env: Record): void { // Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start. const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', - // Why: a structured session's terminal view in a WSL shell must still present its id, so the - // host refuses the cross-host claim instead of the pane handle silently becoming its caller. - `${ORCA_AGENT_SESSION_ID_ENV}/u`, 'ORCA_USER_DATA_PATH/p', // Why /p: the guest reads the content-addressed wrapper tree through /mnt/c, // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index e22d6de28e86..8d7d6fa68126 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -266,40 +266,6 @@ describe('agent-session create operation ledger', () => { ) }) - it("spawns a structured session's terminal view with its session id, never persisting it", async () => { - // Switching a chat to terminal view must not change who it is as an orchestration caller. The - // id rides the spawn env only: the launch config persists, and a relaunch from it would replay - // the id without the handoff that binds the terminal to the session. - const sessionId = 'f7a1c0de-1111-4222-8333-444455556666' - const runtime = createRuntime() - const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) - const resume = { - kind: 'explicit' as const, - worktree: 'id:worktree-1', - agent: 'claude' as const, - providerSession: { key: 'session_id' as const, id: 'provider-session-1' } - } - - await runtime.ensureAgentSession( - resume, - {}, - { - spawnToken: 'spawn-9', - providerRoot: '/accounts/claude', - sessionId - } - ) - await runtime.ensureAgentSession(resume) - - const [handoff, plain] = createTerminal.mock.calls.map(([, opts]) => opts) - expect(handoff).toMatchObject({ - structuredAgentSessionId: sessionId, - env: expect.objectContaining({ ORCA_AGENT_SESSION_ID: sessionId }) - }) - expect(handoff?.launchConfig?.agentEnv).not.toHaveProperty('ORCA_AGENT_SESSION_ID') - expect(plain?.env ?? {}).not.toHaveProperty('ORCA_AGENT_SESSION_ID') - }) - it('selects nested SSH legacy fallback before reading a Pi transcript path locally', async () => { const runtime = createRuntime() const internal = runtime as unknown as { diff --git a/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts b/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts new file mode 100644 index 000000000000..f6f1c1e25745 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { injectedSessionAddress } from '../../../shared/agent-session-caller-env' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { resolveOrcaSessionParty } from './orchestration-party' + +// The CLI spells its own address without the host resolver (it runs before the codec's module +// graph), so it must land on the one mailbox address the resolver gives the same session. +const CHAT = testOrcaSessionId('f7a1c0de-1111-4222-8333-444455556666') +const WORKER = testOrcaSessionId('a0b1c2d3-0000-4000-8000-00000000abcd') + +afterEach(() => { + structuredWorkerIdentities.clear() +}) + +describe("the CLI's own address", () => { + it("is a chat's session address, even beside a pane handle it inherited", () => { + const hostAddress = resolveOrcaSessionParty(CHAT, null).address + expect(injectedSessionAddress({ ORCA_AGENT_SESSION_ID: CHAT })).toBe(hostAddress) + expect( + injectedSessionAddress({ + ORCA_AGENT_SESSION_ID: CHAT, + ORCA_TERMINAL_HANDLE: 'term_inherited' + }) + ).toBe(hostAddress) + }) + + it("is a structured worker's minted handle, as the host resolves it", () => { + const handle = mintStructuredWorkerHandle() + structuredWorkerIdentities.register({ + handle, + sessionId: WORKER, + agent: 'claude', + paneKey: mintStructuredWorkerPaneKey(WORKER), + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + const hostAddress = resolveOrcaSessionParty(WORKER, null).address + expect(hostAddress).toBe(handle) + expect( + injectedSessionAddress({ ORCA_AGENT_SESSION_ID: WORKER, ORCA_TERMINAL_HANDLE: handle }) + ).toBe(hostAddress) + }) +}) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 34f746979a46..1289e979ad63 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -5,10 +5,7 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) -import { - structuredSessionChildIdentityEnv, - withStructuredSessionTerminalViewEnv -} from './structured-session-child-identity-env' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -163,19 +160,6 @@ describe('structuredSessionChildIdentityEnv', () => { expect(console.warn).toHaveBeenCalledOnce() }) - it("gives the terminal view the same id, and leaves any other terminal's env as given", () => { - expect(withStructuredSessionTerminalViewEnv({ CLAUDE_CONFIG_DIR: '/c' }, SESSION_ID)).toEqual({ - CLAUDE_CONFIG_DIR: '/c', - ORCA_AGENT_SESSION_ID: SESSION_ID - }) - expect(withStructuredSessionTerminalViewEnv(undefined, SESSION_ID)).toEqual({ - ORCA_AGENT_SESSION_ID: SESSION_ID - }) - const plain = { CLAUDE_CONFIG_DIR: '/c' } - expect(withStructuredSessionTerminalViewEnv(plain, undefined)).toBe(plain) - expect(withStructuredSessionTerminalViewEnv(undefined, undefined)).toBeUndefined() - }) - it('never puts a pane key in the child environment', () => { // A pane key here flows into hook-emitted agent statuses and the attestation, agent-row and // mobile-projection pipelines, all of which assume it names a live PTY leaf. diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index af17056b23d1..62a901860cf6 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -37,9 +37,7 @@ * `ORCA_STRUCTURED_SESSION` stays beside the id for a CLI that predates it — one reached through a * global install when a shell rc resets PATH — which would otherwise guess a sibling's terminal; * such a CLI refuses on the marker. A current CLI checks the id first, so the marker never makes a - * session with an id identity-less. The terminal view deliberately gets the id WITHOUT the marker: - * there an older CLI has the view's own pane handle and legitimately acts as that pane, and the - * marker would make it refuse its own pane. + * session with an id identity-less. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. @@ -66,20 +64,6 @@ export function structuredSessionChildIdentityEnv( return env } -/** - * The same session id for its terminal view, so switching views never changes who the session is. - * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. - * A terminal that is not a session's view keeps its env exactly as given. No marker (see above), and - * no CLI command: the PTY lane names this app's launcher for every local terminal, and this env also - * crosses to SSH hosts, where a local path means nothing. - */ -export function withStructuredSessionTerminalViewEnv( - env: Record | undefined, - sessionId: string | undefined -): Record | undefined { - return sessionId ? { ...env, [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : env -} - /** * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index e13da348b5aa..5f66128dc5f9 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -1,13 +1,15 @@ /** - * The Orca-minted agent session id, injected into a structured session's own child processes, in - * native chat and in terminal view alike. When it is present it IS the orchestration caller: the - * CLI sends it in the orchestration envelope and the host resolves the session it names, so no - * terminal is resolved or guessed on its behalf. + * The Orca-minted agent session id, injected into a structured session's own child processes. When + * it is present it IS the orchestration caller: the CLI sends it in the orchestration envelope and + * the host resolves the session it names, so no terminal is resolved or guessed on its behalf. * * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, * WSL) re-opens that decision: the host refuses a claim that arrives across one, and the SSH * passthrough never carries the id. */ +import { ORCA_SESSION_ADDRESS_PREFIX } from './orca-session-address-prefix' +import { isStructuredWorkerHandle } from './structured-worker-handle' + export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' export function readInjectedAgentSessionId( @@ -16,3 +18,21 @@ export function readInjectedAgentSessionId( const value = env[ORCA_AGENT_SESSION_ID_ENV]?.trim() return value ? value : undefined } + +/** + * The address the host gives this session (`mailboxAddressOf` on its resolved party): a structured + * worker keeps the handle it was minted, any other session is `session:`. Only for text that + * must match what the host writes; the CLI spells it without the host resolver. + */ +export function injectedSessionAddress( + env: Readonly> = process.env +): string | undefined { + const sessionId = readInjectedAgentSessionId(env) + if (!sessionId) { + return undefined + } + const ownHandle = env.ORCA_TERMINAL_HANDLE + return isStructuredWorkerHandle(ownHandle) + ? ownHandle + : `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` +} From 72eb439291d5ccdf8ce7572729e2096427d6f8a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:24:01 -0700 Subject: [PATCH 18/24] fix(terminal): run the Codex launch preflight through the CLI the terminal names Packaged Linux names the userData shim in ORCA_CLI_COMMAND, while the preflight ran the bundled launcher behind it. The CLI saw a different launcher and handed the preflight off to the shim, booting Electron twice before every codex launch. --- .../pty-daemon-spawn-agent-home-env.test.ts | 33 ++++++++++- src/main/ipc/pty/host-env/assembly.ts | 59 ++++++++++--------- .../pty/codex-shell-launch-preflight.test.ts | 43 +++++++++++++- src/main/pty/codex-shell-launch-preflight.ts | 23 +++++--- 4 files changed, 118 insertions(+), 40 deletions(-) diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index f6ffae641d3c..920563608da2 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { piBuildPtyEnvMock } from './pty-ipc-mock-registry' +import { piBuildPtyEnvMock, statSyncMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { type DaemonSpawnCall, @@ -463,6 +463,37 @@ describe('registerPtyHandlers', () => { }) } }) + it('runs the Codex launch preflight through the CLI the packaged Linux terminal names', async () => { + // Why: the bundled launcher behind the shim is a different file, so running it directly + // made the CLI hand the preflight off to the shim and boot Electron twice per launch. + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux' + }) + const shimPath = join('/tmp/orca-user-data', 'linux-orca-cli-shim', 'orca') + statSyncMock.mockImplementation((target: string) => ({ + isDirectory: () => target !== shimPath, + isFile: () => target === shimPath, + mode: 0o755, + size: 1 + })) + try { + const env = await daemonSpawnAndGetEnv( + { PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) }, + () => '/tmp/orca-codex-home' + ) + expect(env.ORCA_CLI_COMMAND).toBe( + join('/tmp/orca-user-data', 'linux-orca-cli-shim', 'orca') + ) + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(env.ORCA_CLI_COMMAND) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) it('prepends the bundled CLI dir to PATH for packaged macOS spawns', async () => { const resourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') Object.defineProperty(process, 'resourcesPath', { diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index e70a7388e331..f81823cad6fe 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -276,35 +276,6 @@ export function buildPtyHostEnv( } } - // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. - if (opts.skipCodexHomeEnv) { - delete baseEnv.CODEX_HOME - delete baseEnv.ORCA_CODEX_HOME - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } else if (opts.selectedCodexHomePath) { - baseEnv.CODEX_HOME = opts.selectedCodexHomePath - // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. - baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath - const preflightCommand = resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, - isPackaged: opts.isPackaged, - isWsl: opts.isWsl, - managedHomePath: opts.selectedCodexHomePath, - userDataPath: opts.userDataPath, - resourcesPath: opts.resourcesPath - }) - if (preflightCommand) { - baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand - } else { - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } - } else if (opts.stripInheritedOrcaCodexHome) { - stripInheritedOrcaCodexHomeOverride(baseEnv) - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } else { - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } - // Why: an inherited copy (e.g. Orca launched from a WSL pane) names another launch's CLI. delete baseEnv.ORCA_WSL_CLI_DIR // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. @@ -333,6 +304,36 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_CLI_COMMAND } + // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. + if (opts.skipCodexHomeEnv) { + delete baseEnv.CODEX_HOME + delete baseEnv.ORCA_CODEX_HOME + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } else if (opts.selectedCodexHomePath) { + baseEnv.CODEX_HOME = opts.selectedCodexHomePath + // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. + baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath + const preflightCommand = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, + isPackaged: opts.isPackaged, + isWsl: opts.isWsl, + managedHomePath: opts.selectedCodexHomePath, + userDataPath: opts.userDataPath, + resourcesPath: opts.resourcesPath, + cliLauncher: launcher + }) + if (preflightCommand) { + baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand + } else { + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } + } else if (opts.stripInheritedOrcaCodexHome) { + stripInheritedOrcaCodexHomeOverride(baseEnv) + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } else { + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } + if ( opts.routeBrowserOpensToClient === true && baseEnv.BROWSER === undefined && diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index f0673fc9c951..e54d235fd074 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, isAbsolute, join } from 'node:path' +import { delimiter, dirname, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' import { @@ -479,6 +479,47 @@ describe('Codex shell launch preflight command', () => { ).toBe(launcherPath) }) + it('runs the launcher the terminal names as its CLI, so the CLI never hands off to it', () => { + // Why: packaged Linux names the userData shim, not the bundled launcher behind it; running the + // launcher directly would re-run the preflight through the shim and boot Electron twice. + const { userDataPath, resourcesPath } = makeCliRoot() + writeExecutable(join(resourcesPath, 'bin', 'orca-ide'), '#!/bin/sh\nexit 0\n') + const shimPath = join(userDataPath, 'linux-orca-cli-shim', 'orca') + mkdirSync(dirname(shimPath), { recursive: true }) + writeExecutable(shimPath, '#!/bin/sh\nexit 0\n') + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + cliLauncher: shimPath, + platform: 'linux' + }) + ).toBe(shimPath) + }) + + it('keeps the Windows launcher for WSL even when the host terminal names a CLI', () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca.exe') + writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + isWsl: true, + managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home', + userDataPath, + resourcesPath, + cliLauncher: join(userDataPath, 'elsewhere', 'orca.exe'), + platform: 'win32' + }) + ).toBe(launcherPath) + }) + it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => { const { userDataPath, resourcesPath } = makeCliRoot() writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 87e5df758276..84a90f6f90da 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -14,6 +14,8 @@ export type CodexShellLaunchPreflightCommandOptions = { userDataPath: string /** Packaged app resources root; the bundled launcher lives under it. */ resourcesPath?: string | null + /** The launcher this terminal names in `ORCA_CLI_COMMAND`; the preflight runs that same one so it never hands off to it (Linux names a shim in front of the bundled launcher). */ + cliLauncher?: string | null /** Test seam. */ platform?: NodeJS.Platform } @@ -33,15 +35,18 @@ export function resolveCodexShellLaunchPreflightCommand( return null } const platform = options.platform ?? process.platform - const candidate = options.isPackaged - ? options.resourcesPath - ? getBundledLauncherPath(platform, options.resourcesPath) - : null - : join( - options.userDataPath, - ...DEV_LAUNCHER_DIR, - platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME - ) + const candidate = + options.cliLauncher && !options.isWsl + ? options.cliLauncher + : options.isPackaged + ? options.resourcesPath + ? getBundledLauncherPath(platform, options.resourcesPath) + : null + : join( + options.userDataPath, + ...DEV_LAUNCHER_DIR, + platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME + ) if (!candidate || !isExecutableFileOnDisk(candidate, platform)) { return null } From 09f185856ffbf5e082455ecd45661b58eded9907 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:48:05 -0700 Subject: [PATCH 19/24] revert(terminal): keep terminals on main's ORCA_CLI_COMMAND and Codex preflight Only a structured session needs an absolute ORCA_CLI_COMMAND; local terminals go back to naming none (WSL keeps its guest command), and the Codex launch preflight goes back to the bundled launcher. The CLI handoff is scoped to sessions, so a terminal's preflight can no longer be handed off and start Electron twice. This reverts commit d2cefb6c035 and commit dd2853a5a9a. --- .../pty-daemon-spawn-agent-home-env.test.ts | 37 +------------ src/main/ipc/pty/host-env/assembly.ts | 54 +++++++++---------- .../pty/codex-shell-launch-preflight.test.ts | 43 +-------------- src/main/pty/codex-shell-launch-preflight.ts | 23 ++++---- 4 files changed, 36 insertions(+), 121 deletions(-) diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 920563608da2..092886b3fe75 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { piBuildPtyEnvMock, statSyncMock } from './pty-ipc-mock-registry' +import { piBuildPtyEnvMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { type DaemonSpawnCall, @@ -454,39 +454,7 @@ describe('registerPtyHandlers', () => { // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904). expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) - // The same absolute spelling a structured session gets. - expect(env.ORCA_CLI_COMMAND).toBe(join(shimDir, 'orca')) - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) - it('runs the Codex launch preflight through the CLI the packaged Linux terminal names', async () => { - // Why: the bundled launcher behind the shim is a different file, so running it directly - // made the CLI hand the preflight off to the shim and boot Electron twice per launch. - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'linux' - }) - const shimPath = join('/tmp/orca-user-data', 'linux-orca-cli-shim', 'orca') - statSyncMock.mockImplementation((target: string) => ({ - isDirectory: () => target !== shimPath, - isFile: () => target === shimPath, - mode: 0o755, - size: 1 - })) - try { - const env = await daemonSpawnAndGetEnv( - { PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter) }, - () => '/tmp/orca-codex-home' - ) - expect(env.ORCA_CLI_COMMAND).toBe( - join('/tmp/orca-user-data', 'linux-orca-cli-shim', 'orca') - ) - expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(env.ORCA_CLI_COMMAND) + expect(env.ORCA_CLI_COMMAND).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -503,7 +471,6 @@ describe('registerPtyHandlers', () => { try { const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }) expect(env.PATH.split(delimiter)[0]).toBe(join('/tmp/orca-resources', 'bin')) - expect(env.ORCA_CLI_COMMAND?.startsWith(join('/tmp/orca-resources', 'bin'))).toBe(true) } finally { if (resourcesPathDescriptor) { Object.defineProperty(process, 'resourcesPath', resourcesPathDescriptor) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index f81823cad6fe..de61800c7eff 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -276,34 +276,6 @@ export function buildPtyHostEnv( } } - // Why: an inherited copy (e.g. Orca launched from a WSL pane) names another launch's CLI. - delete baseEnv.ORCA_WSL_CLI_DIR - // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. - if (opts.isWsl) { - baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath - const managedCliDir = getManagedWslCliDir(opts) - if (managedCliDir) { - baseEnv.ORCA_WSL_CLI_DIR = managedCliDir - } - } else if (!opts.isPackaged) { - baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath - } - const launcher = prependOrcaCliDirToChildPath(baseEnv, { - isPackaged: opts.isPackaged, - userDataPath: opts.userDataPath, - resourcesPath: opts.resourcesPath - }) - if (opts.isWsl) { - // Why: managed WSL registration uses `orca-ide`; a guest cannot run the host launcher's path. - baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged) - } else if (launcher) { - // Why the absolute launcher, the same spelling a structured session gets: a login shell can - // reorder PATH behind a global install, and a current CLI re-runs itself as this one. - baseEnv.ORCA_CLI_COMMAND = launcher - } else { - delete baseEnv.ORCA_CLI_COMMAND - } - // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. if (opts.skipCodexHomeEnv) { delete baseEnv.CODEX_HOME @@ -319,8 +291,7 @@ export function buildPtyHostEnv( isWsl: opts.isWsl, managedHomePath: opts.selectedCodexHomePath, userDataPath: opts.userDataPath, - resourcesPath: opts.resourcesPath, - cliLauncher: launcher + resourcesPath: opts.resourcesPath }) if (preflightCommand) { baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand @@ -334,6 +305,29 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT } + // Why: an inherited copy (e.g. Orca launched from a WSL pane) names another launch's CLI. + delete baseEnv.ORCA_WSL_CLI_DIR + // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. + if (opts.isWsl) { + baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. + baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged) + const managedCliDir = getManagedWslCliDir(opts) + if (managedCliDir) { + baseEnv.ORCA_WSL_CLI_DIR = managedCliDir + } + } else { + if (!opts.isPackaged) { + baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + } + delete baseEnv.ORCA_CLI_COMMAND + } + prependOrcaCliDirToChildPath(baseEnv, { + isPackaged: opts.isPackaged, + userDataPath: opts.userDataPath, + resourcesPath: opts.resourcesPath + }) + if ( opts.routeBrowserOpensToClient === true && baseEnv.BROWSER === undefined && diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index e54d235fd074..f0673fc9c951 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, dirname, isAbsolute, join } from 'node:path' +import { delimiter, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' import { @@ -479,47 +479,6 @@ describe('Codex shell launch preflight command', () => { ).toBe(launcherPath) }) - it('runs the launcher the terminal names as its CLI, so the CLI never hands off to it', () => { - // Why: packaged Linux names the userData shim, not the bundled launcher behind it; running the - // launcher directly would re-run the preflight through the shim and boot Electron twice. - const { userDataPath, resourcesPath } = makeCliRoot() - writeExecutable(join(resourcesPath, 'bin', 'orca-ide'), '#!/bin/sh\nexit 0\n') - const shimPath = join(userDataPath, 'linux-orca-cli-shim', 'orca') - mkdirSync(dirname(shimPath), { recursive: true }) - writeExecutable(shimPath, '#!/bin/sh\nexit 0\n') - - expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: true, - managedHomePath: '/managed/home', - userDataPath, - resourcesPath, - cliLauncher: shimPath, - platform: 'linux' - }) - ).toBe(shimPath) - }) - - it('keeps the Windows launcher for WSL even when the host terminal names a CLI', () => { - const { userDataPath, resourcesPath } = makeCliRoot() - const launcherPath = join(resourcesPath, 'bin', 'orca.exe') - writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') - - expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: true, - isWsl: true, - managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home', - userDataPath, - resourcesPath, - cliLauncher: join(userDataPath, 'elsewhere', 'orca.exe'), - platform: 'win32' - }) - ).toBe(launcherPath) - }) - it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => { const { userDataPath, resourcesPath } = makeCliRoot() writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 84a90f6f90da..87e5df758276 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -14,8 +14,6 @@ export type CodexShellLaunchPreflightCommandOptions = { userDataPath: string /** Packaged app resources root; the bundled launcher lives under it. */ resourcesPath?: string | null - /** The launcher this terminal names in `ORCA_CLI_COMMAND`; the preflight runs that same one so it never hands off to it (Linux names a shim in front of the bundled launcher). */ - cliLauncher?: string | null /** Test seam. */ platform?: NodeJS.Platform } @@ -35,18 +33,15 @@ export function resolveCodexShellLaunchPreflightCommand( return null } const platform = options.platform ?? process.platform - const candidate = - options.cliLauncher && !options.isWsl - ? options.cliLauncher - : options.isPackaged - ? options.resourcesPath - ? getBundledLauncherPath(platform, options.resourcesPath) - : null - : join( - options.userDataPath, - ...DEV_LAUNCHER_DIR, - platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME - ) + const candidate = options.isPackaged + ? options.resourcesPath + ? getBundledLauncherPath(platform, options.resourcesPath) + : null + : join( + options.userDataPath, + ...DEV_LAUNCHER_DIR, + platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME + ) if (!candidate || !isExecutableFileOnDisk(candidate, platform)) { return null } From bf4f095fa56513dee3b134b9904dcfbcf58c1079 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:50:56 -0700 Subject: [PATCH 20/24] fix(cli): hand off to the session's CLI only inside a structured session The handoff ran whenever an Orca launcher's ORCA_CLI_SELF differed from an absolute ORCA_CLI_COMMAND, so any process with both - a terminal, a script - ran another install's CLI instead of the one invoked: a beta's --version lied, and an AppImage command from a terminal that outlived its Orca failed. It now requires the injected session id, the identity it exists to deliver. The launcher variables are still consumed in every process. --- src/cli/session-cli-reexec.test.ts | 36 ++++++++++++++++++++++++++---- src/cli/session-cli-reexec.ts | 31 ++++++++++++++----------- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/cli/session-cli-reexec.test.ts b/src/cli/session-cli-reexec.test.ts index 383895fe283b..a4b87831422e 100644 --- a/src/cli/session-cli-reexec.test.ts +++ b/src/cli/session-cli-reexec.test.ts @@ -33,6 +33,9 @@ class Exited extends Error { } } +/** A structured session's own child: the only process the handoff applies to. */ +const SESSION = { ORCA_AGENT_SESSION_ID: 'session-1' } as const + function exitSpy(): (code: number) => never { return (code: number) => { throw new Exited(code) @@ -70,18 +73,34 @@ describe('takeSessionCliReexec', () => { expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) }) + it('runs the invoked CLI without a session id, and still consumes the handoff variables', () => { + // A terminal, a script, or a marker-only child of an older host gets the CLI the user ran: a + // beta or ad hoc Orca's `orca`, and its --version, must not silently become another install's. + const env: NodeJS.ProcessEnv = { + ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n'), + [ORCA_CLI_SELF_ENV]: writeScript('beta-orca', 'exit 0\n'), + ORCA_STRUCTURED_SESSION: '1' + } + + expect(takeSessionCliReexec({ env })).toBeNull() + expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) + }) + it('stays when the invoked launcher is the named one, through a symlink', () => { const named = writeScript('session-orca', 'exit 0\n') const link = join(dir, 'usr-local-bin-orca') symlinkSync(named, link) expect( - takeSessionCliReexec({ env: { ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: link } }) + takeSessionCliReexec({ + env: { ...SESSION, ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: link } + }) ).toBeNull() }) it('makes at most one hop, and consumes the guard so no child inherits it', () => { const env: NodeJS.ProcessEnv = { + ...SESSION, ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n'), [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n'), [ORCA_CLI_REEXEC_ENV]: '1' @@ -94,7 +113,9 @@ describe('takeSessionCliReexec', () => { it('stays when no Orca launcher named itself: a dev launcher, or an older one', () => { expect( - takeSessionCliReexec({ env: { ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n') } }) + takeSessionCliReexec({ + env: { ...SESSION, ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n') } + }) ).toBeNull() }) @@ -107,7 +128,11 @@ describe('takeSessionCliReexec', () => { expect( takeSessionCliReexec({ - env: { ORCA_CLI_COMMAND: command, [ORCA_CLI_SELF_ENV]: writeScript('global', 'exit 0\n') } + env: { + ...SESSION, + ORCA_CLI_COMMAND: command, + [ORCA_CLI_SELF_ENV]: writeScript('global', 'exit 0\n') + } }) ).toBeNull() }) @@ -116,6 +141,7 @@ describe('takeSessionCliReexec', () => { expect( takeSessionCliReexec({ env: { + ...SESSION, ORCA_CLI_COMMAND: join(dir, 'gone', 'orca'), [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n') } @@ -137,6 +163,7 @@ describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { runAsSessionCli(run, { env: { ...process.env, + ...SESSION, ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n'), ORCA_NODE_OPTIONS: '--no-warnings' @@ -155,7 +182,7 @@ describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { const run = vi.fn(async () => {}) await runAsSessionCli(run, { - env: { ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: named }, + env: { ...SESSION, ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: named }, exit: exitSpy() }) @@ -170,6 +197,7 @@ describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { await runAsSessionCli(run, { env: { + ...SESSION, ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n') }, diff --git a/src/cli/session-cli-reexec.ts b/src/cli/session-cli-reexec.ts index 5b31646e322c..5c688fc84176 100644 --- a/src/cli/session-cli-reexec.ts +++ b/src/cli/session-cli-reexec.ts @@ -1,27 +1,32 @@ /** - * Hands a command to the CLI the session named, when a different Orca CLI was the one invoked. + * Hands a structured session's command to the CLI the session named, when a different Orca CLI was + * the one invoked. * - * Orca puts the absolute launcher of its own CLI in `ORCA_CLI_COMMAND` for every local terminal and - * structured session. An agent may still reach another install — a login shell reorders PATH behind - * a global `orca`, a helper script hardcodes `orca`, a user types `/usr/local/bin/orca` — and that - * CLI can be older than the session's identity or dial a different instance. So a current CLI that - * is not the named launcher re-runs the command through it, once, and exits with its status. Which - * binary answers stops depending on the agent following instructions. + * Orca puts the absolute launcher of its own CLI in a structured session's `ORCA_CLI_COMMAND`. The + * agent may still reach another install — a login shell reorders PATH behind a global `orca`, a + * helper script hardcodes `orca`, a user types `/usr/local/bin/orca` — and that CLI can be older than + * the session's identity or dial a different instance. So a current CLI that is not the named + * launcher re-runs the command through it, once, and exits with its status. Which binary answers + * stops depending on the agent following instructions. + * + * Only a process carrying the injected session id qualifies: the handoff exists to deliver that + * identity. Anywhere else — a terminal, a script — the CLI the user ran is the one that answers. * * Identity comes from `ORCA_CLI_SELF`, which Orca's packaged launchers and bare-`orca` shims export * (the outermost one wins); this entry's own argv names the JS file, never a launcher. A dev launcher * exports none on purpose: it pins its own instance, so running one is a deliberate choice of - * instance, often from another instance's terminal. `ORCA_CLI_REEXEC=1` bounds the handoff to one - * hop and is also the escape hatch. Both variables are consumed here, so no child of the CLI — an - * Orca app it starts, a terminal that app opens — inherits a stale identity or a disabled handoff. + * instance. `ORCA_CLI_REEXEC=1` bounds the handoff to one hop and is also the escape hatch. Both + * variables are consumed here, in every process, so no child of the CLI — an Orca app it starts, a + * session that app spawns — inherits a stale identity or a disabled handoff. * - * WSL and SSH never qualify: they carry a guest command name or `orca`, not a host path, and a - * relative command is never resolved against the working directory. + * A relative command (a WSL guest name, the SSH relay's `orca`) never qualifies, and is never + * resolved against the working directory. */ import { realpathSync } from 'node:fs' import { constants as osConstants } from 'node:os' import { posix, resolve, win32 } from 'node:path' +import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' export const ORCA_CLI_SELF_ENV = 'ORCA_CLI_SELF' export const ORCA_CLI_REEXEC_ENV = 'ORCA_CLI_REEXEC' @@ -69,7 +74,7 @@ export function takeSessionCliReexec(options: ReexecOptions = {}): SessionCliRee const alreadyHandedOff = env[ORCA_CLI_REEXEC_ENV] === '1' delete env[ORCA_CLI_SELF_ENV] delete env[ORCA_CLI_REEXEC_ENV] - if (alreadyHandedOff || !self) { + if (alreadyHandedOff || !self || !readInjectedAgentSessionId(env)) { return null } const named = env.ORCA_CLI_COMMAND?.trim() From 8cbf4331e3b2f6151203feb891dd7dcc5dbcfc31 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:53:45 -0700 Subject: [PATCH 21/24] fix(cli): name the packaged Windows command after the handoff decision The launcher stopped writing orca/orca-ide over ORCA_CLI_COMMAND so the handoff could see a session's absolute launcher, which also changed what every Windows terminal's CLI read. The CLI entry now applies the launcher's rule itself once the handoff is decided, so terminals and the legacy ask resume command see exactly what they saw before, and the resume-command reader goes back to its original form. --- .../windows-cli-launcher/OrcaCliLauncher.cs | 4 +-- .../orchestration-windows-ask-cli.test.ts | 21 ------------- .../orchestration/runtime-compatibility.ts | 12 ++++++-- src/cli/session-cli-reexec.test.ts | 30 +++++++++++++++++++ src/cli/session-cli-reexec.ts | 11 +++++++ src/main/cli/windows-launcher-asset.test.ts | 2 +- 6 files changed, 54 insertions(+), 26 deletions(-) diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs index 067b4c1ee108..52b1190e8fb2 100644 --- a/native/windows-cli-launcher/OrcaCliLauncher.cs +++ b/native/windows-cli-launcher/OrcaCliLauncher.cs @@ -57,8 +57,8 @@ private static int Main(string[] args) MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE"); Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1"); Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); - // Why: names this launcher as the entry the caller ran, and leaves ORCA_CLI_COMMAND as - // the session set it, so the CLI can hand off to the session's own launcher. + // Why: names this launcher as the entry the caller ran. ORCA_CLI_COMMAND is left as + // the session set it; the CLI names it `orca`/`orca-ide` after its handoff decision. if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ORCA_CLI_SELF"))) { Environment.SetEnvironmentVariable( diff --git a/src/cli/handlers/orchestration-windows-ask-cli.test.ts b/src/cli/handlers/orchestration-windows-ask-cli.test.ts index c29300a70749..ec7f346c0bdc 100644 --- a/src/cli/handlers/orchestration-windows-ask-cli.test.ts +++ b/src/cli/handlers/orchestration-windows-ask-cli.test.ts @@ -64,27 +64,6 @@ describe('packaged Windows legacy ask protocol', () => { } ) - it("names `orca` when a session's ORCA_CLI_COMMAND is the launcher's absolute path", async () => { - process.env.ORCA_CLI_COMMAND = 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe' - callMock.mockResolvedValue({ - result: { - answer: 'yes', - messageId: 'msg_question', - threadId: 'msg_question', - timedOut: false - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk(new Map([['resume', 'msg_question']])) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ compatibilityWindowsCommand: 'orca' }), - expect.any(Object) - ) - }) - it('resumes the committed question without another exit-75 handoff', async () => { process.env.ORCA_CLI_COMMAND = 'orca' callMock.mockResolvedValue({ diff --git a/src/cli/handlers/orchestration/runtime-compatibility.ts b/src/cli/handlers/orchestration/runtime-compatibility.ts index 8335f0c34e53..e4076534daa8 100644 --- a/src/cli/handlers/orchestration/runtime-compatibility.ts +++ b/src/cli/handlers/orchestration/runtime-compatibility.ts @@ -1,3 +1,5 @@ +import { RuntimeClientError } from '../../runtime-client' + export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { const configured = process.env.ORCA_CLI_COMMAND if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { @@ -6,12 +8,18 @@ export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-de return process.platform === 'linux' ? 'orca-ide' : 'orca' } -/** The resume command a legacy host prints: `orca-ide` only when WSL asked for it, else `orca`. */ export function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { return undefined } - return process.env.ORCA_CLI_COMMAND === 'orca-ide' ? 'orca-ide' : 'orca' + const command = process.env.ORCA_CLI_COMMAND + if (command === 'orca' || command === 'orca-ide') { + return command + } + throw new RuntimeClientError( + 'invalid_argument', + 'The packaged Orca launcher did not provide a valid resume command. No question was created.' + ) } export async function flushOrchestrationStdout(): Promise { diff --git a/src/cli/session-cli-reexec.test.ts b/src/cli/session-cli-reexec.test.ts index a4b87831422e..15ba5b0adbdd 100644 --- a/src/cli/session-cli-reexec.test.ts +++ b/src/cli/session-cli-reexec.test.ts @@ -208,3 +208,33 @@ describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { expect(String(stderr.mock.calls[0]?.[0])).toContain("could not run this session's CLI") }) }) + +describe('packaged Windows launcher command name', () => { + it.each([ + ['a terminal with none', {}, 'orca'], + ['a WSL-registered name', { ORCA_CLI_COMMAND: 'orca-ide' }, 'orca-ide'], + [ + "a session's launcher once it is the named CLI", + { ...SESSION, ORCA_CLI_COMMAND: 'C:\\Orca\\resources\\bin\\orca.exe' }, + 'orca' + ] + ])('names %s as the launcher did before the handoff existed', async (_label, extra, expected) => { + const env: NodeJS.ProcessEnv = { ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1', ...extra } + let seen: string | undefined + await runAsSessionCli( + async () => { + seen = env.ORCA_CLI_COMMAND + }, + { env, platform: 'win32', exit: exitSpy() } + ) + + expect(seen).toBe(expected) + }) + + it("leaves every other launcher's command alone", async () => { + const env: NodeJS.ProcessEnv = { ORCA_CLI_COMMAND: '/opt/Orca/resources/bin/orca' } + await runAsSessionCli(async () => {}, { env, exit: exitSpy() }) + + expect(env.ORCA_CLI_COMMAND).toBe('/opt/Orca/resources/bin/orca') + }) +}) diff --git a/src/cli/session-cli-reexec.ts b/src/cli/session-cli-reexec.ts index 5c688fc84176..bc34e8b2237d 100644 --- a/src/cli/session-cli-reexec.ts +++ b/src/cli/session-cli-reexec.ts @@ -60,9 +60,20 @@ export async function runAsSessionCli( if (reexec) { await runSessionCliReexec(reexec, options.exit) } + applyPackagedWindowsCliCommand(options.env ?? process.env) await run() } +/** + * The command name the packaged Windows launcher used to write over `ORCA_CLI_COMMAND` itself; it + * now runs after the handoff decision, which needs a session's absolute launcher. + */ +function applyPackagedWindowsCliCommand(env: NodeJS.ProcessEnv): void { + if (env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER === '1') { + env.ORCA_CLI_COMMAND = env.ORCA_CLI_COMMAND === 'orca-ide' ? 'orca-ide' : 'orca' + } +} + /** * Removes the launcher handoff variables from `env` and returns the re-exec this process owes, or * null when it is already the named CLI, cannot tell, or is itself the one hop. diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index 91986d4e43d3..9c1dc0532905 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -22,7 +22,7 @@ describe('packaged Windows CLI launcher asset', () => { 'Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");' ) // It names itself as the CLI entry and leaves the session's ORCA_CLI_COMMAND untouched, so the - // CLI can compare the two and hand off to the session's own launcher. + // CLI can compare the two before it applies the launcher's command name. expect(source).toContain('"ORCA_CLI_SELF",') expect(source).toContain('typeof(OrcaCliLauncher).Assembly.Location') expect(source).not.toMatch(/SetEnvironmentVariable\(\s*"ORCA_CLI_COMMAND"/) From 62e9b933073388d25a31a1a5a52daf1298ec5f81 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:08:39 -0700 Subject: [PATCH 22/24] refactor(cli): decide the session handoff from the CLI's own entry, not a launcher export Every packaged launcher, shim and dispatcher exported ORCA_CLI_SELF so the CLI could tell which launcher ran it, and compared that with the session's ORCA_CLI_COMMAND. Two launchers of the same app are different files, so a session that reached its own app through a global orca-ide on Linux still handed off and started Electron twice, and the export rode artifacts every terminal uses. A structured session now also names the JS entry its launcher runs (ORCA_SESSION_CLI_ENTRY), and the CLI compares its own argv entry with it: any launcher of the same app stays, another install hands off. The launcher scripts, Linux shim and dispatcher go back to main; the Windows launcher keeps only leaving ORCA_CLI_COMMAND for the CLI to name after the handoff decision. --- .../windows-cli-launcher/OrcaCliLauncher.cs | 11 +- resources/darwin/bin/orca | 2 - resources/linux/bin/orca-ide | 2 - src/cli/session-cli-reexec.test.ts | 146 +++++++++--------- src/cli/session-cli-reexec.ts | 57 ++++--- src/main/cli/cli-entry-path.ts | 15 ++ src/main/cli/cli-self-export.test.ts | 50 ------ src/main/cli/cli-self-export.ts | 7 - src/main/cli/linux-bare-orca-dispatcher.ts | 8 +- .../cli/linux-terminal-orca-cli-shim.test.ts | 4 - src/main/cli/linux-terminal-orca-cli-shim.ts | 3 +- src/main/cli/windows-launcher-asset.test.ts | 5 +- ...codex-structured-child-environment.test.ts | 2 + .../codex-structured-session-adapter.test.ts | 1 + ...uctured-session-child-identity-env.test.ts | 15 +- .../structured-session-child-identity-env.ts | 24 ++- .../ssh/ssh-remote-cli-host-passthrough.ts | 16 +- src/shared/agent-session-caller-env.ts | 3 + 18 files changed, 167 insertions(+), 204 deletions(-) create mode 100644 src/main/cli/cli-entry-path.ts delete mode 100644 src/main/cli/cli-self-export.test.ts delete mode 100644 src/main/cli/cli-self-export.ts diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs index 52b1190e8fb2..08f3bc12a867 100644 --- a/native/windows-cli-launcher/OrcaCliLauncher.cs +++ b/native/windows-cli-launcher/OrcaCliLauncher.cs @@ -57,15 +57,8 @@ private static int Main(string[] args) MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE"); Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1"); Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); - // Why: names this launcher as the entry the caller ran. ORCA_CLI_COMMAND is left as - // the session set it; the CLI names it `orca`/`orca-ide` after its handoff decision. - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ORCA_CLI_SELF"))) - { - Environment.SetEnvironmentVariable( - "ORCA_CLI_SELF", - typeof(OrcaCliLauncher).Assembly.Location - ); - } + // Why: ORCA_CLI_COMMAND is left as the session set it; the CLI names it + // `orca`/`orca-ide` itself after deciding whether to hand off to that launcher. using (Process child = Process.Start(startInfo)) { diff --git a/resources/darwin/bin/orca b/resources/darwin/bin/orca index ab0b0edb2263..4d01b9928cad 100755 --- a/resources/darwin/bin/orca +++ b/resources/darwin/bin/orca @@ -25,8 +25,6 @@ ELECTRON="$CONTENTS/MacOS/Orca" # launcher model instead of requiring a separate npm-distributed binary. CLI="$CONTENTS/Resources/app.asar.unpacked/out/cli/index.js" -# Why: names the entry the caller ran, so the CLI can hand off to a session's own launcher. -export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}" export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}" export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS diff --git a/resources/linux/bin/orca-ide b/resources/linux/bin/orca-ide index 8cbcaf35970d..f191f27c770e 100755 --- a/resources/linux/bin/orca-ide +++ b/resources/linux/bin/orca-ide @@ -33,8 +33,6 @@ fi # launcher model used on macOS instead of maintaining a separate Node binary. CLI="$RESOURCES_DIR/app.asar.unpacked/out/cli/index.js" -# Why: names the entry the caller ran, so the CLI can hand off to a session's own launcher. -export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}" export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}" export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS diff --git a/src/cli/session-cli-reexec.test.ts b/src/cli/session-cli-reexec.test.ts index 15ba5b0adbdd..70c95dec6d37 100644 --- a/src/cli/session-cli-reexec.test.ts +++ b/src/cli/session-cli-reexec.test.ts @@ -2,12 +2,7 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSyn import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ORCA_CLI_REEXEC_ENV, - ORCA_CLI_SELF_ENV, - runAsSessionCli, - takeSessionCliReexec -} from './session-cli-reexec' +import { ORCA_CLI_REEXEC_ENV, runAsSessionCli, takeSessionCliReexec } from './session-cli-reexec' let dir: string @@ -27,96 +22,104 @@ function writeScript(name: string, body: string): string { return path } +function writeEntry(name: string): string { + const path = join(dir, name) + writeFileSync(path, '') + return path +} + +/** The argv a launcher gives the CLI: the runtime, the entry it ran, then the command. */ +function argvFor(entry: string, ...args: string[]): string[] { + return ['/electron', entry, ...args] +} + class Exited extends Error { constructor(readonly code: number) { super(`exit ${code}`) } } -/** A structured session's own child: the only process the handoff applies to. */ -const SESSION = { ORCA_AGENT_SESSION_ID: 'session-1' } as const - function exitSpy(): (code: number) => never { return (code: number) => { throw new Exited(code) } } +/** A structured session's own child, which names its launcher and the entry that launcher runs. */ +function sessionEnv(launcher: string, entry: string): NodeJS.ProcessEnv { + return { + ORCA_AGENT_SESSION_ID: 'session-1', + ORCA_CLI_COMMAND: launcher, + ORCA_SESSION_CLI_ENTRY: entry + } +} + describe('takeSessionCliReexec', () => { - it('hands off when the invoked CLI is not the launcher the session named', () => { - const invoked = writeScript('global-orca', 'exit 0\n') + it("hands off when the invoked CLI runs another entry than the session's", () => { const named = writeScript('session-orca', 'exit 0\n') const env: NodeJS.ProcessEnv = { - ORCA_CLI_COMMAND: named, - [ORCA_CLI_SELF_ENV]: invoked, - ORCA_AGENT_SESSION_ID: 'session-1', + ...sessionEnv(named, writeEntry('session-index.js')), ELECTRON_RUN_AS_NODE: '1', ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1', ORCA_NODE_OPTIONS: '--max-old-space-size=4096', ORCA_NODE_REPL_EXTERNAL_MODULE: '' } - const reexec = takeSessionCliReexec({ env, argv: ['orchestration', 'check'] }) + const reexec = takeSessionCliReexec({ + env, + argv: argvFor(writeEntry('global-index.js'), 'orchestration', 'check') + }) expect(reexec).toEqual({ target: named, argv: ['orchestration', 'check'], // What the invoked launcher was handed, so the named one sees the caller's own environment. env: { - ORCA_CLI_COMMAND: named, - ORCA_AGENT_SESSION_ID: 'session-1', + ...sessionEnv(named, join(dir, 'session-index.js')), NODE_OPTIONS: '--max-old-space-size=4096', [ORCA_CLI_REEXEC_ENV]: '1' } }) - // Consumed: nothing this CLI starts inherits the identity of the launcher that ran it. - expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) - }) - - it('runs the invoked CLI without a session id, and still consumes the handoff variables', () => { - // A terminal, a script, or a marker-only child of an older host gets the CLI the user ran: a - // beta or ad hoc Orca's `orca`, and its --version, must not silently become another install's. - const env: NodeJS.ProcessEnv = { - ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n'), - [ORCA_CLI_SELF_ENV]: writeScript('beta-orca', 'exit 0\n'), - ORCA_STRUCTURED_SESSION: '1' - } - - expect(takeSessionCliReexec({ env })).toBeNull() - expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) }) - it('stays when the invoked launcher is the named one, through a symlink', () => { - const named = writeScript('session-orca', 'exit 0\n') - const link = join(dir, 'usr-local-bin-orca') - symlinkSync(named, link) + it('stays when another launcher of the same app ran the same entry, through a symlink', () => { + // A shim or a global symlink in front of the session's launcher runs the session's own CLI. + const entry = writeEntry('index.js') + const link = join(dir, 'linked-index.js') + symlinkSync(entry, link) expect( takeSessionCliReexec({ - env: { ...SESSION, ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: link } + env: sessionEnv(writeScript('session-orca', 'exit 0\n'), entry), + argv: argvFor(link, 'orchestration', 'check') }) ).toBeNull() }) + it('runs the invoked CLI without a session id', () => { + // A terminal, a script, or a marker-only child of an older host gets the CLI the user ran: a + // beta or ad hoc Orca's `orca`, and its --version, must not silently become another install's. + const env = sessionEnv(writeScript('session-orca', 'exit 0\n'), writeEntry('session-index.js')) + delete env.ORCA_AGENT_SESSION_ID + env.ORCA_STRUCTURED_SESSION = '1' + + expect(takeSessionCliReexec({ env, argv: argvFor(writeEntry('beta-index.js')) })).toBeNull() + }) + it('makes at most one hop, and consumes the guard so no child inherits it', () => { const env: NodeJS.ProcessEnv = { - ...SESSION, - ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n'), - [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n'), + ...sessionEnv(writeScript('session-orca', 'exit 0\n'), writeEntry('session-index.js')), [ORCA_CLI_REEXEC_ENV]: '1' } - expect(takeSessionCliReexec({ env })).toBeNull() + expect(takeSessionCliReexec({ env, argv: argvFor(writeEntry('global-index.js')) })).toBeNull() expect(env).not.toHaveProperty(ORCA_CLI_REEXEC_ENV) - expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) }) - it('stays when no Orca launcher named itself: a dev launcher, or an older one', () => { - expect( - takeSessionCliReexec({ - env: { ...SESSION, ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n') } - }) - ).toBeNull() + it('stays when the session names no entry: a child of a host that predates it', () => { + const env = sessionEnv(writeScript('session-orca', 'exit 0\n'), '') + + expect(takeSessionCliReexec({ env, argv: argvFor(writeEntry('global-index.js')) })).toBeNull() }) it.each([ @@ -128,11 +131,8 @@ describe('takeSessionCliReexec', () => { expect( takeSessionCliReexec({ - env: { - ...SESSION, - ORCA_CLI_COMMAND: command, - [ORCA_CLI_SELF_ENV]: writeScript('global', 'exit 0\n') - } + env: sessionEnv(command, writeEntry('session-index.js')), + argv: argvFor(writeEntry('global-index.js')) }) ).toBeNull() }) @@ -140,11 +140,8 @@ describe('takeSessionCliReexec', () => { it('stays when the named launcher no longer exists', () => { expect( takeSessionCliReexec({ - env: { - ...SESSION, - ORCA_CLI_COMMAND: join(dir, 'gone', 'orca'), - [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n') - } + env: sessionEnv(join(dir, 'gone', 'orca'), writeEntry('session-index.js')), + argv: argvFor(writeEntry('global-index.js')) }) ).toBeNull() }) @@ -163,12 +160,10 @@ describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { runAsSessionCli(run, { env: { ...process.env, - ...SESSION, - ORCA_CLI_COMMAND: named, - [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n'), + ...sessionEnv(named, writeEntry('session-index.js')), ORCA_NODE_OPTIONS: '--no-warnings' }, - argv: ['orchestration', 'check', '--wait'], + argv: argvFor(writeEntry('global-index.js'), 'orchestration', 'check', '--wait'), exit: exitSpy() }) ).rejects.toEqual(new Exited(7)) @@ -177,12 +172,13 @@ describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { expect(run).not.toHaveBeenCalled() }) - it('runs the command here when it is already the named CLI', async () => { - const named = writeScript('session-orca', 'exit 0\n') + it("runs the command here when it already runs the session's entry", async () => { + const entry = writeEntry('index.js') const run = vi.fn(async () => {}) await runAsSessionCli(run, { - env: { ...SESSION, ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: named }, + env: sessionEnv(writeScript('session-orca', 'exit 0\n'), entry), + argv: argvFor(entry), exit: exitSpy() }) @@ -196,11 +192,8 @@ describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { const run = vi.fn(async () => {}) await runAsSessionCli(run, { - env: { - ...SESSION, - ORCA_CLI_COMMAND: named, - [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n') - }, + env: sessionEnv(named, writeEntry('session-index.js')), + argv: argvFor(writeEntry('global-index.js')), exit: exitSpy() }) @@ -215,7 +208,10 @@ describe('packaged Windows launcher command name', () => { ['a WSL-registered name', { ORCA_CLI_COMMAND: 'orca-ide' }, 'orca-ide'], [ "a session's launcher once it is the named CLI", - { ...SESSION, ORCA_CLI_COMMAND: 'C:\\Orca\\resources\\bin\\orca.exe' }, + { + ORCA_AGENT_SESSION_ID: 'session-1', + ORCA_CLI_COMMAND: 'C:\\Orca\\resources\\bin\\orca.exe' + }, 'orca' ] ])('names %s as the launcher did before the handoff existed', async (_label, extra, expected) => { @@ -225,7 +221,7 @@ describe('packaged Windows launcher command name', () => { async () => { seen = env.ORCA_CLI_COMMAND }, - { env, platform: 'win32', exit: exitSpy() } + { env, argv: argvFor('C:\\Orca\\index.js'), platform: 'win32', exit: exitSpy() } ) expect(seen).toBe(expected) @@ -233,7 +229,11 @@ describe('packaged Windows launcher command name', () => { it("leaves every other launcher's command alone", async () => { const env: NodeJS.ProcessEnv = { ORCA_CLI_COMMAND: '/opt/Orca/resources/bin/orca' } - await runAsSessionCli(async () => {}, { env, exit: exitSpy() }) + await runAsSessionCli(async () => {}, { + env, + argv: argvFor('/opt/Orca/index.js'), + exit: exitSpy() + }) expect(env.ORCA_CLI_COMMAND).toBe('/opt/Orca/resources/bin/orca') }) diff --git a/src/cli/session-cli-reexec.ts b/src/cli/session-cli-reexec.ts index bc34e8b2237d..7e19d4913044 100644 --- a/src/cli/session-cli-reexec.ts +++ b/src/cli/session-cli-reexec.ts @@ -2,22 +2,19 @@ * Hands a structured session's command to the CLI the session named, when a different Orca CLI was * the one invoked. * - * Orca puts the absolute launcher of its own CLI in a structured session's `ORCA_CLI_COMMAND`. The - * agent may still reach another install — a login shell reorders PATH behind a global `orca`, a - * helper script hardcodes `orca`, a user types `/usr/local/bin/orca` — and that CLI can be older than - * the session's identity or dial a different instance. So a current CLI that is not the named - * launcher re-runs the command through it, once, and exits with its status. Which binary answers - * stops depending on the agent following instructions. + * Orca puts the absolute launcher of its own CLI in a structured session's `ORCA_CLI_COMMAND`, and + * the JS entry that launcher runs in `ORCA_SESSION_CLI_ENTRY`. The agent may still reach another + * install — a login shell reorders PATH behind a global `orca`, a helper script hardcodes `orca`, a + * user types `/usr/local/bin/orca` — and that CLI can be older than the session's identity or dial a + * different instance. So a current CLI whose own entry is not the session's re-runs the command + * through the named launcher, once, and exits with its status. Which binary answers stops depending + * on the agent following instructions, and any launcher of the same app (a shim, a global symlink) + * runs the same entry, so it never hands off. * * Only a process carrying the injected session id qualifies: the handoff exists to deliver that * identity. Anywhere else — a terminal, a script — the CLI the user ran is the one that answers. - * - * Identity comes from `ORCA_CLI_SELF`, which Orca's packaged launchers and bare-`orca` shims export - * (the outermost one wins); this entry's own argv names the JS file, never a launcher. A dev launcher - * exports none on purpose: it pins its own instance, so running one is a deliberate choice of - * instance. `ORCA_CLI_REEXEC=1` bounds the handoff to one hop and is also the escape hatch. Both - * variables are consumed here, in every process, so no child of the CLI — an Orca app it starts, a - * session that app spawns — inherits a stale identity or a disabled handoff. + * `ORCA_CLI_REEXEC=1` bounds the handoff to one hop and is also the escape hatch; it is consumed + * here so nothing the CLI starts inherits a disabled handoff. * * A relative command (a WSL guest name, the SSH relay's `orca`) never qualifies, and is never * resolved against the working directory. @@ -26,9 +23,11 @@ import { realpathSync } from 'node:fs' import { constants as osConstants } from 'node:os' import { posix, resolve, win32 } from 'node:path' -import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' +import { + ORCA_SESSION_CLI_ENTRY_ENV, + readInjectedAgentSessionId +} from '../shared/agent-session-caller-env' -export const ORCA_CLI_SELF_ENV = 'ORCA_CLI_SELF' export const ORCA_CLI_REEXEC_ENV = 'ORCA_CLI_REEXEC' /** Set by the launcher that started this process; the next launcher sets them again itself. */ @@ -47,6 +46,7 @@ export type SessionCliReexec = { type ReexecOptions = { env?: NodeJS.ProcessEnv + /** This process's argv; `[1]` is the CLI entry its launcher ran. */ argv?: readonly string[] platform?: NodeJS.Platform } @@ -75,31 +75,38 @@ function applyPackagedWindowsCliCommand(env: NodeJS.ProcessEnv): void { } /** - * Removes the launcher handoff variables from `env` and returns the re-exec this process owes, or - * null when it is already the named CLI, cannot tell, or is itself the one hop. + * Consumes the one-hop guard and returns the re-exec this process owes, or null when it already runs + * the session's CLI entry, cannot tell, or is itself the one hop. */ export function takeSessionCliReexec(options: ReexecOptions = {}): SessionCliReexec | null { const env = options.env ?? process.env const platform = options.platform ?? process.platform - const self = env[ORCA_CLI_SELF_ENV]?.trim() + const processArgv = options.argv ?? process.argv const alreadyHandedOff = env[ORCA_CLI_REEXEC_ENV] === '1' - delete env[ORCA_CLI_SELF_ENV] delete env[ORCA_CLI_REEXEC_ENV] - if (alreadyHandedOff || !self || !readInjectedAgentSessionId(env)) { + if (alreadyHandedOff || !readInjectedAgentSessionId(env)) { return null } + const isAbsolute = (platform === 'win32' ? win32 : posix).isAbsolute const named = env.ORCA_CLI_COMMAND?.trim() - if (!named || !(platform === 'win32' ? win32 : posix).isAbsolute(named)) { + const sessionEntry = env[ORCA_SESSION_CLI_ENTRY_ENV]?.trim() + const ownEntry = processArgv[1] + if (!named || !sessionEntry || !ownEntry || !isAbsolute(named) || !isAbsolute(sessionEntry)) { return null } - const target = tryRealpath(named) - const current = tryRealpath(self) - if (target === null || current === null || samePath(target, current, platform)) { + const sessionEntryPath = tryRealpath(sessionEntry) + const ownEntryPath = tryRealpath(ownEntry) + if ( + sessionEntryPath === null || + ownEntryPath === null || + samePath(sessionEntryPath, ownEntryPath, platform) || + tryRealpath(named) === null + ) { return null } return { target: named, - argv: [...(options.argv ?? process.argv.slice(2))], + argv: processArgv.slice(2), env: buildHandoffEnv(env) } } diff --git a/src/main/cli/cli-entry-path.ts b/src/main/cli/cli-entry-path.ts new file mode 100644 index 000000000000..b9d6af6bc78b --- /dev/null +++ b/src/main/cli/cli-entry-path.ts @@ -0,0 +1,15 @@ +import { join } from 'node:path' + +/** The JS entry this app's CLI launchers run under Electron's node mode. */ +export function resolveHostCliEntryPath(app: { + isPackaged: boolean + resourcesPath: string + appPath: string +}): string { + // Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev + // launcher in cli-installer.ts — packaged builds ship the CLI entry outside + // app.asar so Electron node mode can execute it directly. + return app.isPackaged + ? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + : join(app.appPath, 'out', 'cli', 'index.js') +} diff --git a/src/main/cli/cli-self-export.test.ts b/src/main/cli/cli-self-export.test.ts deleted file mode 100644 index af24a3cf9253..000000000000 --- a/src/main/cli/cli-self-export.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { spawnSync } from 'node:child_process' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' - -const ORCA_CLI_SELF_ENV = 'ORCA_CLI_SELF' -let dir: string - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'orca-cli-self-export-')) -}) - -afterEach(() => { - rmSync(dir, { recursive: true, force: true }) -}) - -function writeScript(name: string, body: string): string { - const path = join(dir, name) - writeFileSync(path, `#!/usr/bin/env bash\n${body}`) - chmodSync(path, 0o755) - return path -} - -describe.skipIf(process.platform === 'win32')('the launcher self export', () => { - it('names the outermost Orca script, so a shim that execs a launcher stays the entry', () => { - const report = join(dir, 'report') - const launcher = writeScript( - 'orca-ide', - `${ORCA_CLI_SELF_EXPORT}printf '%s' "$ORCA_CLI_SELF" > '${report}'\n` - ) - const shim = writeScript('orca', `${ORCA_CLI_SELF_EXPORT}exec '${launcher}' "$@"\n`) - const env = { ...process.env } - delete env[ORCA_CLI_SELF_ENV] - - expect(spawnSync(shim, [], { env }).status).toBe(0) - expect(readFileSync(report, 'utf8')).toBe(shim) - - expect(spawnSync(launcher, [], { env }).status).toBe(0) - expect(readFileSync(report, 'utf8')).toBe(launcher) - }) - - it.each(['resources/darwin/bin/orca', 'resources/linux/bin/orca-ide'])( - 'is the line the packaged %s launcher runs', - (path) => { - expect(readFileSync(join(process.cwd(), path), 'utf8')).toContain(ORCA_CLI_SELF_EXPORT) - } - ) -}) diff --git a/src/main/cli/cli-self-export.ts b/src/main/cli/cli-self-export.ts deleted file mode 100644 index 499da55677b0..000000000000 --- a/src/main/cli/cli-self-export.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * The bash line an Orca CLI launcher or shim runs to name itself as the entry the caller invoked. - * The outermost Orca script wins, so a shim that execs a launcher keeps the shim's own path; the CLI - * compares it with the session's `ORCA_CLI_COMMAND` and consumes it (src/cli/session-cli-reexec.ts). - * Kept identical to the line in the packaged launchers under resources/. - */ -export const ORCA_CLI_SELF_EXPORT = 'export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}"\n' diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index d7993590de63..6c4b273fcd8a 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -15,7 +15,6 @@ import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' import { withAppImageRegistrationLock } from './appimage-registration-lock' import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { quoteShell } from './cli-install-path-format' -import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. @@ -75,12 +74,9 @@ export async function installLinuxBareOrcaDispatcher( : { state: 'skipped-foreign', dispatcherPath, target: null } } -/** - * Bare-`orca` script that execs the one Linux CLI launcher. It names itself as the CLI entry, so a - * session that names this script as its CLI does not hand off to the launcher behind it. - */ +/** Bare-`orca` script that execs the one Linux CLI launcher. */ export function buildBareOrcaCliScript(launcherPath: string): string { - return `#!/usr/bin/env bash\n${ORCA_CLI_SELF_EXPORT}exec ${quoteShell(launcherPath)} "$@"\n` + return `#!/usr/bin/env bash\nexec ${quoteShell(launcherPath)} "$@"\n` } /** diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 1820668e3848..905d48073684 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -11,7 +11,6 @@ vi.mock('electron', () => ({ import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' -import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' const created: string[] = [] const canFenceAppImageRuntime = process.platform === 'linux' && existsSync('/proc/self/stat') @@ -58,8 +57,6 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const content = readFileSync(join(shimDir!, 'orca'), 'utf8') // Single-quoted so a resources path with shell metacharacters can't break out. expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`) - // A session names this shim as its CLI, so the shim, not the launcher behind it, is the entry. - expect(content).toContain(ORCA_CLI_SELF_EXPORT) const mode = statSync(join(shimDir!, 'orca')).mode & 0o777 expect(mode & 0o111).not.toBe(0) }) @@ -118,7 +115,6 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(content).toContain(liveLauncherPath) expect(content).toContain('runtime_pid=') expect(content).toContain('/proc/$runtime_pid/stat') - expect(content).toContain(ORCA_CLI_SELF_EXPORT) expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) await expect( runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index 7e4848366733..7697bac01acb 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -23,7 +23,6 @@ import { import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' import { quoteShell } from './cli-install-path-format' -import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' const SHIM_DIR_NAME = 'linux-orca-cli-shim' @@ -204,7 +203,7 @@ runtime_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$runtime_root" 2>/dev/null)" | launcher_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$launcher" 2>/dev/null)" || fail [[ "$launcher_identity" == "$expected_launcher_identity" ]] || fail [[ -f "$launcher" && -x "$launcher" ]] || fail -${ORCA_CLI_SELF_EXPORT}exec "$launcher" "$@" +exec "$launcher" "$@" ` } diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index 9c1dc0532905..0a6ceaccf927 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -21,10 +21,7 @@ describe('packaged Windows CLI launcher asset', () => { expect(source).toContain( 'Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");' ) - // It names itself as the CLI entry and leaves the session's ORCA_CLI_COMMAND untouched, so the - // CLI can compare the two before it applies the launcher's command name. - expect(source).toContain('"ORCA_CLI_SELF",') - expect(source).toContain('typeof(OrcaCliLauncher).Assembly.Location') + // It leaves a session's ORCA_CLI_COMMAND untouched so the CLI can hand off to it first. expect(source).not.toMatch(/SetEnvironmentVariable\(\s*"ORCA_CLI_COMMAND"/) expect(source).toContain('child.WaitForExit();') expect(source).toContain('return child.ExitCode;') diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 4dcd49e96459..c3aec29ea992 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -35,6 +35,7 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_SESSION_CLI_ENTRY: expect.stringMatching(/[\\/]out[\\/]cli[\\/]index\.js$/), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -57,6 +58,7 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_SESSION_CLI_ENTRY: expect.stringMatching(/[\\/]out[\\/]cli[\\/]index\.js$/), ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 2fa337b60011..7256ff16a32f 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -37,6 +37,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ORCA_SESSION_CLI_ENTRY: expect.stringMatching(/[\\/]out[\\/]cli[\\/]index\.js$/), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 1289e979ad63..1d4beed9dae8 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -18,6 +18,7 @@ const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' const USER_DATA = '/data/orca' const RESOURCES = '/app/Resources' const SHIM_DIR = join(USER_DATA, 'linux-orca-cli-shim') +const PACKAGED_CLI_ENTRY = join(RESOURCES, 'app.asar.unpacked', 'out', 'cli', 'index.js') const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! const resourcesDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') @@ -70,6 +71,8 @@ describe('structuredSessionChildIdentityEnv', () => { // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + // The entry the shim runs, so a global launcher of this same app never hands off to it. + ORCA_SESSION_CLI_ENTRY: PACKAGED_CLI_ENTRY, // The instance that minted the id, so any current CLI dials it rather than the default. ORCA_USER_DATA_PATH: USER_DATA }) @@ -112,6 +115,7 @@ describe('structuredSessionChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) expect(env.ORCA_CLI_COMMAND).toBe(join(SHIM_DIR, 'orca')) + expect(env.ORCA_SESSION_CLI_ENTRY).toBe(PACKAGED_CLI_ENTRY) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) }) @@ -121,6 +125,7 @@ describe('structuredSessionChildIdentityEnv', () => { const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca')) + expect(env.ORCA_SESSION_CLI_ENTRY).toBe(PACKAGED_CLI_ENTRY) }) it('on packaged Windows, through the bundled CLI dir under the env block spelling', () => { @@ -131,14 +136,20 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env.PATH).toBeUndefined() // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) + expect(env.ORCA_SESSION_CLI_ENTRY).toBe(PACKAGED_CLI_ENTRY) }) it('unpackaged, through the dev launcher dir', () => { pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + installFakeAppEnvironment({ + isPackaged: () => false, + getPath: () => USER_DATA, + getAppPath: () => '/repo' + }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) expect(env.ORCA_CLI_COMMAND).toBe(join(USER_DATA, 'cli', 'bin', 'orca-dev')) + expect(env.ORCA_SESSION_CLI_ENTRY).toBe(join('/repo', 'out', 'cli', 'index.js')) }) }) @@ -152,9 +163,11 @@ describe('structuredSessionChildIdentityEnv', () => { const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin', ORCA_CLI_COMMAND: '/Applications/Other Orca.app/Contents/Resources/bin/orca', + ORCA_SESSION_CLI_ENTRY: '/Applications/Other Orca.app/Contents/Resources/cli.js', ORCA_USER_DATA_PATH: '/data/other-orca' }) expect(env).not.toHaveProperty('ORCA_CLI_COMMAND') + expect(env).not.toHaveProperty('ORCA_SESSION_CLI_ENTRY') expect(env.PATH).toBe('/usr/bin') expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA) expect(console.warn).toHaveBeenCalledOnce() diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index 62a901860cf6..f95292343e41 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -21,9 +21,12 @@ * `ORCA_CLI_COMMAND` is the absolute launcher in that directory, because a provider can run each * command in a login shell (Codex runs `zsh -lc`), whose profile rebuilds PATH and puts a global * install — possibly an older Orca — ahead of this app's. A current CLI reached that way re-runs - * itself as this launcher (`src/cli/session-cli-reexec.ts`), so an agent that types bare `orca` still acts - * through this app's CLI. When no launcher resolves the key is omitted rather than set to a bare - * name: on Linux a bare `orca` is GNOME's screen reader, and an inherited value names another app. + * itself as this launcher (`src/cli/session-cli-reexec.ts`), so an agent that types bare `orca` + * still acts through this app's CLI. `ORCA_SESSION_CLI_ENTRY` names the JS entry that launcher + * runs; the CLI compares its own entry with it, so any launcher of this same app (the shim, a + * global symlink) never hands off. When no launcher resolves both keys are omitted rather than + * naming a bare `orca`: on Linux that is GNOME's screen reader, and an inherited value names + * another app. * * `ORCA_USER_DATA_PATH` pins this instance beside the identity, so any current CLI — the session's * own or a global one — dials the Orca that minted the id instead of the production default. @@ -44,8 +47,12 @@ */ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' +import { + ORCA_AGENT_SESSION_ID_ENV, + ORCA_SESSION_CLI_ENTRY_ENV +} from '../../shared/agent-session-caller-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' +import { resolveHostCliEntryPath } from '../cli/cli-entry-path' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { structuredWorkerIdentities } from './structured-worker-identity' @@ -70,19 +77,26 @@ export function structuredSessionChildIdentityEnv( */ function applyThisAppCli(env: Record): void { delete env.ORCA_CLI_COMMAND + delete env[ORCA_SESSION_CLI_ENTRY_ENV] if (!hasAppEnvironment()) { return } const app = getAppEnvironment() const userDataPath = app.getPath('userData') + const isPackaged = app.isPackaged() env.ORCA_USER_DATA_PATH = userDataPath const launcher = prependOrcaCliDirToChildPath(env, { - isPackaged: app.isPackaged(), + isPackaged, userDataPath, resourcesPath: process.resourcesPath ?? null }) if (launcher) { env.ORCA_CLI_COMMAND = launcher + env[ORCA_SESSION_CLI_ENTRY_ENV] = resolveHostCliEntryPath({ + isPackaged, + resourcesPath: process.resourcesPath ?? '', + appPath: app.getAppPath() + }) } else { console.warn( "[structured-session] This app's CLI launcher did not resolve; the session's child has no ORCA_CLI_COMMAND." diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 4490343504ff..961a11a30006 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -2,8 +2,9 @@ import { app } from 'electron' import { spawn as nodeSpawn } from 'node:child_process' import { existsSync } from 'node:fs' -import { join } from 'node:path' import { getCanonicalUserDataPath } from '../persistence' +import { resolveHostCliEntryPath } from '../cli/cli-entry-path' +export { resolveHostCliEntryPath } from '../cli/cli-entry-path' import { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline' export { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline' import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay' @@ -85,19 +86,6 @@ const REMOTE_CONTEXT_ENV_VARS = [ // Bound output retained for the relay response. const MAX_CAPTURED_OUTPUT_BYTES = 8 * 1024 * 1024 -export function resolveHostCliEntryPath(app: { - isPackaged: boolean - resourcesPath: string - appPath: string -}): string { - // Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev - // launcher in cli-installer.ts — packaged builds ship the CLI entry outside - // app.asar so Electron node mode can execute it directly. - return app.isPackaged - ? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') - : join(app.appPath, 'out', 'cli', 'index.js') -} - export function buildHostCliEnv(args: { hostEnv: NodeJS.ProcessEnv remoteEnv: Record diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index 5f66128dc5f9..54dded96719b 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -12,6 +12,9 @@ import { isStructuredWorkerHandle } from './structured-worker-handle' export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' +/** The CLI entry the session's `ORCA_CLI_COMMAND` runs; a CLI started from any other hands off. */ +export const ORCA_SESSION_CLI_ENTRY_ENV = 'ORCA_SESSION_CLI_ENTRY' + export function readInjectedAgentSessionId( env: Readonly> = process.env ): string | undefined { From d6753dde8bce939c7c0539a3bb53e40c5bef6d67 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:31:08 -0700 Subject: [PATCH 23/24] refactor(cli): drop the session CLI handoff; the pinned instance and injected id already bind any current CLI Every current Orca CLI dials the instance ORCA_USER_DATA_PATH names and sends the injected session id in the orchestration envelope, so a bare `orca` that reaches another install's current CLI already acts as the session. An older CLI has no handoff code and refuses on the marker. The handoff only lined up versions between two current CLIs, and comparing two separately derived paths kept misfiring (an AppImage's mount against its registered extraction started the CLI twice on every call). Removes the re-exec, ORCA_SESSION_CLI_ENTRY and ORCA_CLI_REEXEC, and the CLI-side Windows command naming; the packaged Windows launcher rewrites ORCA_CLI_COMMAND again, as on main, inside its own process only. resolveHostCliEntryPath goes back to the SSH passthrough. --- .../windows-cli-launcher/OrcaCliLauncher.cs | 7 +- src/cli/index.ts | 5 +- .../orchestration-session-caller-cli.test.ts | 21 ++ src/cli/session-cli-reexec.test.ts | 240 ------------------ src/cli/session-cli-reexec.ts | 169 ------------ src/main/cli/cli-entry-path.ts | 15 -- src/main/cli/windows-launcher-asset.test.ts | 6 +- ...codex-structured-child-environment.test.ts | 2 - .../codex-structured-session-adapter.test.ts | 1 - ...uctured-session-child-identity-env.test.ts | 15 +- .../structured-session-child-identity-env.ts | 28 +- .../ssh/ssh-remote-cli-host-passthrough.ts | 16 +- src/shared/agent-session-caller-env.ts | 3 - 13 files changed, 54 insertions(+), 474 deletions(-) delete mode 100644 src/cli/session-cli-reexec.test.ts delete mode 100644 src/cli/session-cli-reexec.ts delete mode 100644 src/main/cli/cli-entry-path.ts diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs index 08f3bc12a867..3357c7b15963 100644 --- a/native/windows-cli-launcher/OrcaCliLauncher.cs +++ b/native/windows-cli-launcher/OrcaCliLauncher.cs @@ -57,8 +57,11 @@ private static int Main(string[] args) MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE"); Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1"); Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); - // Why: ORCA_CLI_COMMAND is left as the session set it; the CLI names it - // `orca`/`orca-ide` itself after deciding whether to hand off to that launcher. + string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND"); + Environment.SetEnvironmentVariable( + "ORCA_CLI_COMMAND", + requestedCliCommand == "orca-ide" ? "orca-ide" : "orca" + ); using (Process child = Process.Start(startInfo)) { diff --git a/src/cli/index.ts b/src/cli/index.ts index a538a2723505..be5277278766 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,7 +20,6 @@ import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' -import { runAsSessionCli } from './session-cli-reexec' import { refuseConflictingSessionCallerFlags } from './session-caller-flags' export { COMMAND_SPECS } from './specs' @@ -239,7 +238,5 @@ async function runAgentTeamsTmuxShim(argv: string[]): Promise { } if (require.main === module) { - // Why here and not in main(): main() is also called in-process by tests and by wrappers that - // require this module, where exiting or consuming process.env would hit the caller's process. - void runAsSessionCli(() => main()) + void main() } diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 59bcae5b845b..9922094a8c5f 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -24,6 +24,7 @@ import { findCommandSpec } from './args' import { COMMAND_SPECS } from './specs' import { refuseConflictingSessionCallerFlags } from './session-caller-flags' import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' +import { getDefaultUserDataPath } from './runtime/metadata' import { formatCliError, reportCliError } from './cli-error' import { RuntimeRpcFailureError } from './runtime/types' @@ -491,6 +492,26 @@ describe('the orchestration envelope', () => { }) }) + it('binds whichever current Orca CLI the agent reached, not only the one the session names', () => { + // A login shell can put another install's `orca` first, and the packaged Windows launcher + // rewrites ORCA_CLI_COMMAND in its own process. Neither matters: the id rides the envelope + // and the pinned instance is the one dialed. + vi.stubEnv('ORCA_USER_DATA_PATH', '/data/session-orca') + try { + const envelope = createOrchestrationCompatibilityEnvelope({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_CLI_COMMAND: 'orca', + ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1', + ORCA_USER_DATA_PATH: '/data/session-orca' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ agentSessionId: SESSION }) + expect(getDefaultUserDataPath('linux', '/home/u')).toBe('/data/session-orca') + } finally { + vi.unstubAllEnvs() + } + }) + it('claims no session without an injected id', () => { expect( createOrchestrationCompatibilityEnvelope({ ORCA_AGENT_SESSION_ID: ' ' }) diff --git a/src/cli/session-cli-reexec.test.ts b/src/cli/session-cli-reexec.test.ts deleted file mode 100644 index 70c95dec6d37..000000000000 --- a/src/cli/session-cli-reexec.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ORCA_CLI_REEXEC_ENV, runAsSessionCli, takeSessionCliReexec } from './session-cli-reexec' - -let dir: string - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'orca-session-cli-reexec-')) -}) - -afterEach(() => { - vi.restoreAllMocks() - rmSync(dir, { recursive: true, force: true }) -}) - -function writeScript(name: string, body: string): string { - const path = join(dir, name) - writeFileSync(path, `#!/usr/bin/env bash\n${body}`) - chmodSync(path, 0o755) - return path -} - -function writeEntry(name: string): string { - const path = join(dir, name) - writeFileSync(path, '') - return path -} - -/** The argv a launcher gives the CLI: the runtime, the entry it ran, then the command. */ -function argvFor(entry: string, ...args: string[]): string[] { - return ['/electron', entry, ...args] -} - -class Exited extends Error { - constructor(readonly code: number) { - super(`exit ${code}`) - } -} - -function exitSpy(): (code: number) => never { - return (code: number) => { - throw new Exited(code) - } -} - -/** A structured session's own child, which names its launcher and the entry that launcher runs. */ -function sessionEnv(launcher: string, entry: string): NodeJS.ProcessEnv { - return { - ORCA_AGENT_SESSION_ID: 'session-1', - ORCA_CLI_COMMAND: launcher, - ORCA_SESSION_CLI_ENTRY: entry - } -} - -describe('takeSessionCliReexec', () => { - it("hands off when the invoked CLI runs another entry than the session's", () => { - const named = writeScript('session-orca', 'exit 0\n') - const env: NodeJS.ProcessEnv = { - ...sessionEnv(named, writeEntry('session-index.js')), - ELECTRON_RUN_AS_NODE: '1', - ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1', - ORCA_NODE_OPTIONS: '--max-old-space-size=4096', - ORCA_NODE_REPL_EXTERNAL_MODULE: '' - } - - const reexec = takeSessionCliReexec({ - env, - argv: argvFor(writeEntry('global-index.js'), 'orchestration', 'check') - }) - - expect(reexec).toEqual({ - target: named, - argv: ['orchestration', 'check'], - // What the invoked launcher was handed, so the named one sees the caller's own environment. - env: { - ...sessionEnv(named, join(dir, 'session-index.js')), - NODE_OPTIONS: '--max-old-space-size=4096', - [ORCA_CLI_REEXEC_ENV]: '1' - } - }) - }) - - it('stays when another launcher of the same app ran the same entry, through a symlink', () => { - // A shim or a global symlink in front of the session's launcher runs the session's own CLI. - const entry = writeEntry('index.js') - const link = join(dir, 'linked-index.js') - symlinkSync(entry, link) - - expect( - takeSessionCliReexec({ - env: sessionEnv(writeScript('session-orca', 'exit 0\n'), entry), - argv: argvFor(link, 'orchestration', 'check') - }) - ).toBeNull() - }) - - it('runs the invoked CLI without a session id', () => { - // A terminal, a script, or a marker-only child of an older host gets the CLI the user ran: a - // beta or ad hoc Orca's `orca`, and its --version, must not silently become another install's. - const env = sessionEnv(writeScript('session-orca', 'exit 0\n'), writeEntry('session-index.js')) - delete env.ORCA_AGENT_SESSION_ID - env.ORCA_STRUCTURED_SESSION = '1' - - expect(takeSessionCliReexec({ env, argv: argvFor(writeEntry('beta-index.js')) })).toBeNull() - }) - - it('makes at most one hop, and consumes the guard so no child inherits it', () => { - const env: NodeJS.ProcessEnv = { - ...sessionEnv(writeScript('session-orca', 'exit 0\n'), writeEntry('session-index.js')), - [ORCA_CLI_REEXEC_ENV]: '1' - } - - expect(takeSessionCliReexec({ env, argv: argvFor(writeEntry('global-index.js')) })).toBeNull() - expect(env).not.toHaveProperty(ORCA_CLI_REEXEC_ENV) - }) - - it('stays when the session names no entry: a child of a host that predates it', () => { - const env = sessionEnv(writeScript('session-orca', 'exit 0\n'), '') - - expect(takeSessionCliReexec({ env, argv: argvFor(writeEntry('global-index.js')) })).toBeNull() - }) - - it.each([ - ['a WSL guest command name', 'orca-ide'], - ["the SSH host's relay command", 'orca'] - ])('never resolves %s against the working directory', (_label, command) => { - writeScript(command, 'exit 0\n') - vi.spyOn(process, 'cwd').mockReturnValue(dir) - - expect( - takeSessionCliReexec({ - env: sessionEnv(command, writeEntry('session-index.js')), - argv: argvFor(writeEntry('global-index.js')) - }) - ).toBeNull() - }) - - it('stays when the named launcher no longer exists', () => { - expect( - takeSessionCliReexec({ - env: sessionEnv(join(dir, 'gone', 'orca'), writeEntry('session-index.js')), - argv: argvFor(writeEntry('global-index.js')) - }) - ).toBeNull() - }) -}) - -describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { - it("runs the command through the session's launcher and exits with its status", async () => { - const report = join(dir, 'report') - const named = writeScript( - 'session-orca', - `printf '%s|%s|%s' "$*" "$ORCA_CLI_REEXEC" "\${NODE_OPTIONS-}" > '${report}'\nexit 7\n` - ) - const run = vi.fn(async () => {}) - - await expect( - runAsSessionCli(run, { - env: { - ...process.env, - ...sessionEnv(named, writeEntry('session-index.js')), - ORCA_NODE_OPTIONS: '--no-warnings' - }, - argv: argvFor(writeEntry('global-index.js'), 'orchestration', 'check', '--wait'), - exit: exitSpy() - }) - ).rejects.toEqual(new Exited(7)) - - expect(readFileSync(report, 'utf8')).toBe('orchestration check --wait|1|--no-warnings') - expect(run).not.toHaveBeenCalled() - }) - - it("runs the command here when it already runs the session's entry", async () => { - const entry = writeEntry('index.js') - const run = vi.fn(async () => {}) - - await runAsSessionCli(run, { - env: sessionEnv(writeScript('session-orca', 'exit 0\n'), entry), - argv: argvFor(entry), - exit: exitSpy() - }) - - expect(run).toHaveBeenCalledOnce() - }) - - it('runs the command here, and says so, when the named CLI cannot start', async () => { - const named = join(dir, 'not-executable') - writeFileSync(named, 'not a program') - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - const run = vi.fn(async () => {}) - - await runAsSessionCli(run, { - env: sessionEnv(named, writeEntry('session-index.js')), - argv: argvFor(writeEntry('global-index.js')), - exit: exitSpy() - }) - - expect(run).toHaveBeenCalledOnce() - expect(String(stderr.mock.calls[0]?.[0])).toContain("could not run this session's CLI") - }) -}) - -describe('packaged Windows launcher command name', () => { - it.each([ - ['a terminal with none', {}, 'orca'], - ['a WSL-registered name', { ORCA_CLI_COMMAND: 'orca-ide' }, 'orca-ide'], - [ - "a session's launcher once it is the named CLI", - { - ORCA_AGENT_SESSION_ID: 'session-1', - ORCA_CLI_COMMAND: 'C:\\Orca\\resources\\bin\\orca.exe' - }, - 'orca' - ] - ])('names %s as the launcher did before the handoff existed', async (_label, extra, expected) => { - const env: NodeJS.ProcessEnv = { ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1', ...extra } - let seen: string | undefined - await runAsSessionCli( - async () => { - seen = env.ORCA_CLI_COMMAND - }, - { env, argv: argvFor('C:\\Orca\\index.js'), platform: 'win32', exit: exitSpy() } - ) - - expect(seen).toBe(expected) - }) - - it("leaves every other launcher's command alone", async () => { - const env: NodeJS.ProcessEnv = { ORCA_CLI_COMMAND: '/opt/Orca/resources/bin/orca' } - await runAsSessionCli(async () => {}, { - env, - argv: argvFor('/opt/Orca/index.js'), - exit: exitSpy() - }) - - expect(env.ORCA_CLI_COMMAND).toBe('/opt/Orca/resources/bin/orca') - }) -}) diff --git a/src/cli/session-cli-reexec.ts b/src/cli/session-cli-reexec.ts deleted file mode 100644 index 7e19d4913044..000000000000 --- a/src/cli/session-cli-reexec.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Hands a structured session's command to the CLI the session named, when a different Orca CLI was - * the one invoked. - * - * Orca puts the absolute launcher of its own CLI in a structured session's `ORCA_CLI_COMMAND`, and - * the JS entry that launcher runs in `ORCA_SESSION_CLI_ENTRY`. The agent may still reach another - * install — a login shell reorders PATH behind a global `orca`, a helper script hardcodes `orca`, a - * user types `/usr/local/bin/orca` — and that CLI can be older than the session's identity or dial a - * different instance. So a current CLI whose own entry is not the session's re-runs the command - * through the named launcher, once, and exits with its status. Which binary answers stops depending - * on the agent following instructions, and any launcher of the same app (a shim, a global symlink) - * runs the same entry, so it never hands off. - * - * Only a process carrying the injected session id qualifies: the handoff exists to deliver that - * identity. Anywhere else — a terminal, a script — the CLI the user ran is the one that answers. - * `ORCA_CLI_REEXEC=1` bounds the handoff to one hop and is also the escape hatch; it is consumed - * here so nothing the CLI starts inherits a disabled handoff. - * - * A relative command (a WSL guest name, the SSH relay's `orca`) never qualifies, and is never - * resolved against the working directory. - */ - -import { realpathSync } from 'node:fs' -import { constants as osConstants } from 'node:os' -import { posix, resolve, win32 } from 'node:path' -import { - ORCA_SESSION_CLI_ENTRY_ENV, - readInjectedAgentSessionId -} from '../shared/agent-session-caller-env' - -export const ORCA_CLI_REEXEC_ENV = 'ORCA_CLI_REEXEC' - -/** Set by the launcher that started this process; the next launcher sets them again itself. */ -const LAUNCHER_OWNED_ENV = ['ELECTRON_RUN_AS_NODE', 'ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER'] as const -/** Stashed by every launcher so Electron's node bootstrap never sees them; the next one re-stashes. */ -const LAUNCHER_STASHED_ENV = [ - ['ORCA_NODE_OPTIONS', 'NODE_OPTIONS'], - ['ORCA_NODE_REPL_EXTERNAL_MODULE', 'NODE_REPL_EXTERNAL_MODULE'] -] as const - -export type SessionCliReexec = { - target: string - argv: readonly string[] - env: NodeJS.ProcessEnv -} - -type ReexecOptions = { - env?: NodeJS.ProcessEnv - /** This process's argv; `[1]` is the CLI entry its launcher ran. */ - argv?: readonly string[] - platform?: NodeJS.Platform -} - -/** The CLI entry: hand off to the session's own CLI when this is a different one, else `run`. */ -export async function runAsSessionCli( - run: () => Promise, - options: ReexecOptions & { exit?: (code: number) => never } = {} -): Promise { - const reexec = takeSessionCliReexec(options) - if (reexec) { - await runSessionCliReexec(reexec, options.exit) - } - applyPackagedWindowsCliCommand(options.env ?? process.env) - await run() -} - -/** - * The command name the packaged Windows launcher used to write over `ORCA_CLI_COMMAND` itself; it - * now runs after the handoff decision, which needs a session's absolute launcher. - */ -function applyPackagedWindowsCliCommand(env: NodeJS.ProcessEnv): void { - if (env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER === '1') { - env.ORCA_CLI_COMMAND = env.ORCA_CLI_COMMAND === 'orca-ide' ? 'orca-ide' : 'orca' - } -} - -/** - * Consumes the one-hop guard and returns the re-exec this process owes, or null when it already runs - * the session's CLI entry, cannot tell, or is itself the one hop. - */ -export function takeSessionCliReexec(options: ReexecOptions = {}): SessionCliReexec | null { - const env = options.env ?? process.env - const platform = options.platform ?? process.platform - const processArgv = options.argv ?? process.argv - const alreadyHandedOff = env[ORCA_CLI_REEXEC_ENV] === '1' - delete env[ORCA_CLI_REEXEC_ENV] - if (alreadyHandedOff || !readInjectedAgentSessionId(env)) { - return null - } - const isAbsolute = (platform === 'win32' ? win32 : posix).isAbsolute - const named = env.ORCA_CLI_COMMAND?.trim() - const sessionEntry = env[ORCA_SESSION_CLI_ENTRY_ENV]?.trim() - const ownEntry = processArgv[1] - if (!named || !sessionEntry || !ownEntry || !isAbsolute(named) || !isAbsolute(sessionEntry)) { - return null - } - const sessionEntryPath = tryRealpath(sessionEntry) - const ownEntryPath = tryRealpath(ownEntry) - if ( - sessionEntryPath === null || - ownEntryPath === null || - samePath(sessionEntryPath, ownEntryPath, platform) || - tryRealpath(named) === null - ) { - return null - } - return { - target: named, - argv: processArgv.slice(2), - env: buildHandoffEnv(env) - } -} - -/** The environment the invoked launcher was given, plus the one-hop guard. */ -function buildHandoffEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const handoff: NodeJS.ProcessEnv = { ...env } - for (const key of LAUNCHER_OWNED_ENV) { - delete handoff[key] - } - for (const [stash, original] of LAUNCHER_STASHED_ENV) { - const value = handoff[stash] - delete handoff[stash] - if (value) { - handoff[original] = value - } - } - handoff[ORCA_CLI_REEXEC_ENV] = '1' - return handoff -} - -function tryRealpath(path: string): string | null { - try { - return realpathSync(resolve(path)) - } catch { - return null - } -} - -function samePath(left: string, right: string, platform: NodeJS.Platform): boolean { - return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right -} - -/** - * Runs the handoff and exits with its status. Returns only when the named CLI could not be started, - * so the command still runs here — the behavior before the handoff existed — rather than failing. - */ -export async function runSessionCliReexec( - reexec: SessionCliReexec, - exit: (code: number) => never = process.exit -): Promise { - const { runProcessSync } = await import('../shared/child-process/run-process.js') - let result: { code: number | null; signal: NodeJS.Signals | null } - try { - result = runProcessSync({ - program: reexec.target, - args: reexec.argv, - env: reexec.env, - stdio: 'inherit', - timeoutMs: null - }) - } catch (error) { - const reason = error instanceof Error ? error.message : String(error) - process.stderr.write( - `orca: could not run this session's CLI (${reexec.target}): ${reason}. Running this one.\n` - ) - return - } - exit(result.code ?? (result.signal ? 128 + (osConstants.signals[result.signal] ?? 0) : 1)) -} diff --git a/src/main/cli/cli-entry-path.ts b/src/main/cli/cli-entry-path.ts deleted file mode 100644 index b9d6af6bc78b..000000000000 --- a/src/main/cli/cli-entry-path.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { join } from 'node:path' - -/** The JS entry this app's CLI launchers run under Electron's node mode. */ -export function resolveHostCliEntryPath(app: { - isPackaged: boolean - resourcesPath: string - appPath: string -}): string { - // Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev - // launcher in cli-installer.ts — packaged builds ship the CLI entry outside - // app.asar so Electron node mode can execute it directly. - return app.isPackaged - ? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') - : join(app.appPath, 'out', 'cli', 'index.js') -} diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index 0a6ceaccf927..2eec4fee5773 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -21,8 +21,10 @@ describe('packaged Windows CLI launcher asset', () => { expect(source).toContain( 'Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");' ) - // It leaves a session's ORCA_CLI_COMMAND untouched so the CLI can hand off to it first. - expect(source).not.toMatch(/SetEnvironmentVariable\(\s*"ORCA_CLI_COMMAND"/) + expect(source).toContain( + 'string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND");' + ) + expect(source).toContain('requestedCliCommand == "orca-ide" ? "orca-ide" : "orca"') expect(source).toContain('child.WaitForExit();') expect(source).toContain('return child.ExitCode;') }) diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index c3aec29ea992..4dcd49e96459 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -35,7 +35,6 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), - ORCA_SESSION_CLI_ENTRY: expect.stringMatching(/[\\/]out[\\/]cli[\\/]index\.js$/), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -58,7 +57,6 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), - ORCA_SESSION_CLI_ENTRY: expect.stringMatching(/[\\/]out[\\/]cli[\\/]index\.js$/), ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 7256ff16a32f..2fa337b60011 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -37,7 +37,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), - ORCA_SESSION_CLI_ENTRY: expect.stringMatching(/[\\/]out[\\/]cli[\\/]index\.js$/), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 1d4beed9dae8..1289e979ad63 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -18,7 +18,6 @@ const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' const USER_DATA = '/data/orca' const RESOURCES = '/app/Resources' const SHIM_DIR = join(USER_DATA, 'linux-orca-cli-shim') -const PACKAGED_CLI_ENTRY = join(RESOURCES, 'app.asar.unpacked', 'out', 'cli', 'index.js') const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! const resourcesDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') @@ -71,8 +70,6 @@ describe('structuredSessionChildIdentityEnv', () => { // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), - // The entry the shim runs, so a global launcher of this same app never hands off to it. - ORCA_SESSION_CLI_ENTRY: PACKAGED_CLI_ENTRY, // The instance that minted the id, so any current CLI dials it rather than the default. ORCA_USER_DATA_PATH: USER_DATA }) @@ -115,7 +112,6 @@ describe('structuredSessionChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) expect(env.ORCA_CLI_COMMAND).toBe(join(SHIM_DIR, 'orca')) - expect(env.ORCA_SESSION_CLI_ENTRY).toBe(PACKAGED_CLI_ENTRY) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) }) @@ -125,7 +121,6 @@ describe('structuredSessionChildIdentityEnv', () => { const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca')) - expect(env.ORCA_SESSION_CLI_ENTRY).toBe(PACKAGED_CLI_ENTRY) }) it('on packaged Windows, through the bundled CLI dir under the env block spelling', () => { @@ -136,20 +131,14 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env.PATH).toBeUndefined() // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) - expect(env.ORCA_SESSION_CLI_ENTRY).toBe(PACKAGED_CLI_ENTRY) }) it('unpackaged, through the dev launcher dir', () => { pinPlatform('darwin') - installFakeAppEnvironment({ - isPackaged: () => false, - getPath: () => USER_DATA, - getAppPath: () => '/repo' - }) + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) expect(env.ORCA_CLI_COMMAND).toBe(join(USER_DATA, 'cli', 'bin', 'orca-dev')) - expect(env.ORCA_SESSION_CLI_ENTRY).toBe(join('/repo', 'out', 'cli', 'index.js')) }) }) @@ -163,11 +152,9 @@ describe('structuredSessionChildIdentityEnv', () => { const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin', ORCA_CLI_COMMAND: '/Applications/Other Orca.app/Contents/Resources/bin/orca', - ORCA_SESSION_CLI_ENTRY: '/Applications/Other Orca.app/Contents/Resources/cli.js', ORCA_USER_DATA_PATH: '/data/other-orca' }) expect(env).not.toHaveProperty('ORCA_CLI_COMMAND') - expect(env).not.toHaveProperty('ORCA_SESSION_CLI_ENTRY') expect(env.PATH).toBe('/usr/bin') expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA) expect(console.warn).toHaveBeenCalledOnce() diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index f95292343e41..4d3e85c34140 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -18,15 +18,13 @@ * so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and on packaged macOS/Windows * the bundled launcher is reachable only from the app's own resources dir. * - * `ORCA_CLI_COMMAND` is the absolute launcher in that directory, because a provider can run each - * command in a login shell (Codex runs `zsh -lc`), whose profile rebuilds PATH and puts a global - * install — possibly an older Orca — ahead of this app's. A current CLI reached that way re-runs - * itself as this launcher (`src/cli/session-cli-reexec.ts`), so an agent that types bare `orca` - * still acts through this app's CLI. `ORCA_SESSION_CLI_ENTRY` names the JS entry that launcher - * runs; the CLI compares its own entry with it, so any launcher of this same app (the shim, a - * global symlink) never hands off. When no launcher resolves both keys are omitted rather than - * naming a bare `orca`: on Linux that is GNOME's screen reader, and an inherited value names - * another app. + * `ORCA_CLI_COMMAND` names that same launcher by absolute path, because a provider can run each + * command in a login shell (Codex runs `zsh -lc`) whose profile rebuilds PATH and puts a global + * install ahead of this app's. A bare `orca` that reaches another install still acts as this + * session: any current CLI sends the injected id and dials the instance `ORCA_USER_DATA_PATH` pins + * below, and a CLI that predates the id refuses on the marker. When no launcher resolves the key + * is omitted rather than naming a bare `orca`: on Linux that is GNOME's screen reader, and an + * inherited value names another app. * * `ORCA_USER_DATA_PATH` pins this instance beside the identity, so any current CLI — the session's * own or a global one — dials the Orca that minted the id instead of the production default. @@ -47,12 +45,8 @@ */ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { - ORCA_AGENT_SESSION_ID_ENV, - ORCA_SESSION_CLI_ENTRY_ENV -} from '../../shared/agent-session-caller-env' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' -import { resolveHostCliEntryPath } from '../cli/cli-entry-path' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { structuredWorkerIdentities } from './structured-worker-identity' @@ -77,7 +71,6 @@ export function structuredSessionChildIdentityEnv( */ function applyThisAppCli(env: Record): void { delete env.ORCA_CLI_COMMAND - delete env[ORCA_SESSION_CLI_ENTRY_ENV] if (!hasAppEnvironment()) { return } @@ -92,11 +85,6 @@ function applyThisAppCli(env: Record): void { }) if (launcher) { env.ORCA_CLI_COMMAND = launcher - env[ORCA_SESSION_CLI_ENTRY_ENV] = resolveHostCliEntryPath({ - isPackaged, - resourcesPath: process.resourcesPath ?? '', - appPath: app.getAppPath() - }) } else { console.warn( "[structured-session] This app's CLI launcher did not resolve; the session's child has no ORCA_CLI_COMMAND." diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 961a11a30006..4490343504ff 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -2,9 +2,8 @@ import { app } from 'electron' import { spawn as nodeSpawn } from 'node:child_process' import { existsSync } from 'node:fs' +import { join } from 'node:path' import { getCanonicalUserDataPath } from '../persistence' -import { resolveHostCliEntryPath } from '../cli/cli-entry-path' -export { resolveHostCliEntryPath } from '../cli/cli-entry-path' import { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline' export { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline' import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay' @@ -86,6 +85,19 @@ const REMOTE_CONTEXT_ENV_VARS = [ // Bound output retained for the relay response. const MAX_CAPTURED_OUTPUT_BYTES = 8 * 1024 * 1024 +export function resolveHostCliEntryPath(app: { + isPackaged: boolean + resourcesPath: string + appPath: string +}): string { + // Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev + // launcher in cli-installer.ts — packaged builds ship the CLI entry outside + // app.asar so Electron node mode can execute it directly. + return app.isPackaged + ? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + : join(app.appPath, 'out', 'cli', 'index.js') +} + export function buildHostCliEnv(args: { hostEnv: NodeJS.ProcessEnv remoteEnv: Record diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index 54dded96719b..5f66128dc5f9 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -12,9 +12,6 @@ import { isStructuredWorkerHandle } from './structured-worker-handle' export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' -/** The CLI entry the session's `ORCA_CLI_COMMAND` runs; a CLI started from any other hands off. */ -export const ORCA_SESSION_CLI_ENTRY_ENV = 'ORCA_SESSION_CLI_ENTRY' - export function readInjectedAgentSessionId( env: Readonly> = process.env ): string | undefined { From ccb4b95bfd6b924c4e83a095293b100633da74a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:59:46 -0700 Subject: [PATCH 24/24] test(orchestration): say why the registered worker case pins the handle, now that every session's env is populated --- src/main/runtime/structured-worker-identity.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 1737a96036c8..17ba897de479 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -301,8 +301,8 @@ describe('structured workers stay outside the PTY-only fail-closed paths', () => }) try { const env = structuredSessionChildIdentityEnv(SESSION_ID, {}) - // Registered, so this is a populated env — not the empty one an unregistered session gets, - // which would satisfy the pane-key assertion for the wrong reason. + // Registered, so the worker's handle is present; without it the pane-key assertion would pass + // for the wrong reason. expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) expect(Object.keys(env)).not.toContain('ORCA_PANE_KEY') } finally {