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/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/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 afe79ab8e2f3..c8ade53e907a 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -6,6 +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 '../../../shared/agent-session-caller-env' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { @@ -42,9 +43,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/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts index f2af827ee048..6ace43eac31a 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 pane key it inherited 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..b97500db89b2 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -2,15 +2,28 @@ 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 { + injectedSessionAddress, + readInjectedAgentSessionId +} from '../../../shared/agent-session-caller-env' +/** + * 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 { + // 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) if (explicit) { return explicit @@ -35,7 +48,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') { @@ -157,11 +170,16 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } +/** How check output names its caller: the handle, or the session's address. */ +export function orchestrationCallerLabel(handle: string | undefined): string { + return handle ?? injectedSessionAddress() ?? 'unknown' +} + export async function resolveCoordinatorTerminalHandle( flags: Map, cwd: string, client: RuntimeClient -): Promise { +): Promise { return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { validateEnvHandle: true }) @@ -204,7 +222,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/cli/index-orchestration.test.ts b/src/cli/index-orchestration.test.ts index c4e4438f2e9e..bc7366f5b11c 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 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(() => {}) + 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 b2e01c04e56f..be5277278766 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 { refuseConflictingSessionCallerFlags } from './session-caller-flags' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -111,6 +112,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 new file mode 100644 index 000000000000..9922094a8c5f --- /dev/null +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -0,0 +1,636 @@ +/** + * 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 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' + +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 { 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' + +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' } +] + +/** 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, + json = true +): Promise { + const handler = ORCHESTRATION_HANDLERS[`orchestration ${command}`] + 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. + 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 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_inherited_pane', + ORCA_PANE_KEY: 'tab_inherited: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(asSessionWithInheritedPane) + + 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() + // An inherited 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 caller, 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 an inherited pane handle too: the session, not the pane, is the caller', + async () => { + 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() + } + ) + + 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.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(asSessionWithInheritedPane) + + 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() + + 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' }) + + 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 + } + 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') + 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 () => { + asSessionWithInheritedPane() + 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('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) => { + asSessionWithInheritedPane() + 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({ + ORCA_AGENT_SESSION_ID: ` ${SESSION} `, + ORCA_TERMINAL_HANDLE: 'term_inherited_pane' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ + terminalHandle: 'term_inherited_pane', + agentSessionId: SESSION + }) + }) + + 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: ' ' }) + .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('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 caller, before any request', + async ({ command, flag }) => { + asSessionWithInheritedPane() + 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 }) => { + asSessionWithInheritedPane() + 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[] = [] + 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' + ]) + + asSessionWithInheritedPane() + expect(await verbsThatGuess()).toEqual([]) + }) +}) 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/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts new file mode 100644 index 000000000000..cbefa9ee2b8d --- /dev/null +++ b/src/cli/session-caller-flags.ts @@ -0,0 +1,57 @@ +/** + * 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 { + injectedSessionAddress, + readInjectedAgentSessionId +} from '../shared/agent-session-caller-env' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../shared/orca-session-address-prefix' + +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 ` + + `${ORCA_SESSION_ADDRESS_PREFIX}${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. Plain + * 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 === `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` || + value === injectedSessionAddress(env) + ) +} 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.'] }, { 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..66fed64c3230 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: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/) + }) + 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/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/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 201efc0b0c5b..4dcd49e96459 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,10 @@ import { structuredWorkerProcessIncarnation } 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', () => { expect( @@ -28,11 +32,16 @@ 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_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) }) }) - 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 +53,12 @@ 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_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) }) const handle = mintStructuredWorkerHandle() @@ -61,7 +74,8 @@ describe('buildCodexStructuredChildEnvironment', () => { try { const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) - expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.ORCA_AGENT_SESSION_ID).toBe(sessionId) + 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 { @@ -69,3 +83,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: expect.stringMatching(DEV_CLI_LAUNCHER), + path: expect.stringMatching(DEV_CLI_BIN_FIRST) + }) + } finally { + await connection.close() + } + }) +}) 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/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 43b76536c64e..2fa337b60011 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,12 @@ 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_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[:;]/) }) 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..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 @@ -413,6 +413,17 @@ describe('registerPtyHandlers', () => { ]) ) }) + 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, { + 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']) + ) + }) 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..bed6953e2b12 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,16 @@ 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 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 05258f0bdc6c..caf18e3e1aa7 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,16 @@ 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) + // 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/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/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/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/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 0d1f04cb83bd..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 } @@ -19,8 +21,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 +84,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({ @@ -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') }) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts new file mode 100644 index 000000000000..1289e979ad63 --- /dev/null +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -0,0 +1,193 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +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 { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerHostScope, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from './structured-worker-identity' + +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 platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! +const resourcesDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') + +function pinPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +function registerWorker(): string { + const handle = mintStructuredWorkerHandle() + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_ID, + agent: 'claude', + paneKey: mintStructuredWorkerPaneKey(SESSION_ID), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return handle +} + +beforeEach(() => { + shim.ensureLinuxTerminalOrcaCliShimDir.mockReset() + shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(SHIM_DIR) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: RESOURCES }) +}) + +afterEach(() => { + structuredWorkerIdentities.clear() + Object.defineProperty(process, 'platform', platformDescriptor) + if (resourcesDescriptor) { + Object.defineProperty(process, 'resourcesPath', resourcesDescriptor) + } else { + Reflect.deleteProperty(process, 'resourcesPath') + } +}) + +describe('structuredSessionChildIdentityEnv', () => { + 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: `${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: 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() + expect(env.ORCA_PANE_KEY).toBeUndefined() + expect(childEnv).toEqual({ PATH: '/usr/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 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.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) + expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) + }) + + 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, 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(join(SHIM_DIR, '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`) + expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca')) + }) + + 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() + // 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', () => { + 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`) + expect(env.ORCA_CLI_COMMAND).toBe(join(USER_DATA, 'cli', 'bin', 'orca-dev')) + }) + }) + + 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('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. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + registerWorker() + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.ORCA_PANE_KEY).toBeUndefined() + expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) + }) + + it('never names the WSL-scoped launcher, because a structured worker cannot run in WSL', () => { + // `orca-ide` is the literal the PTY lane exports for WSL only. A structured session that + // resolves to a WSL distro is refused a host scope, so it never becomes a worker at all — + // which is why the bare-`orca` shim, not the literal, is the right fix on Linux. + expect( + structuredWorkerHostScope({ + executionHostId: 'local', + workspaceId: 'wt_1', + workspaceKind: 'git-worktree', + wslDistro: 'Ubuntu' + }) + ).toBeNull() + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + registerWorker() + expect( + structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND + ).not.toBe('orca-ide') + }) +}) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts new file mode 100644 index 000000000000..4d3e85c34140 --- /dev/null +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -0,0 +1,93 @@ +/** + * 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. + * + * 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. + * + * 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` 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. + * + * 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. + * + * `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. + */ + +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' + +export function structuredSessionChildIdentityEnv( + sessionId: string, + childEnv: Record +): Record { + const identity = structuredWorkerIdentities.getBySessionId(sessionId) + const env: Record = { + ...childEnv, + ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), + [ORCA_AGENT_SESSION_ID_ENV]: sessionId, + [ORCA_STRUCTURED_SESSION_ENV]: '1' + } + applyThisAppCli(env) + return 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. + */ +function applyThisAppCli(env: Record): void { + delete env.ORCA_CLI_COMMAND + 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, + 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/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 new file mode 100644 index 000000000000..2079dc44eb66 --- /dev/null +++ b/src/main/runtime/structured-session-cli-login-shell.test.ts @@ -0,0 +1,38 @@ +/** + * 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 { 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 bash login shell', () => { + let harness: LoginShellHarness + + beforeEach(() => { + harness = createLoginShellHarness() + }) + + afterEach(() => { + harness.dispose() + }) + + 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 () => { + 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 }) + } +} diff --git a/src/main/runtime/structured-worker-child-identity-env.test.ts b/src/main/runtime/structured-worker-child-identity-env.test.ts deleted file mode 100644 index e966dd558240..000000000000 --- a/src/main/runtime/structured-worker-child-identity-env.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' - -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 { - mintStructuredWorkerHandle, - mintStructuredWorkerPaneKey, - structuredWorkerHostScope, - structuredWorkerIdentities, - structuredWorkerProcessIncarnation -} from './structured-worker-identity' - -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 platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! -const resourcesDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') - -function pinPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) -} - -function registerWorker(): string { - const handle = mintStructuredWorkerHandle() - structuredWorkerIdentities.register({ - handle, - sessionId: SESSION_ID, - agent: 'claude', - paneKey: mintStructuredWorkerPaneKey(SESSION_ID), - processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), - worktreeId: 'wt_1', - hostScope: { kind: 'local', hostId: 'local' } - }) - return handle -} - -beforeEach(() => { - shim.ensureLinuxTerminalOrcaCliShimDir.mockReset() - shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(SHIM_DIR) - Object.defineProperty(process, 'resourcesPath', { configurable: true, value: RESOURCES }) -}) - -afterEach(() => { - structuredWorkerIdentities.clear() - Object.defineProperty(process, 'platform', platformDescriptor) - if (resourcesDescriptor) { - Object.defineProperty(process, 'resourcesPath', resourcesDescriptor) - } else { - Reflect.deleteProperty(process, 'resourcesPath') - } -}) - -describe('structuredWorkerChildIdentityEnv', () => { - 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. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - const childEnv = { PATH: '/usr/bin' } - const env = structuredWorkerChildIdentityEnv(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() - 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() - }) - - 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 = structuredWorkerChildIdentityEnv(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('gives a packaged-macOS worker the bundled CLI dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) - }) - - 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 = structuredWorkerChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) - expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) - expect(env.PATH).toBeUndefined() - }) - - it('gives an unpackaged worker the dev launcher dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) - }) - - 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. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) - }) - - it('never names the WSL-scoped launcher, because a structured worker cannot run in WSL', () => { - // `orca-ide` is the literal the PTY lane exports for WSL only. A structured session that - // resolves to a WSL distro is refused a host scope, so it never becomes a worker at all — - // which is why the bare-`orca` shim, not the literal, is the right fix on Linux. - expect( - structuredWorkerHostScope({ - executionHostId: 'local', - workspaceId: 'wt_1', - workspaceKind: 'git-worktree', - wslDistro: 'Ubuntu' - }) - ).toBeNull() - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - expect( - structuredWorkerChildIdentityEnv(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-worker-child-identity-env.ts deleted file mode 100644 index 6cf9f46e910c..000000000000 --- a/src/main/runtime/structured-worker-child-identity-env.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * The orchestration identity — and the CLI reachability — a structured worker's own child needs - * to speak for itself. - * - * 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. - * - * `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. - * - * 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. - * - * 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 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 { 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( - 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, - ORCA_CLI_COMMAND: 'orca' - } - applyOrcaCliPath(env) - return 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. - */ -function applyOrcaCliPath(env: Record): void { - if (!hasAppEnvironment()) { - return - } - const app = getAppEnvironment() - prependOrcaCliDirToChildPath(env, { - isPackaged: app.isPackaged(), - userDataPath: app.getPath('userData'), - resourcesPath: process.resourcesPath ?? null - }) -} diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index d20cadf5c770..17ba897de479 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,9 +300,9 @@ describe('structured workers stay outside the PTY-only fail-closed paths', () => hostScope: { kind: 'local', hostId: 'local' } }) try { - const env = structuredWorkerChildIdentityEnv(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. + const env = structuredSessionChildIdentityEnv(SESSION_ID, {}) + // 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 { 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.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', 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..5f66128dc5f9 --- /dev/null +++ b/src/shared/agent-session-caller-env.ts @@ -0,0 +1,38 @@ +/** + * 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( + env: Readonly> = process.env +): string | undefined { + 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}` +} 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 339ccb01f435..d0098ea2ef9d 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -1,17 +1,19 @@ 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), 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 * 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 @@ -24,7 +26,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..c30dc5a6139d 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,13 +1,18 @@ /** - * The marker a structured chat session's child carries when it has NO orchestration identity. + * Marks a structured session's child. It names nothing — no handle, no pane key, no token. * - * 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. + * 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. + * + * 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 } diff --git a/src/shared/structured-worker-handle.ts b/src/shared/structured-worker-handle.ts new file mode 100644 index 000000000000..c9fd3927c02c --- /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): handle is string { + return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) +}