From 7aa91f730a3962aebcf7b87a75e26ea628c19d71 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 03:19:14 -0700 Subject: [PATCH] fix(cli): check the runtime source before sending (#27206) * fix(cli): check the runtime source before sending A terminal Orca launches now carries which Orca launched it (ORCA_RUNTIME_SOURCE_ID = the profile's installation id, ORCA_RUNTIME_SOURCE_INCARNATION = the launching runtime id). Before a request is sent, the CLI compares that with the runtime the profile's runtime record names. Same runtime: sent as before. Different runtime: a read-only status.get asks which profile it serves; the same profile (a restart or update) is accepted, anything else is refused before the request reaches it. The vars cross into WSL through WSLENV. The SSH bridge stamps the bridging runtime itself rather than trusting the remote env. The existing orchestration compatibility stamp is untouched. * fix(orcad): drop an inherited runtime source at startup * fix(cli): an explicit switch to another profile is not fenced The stamp also carries the launching runtime's userData path. A CLI that dials a different profile than the one that launched its terminal, and not the platform default an unpinned terminal falls back to, was pointed there on purpose (orca-dev from a packaged Orca's terminal), so the fence does not apply. * refactor(cli): reuse the shared userData path comparison --------- Co-authored-by: m4air --- src/cli/runtime/client.ts | 7 + src/cli/runtime/runtime-source-fence.test.ts | 178 ++++++++++++++++++ src/cli/runtime/runtime-source-fence.ts | 87 +++++++++ src/main/ipc/pty/ipc/spawn-env-codex.ts | 6 + src/main/ipc/pty/provider/local-configure.ts | 6 + src/main/ipc/pty/runtime/spawn-preflight.ts | 6 + src/main/orcad/orcad-entry.ts | 3 + src/main/pty/wsl-orca-env.test.ts | 8 +- src/main/pty/wsl-orca-env.ts | 9 + src/main/runtime/host-descriptor.test.ts | 27 ++- src/main/runtime/host-descriptor.ts | 17 ++ .../ssh-remote-cli-host-passthrough.test.ts | 29 +++ .../ssh/ssh-remote-cli-host-passthrough.ts | 13 +- src/main/ssh/ssh-remote-orca-cli.ts | 6 +- src/main/startup/configure-process.test.ts | 19 ++ src/main/startup/configure-process.ts | 3 + src/shared/runtime-source-env.test.ts | 32 ++++ src/shared/runtime-source-env.ts | 50 +++++ 18 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 src/cli/runtime/runtime-source-fence.test.ts create mode 100644 src/cli/runtime/runtime-source-fence.ts create mode 100644 src/shared/runtime-source-env.test.ts create mode 100644 src/shared/runtime-source-env.ts diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 5efe54c7ba3..fe25b3ef4fd 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -21,6 +21,7 @@ import { didAnotherRuntimeHandleTerminalPrompt } from './terminal-prompt-mutation-recovery' import { markEnvironmentUsed } from './environments' +import { createRuntimeSourceFence, type RuntimeSourceFence } from './runtime-source-fence' import { resolveRemotePairing } from './runtime-remote-pairing' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, @@ -56,6 +57,7 @@ export class RuntimeClient { private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope( process.env ) + private readonly runtimeSourceFence: RuntimeSourceFence // Why: browser commands trigger first-time session init (agent-browser connect + // CDP proxy setup) which can take 15-30s. 60s accommodates cold start without @@ -75,6 +77,7 @@ export class RuntimeClient { this.originalArgs = originalArgs ? [...originalArgs] : undefined this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector) this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector) + this.runtimeSourceFence = createRuntimeSourceFence(process.env, userDataPath) } get isRemote(): boolean { @@ -162,6 +165,10 @@ export class RuntimeClient { return response } const metadata = readMetadata(this.userDataPath) + // Why outside recover(): a refusal here happens before anything is sent, so there is nothing to retry. + await this.runtimeSourceFence.check(metadata, method, (target) => + sendRequest(target, 'status.get', undefined, this.requestTimeoutMs) + ) let response try { response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope) diff --git a/src/cli/runtime/runtime-source-fence.test.ts b/src/cli/runtime/runtime-source-fence.test.ts new file mode 100644 index 00000000000..828588007f6 --- /dev/null +++ b/src/cli/runtime/runtime-source-fence.test.ts @@ -0,0 +1,178 @@ +import { createServer, type Server } from 'node:net' +import { randomUUID } from 'node:crypto' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + RUNTIME_SOURCE_ID_ENV, + RUNTIME_SOURCE_INCARNATION_ENV, + RUNTIME_SOURCE_PROFILE_PATH_ENV +} from '../../shared/runtime-source-env' +import { RuntimeClient, RuntimeClientError } from '../runtime-client' +import { resolveFencedRuntimeSource } from './runtime-source-fence' + +const SOURCE_A = '11111111-1111-4111-8111-111111111111' +const SOURCE_B = '22222222-2222-4222-8222-222222222222' + +const servers = new Set() +const savedEnv = { + id: process.env[RUNTIME_SOURCE_ID_ENV], + incarnation: process.env[RUNTIME_SOURCE_INCARNATION_ENV], + profilePath: process.env[RUNTIME_SOURCE_PROFILE_PATH_ENV] +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} + +afterEach(async () => { + restoreEnv(RUNTIME_SOURCE_ID_ENV, savedEnv.id) + restoreEnv(RUNTIME_SOURCE_INCARNATION_ENV, savedEnv.incarnation) + restoreEnv(RUNTIME_SOURCE_PROFILE_PATH_ENV, savedEnv.profilePath) + await Promise.all( + [...servers].map((server) => new Promise((resolve) => server.close(() => resolve()))) + ) + servers.clear() +}) + +type FakeRuntime = { userDataPath: string; methods: string[] } + +/** A live runtime on its own socket that answers every method and records what reached it. */ +async function startRuntime(args: { + runtimeId: string + installationId?: string +}): Promise { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-source-fence-')) + const isWindows = process.platform === 'win32' + const endpoint = isWindows + ? `\\\\.\\pipe\\orca-source-fence-${randomUUID()}` + : join(userDataPath, 'runtime.sock') + const methods: string[] = [] + const server = createServer((socket) => { + let buffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const request: { id: string; method: string } = JSON.parse(buffer.slice(0, newline)) + methods.push(request.method) + const result = + request.method === 'status.get' + ? { + runtimeId: args.runtimeId, + ...(args.installationId + ? { hostDescriptor: { installationId: args.installationId } } + : {}) + } + : { ok: true } + socket.end( + `${JSON.stringify({ id: request.id, ok: true, result, _meta: { runtimeId: args.runtimeId } })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeFileSync( + join(userDataPath, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: args.runtimeId, + pid: 1, + transports: [{ kind: isWindows ? 'named-pipe' : 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + }) + ) + return { userDataPath, methods } +} + +function launchedBy(sourceId: string, incarnation: string, profilePath?: string): void { + process.env[RUNTIME_SOURCE_ID_ENV] = sourceId + process.env[RUNTIME_SOURCE_INCARNATION_ENV] = incarnation + restoreEnv(RUNTIME_SOURCE_PROFILE_PATH_ENV, profilePath) +} + +async function expectRefusal(promise: Promise, code: string): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught + ) + expect(error).toBeInstanceOf(RuntimeClientError) + expect(error).toMatchObject({ code }) +} + +describe('RuntimeClient runtime-source fence', () => { + it('refuses another live runtime before the request reaches it', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-b', installationId: SOURCE_B }) + launchedBy(SOURCE_A, 'runtime-a') + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await expectRefusal( + client.call('terminal.send', { terminal: 't1', text: 'hi' }), + 'runtime_source_mismatch' + ) + await expectRefusal(client.call('worktree.list'), 'runtime_source_mismatch') + expect(runtime.methods).toEqual(['status.get', 'status.get']) + }) + + it('accepts a restarted runtime of the same source and probes it only once', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-a2', installationId: SOURCE_A }) + launchedBy(SOURCE_A, 'runtime-a1') + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await client.call('terminal.send', { terminal: 't1', text: 'hi' }) + await client.call('worktree.list') + expect(runtime.methods).toEqual(['status.get', 'terminal.send', 'worktree.list']) + }) + + it('sends without a probe when the launching runtime is still the live one', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-a', installationId: SOURCE_A }) + launchedBy(SOURCE_A, 'runtime-a') + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await client.call('worktree.list') + expect(runtime.methods).toEqual(['worktree.list']) + }) + + it('refuses when the live runtime cannot say which profile it serves', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-old' }) + launchedBy(SOURCE_A, 'runtime-a') + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await expectRefusal(client.call('worktree.list'), 'runtime_source_unverifiable') + expect(runtime.methods).toEqual(['status.get']) + }) + + it('keeps the unfenced path for a CLI started outside an Orca terminal', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-b', installationId: SOURCE_B }) + delete process.env[RUNTIME_SOURCE_ID_ENV] + delete process.env[RUNTIME_SOURCE_INCARNATION_ENV] + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await client.call('worktree.list') + expect(runtime.methods).toEqual(['worktree.list']) + }) + + it('lets an explicit switch to another profile through, e.g. orca-dev from a packaged terminal', async () => { + const runtime = await startRuntime({ runtimeId: 'runtime-dev', installationId: SOURCE_B }) + launchedBy(SOURCE_A, 'runtime-a', join(tmpdir(), 'orca-packaged-profile')) + const client = new RuntimeClient(runtime.userDataPath, 2_000, null, null, 'orca') + + await client.call('worktree.list') + expect(runtime.methods).toEqual(['worktree.list']) + }) + + it('still fences a terminal whose CLI fell back to the platform default profile', () => { + const stamp = { sourceId: SOURCE_A, incarnation: 'runtime-a', profilePath: '/profiles/custom' } + expect(resolveFencedRuntimeSource(stamp, '/profiles/default', '/profiles/default')).toBe(stamp) + expect(resolveFencedRuntimeSource(stamp, '/profiles/custom', '/profiles/default')).toBe(stamp) + expect(resolveFencedRuntimeSource(stamp, '/profiles/dev', '/profiles/default')).toBeNull() + }) +}) diff --git a/src/cli/runtime/runtime-source-fence.ts b/src/cli/runtime/runtime-source-fence.ts new file mode 100644 index 00000000000..1057a018f5e --- /dev/null +++ b/src/cli/runtime/runtime-source-fence.ts @@ -0,0 +1,87 @@ +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { isSameUserDataPath } from '../../shared/serve-user-data-path' +import { readRuntimeSourceStamp, type RuntimeSourceStamp } from '../../shared/runtime-source-env' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { getPlatformUserDataPath } from './metadata' +import { RuntimeClientError, type RuntimeRpcResponse } from './types' + +type StatusProbe = (metadata: RuntimeMetadata) => Promise> + +/** + * The stamp to enforce for a CLI dialing `userDataPath`. Explicitly choosing another profile (e.g. + * `orca-dev` from a packaged Orca's terminal) is the user's target, not a fallback, so it is not fenced. + */ +export function resolveFencedRuntimeSource( + stamp: RuntimeSourceStamp | null, + userDataPath: string, + platformDefaultPath: string | null +): RuntimeSourceStamp | null { + if (!stamp?.profilePath || isSameUserDataPath(userDataPath, stamp.profilePath)) { + return stamp + } + // Why: the platform default is what an unpinned terminal falls back to, which is the case to fence. + return platformDefaultPath !== null && isSameUserDataPath(userDataPath, platformDefaultPath) + ? stamp + : null +} + +/** + * Refuses, before the request is sent, a runtime other than the one that launched this process. + * A new incarnation of the same source (restart, update) is accepted so surviving panes keep working. + */ +export class RuntimeSourceFence { + private readonly verifiedRuntimeIds = new Set() + + constructor(private readonly expected: RuntimeSourceStamp | null) { + if (expected) { + this.verifiedRuntimeIds.add(expected.incarnation) + } + } + + async check(metadata: RuntimeMetadata, method: string, probe: StatusProbe): Promise { + // Why status.get is exempt: it is the read-only probe itself. + if (!this.expected || method === 'status.get') { + return + } + if (this.verifiedRuntimeIds.has(metadata.runtimeId)) { + return + } + // Why trust the answer: the transport rejects a response from any runtime but metadata's. + const response = await probe(metadata) + if (response.ok === false) { + throw new RuntimeClientError( + 'runtime_source_unverifiable', + `Could not confirm that the running Orca is the one that launched this terminal: ${response.error.message}` + ) + } + const sourceId = response.result.hostDescriptor?.installationId + if (!sourceId) { + throw new RuntimeClientError( + 'runtime_source_unverifiable', + 'The running Orca does not report which profile it serves, so this terminal cannot confirm it is the Orca that launched it. Nothing was sent. Open a new terminal from the Orca you want to control.' + ) + } + if (sourceId !== this.expected.sourceId) { + throw new RuntimeClientError( + 'runtime_source_mismatch', + 'The running Orca is not the one that launched this terminal (another profile or instance took over its runtime record). Nothing was sent. Open a new terminal from the Orca you want to control.' + ) + } + this.verifiedRuntimeIds.add(metadata.runtimeId) + } +} + +export function createRuntimeSourceFence( + env: Readonly>, + userDataPath: string +): RuntimeSourceFence { + let platformDefaultPath: string | null = null + try { + platformDefaultPath = getPlatformUserDataPath() + } catch { + // Why: no resolvable default (Windows without APPDATA) means nothing can fall back to it. + } + return new RuntimeSourceFence( + resolveFencedRuntimeSource(readRuntimeSourceStamp(env), userDataPath, platformDefaultPath) + ) +} diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 06eedaf8b78..5634928b81f 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -5,6 +5,8 @@ import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-ho import { isSafePtySessionId } from '../../../daemon/pty-session-id' import { isNativeWindowsLocalPtySpawn } from '../../../runtime/terminal-model-query-authority' import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env' +import { getRuntimeSourceStamp } from '../../../runtime/host-descriptor' import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' import { buildPtyHostEnv } from '../host-env/assembly' import { @@ -160,6 +162,10 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null ) + stampRuntimeSourceEnv( + ctx.env, + getRuntimeSourceStamp(ctx.deps.runtime, getAppEnvironment().getPath('userData')) + ) promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) } catch (err) { // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index eeb5fef028c..774ac8d2594 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -11,6 +11,8 @@ import { } from '../../../pty/wsl-orca-env' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import { buildPtyHostEnv } from '../host-env/assembly' +import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env' +import { getRuntimeSourceStamp } from '../../../runtime/host-descriptor' import { getCompatibleSelectedCodexHomePath, shouldStripInheritedOrcaCodexHome @@ -113,6 +115,10 @@ export function configureLocalPtyProvider(args: { runtime?.getOrchestrationCompatibilityHostId?.(), ctx?.isWsl === true ? ctx.wslDistro : null ) + stampRuntimeSourceEnv( + env, + getRuntimeSourceStamp(runtime, getAppEnvironment().getPath('userData')) + ) if (ctx?.isWsl === true) { addOrcaWslInteropEnv(env) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 4c3f274ec7a..0e7112619a1 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -35,6 +35,8 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project- import { resolveManagedSshHostLoginShell } from '../../../pty/managed-ssh-host-login-shell' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env' +import { getRuntimeSourceStamp } from '../../../runtime/host-descriptor' import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' import { clearProviderPtyState } from '../provider/state-cleanup' import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' @@ -284,6 +286,10 @@ export async function prepareRuntimePtySpawn( ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null ) + stampRuntimeSourceEnv( + ctx.env, + getRuntimeSourceStamp(ctx.deps.runtime, getAppEnvironment().getPath('userData')) + ) promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) } catch (error) { // Why: host-env setup can materialize agent hooks/extensions before failing. diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index fb30cd3aba5..5047dd82027 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -37,6 +37,7 @@ import { changedAiVaultSearchSettings, type AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' +import { stampRuntimeSourceEnv } from '../../shared/runtime-source-env' export { parseArgs } @@ -133,6 +134,8 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() + // Why: an orcad started from an Orca terminal must not fence its own CLI children to that Orca. + stampRuntimeSourceEnv(process.env, null) const { readiness, instance, stop } = await startOrcadWithHost( resolveUserDataPath(), (registerCleanup) => startOrcadRuntime(options, registerCleanup), diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index a108a1ae55f..2616597ef9c 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -81,7 +81,10 @@ describe('addOrcaWslInteropEnv', () => { ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', - ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu', + ORCA_RUNTIME_SOURCE_ID: '11111111-1111-4111-8111-111111111111', + ORCA_RUNTIME_SOURCE_INCARNATION: 'runtime-a', + ORCA_RUNTIME_SOURCE_PROFILE_PATH: 'C:\\Users\\a\\AppData\\Roaming\\orca' } addOrcaWslInteropEnv(env) @@ -109,6 +112,9 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u') + expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_ID/u') + expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_INCARNATION/u') + expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_PROFILE_PATH/u') }) it('overwrites caller host evidence with native runtime WSL authority', () => { diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 0044399009f..80b42bf3da7 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -10,6 +10,11 @@ import { } from '../../shared/setup-agent-sequencing' import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' import { ORCA_IMAGE_PROTOCOL_ENV } from '../../shared/terminal-image-protocol' +import { + RUNTIME_SOURCE_ID_ENV, + RUNTIME_SOURCE_INCARNATION_ENV, + RUNTIME_SOURCE_PROFILE_PATH_ENV +} from '../../shared/runtime-source-env' const WSLENV_ENTRY_SEPARATOR = ':' @@ -95,6 +100,10 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u', 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u', 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u', + `${RUNTIME_SOURCE_ID_ENV}/u`, + `${RUNTIME_SOURCE_INCARNATION_ENV}/u`, + // Why /u: compared with the Windows CLI's own userData path, never read as a guest path. + `${RUNTIME_SOURCE_PROFILE_PATH_ENV}/u`, 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_AGENT_HOOK_ENV/u', diff --git a/src/main/runtime/host-descriptor.test.ts b/src/main/runtime/host-descriptor.test.ts index 43adeff1bfd..b4f7e4ac42b 100644 --- a/src/main/runtime/host-descriptor.test.ts +++ b/src/main/runtime/host-descriptor.test.ts @@ -7,7 +7,9 @@ import { HOST_INSTALLATION_FILENAME, computeHostMachineBinding, loadHostDescriptor, - loadOrCreateHostInstallationId + getRuntimeSourceStamp, + loadOrCreateHostInstallationId, + publishHostDescriptor } from './host-descriptor' function profile(): string { @@ -96,3 +98,26 @@ describe('loadHostDescriptor', () => { warn.mockRestore() }) }) + +describe('getRuntimeSourceStamp', () => { + it('names the profile as the source and the runtime id as the incarnation', () => { + const installationId = '0b4c3d5e-1f2a-4b6c-8d7e-9f0a1b2c3d4e' + publishHostDescriptor('runtime-stamp-1', { installationId }) + try { + expect( + getRuntimeSourceStamp({ getRuntimeId: () => 'runtime-stamp-1' }, '/profiles/a') + ).toEqual({ + sourceId: installationId, + incarnation: 'runtime-stamp-1', + profilePath: '/profiles/a' + }) + } finally { + publishHostDescriptor('runtime-stamp-1', null) + } + }) + + it('stamps nothing before a descriptor is published or without a runtime', () => { + expect(getRuntimeSourceStamp({ getRuntimeId: () => 'runtime-unpublished' })).toBeNull() + expect(getRuntimeSourceStamp(undefined)).toBeNull() + }) +}) diff --git a/src/main/runtime/host-descriptor.ts b/src/main/runtime/host-descriptor.ts index 6ae80441246..73cf5e7284f 100644 --- a/src/main/runtime/host-descriptor.ts +++ b/src/main/runtime/host-descriptor.ts @@ -7,6 +7,7 @@ import { } from '../../shared/runtime-host-descriptor' import { profileStateAccessMachineIdentity } from '../persistence/profile-state/profile-state-access-identity' import { withFileTransactionLock } from '../file-transaction-lock' +import type { RuntimeSourceStamp } from '../../shared/runtime-source-env' export const HOST_INSTALLATION_FILENAME = 'host-installation.json' const MAX_INSTALLATION_FILE_BYTES = 4 * 1024 @@ -119,3 +120,19 @@ export function publishHostDescriptor( export function getPublishedHostDescriptor(runtimeId: string): RuntimeHostDescriptor | null { return publishedByRuntimeId.get(runtimeId) ?? null } + +/** Null until this runtime publishes a descriptor; unstamped processes keep the unfenced CLI path. */ +export function getRuntimeSourceStamp( + runtime: { getRuntimeId?: () => string } | null | undefined, + userDataPath?: string +): RuntimeSourceStamp | null { + const runtimeId = runtime?.getRuntimeId?.() + const descriptor = runtimeId ? getPublishedHostDescriptor(runtimeId) : null + return runtimeId && descriptor + ? { + sourceId: descriptor.installationId, + incarnation: runtimeId, + ...(userDataPath ? { profilePath: userDataPath } : {}) + } + : null +} 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 fe0c8a4260d..8ef28fd18e8 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -132,6 +132,35 @@ describe('buildHostCliEnv', () => { expect(env.ORCA_CLI_COMMAND).toBe('orca') }) + it('stamps this runtime as the source and ignores remote or inherited stamps', () => { + const remoteSource = { + ORCA_RUNTIME_SOURCE_ID: 'remote-chosen-source', + ORCA_RUNTIME_SOURCE_INCARNATION: 'remote-chosen-runtime' + } + const build = (runtimeSource?: { sourceId: string; incarnation: string } | null) => + buildHostCliEnv({ + bridgeCredential: 'sshb_test', + hostEnv: { + ORCA_RUNTIME_SOURCE_ID: 'parent-source', + ORCA_RUNTIME_SOURCE_INCARNATION: 'parent-runtime' + }, + remoteEnv: remoteSource, + userDataPath: '/host/user-data', + remoteCwd: '/srv/repo', + runtimeSource + }) + + expect(build({ sourceId: 'this-source', incarnation: 'this-runtime' })).toMatchObject({ + ORCA_RUNTIME_SOURCE_ID: 'this-source', + ORCA_RUNTIME_SOURCE_INCARNATION: 'this-runtime', + ORCA_RUNTIME_SOURCE_PROFILE_PATH: '/host/user-data', + ORCA_USER_DATA_PATH: '/host/user-data' + }) + const unstamped = build(null) + expect(unstamped.ORCA_RUNTIME_SOURCE_ID).toBeUndefined() + expect(unstamped.ORCA_RUNTIME_SOURCE_INCARNATION).toBeUndefined() + }) + 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. diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 9c15287d166..5e5eb59e2ab 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -16,6 +16,7 @@ import { import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' +import { stampRuntimeSourceEnv, type RuntimeSourceStamp } from '../../shared/runtime-source-env' import { sshBridgeCredentials, type SshBridgeCallerScope, @@ -75,6 +76,8 @@ export type HostCliPassthroughOptions = { entryExists?: (path: string) => boolean killTimeoutMs?: number credentials?: SshBridgeCredentialRegistry + /** The runtime this bridge belongs to; the host CLI must reach exactly that one. */ + runtimeSource?: RuntimeSourceStamp | null } /** Thrown when the host CLI entry cannot be launched at all; callers fall back @@ -115,6 +118,7 @@ export function buildHostCliEnv(args: { bridgeCredential: string runtimeAuthority?: SshCliRuntimeAuthority artifactInput?: RemoteArtifactInput + runtimeSource?: RuntimeSourceStamp | null }): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...args.hostEnv } for (const key of REMOTE_CONTEXT_ENV_VARS) { @@ -152,6 +156,12 @@ export function buildHostCliEnv(args: { delete env.ORCA_REMOTE_PAIRING delete env.ORCA_ENVIRONMENT env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] = args.bridgeCredential + // Why: stamped from this process, never from the remote env, so remote input cannot pick the runtime. + // The host CLI dials exactly this profile, so the stamp names the same one. + stampRuntimeSourceEnv( + env, + args.runtimeSource ? { ...args.runtimeSource, profilePath: args.userDataPath } : null + ) if (args.runtimeAuthority) { env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh' env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId @@ -219,7 +229,8 @@ export async function runHostOrcaCliPassthrough( remoteCwd: request.cwd, bridgeCredential: credential.token, runtimeAuthority: request.runtimeAuthority, - artifactInput: request.artifactInput + artifactInput: request.artifactInput, + runtimeSource: options.runtimeSource }) try { diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 74318cd7182..721df7c0a2d 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -8,6 +8,7 @@ import type { RpcResponse } from '../runtime/rpc/core' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { getRuntimeSourceStamp } from '../runtime/host-descriptor' import { HostCliUnavailableError, runHostOrcaCliPassthrough, @@ -84,7 +85,10 @@ export async function runRemoteOrcaCli( let passthroughFailure: HostCliUnavailableError | null = null try { - return await runHostOrcaCliPassthrough(request, passthroughOptions) + return await runHostOrcaCliPassthrough(request, { + runtimeSource: getRuntimeSourceStamp(runtime), + ...passthroughOptions + }) } catch (err) { if (!(err instanceof HostCliUnavailableError)) { throw err diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 70747c926f4..c0d8123f3a7 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -393,6 +393,25 @@ describe('configureOrcaUserDataPathEnv', () => { expect(configuredUserDataPath).toBe('/tmp/current-orca-user-data') }) + + it('drops a runtime source inherited from the Orca terminal that started this Orca', async () => { + const { app } = await import('electron') + const { configureOrcaUserDataPathEnv } = await import('./configure-process') + const saved = { ...process.env } + process.env.ORCA_RUNTIME_SOURCE_ID = 'parent-source' + process.env.ORCA_RUNTIME_SOURCE_INCARNATION = 'parent-runtime' + app.setPath('userData', '/tmp/current-orca-user-data') + let inherited: (string | undefined)[] = [] + + try { + configureOrcaUserDataPathEnv() + inherited = [process.env.ORCA_RUNTIME_SOURCE_ID, process.env.ORCA_RUNTIME_SOURCE_INCARNATION] + } finally { + process.env = saved + } + + expect(inherited).toEqual([undefined, undefined]) + }) }) describe('shouldInstallManagedHooks', () => { diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index e7f3385ef04..3cb41bcff98 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -8,6 +8,7 @@ import { getMainE2EConfig } from '../e2e-config' import { DISABLED_CHROMIUM_FEATURES } from './disabled-chromium-features' import { readHttp1CompatibilityMarker } from './http1-compatibility-marker' import { checkServeUserDataPath } from './serve-user-data-path-guard' +import { stampRuntimeSourceEnv } from '../../shared/runtime-source-env' import { hasMissingProfileStateDatabaseWithRetainedAuthority, readActiveProfileId, @@ -243,6 +244,8 @@ export function configureOrcaUserDataPathEnv(): void { } // Why: relaunches can inherit a stale ORCA_USER_DATA_PATH; canonicalize before CLI-shared modules build runtime-home paths. process.env.ORCA_USER_DATA_PATH = userDataPath + // Why: an Orca started from another Orca's terminal must not fence its own CLI children to the parent. + stampRuntimeSourceEnv(process.env, null) } export function shouldInstallManagedHooks(isDev: boolean): boolean { diff --git a/src/shared/runtime-source-env.test.ts b/src/shared/runtime-source-env.test.ts new file mode 100644 index 00000000000..435b33072b6 --- /dev/null +++ b/src/shared/runtime-source-env.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { readRuntimeSourceStamp, stampRuntimeSourceEnv } from './runtime-source-env' + +describe('runtime source env', () => { + it('round-trips a stamp and replaces an inherited one', () => { + const env: Record = { + ORCA_RUNTIME_SOURCE_ID: 'parent', + ORCA_RUNTIME_SOURCE_INCARNATION: 'parent-runtime' + } + stampRuntimeSourceEnv(env, { sourceId: 'child', incarnation: 'child-runtime' }) + expect(readRuntimeSourceStamp(env)).toEqual({ sourceId: 'child', incarnation: 'child-runtime' }) + }) + + it('clears an inherited stamp when this runtime has none', () => { + const env: Record = { + ORCA_RUNTIME_SOURCE_ID: 'parent', + ORCA_RUNTIME_SOURCE_INCARNATION: 'parent-runtime' + } + stampRuntimeSourceEnv(env, null) + expect(env).toEqual({}) + }) + + it('treats a partial or oversized stamp as no stamp', () => { + expect(readRuntimeSourceStamp({ ORCA_RUNTIME_SOURCE_ID: 'only-id' })).toBeNull() + expect( + readRuntimeSourceStamp({ + ORCA_RUNTIME_SOURCE_ID: 'x'.repeat(4_097), + ORCA_RUNTIME_SOURCE_INCARNATION: 'runtime' + }) + ).toBeNull() + }) +}) diff --git a/src/shared/runtime-source-env.ts b/src/shared/runtime-source-env.ts new file mode 100644 index 00000000000..b58bbf68849 --- /dev/null +++ b/src/shared/runtime-source-env.ts @@ -0,0 +1,50 @@ +// Which Orca runtime launched a process. Kept separate from the orchestration compatibility stamp, +// whose WSL/SSH union carries caller attestation and must stay untouched. +export const RUNTIME_SOURCE_ID_ENV = 'ORCA_RUNTIME_SOURCE_ID' +export const RUNTIME_SOURCE_INCARNATION_ENV = 'ORCA_RUNTIME_SOURCE_INCARNATION' +export const RUNTIME_SOURCE_PROFILE_PATH_ENV = 'ORCA_RUNTIME_SOURCE_PROFILE_PATH' + +export type RuntimeSourceStamp = { + /** The launching profile's installation id; survives restarts. */ + sourceId: string + /** The launching runtime's per-process id; only a hint that skips the probe. */ + incarnation: string + /** The launching runtime's userData path, so an explicit switch to another profile is not refused. */ + profilePath?: string +} + +const MAX_SOURCE_FIELD_LENGTH = 4_096 + +function boundedValue(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed && trimmed.length <= MAX_SOURCE_FIELD_LENGTH ? trimmed : undefined +} + +export function readRuntimeSourceStamp( + env: Readonly> +): RuntimeSourceStamp | null { + const sourceId = boundedValue(env[RUNTIME_SOURCE_ID_ENV]) + const incarnation = boundedValue(env[RUNTIME_SOURCE_INCARNATION_ENV]) + const profilePath = boundedValue(env[RUNTIME_SOURCE_PROFILE_PATH_ENV]) + if (!sourceId || !incarnation) { + return null + } + return profilePath ? { sourceId, incarnation, profilePath } : { sourceId, incarnation } +} + +/** Replaces any inherited stamp, so a nested Orca never passes on its parent's source. */ +export function stampRuntimeSourceEnv( + env: Record, + stamp: RuntimeSourceStamp | null +): void { + delete env[RUNTIME_SOURCE_ID_ENV] + delete env[RUNTIME_SOURCE_INCARNATION_ENV] + delete env[RUNTIME_SOURCE_PROFILE_PATH_ENV] + if (stamp) { + env[RUNTIME_SOURCE_ID_ENV] = stamp.sourceId + env[RUNTIME_SOURCE_INCARNATION_ENV] = stamp.incarnation + if (stamp.profilePath) { + env[RUNTIME_SOURCE_PROFILE_PATH_ENV] = stamp.profilePath + } + } +}