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 + } + } +}