diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index fe25b3ef4fd..40f8affd476 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -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(target, 'status.get', undefined, this.requestTimeoutMs) - ) + const expectedRuntimeSource = await this.runtimeSourceFence.check(metadata, method) let response try { - response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope) + response = await sendRequest( + metadata, + method, + params, + effectiveTimeoutMs, + envelope, + expectedRuntimeSource + ) } catch (error) { throw recover(error, metadata.runtimeId ?? null) } diff --git a/src/cli/runtime/runtime-source-fence.test.ts b/src/cli/runtime/runtime-source-fence.test.ts index 828588007f6..91db5aeb870 100644 --- a/src/cli/runtime/runtime-source-fence.test.ts +++ b/src/cli/runtime/runtime-source-fence.test.ts @@ -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 () => { diff --git a/src/cli/runtime/runtime-source-fence.ts b/src/cli/runtime/runtime-source-fence.ts index 1057a018f5e..0210da945d7 100644 --- a/src/cli/runtime/runtime-source-fence.ts +++ b/src/cli/runtime/runtime-source-fence.ts @@ -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> @@ -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() - 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 { + async check( + metadata: RuntimeMetadata, + method: string + ): Promise { // 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>, - 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(metadata, 'status.get', undefined, probeTimeoutMs) ) } diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index f17eb7db92e..fc38e500bd9 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -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( method: string, params: unknown, timeoutMs: number, - envelope?: RuntimeOrchestrationEnvelope + envelope?: RuntimeOrchestrationEnvelope, + // Why separate from the envelope: federation forwards envelopes to other runtimes. + expectedRuntimeSource?: ExpectedRuntimeSource ): Promise> { if (!isSafeTimerDelayMs(timeoutMs)) { throw new RuntimeClientError( @@ -205,7 +210,8 @@ export async function sendRequest( orchestrationContractVersion: envelope?.orchestrationContractVersion, orchestrationRequestId: envelope?.orchestrationRequestId, compatibilityInvocationId: envelope?.compatibilityInvocationId, - orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence + orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence, + expectedRuntimeSource })}\n` ) }) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 88b6b5220a7..85ab2c94f5e 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -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<{ diff --git a/src/main/runtime/rpc/rpc-request-admission-prologue.ts b/src/main/runtime/rpc/rpc-request-admission-prologue.ts index 90369e56b5a..9d2f7b562c5 100644 --- a/src/main/runtime/rpc/rpc-request-admission-prologue.ts +++ b/src/main/runtime/rpc/rpc-request-admission-prologue.ts @@ -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( 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) diff --git a/src/main/runtime/rpc/runtime-source-admission.test.ts b/src/main/runtime/rpc/runtime-source-admission.test.ts new file mode 100644 index 00000000000..1cfaca8ffdf --- /dev/null +++ b/src/main/runtime/rpc/runtime-source-admission.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/rpc/runtime-source-admission.ts b/src/main/runtime/rpc/runtime-source-admission.ts new file mode 100644 index 00000000000..f9c417ca112 --- /dev/null +++ b/src/main/runtime/rpc/runtime-source-admission.ts @@ -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 = 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 +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ee05b227cfb..ad5bce4aed9 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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 diff --git a/src/shared/runtime-rpc-envelope.ts b/src/shared/runtime-rpc-envelope.ts index d407de72cb9..8e9f8884785 100644 --- a/src/shared/runtime-rpc-envelope.ts +++ b/src/shared/runtime-rpc-envelope.ts @@ -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 export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame { diff --git a/tests/e2e/cross-version-wire/runtime-source-fence-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/runtime-source-fence-downgrade.unit.test.ts new file mode 100644 index 00000000000..44e1bb55121 --- /dev/null +++ b/tests/e2e/cross-version-wire/runtime-source-fence-downgrade.unit.test.ts @@ -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 } +type HostBuild = (effect: () => unknown) => Dispatcher + +function member(module: Record, 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, + core: Record +): HostBuild { + const RpcDispatcher = member< + new (options: { runtime: unknown; methods: unknown[] }) => Dispatcher + >(dispatcher, 'RpcDispatcher') + const defineMethod = member<(declaration: Record) => 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 { + 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((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((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) + }) +})