mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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 <m4air@Mac.localdomain>
This commit is contained in:
@@ -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<RuntimeStatus>(target, 'status.get', undefined, this.requestTimeoutMs)
|
||||
)
|
||||
let response
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
|
||||
@@ -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<Server>()
|
||||
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<void>((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<FakeRuntime> {
|
||||
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<void>((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<unknown>, code: string): Promise<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<RuntimeRpcResponse<RuntimeStatus>>
|
||||
|
||||
/**
|
||||
* 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<string>()
|
||||
|
||||
constructor(private readonly expected: RuntimeSourceStamp | null) {
|
||||
if (expected) {
|
||||
this.verifiedRuntimeIds.add(expected.incarnation)
|
||||
}
|
||||
}
|
||||
|
||||
async check(metadata: RuntimeMetadata, method: string, probe: StatusProbe): Promise<void> {
|
||||
// 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<Record<string, string | undefined>>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<OrcadHandle> {
|
||||
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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, string>): 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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, string | undefined>>
|
||||
): 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<string, string | undefined>,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user