diff --git a/docs/agents/adr-0019-unit.md b/docs/agents/adr-0019-unit.md index 8a3ae7c6a..09961f820 100644 --- a/docs/agents/adr-0019-unit.md +++ b/docs/agents/adr-0019-unit.md @@ -30,7 +30,7 @@ Each step names its declaration site; read that, not prose. | --- | --- | | Declare the use(s) with the one neutral `defineUse`; required-only by default, a preferred operation needs a recorded measurement in the unit review (§9) | `packages/contracts/src/platform-runtime-operations.ts` | | Plan resolution is pure and lives in contracts (`resolveRuntimePlan`), never in the handler | `packages/contracts/src/*-runtime-plan.ts` | -| Handler: resolve plan → `inspectRequiredRuntimeUse({ device, use: plan.use, inspectFacts })` → on `admitted`, bind **once** with that plan's use → operate. No admission-only binds, no `requireCommandSupported`, no capability bucket. (#1841 replaces this with `admitRuntimePlan({ device, plan, inspectFacts })`, whose returned token names both device and plan and is what the binder requires; this row follows it when it lands) | `src/daemon/handlers/session-runtime-admission.ts`; the `snapshot` route in `src/daemon/snapshot-runtime.ts` + `snapshot-runtime-binding.ts` as the model | +| Handler: resolve plan → `admitRuntimePlan({ device, plan, inspectFacts })` → hand the returned `AdmittedRuntimePlan` to the command's facts-first binder, which unwraps it (`unwrapAdmittedRuntimePlan`) and binds **once**, on the admitted device, with the admitted plan's use → operate. The token is minted only by admission and read back by exact identity, so admit-before-bind — and admit-*this-device*-before-binding-it — is a type and runtime fact, not a gate. No admission-only binds, no `requireCommandSupported`, no capability bucket | `src/daemon/handlers/session-runtime-admission.ts`; the shared snapshot/diff owning interface `resolveBoundSnapshotCaptureRuntime` in `src/daemon/snapshot-runtime-binding.ts` as the model | | Descriptor flips to `platformExecution: { kind: 'device-runtime', use(s) }` in the same PR; the discriminator is exhaustive, so a forgotten descriptor fails typecheck | `src/core/command-descriptor/registry.ts` | | Each platform package reports exact-owner facts and implements the operations; provider ownership fails closed (missing behavior never falls through to a local owner) | `packages/platform-*/src/**`, `packages/provider-*/src/**` | | Add one row to the parametrized cutover table: `legacyRetirement` (what must be gone), `runtimeTypeNames`, `operations`, `singularExecution` with lexical `operationOwners`. The mechanism already carries the planted-red proof; a row that leaves a claim unstated is rejected by `cutoverRowDefects` | `scripts/layering/runtime-command-cutover-table.ts` (rule ids allocate upward; snapshot is R32) | diff --git a/scripts/layering/runtime-command-cutover-policy.test.ts b/scripts/layering/runtime-command-cutover-policy.test.ts index 7e9d9bd2a..6f5d26a94 100644 --- a/scripts/layering/runtime-command-cutover-policy.test.ts +++ b/scripts/layering/runtime-command-cutover-policy.test.ts @@ -22,6 +22,7 @@ test('the parametrized gate goes red on a planted row across every generalized c "import { resolvePlantedBackend } from './planted-legacy.ts';", "requireCommandSupported('planted', device);", 'const widened = runtime as PlantedRuntimeOperations;', + 'const forged = { admitted: true, plan } as AdmittedRuntimePlan;', "function handlePlantedCommand() { widened.operations['plantedDump']({}); }", ].join('\n'), ], @@ -44,6 +45,9 @@ test('the parametrized gate goes red on a planted row across every generalized c 'src/daemon/planted-handler.ts: legacy planted route resolvePlantedBackend', 'src/daemon/planted-handler.ts: legacy planted capability admission requireCommandSupported', 'src/daemon/planted-handler.ts: widened planted runtime type assertion', + // The admission proof is shared across rows: a route that casts its way to an + // AdmittedRuntimePlan has manufactured the facts-first admission the binder requires. + 'src/daemon/planted-handler.ts: widened planted runtime type assertion', 'src/daemon/planted-handler.ts: bracketed planted operation access', 'src/core/command-descriptor/registry.ts: planted descriptor retains legacy capability admission', 'src/core/capabilities.ts: static platform command set retains planted admission', diff --git a/scripts/layering/runtime-command-cutover-policy.ts b/scripts/layering/runtime-command-cutover-policy.ts index eb5e35d45..fe4335413 100644 --- a/scripts/layering/runtime-command-cutover-policy.ts +++ b/scripts/layering/runtime-command-cutover-policy.ts @@ -13,7 +13,11 @@ import { memberName, propertyName, visitAst, type ProductionSource } from './cut type AstNode = Record; -const SHARED_RUNTIME_TYPE_NAME = 'BoundDeviceRuntime'; +// Types no daemon route may manufacture with an assertion: the bound runtime itself, and the +// admission proof a facts-first binder requires (`AdmittedRuntimePlan`, minted only by +// `admitRuntimePlan`). Together with the row's own runtime type names they are the +// manufactured-proof column — a cast to any of them is a route repairing missing proof. +const SHARED_RUNTIME_TYPE_NAMES = ['BoundDeviceRuntime', 'AdmittedRuntimePlan'] as const; /** * The one parametrized runtime-command-cutover gate (ADR 0019 §8). Every migrated @@ -372,7 +376,7 @@ function narrowingViolations( // An inventory row binds no device runtime, so it has nothing to re-widen. if (!file.path.startsWith('src/daemon/') || row.execution !== 'device-runtime') return []; const violations: UnruledViolation[] = []; - const runtimeTypes = new Set([SHARED_RUNTIME_TYPE_NAME, ...row.runtimeTypeNames]); + const runtimeTypes = new Set([...SHARED_RUNTIME_TYPE_NAMES, ...row.runtimeTypeNames]); visitAst(program, (node) => { if ( (node['type'] === 'TSAsExpression' || node['type'] === 'TSTypeAssertion') && diff --git a/scripts/layering/runtime-command-cutover-snapshot.test.ts b/scripts/layering/runtime-command-cutover-snapshot.test.ts deleted file mode 100644 index 04e2680ed..000000000 --- a/scripts/layering/runtime-command-cutover-snapshot.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { snapshotPlatformPolicyBranchViolations } from './runtime-command-cutover-snapshot.ts'; - -const ROUTE = 'src/daemon/snapshot-runtime.ts'; -const COMMAND = 'src/daemon/snapshot-command-runtime.ts'; -const BINDING = 'src/daemon/snapshot-runtime-binding.ts'; - -function violationsFor(route: string, binding = '', command = SHARED_COMMAND): string[] { - return snapshotPlatformPolicyBranchViolations( - new Map([ - [ROUTE, route], - [COMMAND, command], - [BINDING, binding], - ]), - ).map(({ message }) => message); -} - -const PUBLIC_ROUTE = ` - async function dispatchSnapshotViaRuntime(params) { - return dispatchSnapshotRuntimeCommand({ ...params, command: 'snapshot' }); - } -`; - -const SHARED_COMMAND = ` - async function dispatchSnapshotRuntimeCommand(params) { - return resolveBoundSnapshotCaptureRuntime(params, params.command); - } -`; - -const FACTS_FIRST_BINDING = ` - async function resolveBoundSnapshotCaptureRuntime(params) { - const plan = resolveSnapshotRuntimePlan(normalizedIntent); - const admission = await inspectRequiredRuntimeUse({ - device, - use: plan.use, - inspectFacts: params.inspectFacts, - }); - if (!admission.admitted) return unavailable(admission); - return bindSnapshotCaptureRuntime(params.bindDevice, device, plan); - } -`; - -test('R32 accepts the normalized plan through the shared facts-first seam', () => { - assert.deepEqual(violationsFor(PUBLIC_ROUTE, FACTS_FIRST_BINDING), []); -}); - -test('R32 rejects a locally reimplemented admission policy, including object-wrapped identity', () => { - assert.deepEqual( - violationsFor( - PUBLIC_ROUTE, - ` - async function resolveBoundSnapshotCaptureRuntime(params) { - const plan = resolveSnapshotRuntimePlan(normalizedIntent); - return inspectSnapshotCaptureAdmission(params, plan); - } - function inspectSnapshotCaptureAdmission(device) { - const wrapped = { device }; - if (wrapped.device.platform === 'apple') return { admitted: true }; - } - `, - ), - [ - 'snapshot owning interface must admit exactly once through inspectRequiredRuntimeUse(device, plan.use, inspectFacts)', - 'snapshot admission must not be reimplemented beside the shared facts seam', - ], - ); -}); - -test('R32 rejects an admission call not coupled to the selected plan use', () => { - assert.deepEqual( - violationsFor( - PUBLIC_ROUTE, - FACTS_FIRST_BINDING.replace('use: plan.use', 'use: captureSnapshotUse'), - ), - [ - 'snapshot owning interface must admit exactly once through inspectRequiredRuntimeUse(device, plan.use, inspectFacts)', - ], - ); -}); diff --git a/scripts/layering/runtime-command-cutover-snapshot.ts b/scripts/layering/runtime-command-cutover-snapshot.ts deleted file mode 100644 index f5e6cce9b..000000000 --- a/scripts/layering/runtime-command-cutover-snapshot.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { parseSync } from 'oxc-parser'; -import { memberPath, propertyName, visitAst } from './cutover-policy-ast.ts'; -import { countNamedCalls, lineOf, namedFunction } from './runtime-command-cutover-ast.ts'; -import type { UnruledViolation } from './runtime-command-cutover-model.ts'; - -type AstNode = Record; - -const SNAPSHOT_ROUTE_FILE = 'src/daemon/snapshot-runtime.ts'; -const SNAPSHOT_COMMAND_FILE = 'src/daemon/snapshot-command-runtime.ts'; -const SNAPSHOT_BINDING_FILE = 'src/daemon/snapshot-runtime-binding.ts'; - -/** R32 admits only through the shared facts-first seam selected by the normalized runtime plan. */ -export function snapshotPlatformPolicyBranchViolations( - sources: ReadonlyMap, -): UnruledViolation[] { - const routeSource = sources.get(SNAPSHOT_ROUTE_FILE); - if (routeSource === undefined) { - return [violation(SNAPSHOT_ROUTE_FILE, 1, 'snapshot runtime route is missing')]; - } - const commandSource = sources.get(SNAPSHOT_COMMAND_FILE); - if (commandSource === undefined) { - return [violation(SNAPSHOT_COMMAND_FILE, 1, 'snapshot runtime command owner is missing')]; - } - const bindingSource = sources.get(SNAPSHOT_BINDING_FILE); - if (bindingSource === undefined) { - return [violation(SNAPSHOT_BINDING_FILE, 1, 'snapshot runtime binding is missing')]; - } - - const routeProgram = parseSync(SNAPSHOT_ROUTE_FILE, routeSource).program as AstNode; - const route = namedFunction(routeProgram, 'dispatchSnapshotViaRuntime'); - if (route === undefined) { - return [violation(SNAPSHOT_ROUTE_FILE, 1, 'snapshot public runtime route is missing')]; - } - const commandProgram = parseSync(SNAPSHOT_COMMAND_FILE, commandSource).program as AstNode; - const commandOwner = namedFunction(commandProgram, 'dispatchSnapshotRuntimeCommand'); - if (commandOwner === undefined) { - return [violation(SNAPSHOT_COMMAND_FILE, 1, 'snapshot runtime command owner is missing')]; - } - const bindingProgram = parseSync(SNAPSHOT_BINDING_FILE, bindingSource).program as AstNode; - const owner = namedFunction(bindingProgram, 'resolveBoundSnapshotCaptureRuntime'); - if (owner === undefined) { - return [ - violation(SNAPSHOT_BINDING_FILE, 1, 'snapshot bound capture owning interface is missing'), - ]; - } - - const violations: UnruledViolation[] = []; - if (countNamedCalls(route, 'dispatchSnapshotRuntimeCommand') !== 1) { - violations.push( - violation( - SNAPSHOT_ROUTE_FILE, - lineOf(routeSource, route), - 'snapshot route must delegate exactly once to the shared runtime command owner', - ), - ); - } - if (countNamedCalls(commandOwner, 'resolveBoundSnapshotCaptureRuntime') !== 1) { - violations.push( - violation( - SNAPSHOT_COMMAND_FILE, - lineOf(commandSource, commandOwner), - 'snapshot runtime command owner must resolve one bound capture', - ), - ); - } - if (countNamedCalls(owner, 'resolveSnapshotRuntimePlan') !== 1) { - violations.push( - violation( - SNAPSHOT_BINDING_FILE, - lineOf(bindingSource, owner), - 'snapshot owning interface must select exactly one normalized plan', - ), - ); - } - - const admissions = namedCalls(owner, 'inspectRequiredRuntimeUse'); - if (admissions.length !== 1 || !hasExactAdmissionInput(admissions[0])) { - violations.push( - violation( - SNAPSHOT_BINDING_FILE, - lineOf(bindingSource, admissions[0] ?? owner), - 'snapshot owning interface must admit exactly once through inspectRequiredRuntimeUse(device, plan.use, inspectFacts)', - ), - ); - } - - if (bindingSource.includes('inspectSnapshotCaptureAdmission')) { - violations.push({ - file: SNAPSHOT_BINDING_FILE, - line: 1, - message: 'snapshot admission must not be reimplemented beside the shared facts seam', - }); - } - return violations; -} - -function namedCalls(node: AstNode, name: string): AstNode[] { - const calls: AstNode[] = []; - visitAst(node, (candidate) => { - if (candidate.type !== 'CallExpression') return; - const callee = candidate.callee as AstNode | undefined; - if (callee?.type === 'Identifier' && callee.name === name) calls.push(candidate); - }); - return calls; -} - -function hasExactAdmissionInput(call: AstNode | undefined): boolean { - const argument = Array.isArray(call?.arguments) ? call.arguments[0] : undefined; - if (!isNode(argument, 'ObjectExpression') || !Array.isArray(argument.properties)) return false; - const properties = new Map(); - for (const candidate of argument.properties) { - if (!isNode(candidate, 'Property')) continue; - const name = propertyName(candidate.key); - if (name !== undefined) properties.set(name, candidate.value); - } - return ( - hasPath(properties.get('device'), ['device']) && - hasPath(properties.get('use'), ['plan', 'use']) && - hasPath(properties.get('inspectFacts'), ['params', 'inspectFacts']) - ); -} - -function hasPath(node: unknown, expected: readonly string[]): boolean { - const path = memberPath(node); - return path?.length === expected.length && path.every((part, index) => part === expected[index]); -} - -function isNode(node: unknown, type: string): node is AstNode { - return node !== null && typeof node === 'object' && (node as AstNode).type === type; -} - -function violation(file: string, line: number, message: string): UnruledViolation { - return { file, line, message }; -} diff --git a/scripts/layering/runtime-command-cutover-table.test.ts b/scripts/layering/runtime-command-cutover-table.test.ts index 7c79dab43..87c9cb777 100644 --- a/scripts/layering/runtime-command-cutover-table.test.ts +++ b/scripts/layering/runtime-command-cutover-table.test.ts @@ -91,11 +91,7 @@ test('R32 snapshot rejects legacy admission and dispatcher projection', () => { } function resolveBoundSnapshotCaptureRuntime(params) { const plan = resolveSnapshotRuntimePlan(normalizedIntent); - inspectRequiredRuntimeUse({ - device, - use: plan.use, - inspectFacts: params.inspectFacts, - }); + admitRuntimePlan({ device, plan, inspectFacts: params.inspectFacts }); return bindSnapshotCaptureRuntime(params.bindDevice, device, plan); } `, diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index c82a61b87..c0053db7e 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -10,7 +10,6 @@ import { runtimeLifecycleRouteBindingViolations, sourceExecutedUsingDeclarationViolations, } from './runtime-command-cutover-extensions.ts'; -import { snapshotPlatformPolicyBranchViolations } from './runtime-command-cutover-snapshot.ts'; import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; import { retiredDispatchProjectionViolations } from './runtime-command-cutover-descriptor.ts'; @@ -471,7 +470,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'], }, }, - extensions: [snapshotRetiredDispatchProjectionProof, snapshotPlatformPolicyBranchViolations], + extensions: [snapshotRetiredDispatchProjectionProof], }, { rule: 'R33 diff-runtime-cutover', diff --git a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts index 2ff6ed7f3..92d26488e 100644 --- a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts @@ -1,21 +1,7 @@ -import { readFileSync } from 'node:fs'; import { snapshotRuntimePlanUses } from '@agent-device/contracts/platform'; import { expect, test } from 'vitest'; import { commandDescriptors } from '../registry.ts'; -const snapshotRuntimeSource = readFileSync( - new URL('../../../daemon/snapshot-runtime.ts', import.meta.url), - 'utf8', -); -const snapshotRuntimeBindingSource = readFileSync( - new URL('../../../daemon/snapshot-runtime-binding.ts', import.meta.url), - 'utf8', -); -const snapshotRuntimeCommandSource = readFileSync( - new URL('../../../daemon/snapshot-command-runtime.ts', import.meta.url), - 'utf8', -); - test('snapshot descriptor declares its complete planned capture uses with no legacy projection', () => { const snapshot = commandDescriptors.find(({ name }) => name === 'snapshot'); @@ -43,39 +29,3 @@ test('diff descriptor reuses the complete snapshot plan uses with no legacy proj uses: snapshotRuntimePlanUses, }); }); - -test('shared snapshot owning interface inspects facts and binds the declared use exactly once', () => { - const publicRoute = snapshotRuntimeSource.slice( - snapshotRuntimeSource.indexOf('export async function dispatchSnapshotViaRuntime'), - snapshotRuntimeSource.indexOf('function publishedSnapshotGeneration'), - ); - const owningInterface = snapshotRuntimeBindingSource.slice( - snapshotRuntimeBindingSource.indexOf( - 'export async function resolveBoundSnapshotCaptureRuntime', - ), - snapshotRuntimeBindingSource.indexOf('async function bindSnapshotCaptureRuntime'), - ); - - expect(publicRoute).toContain('dispatchSnapshotRuntimeCommand({'); - expect( - snapshotRuntimeCommandSource.match( - /resolveBoundSnapshotCaptureRuntime\(params, params\.command\)/g, - ), - ).toHaveLength(1); - expect(owningInterface.match(/resolveSnapshotRuntimePlan\(\{/g)).toHaveLength(1); - expect(owningInterface.match(/inspectRequiredRuntimeUse\(\{/g)).toHaveLength(1); - expect( - owningInterface.match(/bindSnapshotCaptureRuntime\(params\.bindDevice, device, plan\)/g), - ).toHaveLength(1); - expect( - owningInterface.match(/use: plan\.use,[\s\S]*inspectFacts: params\.inspectFacts/g), - ).toHaveLength(1); - expect( - snapshotRuntimeBindingSource.match(/const bind = requireRuntimeBinding\(bindDevice\)/g), - ).toHaveLength(1); - expect(owningInterface.match(/runtime\.captureSnapshot\(/g)).toHaveLength(1); - expect(publicRoute).not.toContain("requireCommandSupported('snapshot'"); - for (const policyName of ['isIosFamily', 'isIosSimulator', 'providerOwned']) { - expect(snapshotRuntimeBindingSource).not.toContain(policyName); - } -}); diff --git a/src/daemon/__tests__/snapshot-runtime-binding.test.ts b/src/daemon/__tests__/snapshot-runtime-binding.test.ts new file mode 100644 index 000000000..6ca0c5a3e --- /dev/null +++ b/src/daemon/__tests__/snapshot-runtime-binding.test.ts @@ -0,0 +1,50 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test } from 'vitest'; +import { + IOS_SIMULATOR, + makeIosSession, + makeSessionStore, +} from '../../__tests__/test-utils/index.ts'; +import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; +import { resolveBoundSnapshotCaptureRuntime } from '../snapshot-runtime-binding.ts'; +import type { DaemonRequest } from '../types.ts'; +import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; + +// The owning interface (ADR 0019 §9): one plan, one facts-first admission, one bind — and the +// bind target is whatever the admission was minted for, read back by token identity. The binder +// itself is module-private; this is its only entry. +test('the owning interface binds exactly the session device the facts were admitted for', async () => { + const { inspectFacts, bindDevice } = snapshotRuntimeFixture(); + const boundDevices: DeviceInfo[] = []; + const recordingBind: BindDeviceRuntime = async (device, use) => { + boundDevices.push(device); + return await bindDevice(device, use); + }; + const sessionStore = makeSessionStore(); + // An active app: the fixture admits captureSnapshot for a local iOS simulator only with one. + sessionStore.set( + 'bind-test', + makeIosSession('bind-test', { device: IOS_SIMULATOR, appBundleId: 'com.example.app' }), + ); + const req: DaemonRequest = { + command: 'snapshot', + positionals: [], + session: 'bind-test', + token: 'test-token', + }; + + const resolved = await resolveBoundSnapshotCaptureRuntime( + { + req, + sessionName: 'bind-test', + logPath: '/tmp/bind-test.log', + sessionStore, + inspectFacts, + bindDevice: recordingBind, + }, + 'snapshot', + ); + + expect(resolved.ok).toBe(true); + expect(boundDevices).toEqual([IOS_SIMULATOR]); +}); diff --git a/src/daemon/handlers/__tests__/session-runtime-admission.test.ts b/src/daemon/handlers/__tests__/session-runtime-admission.test.ts new file mode 100644 index 000000000..0fa7fdf68 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-runtime-admission.test.ts @@ -0,0 +1,86 @@ +import { resolveSnapshotRuntimePlan } from '@agent-device/contracts/platform'; +import { expect, test } from 'vitest'; +import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts'; +import { + admitRuntimePlan, + unwrapAdmittedRuntimePlan, + type AdmittedRuntimePlan, +} from '../session-runtime-admission.ts'; + +const { inspectFacts } = snapshotRuntimeFixture(); + +test('admits the plan whose required operations the owner facts report available', async () => { + const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true }); + const admission = await admitRuntimePlan({ device: IOS_SIMULATOR, plan, inspectFacts }); + expect(admission.admitted).toBe(true); + if (!admission.admitted) throw new Error('unreachable'); + // The payload is the very plan and the device the facts were read for (a frozen copy of the + // device, so the caller's own DeviceInfo cannot move the admitted identity later). + const payload = unwrapAdmittedRuntimePlan(admission); + expect(payload.plan).toBe(plan); + expect(payload.device).toEqual(IOS_SIMULATOR); + expect(Object.isFrozen(payload.device)).toBe(true); +}); + +test('refuses on the first required operation the owner facts report unavailable', async () => { + const plan = resolveSnapshotRuntimePlan({ customActions: true, hasActiveApp: true }); + const admission = await admitRuntimePlan({ device: ANDROID_EMULATOR, plan, inspectFacts }); + expect(admission).toMatchObject({ + admitted: false, + operation: 'captureSnapshotWithCustomActions', + fact: { available: false, reason: 'unsupported-platform-leaf' }, + }); +}); + +test('throws when no facts inspection seam was supplied', async () => { + const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true }); + await expect(admitRuntimePlan({ device: IOS_SIMULATOR, plan })).rejects.toThrow( + 'Device runtime facts inspection is unavailable.', + ); +}); + +test('the admission cannot be written down or copied: only admitRuntimePlan mints it', async () => { + const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true }); + const admission = await admitRuntimePlan({ device: IOS_SIMULATOR, plan, inspectFacts }); + if (!admission.admitted) throw new Error('unreachable'); + // Planted reds for the seam itself: each directive below becomes unused — and tsc fails — if + // the token loses its #private member (degrades to a plain shape). A route holds a token only + // by having called admitRuntimePlan (or by a type assertion, which the cutover gate rejects in + // src/daemon/). The class value is not exported, so `new` is not even nameable from here. + // @ts-expect-error a literal is not an admission: it lacks the #private member + const literal: AdmittedRuntimePlan = { admitted: true }; + // @ts-expect-error a spread of a real admission is a plain object without the #private member + const retargeted: AdmittedRuntimePlan = { ...admission }; + expect([literal, retargeted].length).toBe(2); +}); + +test('the payload is reachable only by exact token identity: a Proxy, a spread, or a look-alike is refused', async () => { + const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true }); + const admission = await admitRuntimePlan({ device: IOS_SIMULATOR, plan, inspectFacts }); + if (!admission.admitted) throw new Error('unreachable'); + // A Proxy around a real token types as the token without any assertion (the finding), and + // could trap any public getter — which is why the token exposes none and the binder unwraps + // through the identity-keyed store instead. The Proxy is a different identity: refused. + const proxied: AdmittedRuntimePlan = new Proxy(admission, { + get: (target, property, receiver) => + property === 'device' ? ANDROID_EMULATOR : Reflect.get(target, property, receiver), + }); + expect(() => unwrapAdmittedRuntimePlan(proxied)).toThrow(/not one minted by admitRuntimePlan/); + // The token's own surface has nothing to retarget: no device/plan getters, frozen, no own keys. + expect(Object.keys({ ...admission })).toEqual([]); + expect(() => Object.defineProperty(admission, 'device', { value: ANDROID_EMULATOR })).toThrow( + TypeError, + ); + // And the real token still unwraps to what was admitted. + expect(unwrapAdmittedRuntimePlan(admission).device).toEqual(IOS_SIMULATOR); +}); + +test('mutating the caller’s DeviceInfo after admission does not move the admitted device', async () => { + const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true }); + const mutable = { ...IOS_SIMULATOR }; + const admission = await admitRuntimePlan({ device: mutable, plan, inspectFacts }); + if (!admission.admitted) throw new Error('unreachable'); + mutable.id = 'someone-else'; + expect(unwrapAdmittedRuntimePlan(admission).device.id).toBe(IOS_SIMULATOR.id); +}); diff --git a/src/daemon/handlers/session-runtime-admission.ts b/src/daemon/handlers/session-runtime-admission.ts index edea741da..1568245a1 100644 --- a/src/daemon/handlers/session-runtime-admission.ts +++ b/src/daemon/handlers/session-runtime-admission.ts @@ -39,30 +39,110 @@ export function requireRuntimeFacts( throw new AppError('COMMAND_FAILED', 'Device runtime facts inspection is unavailable.'); } -export async function inspectRequiredRuntimeUse< - const Use extends Readonly<{ - required: readonly RuntimeOperationKey[]; - }>, ->( +/** Any command plan: the runtime use it binds, plus whatever discriminator the command needs. */ +export type RuntimePlan = Readonly<{ + use: Readonly<{ required: readonly RuntimeOperationKey[] }>; +}>; + +type AdmissionPayload = Readonly<{ device: DeviceInfo; plan: Plan }>; + +// The payload store, keyed by the exact token object. Only `admitRuntimePlan` writes to it and +// only `unwrapAdmittedRuntimePlan` reads it, so what a binder consumes is looked up by identity +// — a Proxy around a token, a spread, or any other look-alike is a different object with no +// entry here and is refused. Nothing about the token's own surface can be trapped to lie. +const admissionPayloads = new WeakMap>(); + +// Minted only inside the class's static block below: the constructor is private and the class +// value is not exported, so no other module can construct one. +let mintAdmission: ( + device: DeviceInfo, + plan: Plan, +) => AdmittedRuntimePlanToken; + +/** + * Proof that every operation `plan.use` requires is available on `device`'s owner facts. The + * token names the device the facts were read for, and a facts-first binder binds *that* device + * from the token rather than taking one separately — so facts admitted for device A can never + * bind device B. + * + * The token itself carries nothing readable: it is a nominal class (a `#private` member keeps + * literals and spreads from being assignable to the type) whose payload lives in a module-private + * WeakMap keyed by the token's identity. A binder gets at the device and plan only through + * `unwrapAdmittedRuntimePlan`, which refuses any object that was not minted here — including a + * Proxy around a real token, which types as the token but is a different identity. What remains + * is a type assertion, which the cutover gate's manufactured-proof column rejects in src/daemon/. + * A facts-first binder that requires the token therefore enforces "admit before bind" — for this + * device, for this plan — at the seam. + */ +class AdmittedRuntimePlanToken { + static { + mintAdmission = (device, plan) => { + const token = new AdmittedRuntimePlanToken(); + // A frozen copy: the caller's DeviceInfo stays mutable in their hands, the admitted + // identity does not move with it. + admissionPayloads.set(token, { device: Object.freeze({ ...device }), plan }); + Object.freeze(token); + return token; + }; + } + + // Its presence makes the type nominal (no literal or spread satisfies it) and its phantom + // parameter keeps the plan type on the token for the binder's narrowing; it is not the payload. + readonly #plan: Plan | undefined; + + private constructor() { + this.#plan = undefined; + } + + get admitted(): true { + void this.#plan; + return true; + } +} + +export type AdmittedRuntimePlan = AdmittedRuntimePlanToken; + +/** + * The device and plan an admission was minted for, by exact identity of the token. Throws for + * anything else — a look-alike, a spread, a Proxy — because those were never admitted. + */ +export function unwrapAdmittedRuntimePlan( + admission: AdmittedRuntimePlan, +): AdmissionPayload { + const payload = admissionPayloads.get(admission); + if (payload === undefined) { + throw new AppError( + 'COMMAND_FAILED', + 'Runtime plan admission is not one minted by admitRuntimePlan; refusing to bind.', + ); + } + return payload as AdmissionPayload; +} + +export type RefusedRuntimePlan = Readonly<{ + admitted: false; + operation: Plan['use']['required'][number]; + fact: RuntimeOperationFact; +}>; + +/** + * Facts-first admission (ADR 0019 §9): side-effect-free, no binding, no helper acquisition. + * Refuses on the first required operation the owner facts report unavailable; otherwise returns + * the admitted plan, which is what a facts-first binder takes instead of a bare plan. + */ +export async function admitRuntimePlan( params: Readonly<{ device: DeviceInfo; - use: Use; + plan: Plan; inspectFacts?: InspectDeviceRuntimeFacts; }>, -): Promise< - | Readonly<{ admitted: true }> - | Readonly<{ - admitted: false; - operation: Use['required'][number]; - fact: RuntimeOperationFact; - }> -> { +): Promise | RefusedRuntimePlan> { const facts = await requireRuntimeFacts(params.inspectFacts)(params.device); - for (const operation of params.use.required) { + for (const operation of params.plan.use.required) { const fact = facts.operations[operation]; if (!fact.available) return { admitted: false, operation, fact }; } - return { admitted: true }; + return mintAdmission(params.device, params.plan); } export function requireRuntimeBinding( diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index 84e854c69..3d9815537 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -12,9 +12,11 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-run import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { - inspectRequiredRuntimeUse, + admitRuntimePlan, requireRuntimeBinding, unavailableRuntimeOperationResponse, + unwrapAdmittedRuntimePlan, + type AdmittedRuntimePlan, } from './handlers/session-runtime-admission.ts'; import { errorResponse } from './handlers/response.ts'; import { resolveSnapshotScope } from './handlers/snapshot-capture.ts'; @@ -53,11 +55,7 @@ export async function resolveBoundSnapshotCaptureRuntime( customActions: req.flags?.snapshotCustomActions === true, hasActiveApp: session?.appBundleId !== undefined, }); - const admission = await inspectRequiredRuntimeUse({ - device, - use: plan.use, - inspectFacts: params.inspectFacts, - }); + const admission = await admitRuntimePlan({ device, plan, inspectFacts: params.inspectFacts }); if (!admission.admitted) { return { ok: false, @@ -71,7 +69,7 @@ export async function resolveBoundSnapshotCaptureRuntime( }; } - const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, device, plan); + const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, admission); const captureInput = buildRuntimeCaptureInput(params, session, resolvedScope.scope); return Object.freeze({ ok: true, @@ -82,12 +80,17 @@ export async function resolveBoundSnapshotCaptureRuntime( }); } +/** + * Binds only an admitted plan, on the device it was admitted for: the token is minted by + * `admitRuntimePlan` alone and unwrapped by exact identity, so nothing that was not admitted — + * a bare plan, a separate device, a look-alike or Proxy — can reach the capture operations. + */ async function bindSnapshotCaptureRuntime( bindDevice: BindDeviceRuntime | undefined, - device: SessionState['device'], - plan: SnapshotRuntimePlan, + admission: AdmittedRuntimePlan, ): Promise }>> { const bind = requireRuntimeBinding(bindDevice); + const { device, plan } = unwrapAdmittedRuntimePlan(admission); switch (plan.kind) { case 'active-app': { const runtime = await bind(device, plan.use);