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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user