mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(runtime): the host refuses a request meant for another runtime (#27224)
A CLI launched from an Orca terminal now sends, with each request, the runtime it verified before sending (expectedRuntimeSource: the launching profile's installation id plus the verified runtime id). The host checks it before parsing params or running any handler and refuses a mismatch with runtime_source_mismatch, so a runtime that took over the endpoint between the CLI's check and its send cannot apply the request. Optional field: an old CLI sends none and an old host ignores it (the CLI's pre-send probe stays the fence there). Hosts advertise runtime-source-fence.v1. Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -77,7 +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)
|
||||
this.runtimeSourceFence = createRuntimeSourceFence(process.env, userDataPath, requestTimeoutMs)
|
||||
}
|
||||
|
||||
get isRemote(): boolean {
|
||||
@@ -166,12 +166,17 @@ export class RuntimeClient {
|
||||
}
|
||||
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)
|
||||
)
|
||||
const expectedRuntimeSource = await this.runtimeSourceFence.check(metadata, method)
|
||||
let response
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
response = await sendRequest<TResult>(
|
||||
metadata,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
envelope,
|
||||
expectedRuntimeSource
|
||||
)
|
||||
} catch (error) {
|
||||
throw recover(error, metadata.runtimeId ?? null)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ afterEach(async () => {
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
type FakeRuntime = { userDataPath: string; methods: string[] }
|
||||
type FakeRuntime = { userDataPath: string; methods: string[]; expectations: unknown[] }
|
||||
|
||||
/** A live runtime on its own socket that answers every method and records what reached it. */
|
||||
async function startRuntime(args: {
|
||||
@@ -53,6 +53,7 @@ async function startRuntime(args: {
|
||||
? `\\\\.\\pipe\\orca-source-fence-${randomUUID()}`
|
||||
: join(userDataPath, 'runtime.sock')
|
||||
const methods: string[] = []
|
||||
const expectations: unknown[] = []
|
||||
const server = createServer((socket) => {
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
@@ -62,8 +63,11 @@ async function startRuntime(args: {
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
const request: { id: string; method: string } = JSON.parse(buffer.slice(0, newline))
|
||||
const request: { id: string; method: string; expectedRuntimeSource?: unknown } = JSON.parse(
|
||||
buffer.slice(0, newline)
|
||||
)
|
||||
methods.push(request.method)
|
||||
expectations.push(request.expectedRuntimeSource)
|
||||
const result =
|
||||
request.method === 'status.get'
|
||||
? {
|
||||
@@ -90,7 +94,7 @@ async function startRuntime(args: {
|
||||
startedAt: 1
|
||||
})
|
||||
)
|
||||
return { userDataPath, methods }
|
||||
return { userDataPath, methods, expectations }
|
||||
}
|
||||
|
||||
function launchedBy(sourceId: string, incarnation: string, profilePath?: string): void {
|
||||
@@ -130,6 +134,9 @@ describe('RuntimeClient runtime-source fence', () => {
|
||||
await client.call('terminal.send', { terminal: 't1', text: 'hi' })
|
||||
await client.call('worktree.list')
|
||||
expect(runtime.methods).toEqual(['status.get', 'terminal.send', 'worktree.list'])
|
||||
// Why: the host refuses the send itself if it is no longer the runtime the probe verified.
|
||||
const expected = { sourceId: SOURCE_A, runtimeId: 'runtime-a2' }
|
||||
expect(runtime.expectations).toEqual([undefined, expected, expected])
|
||||
})
|
||||
|
||||
it('sends without a probe when the launching runtime is still the live one', async () => {
|
||||
@@ -139,6 +146,7 @@ describe('RuntimeClient runtime-source fence', () => {
|
||||
|
||||
await client.call('worktree.list')
|
||||
expect(runtime.methods).toEqual(['worktree.list'])
|
||||
expect(runtime.expectations).toEqual([{ sourceId: SOURCE_A, runtimeId: 'runtime-a' }])
|
||||
})
|
||||
|
||||
it('refuses when the live runtime cannot say which profile it serves', async () => {
|
||||
@@ -158,6 +166,7 @@ describe('RuntimeClient runtime-source fence', () => {
|
||||
|
||||
await client.call('worktree.list')
|
||||
expect(runtime.methods).toEqual(['worktree.list'])
|
||||
expect(runtime.expectations).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('lets an explicit switch to another profile through, e.g. orca-dev from a packaged terminal', async () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { ExpectedRuntimeSource } from '../../shared/runtime-rpc-envelope'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { getPlatformUserDataPath } from './metadata'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
|
||||
|
||||
type StatusProbe = (metadata: RuntimeMetadata) => Promise<RuntimeRpcResponse<RuntimeStatus>>
|
||||
@@ -28,26 +30,34 @@ export function resolveFencedRuntimeSource(
|
||||
/**
|
||||
* 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.
|
||||
* Returns what the request must carry so the host itself refuses it if it is not that runtime.
|
||||
*/
|
||||
export class RuntimeSourceFence {
|
||||
private readonly verifiedRuntimeIds = new Set<string>()
|
||||
|
||||
constructor(private readonly expected: RuntimeSourceStamp | null) {
|
||||
constructor(
|
||||
private readonly expected: RuntimeSourceStamp | null,
|
||||
private readonly probe: StatusProbe
|
||||
) {
|
||||
if (expected) {
|
||||
this.verifiedRuntimeIds.add(expected.incarnation)
|
||||
}
|
||||
}
|
||||
|
||||
async check(metadata: RuntimeMetadata, method: string, probe: StatusProbe): Promise<void> {
|
||||
async check(
|
||||
metadata: RuntimeMetadata,
|
||||
method: string
|
||||
): Promise<ExpectedRuntimeSource | undefined> {
|
||||
// Why status.get is exempt: it is the read-only probe itself.
|
||||
if (!this.expected || method === 'status.get') {
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
const expectation = { sourceId: this.expected.sourceId, runtimeId: metadata.runtimeId }
|
||||
if (this.verifiedRuntimeIds.has(metadata.runtimeId)) {
|
||||
return
|
||||
return expectation
|
||||
}
|
||||
// Why trust the answer: the transport rejects a response from any runtime but metadata's.
|
||||
const response = await probe(metadata)
|
||||
const response = await this.probe(metadata)
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_source_unverifiable',
|
||||
@@ -68,12 +78,14 @@ export class RuntimeSourceFence {
|
||||
)
|
||||
}
|
||||
this.verifiedRuntimeIds.add(metadata.runtimeId)
|
||||
return expectation
|
||||
}
|
||||
}
|
||||
|
||||
export function createRuntimeSourceFence(
|
||||
env: Readonly<Record<string, string | undefined>>,
|
||||
userDataPath: string
|
||||
userDataPath: string,
|
||||
probeTimeoutMs: number
|
||||
): RuntimeSourceFence {
|
||||
let platformDefaultPath: string | null = null
|
||||
try {
|
||||
@@ -82,6 +94,7 @@ export function createRuntimeSourceFence(
|
||||
// Why: no resolvable default (Windows without APPDATA) means nothing can fall back to it.
|
||||
}
|
||||
return new RuntimeSourceFence(
|
||||
resolveFencedRuntimeSource(readRuntimeSourceStamp(env), userDataPath, platformDefaultPath)
|
||||
resolveFencedRuntimeSource(readRuntimeSourceStamp(env), userDataPath, platformDefaultPath),
|
||||
(metadata) => sendRequest<RuntimeStatus>(metadata, 'status.get', undefined, probeTimeoutMs)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { readSshBridgeCredential } from '../../shared/ssh-bridge-credential-env'
|
||||
import { createConnection } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import type {
|
||||
ExpectedRuntimeSource,
|
||||
RuntimeOrchestrationEnvelope
|
||||
} from '../../shared/runtime-rpc-envelope'
|
||||
import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema'
|
||||
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
|
||||
import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay'
|
||||
@@ -13,7 +16,9 @@ export async function sendRequest<TResult>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
// Why separate from the envelope: federation forwards envelopes to other runtimes.
|
||||
expectedRuntimeSource?: ExpectedRuntimeSource
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
if (!isSafeTimerDelayMs(timeoutMs)) {
|
||||
throw new RuntimeClientError(
|
||||
@@ -205,7 +210,8 @@ export async function sendRequest<TResult>(
|
||||
orchestrationContractVersion: envelope?.orchestrationContractVersion,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId,
|
||||
compatibilityInvocationId: envelope?.compatibilityInvocationId,
|
||||
orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence
|
||||
orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence,
|
||||
expectedRuntimeSource
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,6 +53,8 @@ export type RpcRequest = {
|
||||
orchestrationRequestId?: string
|
||||
compatibilityInvocationId?: string
|
||||
orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence
|
||||
// Why unknown: untrusted wire input; runtime-source-admission.ts validates it.
|
||||
expectedRuntimeSource?: unknown
|
||||
}
|
||||
|
||||
export type LegacyCoordinatorAuthorityProof = Readonly<{
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { errorResponse } from './errors'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { orchestrationMigrationFence } from './orchestration-contract-fence'
|
||||
import { runtimeSourceFence } from './runtime-source-admission'
|
||||
import type { RpcDispatchStreamingOptions } from './dispatcher-stream-options'
|
||||
import { mapDispatcherError } from './dispatcher-error-response'
|
||||
import { parseRpcRequestParams } from './dispatcher-request-parsing'
|
||||
@@ -51,6 +52,10 @@ export function admitRpcRequest<M extends RpcMethod | RpcStreamingMethod>(
|
||||
errorResponse(rawRequest.id, meta, 'method_not_found', `Unknown method: ${rawRequest.method}`)
|
||||
)
|
||||
}
|
||||
const sourceFence = runtimeSourceFence(rawRequest, meta)
|
||||
if (sourceFence) {
|
||||
return reject(sourceFence)
|
||||
}
|
||||
const migrationFence = orchestrationMigrationFence(rawRequest, meta)
|
||||
if (migrationFence) {
|
||||
return reject(migrationFence)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import './unused-default-rpc-methods.test-fixture'
|
||||
import { z } from 'zod'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { publishHostDescriptor } from '../host-descriptor'
|
||||
import { defineMethod, type RpcRequest } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
|
||||
const SOURCE_A = '11111111-1111-4111-8111-111111111111'
|
||||
const SOURCE_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
describe('runtime source admission', () => {
|
||||
const published: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const runtimeId of published.splice(0)) {
|
||||
publishHostDescriptor(runtimeId, null)
|
||||
}
|
||||
})
|
||||
|
||||
function createHarness(installationId: string | null = SOURCE_A) {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const runtimeId = runtime.getRuntimeId()
|
||||
if (installationId) {
|
||||
publishHostDescriptor(runtimeId, { installationId })
|
||||
published.push(runtimeId)
|
||||
}
|
||||
const effect = vi.fn(() => ({ sent: true }))
|
||||
const dispatcher = new RpcDispatcher({
|
||||
runtime,
|
||||
methods: [
|
||||
defineMethod({
|
||||
name: 'terminal.send',
|
||||
permission: 'workspace',
|
||||
params: z.object({ text: z.string() }),
|
||||
handler: effect
|
||||
})
|
||||
]
|
||||
})
|
||||
const request = (expectedRuntimeSource?: unknown): RpcRequest => ({
|
||||
id: 'rpc_1',
|
||||
authToken: 'token',
|
||||
method: 'terminal.send',
|
||||
params: { text: 'hi' },
|
||||
...(expectedRuntimeSource === undefined ? {} : { expectedRuntimeSource })
|
||||
})
|
||||
return { runtimeId, dispatcher, effect, request }
|
||||
}
|
||||
|
||||
it('refuses a request meant for another profile before the handler runs', async () => {
|
||||
const { runtimeId, dispatcher, effect, request } = createHarness()
|
||||
|
||||
const response = await dispatcher.dispatch(request({ sourceId: SOURCE_B, runtimeId }))
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_source_mismatch' } })
|
||||
expect(effect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a request verified against another runtime of the same profile', async () => {
|
||||
const { dispatcher, effect, request } = createHarness()
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
request({ sourceId: SOURCE_A, runtimeId: 'runtime-other' })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_source_mismatch' } })
|
||||
expect(effect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs a request meant for this runtime', async () => {
|
||||
const { runtimeId, dispatcher, effect, request } = createHarness()
|
||||
|
||||
const response = await dispatcher.dispatch(request({ sourceId: SOURCE_A, runtimeId }))
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { sent: true } })
|
||||
expect(effect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps today’s behaviour for a client that sends no expectation (old CLI)', async () => {
|
||||
const { dispatcher, effect, request } = createHarness(null)
|
||||
|
||||
const response = await dispatcher.dispatch(request())
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(effect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refuses when this runtime cannot say which profile it serves', async () => {
|
||||
const { runtimeId, dispatcher, effect, request } = createHarness(null)
|
||||
|
||||
const response = await dispatcher.dispatch(request({ sourceId: SOURCE_A, runtimeId }))
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_source_unverifiable' } })
|
||||
expect(effect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([null, 'source', { sourceId: SOURCE_A }, { sourceId: '', runtimeId: 'r' }])(
|
||||
'refuses a malformed expectation %j',
|
||||
async (expectation) => {
|
||||
const { dispatcher, effect, request } = createHarness()
|
||||
|
||||
const response = await dispatcher.dispatch(request(expectation))
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'bad_request' } })
|
||||
expect(effect).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('fences the streaming transport too', async () => {
|
||||
const { runtimeId, dispatcher, effect, request } = createHarness()
|
||||
const replies: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(request({ sourceId: SOURCE_B, runtimeId }), (reply) =>
|
||||
replies.push(reply)
|
||||
)
|
||||
|
||||
expect(JSON.parse(replies[0])).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'runtime_source_mismatch' }
|
||||
})
|
||||
expect(effect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod'
|
||||
import type { ExpectedRuntimeSource } from '../../../shared/runtime-rpc-envelope'
|
||||
import { getPublishedHostDescriptor } from '../host-descriptor'
|
||||
import type { RpcEnvelopeMeta, RpcRequest, RpcResponse } from './core'
|
||||
import { errorResponse } from './errors'
|
||||
|
||||
const SourceField = z.string().min(1).max(4_096)
|
||||
const ExpectedRuntimeSourceSchema: z.ZodType<ExpectedRuntimeSource> = z.object({
|
||||
sourceId: SourceField,
|
||||
runtimeId: SourceField
|
||||
})
|
||||
|
||||
/** Refuses, before any handler runs, a request whose client expects another runtime. */
|
||||
export function runtimeSourceFence(request: RpcRequest, meta: RpcEnvelopeMeta): RpcResponse | null {
|
||||
if (request.expectedRuntimeSource === undefined) {
|
||||
return null
|
||||
}
|
||||
const parsed = ExpectedRuntimeSourceSchema.safeParse(request.expectedRuntimeSource)
|
||||
if (!parsed.success) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'bad_request',
|
||||
'Malformed expectedRuntimeSource. Nothing was run.'
|
||||
)
|
||||
}
|
||||
const sourceId = getPublishedHostDescriptor(meta.runtimeId)?.installationId
|
||||
if (!sourceId) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'runtime_source_unverifiable',
|
||||
'This Orca does not know which profile it serves, so it cannot confirm it is the Orca that launched this terminal. Nothing was run.'
|
||||
)
|
||||
}
|
||||
if (sourceId !== parsed.data.sourceId || meta.runtimeId !== parsed.data.runtimeId) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'runtime_source_mismatch',
|
||||
'This request was meant for another Orca (another profile or instance). Nothing was run. Open a new terminal from the Orca you want to control.'
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -334,6 +334,9 @@ export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime'
|
||||
// only on the endpoint itself, never on a workspace's SSH host.
|
||||
export const WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY =
|
||||
'workspace-ports.host-scoped.v1' as const
|
||||
// Hosts without this capability ignore a request's expectedRuntimeSource; the CLI's pre-send probe
|
||||
// is then the only fence.
|
||||
export const RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY = 'runtime-source-fence.v1' as const
|
||||
// Hosts without this capability have no notifications.registerPush RPC.
|
||||
export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const
|
||||
|
||||
@@ -442,6 +445,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY,
|
||||
PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY,
|
||||
WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY,
|
||||
RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY,
|
||||
...AGENT_LAUNCH_RUNTIME_CAPABILITIES
|
||||
] as const
|
||||
|
||||
|
||||
@@ -83,6 +83,14 @@ export type RuntimeOrchestrationEnvelope = {
|
||||
orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence
|
||||
}
|
||||
|
||||
/** Which runtime a request is meant for; a host that is not that runtime refuses it before any handler. */
|
||||
export type ExpectedRuntimeSource = {
|
||||
/** The launching profile's installation id. */
|
||||
sourceId: string
|
||||
/** The runtime the client verified for this send. */
|
||||
runtimeId: string
|
||||
}
|
||||
|
||||
export type RuntimeRpcKeepaliveFrame = z.infer<typeof Keepalive>
|
||||
|
||||
export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// The host-side runtime-source fence, paired across two builds over a real socket: current code
|
||||
// against the newest published release's host. Release checkouts carry no CLI sources, and an old
|
||||
// CLI's frame is exactly the current one without the field, so the old client is the current
|
||||
// transport sending none.
|
||||
//
|
||||
// A new CLI adds an optional `expectedRuntimeSource` to its request frame. Each skew must hold:
|
||||
//
|
||||
// - an old CLI sends no expectation, and a new host runs its request as before;
|
||||
// - an old host ignores the field and runs the request (the CLI's pre-send probe is then the only
|
||||
// fence), so a new CLI never breaks against it;
|
||||
// - a new host refuses a request meant for another runtime before its handler runs.
|
||||
|
||||
import { createServer, type Server } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
RUNTIME_CAPABILITIES,
|
||||
RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import { publishHostDescriptor } from '../../../src/main/runtime/host-descriptor'
|
||||
import type { RuntimeMetadata } from '../../../src/shared/runtime-bootstrap'
|
||||
import type { ExpectedRuntimeSource } from '../../../src/shared/runtime-rpc-envelope'
|
||||
import { sendRequest } from '../../../src/cli/runtime/transport'
|
||||
import {
|
||||
importReleaseCheckoutModule,
|
||||
materializeReleaseCheckout,
|
||||
resolveBaselineReleaseRef
|
||||
} from './release-checkout'
|
||||
|
||||
// Why: a cold CI run extracts the baseline checkout before the first pairing.
|
||||
const SUITE_TIMEOUT_MS = 180_000
|
||||
const RUNTIME_ID = 'runtime-a'
|
||||
const SOURCE_A = '11111111-1111-4111-8111-111111111111'
|
||||
const SOURCE_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
type Dispatcher = { dispatch(request: unknown): Promise<unknown> }
|
||||
type HostBuild = (effect: () => unknown) => Dispatcher
|
||||
|
||||
function member<T>(module: Record<string, unknown>, name: string): T {
|
||||
const value = module[name]
|
||||
if (value === undefined) {
|
||||
throw new Error(`the module exports no ${name}`)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a release module is typed unknown; the name is the same export this build calls, and a missing one throws above.
|
||||
return value as T
|
||||
}
|
||||
|
||||
function hostBuildOf(
|
||||
dispatcher: Record<string, unknown>,
|
||||
core: Record<string, unknown>
|
||||
): HostBuild {
|
||||
const RpcDispatcher = member<
|
||||
new (options: { runtime: unknown; methods: unknown[] }) => Dispatcher
|
||||
>(dispatcher, 'RpcDispatcher')
|
||||
const defineMethod = member<(declaration: Record<string, unknown>) => unknown>(
|
||||
core,
|
||||
'defineMethod'
|
||||
)
|
||||
return (effect) =>
|
||||
new RpcDispatcher({
|
||||
runtime: { getRuntimeId: () => RUNTIME_ID },
|
||||
methods: [
|
||||
defineMethod({
|
||||
name: 'terminal.send',
|
||||
permission: 'workspace',
|
||||
params: z.object({ text: z.string() }),
|
||||
handler: effect
|
||||
})
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const servers: Server[] = []
|
||||
|
||||
async function serve(host: Dispatcher): Promise<RuntimeMetadata> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-source-fence-wire-'))
|
||||
const isWindows = process.platform === 'win32'
|
||||
const endpoint = isWindows
|
||||
? `\\\\.\\pipe\\orca-source-fence-wire-${randomUUID()}`
|
||||
: join(dir, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline !== -1) {
|
||||
void host
|
||||
.dispatch(JSON.parse(buffer.slice(0, newline)))
|
||||
.then((response) => socket.end(`${JSON.stringify(response)}\n`))
|
||||
}
|
||||
})
|
||||
})
|
||||
servers.push(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
return {
|
||||
runtimeId: RUNTIME_ID,
|
||||
pid: 1,
|
||||
transports: [{ kind: isWindows ? 'named-pipe' : 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
let releasedHost: HostBuild
|
||||
let currentHost: HostBuild
|
||||
|
||||
beforeAll(async () => {
|
||||
const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef())
|
||||
const load = (path: string) => importReleaseCheckoutModule(checkout, path)
|
||||
const [oldDispatcher, oldCore, newDispatcher, newCore] = await Promise.all([
|
||||
load('src/main/runtime/rpc/dispatcher.ts'),
|
||||
load('src/main/runtime/rpc/core.ts'),
|
||||
import('../../../src/main/runtime/rpc/dispatcher'),
|
||||
import('../../../src/main/runtime/rpc/core')
|
||||
])
|
||||
releasedHost = hostBuildOf(oldDispatcher, oldCore)
|
||||
currentHost = hostBuildOf(newDispatcher, newCore)
|
||||
publishHostDescriptor(RUNTIME_ID, { installationId: SOURCE_A })
|
||||
}, SUITE_TIMEOUT_MS)
|
||||
|
||||
afterAll(async () => {
|
||||
publishHostDescriptor(RUNTIME_ID, null)
|
||||
await Promise.all(
|
||||
servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve())))
|
||||
)
|
||||
})
|
||||
|
||||
async function call(
|
||||
host: HostBuild,
|
||||
expectedRuntimeSource?: ExpectedRuntimeSource
|
||||
): Promise<{ response: { ok: boolean; error?: { code: string } }; ran: number }> {
|
||||
let ran = 0
|
||||
const metadata = await serve(
|
||||
host(() => {
|
||||
ran += 1
|
||||
return { sent: true }
|
||||
})
|
||||
)
|
||||
const response = await sendRequest(
|
||||
metadata,
|
||||
'terminal.send',
|
||||
{ text: 'hi' },
|
||||
5_000,
|
||||
undefined,
|
||||
expectedRuntimeSource
|
||||
)
|
||||
return { response, ran }
|
||||
}
|
||||
|
||||
describe('runtime-source fence across versions', () => {
|
||||
it('pairs two real host builds', () => {
|
||||
expect(releasedHost).not.toBe(currentHost)
|
||||
})
|
||||
|
||||
it('an old CLI sends no expectation, and a new host runs its request', async () => {
|
||||
const { response, ran } = await call(currentHost)
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(ran).toBe(1)
|
||||
})
|
||||
|
||||
it('an old host ignores a new CLI’s expectation rather than refusing', async () => {
|
||||
const { response, ran } = await call(releasedHost, {
|
||||
sourceId: SOURCE_A,
|
||||
runtimeId: RUNTIME_ID
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(ran).toBe(1)
|
||||
})
|
||||
|
||||
it('a new host refuses a new CLI’s request meant for another runtime before running it', async () => {
|
||||
const { response, ran } = await call(currentHost, {
|
||||
sourceId: SOURCE_B,
|
||||
runtimeId: RUNTIME_ID
|
||||
})
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_source_mismatch' } })
|
||||
expect(ran).toBe(0)
|
||||
})
|
||||
|
||||
it('a new host runs a new CLI’s request meant for it and advertises the fence', async () => {
|
||||
const { response, ran } = await call(currentHost, {
|
||||
sourceId: SOURCE_A,
|
||||
runtimeId: RUNTIME_ID
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(ran).toBe(1)
|
||||
expect(RUNTIME_CAPABILITIES).toContain(RUNTIME_SOURCE_FENCE_RUNTIME_CAPABILITY)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user