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 00000000000..1d14998e46c --- /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 c42b937a467..a998460a457 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/preload/**/*", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index beaf82f4e12..2514067e7cc 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -26,7 +26,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: [ 'src/**/*.test.ts', diff --git a/src/cli/handlers/orchestration/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts index f2af827ee04..26cefcc06ea 100644 --- a/src/cli/handlers/orchestration/message-check-handler.ts +++ b/src/cli/handlers/orchestration/message-check-handler.ts @@ -13,7 +13,7 @@ import { startCheckKeepalive } from './check-keepalive' import { callOrchestrationMutation } from './mutation-request' import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' import { flushOrchestrationStdout, resolveCompatibilityCliCommand } from './runtime-compatibility' -import { resolveOrchestrationTerminalHandle } from './terminal-identity' +import { orchestrationCallerLabel, resolveOrchestrationTerminalHandle } from './terminal-identity' type CheckResult = { messages: MessageSummary[] @@ -41,12 +41,16 @@ export const ORCHESTRATION_CHECK_HANDLER: Record = { const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') const explicitTerminal = getOptionalStringFlag(flags, 'terminal') const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') + // Why: a session names itself by its id alone; a terminal view's pane is not its identity. + const paneKey = + explicitTerminal || terminal === undefined ? undefined : process.env.ORCA_PANE_KEY + const callerLabel = orchestrationCallerLabel(terminal) const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null let result: Awaited>> try { result = await callOrchestrationMutation(client, flags, 'orchestration.check', { terminal, - terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, + terminalPaneKey: paneKey || undefined, // Why: old runtimes degrade peek to non-consuming all mode instead of destructive mark-read. unread: flags.has('unread') ? true : peek ? false : undefined, peek: peek ? true : undefined, @@ -68,9 +72,9 @@ export const ORCHESTRATION_CHECK_HANDLER: Record = { } result = { ...result, - result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) + result: prepareOrchestrationCheckOutput(result.result, callerLabel, flags.has('format')) } - printResult(result, json, (value) => formatOrchestrationCheckText(value, terminal)) + printResult(result, json, (value) => formatOrchestrationCheckText(value, callerLabel)) const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds if (compatibilityAck && compatibilityAck.length > 0) { await flushOrchestrationStdout() diff --git a/src/cli/handlers/orchestration/message-send-handler.ts b/src/cli/handlers/orchestration/message-send-handler.ts index 088d1361e69..f79a846328b 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 bd9e239b6d5..67aaeb16e4d 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 7c77913df15..d8abe3533c2 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 e693d99079d..f9d72d76e3a 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -3,14 +3,25 @@ import { getOptionalStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' import { isStructuredSessionWithoutIdentity } from '../../../shared/structured-session-marker' +import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' +import { normalizeOrchestrationActor } from '../../../shared/orchestration-actor' +import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' +/** + * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: + * the orchestration envelope carries that id and the host binds the caller param to it, so nothing + * is resolved or guessed here. + */ export async function resolveOrchestrationTerminalHandle( flags: Map, cwd: string, client: RuntimeClient, flagName: 'from' | 'terminal', options: { validateEnvHandle?: boolean } = {} -): Promise { +): Promise { + if (resolveInjectedSessionCaller(flags, flagName)) { + return undefined + } const explicit = getOptionalStringFlag(flags, flagName) if (explicit) { return explicit @@ -157,11 +168,51 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } +/** + * The injected session id when this command runs as an agent session. The id wins over every other + * identity this process carries; a caller flag may restate that same session but never name + * another, and a conflicting one is refused here, before any request is sent. + */ +export function resolveInjectedSessionCaller( + flags: Map, + flagName: 'from' | 'terminal' +): string | undefined { + const sessionId = readInjectedAgentSessionId() + if (!sessionId) { + return undefined + } + const declared = getOptionalStringFlag(flags, flagName) + if (declared !== undefined && !namesInjectedSession(declared, sessionId)) { + throw new RuntimeClientError( + 'consumer_fenced', + `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + + `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + + `session:${sessionId}. No request was sent.` + ) + } + return sessionId +} + +/** The session's own spellings, plus the handle a structured worker session was minted. */ +function namesInjectedSession(value: string, sessionId: string): boolean { + if (normalizeOrchestrationActor(value)?.id === sessionId) { + return true + } + const ownHandle = process.env.ORCA_TERMINAL_HANDLE + return isStructuredWorkerHandle(ownHandle) && value === ownHandle +} + +/** How check output names its caller: the handle, or the session's address. */ +export function orchestrationCallerLabel(handle: string | undefined): string { + const sessionId = handle === undefined ? readInjectedAgentSessionId() : undefined + return handle ?? (sessionId ? `session:${sessionId}` : 'unknown') +} + export async function resolveCoordinatorTerminalHandle( flags: Map, cwd: string, client: RuntimeClient -): Promise { +): Promise { return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { validateEnvHandle: true }) diff --git a/src/cli/handlers/orchestration/worker-list-run-scope.ts b/src/cli/handlers/orchestration/worker-list-run-scope.ts index 13037616153..83bfcd5b9e0 100644 --- a/src/cli/handlers/orchestration/worker-list-run-scope.ts +++ b/src/cli/handlers/orchestration/worker-list-run-scope.ts @@ -1,6 +1,9 @@ import { getOptionalStringFlag } from '../../flags' import type { RuntimeClient } from '../../runtime-client' -import { resolveOrchestrationTerminalHandle } from './terminal-identity' +import { + resolveInjectedSessionCaller, + resolveOrchestrationTerminalHandle +} from './terminal-identity' /** Which Run `worker-list` enumerated, and why. Additive: old readers ignore it. */ export type WorkerListRunScope = { run?: string; source: 'flag' | 'bound' | 'all' } @@ -20,6 +23,9 @@ export async function resolveWorkerListRunScope( if (explicit) { return { run: explicit, source: 'flag' } } + // Outside the catch: a --terminal naming someone other than this session is refused, never + // widened into an unscoped listing. + resolveInjectedSessionCaller(flags, 'terminal') try { const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') const current = await client.call<{ run: { id: string } | null }>('orchestration.runCurrent', { diff --git a/src/cli/runtime/orchestration-compatibility-envelope.ts b/src/cli/runtime/orchestration-compatibility-envelope.ts index 8172e556dc1..7d6c0dfda80 100644 --- a/src/cli/runtime/orchestration-compatibility-envelope.ts +++ b/src/cli/runtime/orchestration-compatibility-envelope.ts @@ -1,12 +1,17 @@ import { randomUUID } from 'node:crypto' +import { readInjectedAgentSessionId } from '../../shared/agent-session-caller-env' import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' export function createOrchestrationCompatibilityEnvelope( env: NodeJS.ProcessEnv ): RuntimeOrchestrationEnvelope { + const evidence = readOrchestrationCompatibilityEvidence(env) + // Read here, from this CLI's own environment only: the SSH paths build evidence from a remote + // shell's environment, where a session id could never name a session on this host. + const agentSessionId = readInjectedAgentSessionId(env) return { compatibilityInvocationId: randomUUID(), - orchestrationCompatibilityEvidence: readOrchestrationCompatibilityEvidence(env) + orchestrationCompatibilityEvidence: agentSessionId ? { ...evidence, agentSessionId } : evidence } } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index a2b3d41bbfd..9821b6ab8ae 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, @@ -283,9 +283,8 @@ export function createClaudeStructuredLaunchResolver( (await deps.resolvePermissionMode?.()) ?? 'default' ) const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => - // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on - // PATH; an ordinary chat session's env passes through untouched. - structuredWorkerChildIdentityEnv(record.sessionId, { + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + structuredSessionChildIdentityEnv(record.sessionId, { ...base, // The turn translator relies on Claude's authoritative idle frame when no result arrives. [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts index 72bf17a1bce..38ea91abe26 100644 --- a/src/main/codex/codex-structured-child-environment.ts +++ b/src/main/codex/codex-structured-child-environment.ts @@ -1,6 +1,6 @@ import type { CodexStructuredLaunch } from './codex-structured-session-state' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' -import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' export function buildCodexStructuredChildEnvironment( launch: CodexStructuredLaunch, @@ -8,9 +8,8 @@ export function buildCodexStructuredChildEnvironment( sessionId: string ): Record { return { - // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on - // PATH; an ordinary chat session's env passes through untouched. - ...structuredWorkerChildIdentityEnv(sessionId, { + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + ...structuredSessionChildIdentityEnv(sessionId, { ...launch.env, ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}) }), diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index 545d04d063d..aa8a7d2a21c 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, @@ -133,13 +137,15 @@ export function getInheritedAgentHookEnvKeysToDelete( return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) } -export function getInheritedClaudeSessionStampEnvKeysToDelete( +export function getInheritedAgentSessionStampEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { const env = spawnEnv ?? {} // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (deliberately spawning a nested Claude child) keeps it. - return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) + // provides a stamp (a nested Claude child, a structured session's terminal view) keeps it. + return [...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS].filter( + (key) => env[key] === undefined + ) } // Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. 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 cabbde7b0ec..ed070c7d043 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 a2adf48771b..eedbcefcf2a 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -7,7 +7,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' @@ -43,7 +43,7 @@ export async function buildPtyIpcSpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], - 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 67c633560e9..0a8b8cff801 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/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 { @@ -73,7 +73,7 @@ export async function buildRuntimePtySpawnOptions( ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) + getInheritedAgentSessionStampEnvKeysToDelete(ctx.env) ) if (ctx.skipCodexHomeEnv) { ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 04035a957ca..5e89e4eb350 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -9,6 +9,7 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../shared/setup-agent-sequencing' import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_IMAGE_PROTOCOL_ENV } from '../../shared/terminal-image-protocol' const WSLENV_ENTRY_SEPARATOR = ':' @@ -74,6 +75,9 @@ export function addOrcaWslInteropEnv(env: Record): void { // Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start. const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', + // Why: a structured session's terminal view in a WSL shell must still present its id, so the + // host refuses the cross-host claim instead of the pane handle silently becoming its caller. + `${ORCA_AGENT_SESSION_ID_ENV}/u`, 'ORCA_USER_DATA_PATH/p', // Why /p: the guest reads the content-addressed wrapper tree through /mnt/c, // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 493f9d092c3..501897b8731 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -19,8 +19,8 @@ const { } = await import('./orchestration-structured-worker-session') const { isUnknownWorkerStartOutcome } = await import('./orchestration/worker/worker-topology') const { structuredWorkerIdentities } = await import('../../structured-worker-identity') -const { structuredWorkerChildIdentityEnv } = - await import('../../structured-worker-child-identity-env') +const { structuredSessionChildIdentityEnv } = + await import('../../structured-session-child-identity-env') function installHost() { const hold = vi.fn(async () => {}) @@ -83,7 +83,7 @@ describe('structured worker session hold', () => { createSpy.mockImplementation(async (args: { envelope: { sessionId: string } }) => { // `attach` is what spawns the provider child, and the child's env is read from the registry // at spawn time. Registering afterwards ships a worker with no ORCA_TERMINAL_HANDLE. - envAtSpawn = structuredWorkerChildIdentityEnv(args.envelope.sessionId, {}) + envAtSpawn = structuredSessionChildIdentityEnv(args.envelope.sessionId, {}) return { ok: true, value: { sessionId: args.envelope.sessionId } } }) const created = await createStructuredWorkerSession({ diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index d96e214d442..c91ad0ff169 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -43,7 +43,6 @@ const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle'] a const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', 'orchestration.runShow from': 'reads a Run by id; `from` is unused', - 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal' @@ -59,6 +58,7 @@ const MINIMAL_PARAMS: Readonly>> = { 'orchestration.reply': { id: 'msg_missing', body: 'b' }, 'orchestration.ask': { question: 'q', to: 'term_worker' }, 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, + 'orchestration.dispatchShow': { task: 'task_missing', preamble: true }, 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, 'orchestration.gateList': {}, diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index e4bd09f2619..deeeb397ca3 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -50,6 +50,8 @@ export const ORCHESTRATION_CALLER_PARAM: Readonly> = 'orchestration.reply': 'from', 'orchestration.ask': 'from', 'orchestration.dispatch': 'from', + // The preview names its caller as the coordinator, exactly as the dispatch it previews does. + 'orchestration.dispatchShow': 'from', 'orchestration.gateCreate': 'from', 'orchestration.gateResolve': 'from', 'orchestration.gateList': 'from', diff --git a/src/main/runtime/structured-worker-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts similarity index 88% rename from src/main/runtime/structured-worker-child-identity-env.test.ts rename to src/main/runtime/structured-session-child-identity-env.test.ts index e966dd55824..7fe19c491e5 100644 --- a/src/main/runtime/structured-worker-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -5,7 +5,7 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) -import { structuredWorkerChildIdentityEnv } from './structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -56,7 +56,7 @@ afterEach(() => { } }) -describe('structuredWorkerChildIdentityEnv', () => { +describe('structuredSessionChildIdentityEnv', () => { it('marks an ordinary chat session as having NO identity, and grants it nothing', () => { // The marker names nothing — no handle, no pane key, no session id, no token — so it cannot be // replayed or impersonated, and it does not reach the hook, agent-row or mobile-projection @@ -66,7 +66,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const childEnv = { PATH: '/usr/bin' } - const env = structuredWorkerChildIdentityEnv(SESSION_ID, childEnv) + const env = structuredSessionChildIdentityEnv(SESSION_ID, childEnv) expect(env).toEqual({ PATH: '/usr/bin', ORCA_STRUCTURED_SESSION: '1' }) expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() expect(env.ORCA_PANE_KEY).toBeUndefined() @@ -82,7 +82,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const handle = registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) expect(env.ORCA_CLI_COMMAND).toBe('orca') expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) @@ -92,7 +92,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('darwin') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) }) @@ -100,7 +100,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('win32') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) expect(env.PATH).toBeUndefined() }) @@ -109,7 +109,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('darwin') installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) }) @@ -119,7 +119,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.ORCA_PANE_KEY).toBeUndefined() expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) }) @@ -140,7 +140,7 @@ describe('structuredWorkerChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() expect( - structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND + structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND ).not.toBe('orca-ide') }) }) diff --git a/src/main/runtime/structured-worker-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts similarity index 50% rename from src/main/runtime/structured-worker-child-identity-env.ts rename to src/main/runtime/structured-session-child-identity-env.ts index 6cf9f46e910..f2ab92b3d66 100644 --- a/src/main/runtime/structured-worker-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -1,62 +1,67 @@ /** - * The orchestration identity — and the CLI reachability — a structured worker's own child needs - * to speak for itself. + * The orchestration identity — and the CLI reachability — a structured session's own child needs to + * speak for itself. Both providers' native launches (Claude, Codex) build their child env here. * - * Without `ORCA_TERMINAL_HANDLE` the worker's Bash tool has nothing to pass as `--from`, and - * `resolveOrchestrationTerminalHandle` falls back to a cwd lookup that returns whichever leaf in - * the worktree comes first. Two attacks follow from that: a bare `check` reads and consumes a - * SIBLING's dispatch mailbox, and a bare `send --type worker_done` can settle a sibling's - * context-only dispatch, a tier that has no capability token to reject on. + * Every structured session carries `ORCA_AGENT_SESSION_ID`, the id Orca minted for it — never the + * provider's, which rotates on `/clear`. The CLI sends it as the caller, so a bare + * `orca orchestration check` acts as this session instead of guessing a terminal: with no pane of + * its own, every guess landed on a sibling, and a destructive `check` consumed that sibling's mail. + * Identity by session id assumes one machine and one user; crossing a host boundary (SSH, a paired + * peer) re-opens that decision, and the host refuses a session claim from across one. + * + * A dispatched structured worker also keeps the `structworker_` handle it was minted, for the + * handle-based surfaces outside orchestration; for orchestration the id wins and the host maps it + * back to that handle, so the worker keeps one identity. * * `ORCA_CLI_COMMAND: 'orca'` is honest ONLY because of the PATH prepend below. Orca's Linux CLI * installs as `orca-ide` so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and * on packaged macOS/Windows the bundled launcher is reachable only from the app's own resources - * dir. A PTY worker gets that treatment from `buildPtyHostEnv`; a structured worker has no PTY, - * so it applies the SAME function here rather than a second, drifting copy of the rule. + * dir. A PTY agent gets that treatment from `buildPtyHostEnv`; a structured session has no PTY, so + * it applies the SAME function here rather than a second, drifting copy of the rule. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, * agent-row and mobile-projection pipelines, every one of which assumes a pane key names a live * PTY leaf. It would also open `selectExactWorkerProviderSession`, which is fail-closed today - * precisely because a structured session emits no hook agent status. The CLI needs none of it once - * the handle is present. + * precisely because a structured session emits no hook agent status. * - * A session that is not a dispatched worker gets ONE variable, `ORCA_STRUCTURED_SESSION`, and it - * names nothing: no handle, no pane key, no session id, no token. Its only meaning is "this child - * is a structured session with no orchestration identity", which is what a verb needs in order to - * REFUSE rather than guess one. Because it names nothing it cannot be replayed, cannot impersonate, - * and cannot flow into the hook, agent-row or mobile-projection pipelines the way a pane key would - * — which is why it is a different decision from withholding `ORCA_PANE_KEY`, not a reversal of it. - * Without it, `check` fell through to the active-terminal guess and destructively consumed a - * SIBLING pane's oldest unread batch; `requireUnambiguous` only narrows that, because with exactly - * one terminal pane in the worktree the guess still resolves — to a sibling. + * The identity-less marker (`ORCA_STRUCTURED_SESSION`) is no longer written: a child with an id is + * never identity-less, so a marker inherited from an Orca launched inside an older session is inert. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. */ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { structuredWorkerIdentities } from './structured-worker-identity' -export function structuredWorkerChildIdentityEnv( +export function structuredSessionChildIdentityEnv( sessionId: string, childEnv: Record ): Record { const identity = structuredWorkerIdentities.getBySessionId(sessionId) - if (!identity) { - return { ...childEnv, [ORCA_STRUCTURED_SESSION_ENV]: '1' } - } const env: Record = { ...childEnv, - ORCA_TERMINAL_HANDLE: identity.handle, + ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), + [ORCA_AGENT_SESSION_ID_ENV]: sessionId, ORCA_CLI_COMMAND: 'orca' } applyOrcaCliPath(env) return env } +/** + * The same session id for its terminal view, so switching views never changes who the session is. + * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. + */ +export function structuredSessionTerminalViewEnv( + sessionId: string | undefined +): Record { + return sessionId ? { [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : {} +} + /** * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 98989a85891..21d56d7fa37 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, @@ -294,7 +294,7 @@ describe('structured workers stay outside the PTY-only fail-closed paths', () => hostScope: { kind: 'local', hostId: 'local' } }) try { - const env = structuredWorkerChildIdentityEnv(SESSION_ID, {}) + const env = structuredSessionChildIdentityEnv(SESSION_ID, {}) // Registered, so this is a populated env — not the empty one an unregistered session gets, // which would satisfy the pane-key assertion for the wrong reason. expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index c32d6c37f15..77357cf1e99 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -26,14 +26,19 @@ import { } from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isOrcaSessionId, type OrcaSessionId } from '../../shared/orca-session-address' +import { + STRUCTURED_WORKER_HANDLE_PREFIX, + isStructuredWorkerHandle +} from '../../shared/structured-worker-handle' import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' -// Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the -// renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. -export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' +export { + STRUCTURED_WORKER_HANDLE_PREFIX, + isStructuredWorkerHandle +} from '../../shared/structured-worker-handle' export const STRUCTURED_WORKER_INCARNATION_PREFIX = 'structured:' export type StructuredWorkerIdentity = { @@ -47,10 +52,6 @@ export type StructuredWorkerIdentity = { hostScope: WorkerTerminalHostScope } -export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { - return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) -} - export function mintStructuredWorkerHandle(): string { return `${STRUCTURED_WORKER_HANDLE_PREFIX}${randomUUID()}` } diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index d62a23d8e30..4490343504f 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 00000000000..d0f5e860559 --- /dev/null +++ b/src/shared/agent-session-caller-env.ts @@ -0,0 +1,17 @@ +/** + * The Orca-minted agent session id, injected into a structured session's own child processes, in + * native chat and in terminal view alike. When it is present it IS the orchestration caller: the + * CLI sends it in the orchestration envelope and the host resolves it to the session's actor, so no + * terminal is resolved or guessed on its behalf. + * + * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, + * WSL) re-opens that decision, which is why nothing forwards the id across one as a caller. + */ +export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' + +export function readInjectedAgentSessionId( + env: Readonly> = process.env +): string | undefined { + const value = env[ORCA_AGENT_SESSION_ID_ENV]?.trim() + return value ? value : undefined +} diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index 339ccb01f43..9762b95d63c 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -1,11 +1,12 @@ import { isAgentSessionId } from './agent-session-record' +import { STRUCTURED_WORKER_HANDLE_PREFIX } from './structured-worker-handle' /** - * The Orca session id is the id Orca minted for a structured session (its session record id), never - * the provider's own session id. Orchestration stores, bare, the one the agent is addressed by: for - * a `/clear`ed chat, its lineage root's, not the live session's. Mail addresses the session as - * `session:`, beside `run:` and `dispatch:`, and derives that spelling here rather than - * storing it. + * The Orca session id is the id Orca minted for a structured session (its session record id, the + * value of `ORCA_AGENT_SESSION_ID`), never the provider's own session id. Orchestration stores, + * bare, the one the agent is addressed by: for a `/clear`ed chat, its lineage root's, not the live + * session's. Mail addresses the session as `session:`, beside `run:` and `dispatch:`, + * and derives that spelling here rather than storing it. * * Where the session runs is not part of the id; it is read from the session record when needed. PTY * agents have none today, and never a pane-keyed one: a pane outlives the agent in it, so such an id @@ -24,7 +25,7 @@ export type OrcaSessionAddress = string & { readonly [orcaSessionAddressBrand]: // Terminal handles (`term_` from the PTY runtime, `structworker_` from structured-worker-identity) // share the session-id charset. A handle is never a session, so one handed over by mistake must not // become a durable Orca session id. -const TERMINAL_HANDLE_PREFIXES = ['term_', 'structworker_'] as const +const TERMINAL_HANDLE_PREFIXES = ['term_', STRUCTURED_WORKER_HANDLE_PREFIX] as const export function isOrcaSessionId(id: string): id is OrcaSessionId { return isAgentSessionId(id) && !TERMINAL_HANDLE_PREFIXES.some((prefix) => id.startsWith(prefix)) diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 36c90f432ee..1725a6ef337 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,13 +1,20 @@ +import { readInjectedAgentSessionId } from './agent-session-caller-env' + /** - * The marker a structured chat session's child carries when it has NO orchestration identity. + * The marker a structured chat session's child carried when it had NO orchestration identity. * - * It names nothing on purpose — no handle, no pane key, no session id, no token — so it grants no - * authority and cannot be replayed or impersonated. Its only job is to let a CLI verb that would - * otherwise GUESS an implicit terminal refuse instead: a structured session has no pane, so every - * guess resolves to a sibling, and `orchestration check` is destructive by default. + * Current hosts no longer write it: every structured child carries its injected session id + * instead. The reader stays for a child spawned by an Orca that predates injection, which can still + * reach a newer CLI through a global install — it must refuse, not guess, because a structured + * session has no pane, so every implicit-terminal guess resolves to a sibling and + * `orchestration check` is destructive by default. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' +/** An injected session id is an identity, so a child carrying one is never identity-less. */ export function isStructuredSessionWithoutIdentity(env: NodeJS.ProcessEnv = process.env): boolean { - return (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 + return ( + (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 && + readInjectedAgentSessionId(env) === undefined + ) } diff --git a/src/shared/structured-worker-handle.ts b/src/shared/structured-worker-handle.ts new file mode 100644 index 00000000000..e36df813570 --- /dev/null +++ b/src/shared/structured-worker-handle.ts @@ -0,0 +1,7 @@ +// Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the +// renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. +export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' + +export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { + return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) +}