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:
OrcaWin
2026-10-10 03:50:26 -07:00
committed by GitHub
co-authored by m4air
parent e0376015a4
commit fff0bb0d06
11 changed files with 431 additions and 18 deletions
+10 -5
View File
@@ -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)
}
+12 -3
View File
@@ -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 () => {
+20 -7
View File
@@ -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)
)
}
+9 -3
View File
@@ -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`
)
})
+2
View File
@@ -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
}
+4
View File
@@ -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
+8
View File
@@ -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)
})
})