Skip to content

Commit 2b9d1da

Browse files
committed
refactor(daemon): the admitted-plan token names its device; the snapshot binder binds only from the token
Review (P1): the token proved the plan but not the device, so a caller could admit facts for device A and bind device B through the binder's separate device argument. AdmittedRuntimePlan now carries the device the facts were read for, bindSnapshotCaptureRuntime takes (bindDevice, admission) and binds admission.device, and the route passes the token alone. Mismatched-device binding is no longer expressible: a @ts-expect-error pins the removed parameter (re-adding it fails tsc), and a recording binder proves the bind target is the token's device.
1 parent 3d4afad commit 2b9d1da

5 files changed

Lines changed: 51 additions & 20 deletions

File tree

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,44 @@
11
import { resolveSnapshotRuntimePlan } from '@agent-device/contracts/platform';
2+
import type { DeviceInfo } from '@agent-device/kernel/device';
23
import { expect, test } from 'vitest';
3-
import { IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts';
4+
import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts';
45
import { admitRuntimePlan } from '../handlers/session-runtime-admission.ts';
6+
import type { BindDeviceRuntime } from '../request-runtime-binding.ts';
57
import { bindSnapshotCaptureRuntime } from '../snapshot-runtime-binding.ts';
68
import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts';
79

8-
test('the snapshot binder takes an admission, never a bare plan', () => {
10+
test('the snapshot binder takes an admission, never a bare plan and never a separate device', async () => {
11+
const { inspectFacts } = snapshotRuntimeFixture();
912
const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true });
10-
// Type-level proof that admit-before-bind is enforced at the seam: the binder's parameter is
11-
// AdmittedRuntimePlan<SnapshotRuntimePlan>, which only admitRuntimePlan produces. Widening it
12-
// back to SnapshotRuntimePlan makes this directive unused and tsc fails.
13+
const admission = await admitRuntimePlan({ device: IOS_SIMULATOR, plan, inspectFacts });
14+
// Type-level proof that admit-before-bind is enforced at the seam: the binder's only
15+
// input besides the request-scope binder is AdmittedRuntimePlan<SnapshotRuntimePlan>, which
16+
// only admitRuntimePlan produces and which names the device it was admitted for. Widening the
17+
// parameter back to a plan, or re-adding a device parameter, makes a directive below unused
18+
// and tsc fails.
1319
// @ts-expect-error the facts-first binder requires the admission token, not the plan
14-
const bindBarePlan = () => bindSnapshotCaptureRuntime(undefined, IOS_SIMULATOR, plan);
15-
expect(bindBarePlan).toBeTypeOf('function');
20+
const bindBarePlan = () => bindSnapshotCaptureRuntime(undefined, plan);
21+
// @ts-expect-error there is no separate device argument: the token's device is the bind target
22+
const bindOtherDevice = () => bindSnapshotCaptureRuntime(undefined, ANDROID_EMULATOR, admission);
23+
expect([bindBarePlan, bindOtherDevice].every((fn) => typeof fn === 'function')).toBe(true);
1624
});
1725

18-
test('binds the operation the admitted plan selected and nothing wider', async () => {
26+
test('binds exactly the device the facts were admitted for, and only the plan’s operation', async () => {
1927
const { inspectFacts, bindDevice } = snapshotRuntimeFixture();
28+
const boundDevices: DeviceInfo[] = [];
29+
const recordingBind: BindDeviceRuntime = async (device, use) => {
30+
boundDevices.push(device);
31+
return await bindDevice(device, use);
32+
};
2033
const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true });
2134
const admission = await admitRuntimePlan({ device: IOS_SIMULATOR, plan, inspectFacts });
2235
if (!admission.admitted) throw new Error('fixture admits captureSnapshot on an iOS simulator');
23-
const runtime = await bindSnapshotCaptureRuntime(bindDevice, IOS_SIMULATOR, admission);
36+
37+
const runtime = await bindSnapshotCaptureRuntime(recordingBind, admission);
38+
39+
// Whatever a caller holds besides the token (a session, another device) is irrelevant to the
40+
// bind: the request-scope binder only ever sees the token's own device.
41+
expect(boundDevices).toEqual([IOS_SIMULATOR]);
42+
expect(admission.device).toBe(IOS_SIMULATOR);
2443
expect(Object.keys(runtime)).toEqual(['captureSnapshot']);
2544
});

src/daemon/handlers/__tests__/session-runtime-admission.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ test('admits the plan whose required operations the owner facts report available
1111
const admission = await admitRuntimePlan({ device: IOS_SIMULATOR, plan, inspectFacts });
1212
expect(admission.admitted).toBe(true);
1313
if (!admission.admitted) throw new Error('unreachable');
14-
// The token carries the very plan that was admitted, so a binder that takes the token
15-
// binds exactly the use the facts were checked against.
14+
// The token carries the very plan and the very device that were admitted, so a binder that
15+
// takes the token binds exactly the use the facts were checked against, on exactly the device
16+
// the facts were read for.
1617
expect(admission.plan).toBe(plan);
18+
expect(admission.device).toBe(IOS_SIMULATOR);
1719
});
1820

1921
test('refuses on the first required operation the owner facts report unavailable', async () => {
@@ -40,6 +42,6 @@ test('the admission proof cannot be written down: only admitRuntimePlan mints it
4042
// literal in any other module can satisfy the type — a route holds a token only by having
4143
// called admitRuntimePlan (or by a type assertion, which the cutover gate rejects in src/daemon/).
4244
// @ts-expect-error a literal without the module-private proof key is not an admission
43-
const forged: AdmittedRuntimePlan<typeof plan> = { admitted: true, plan };
45+
const forged: AdmittedRuntimePlan<typeof plan> = { admitted: true, device: IOS_SIMULATOR, plan };
4446
expect(forged.plan).toBe(plan);
4547
});

src/daemon/handlers/session-runtime-admission.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,15 @@ export type RuntimePlan = Readonly<{
5151
// "admit before bind" at the seam, without inspecting the route's syntax.
5252
const admissionProof: unique symbol = Symbol('agent-device.runtime-plan-admitted');
5353

54-
/** Proof that every operation `plan.use` requires is available on the device's owner facts. */
54+
/**
55+
* Proof that every operation `plan.use` requires is available on `device`'s owner facts. The
56+
* token names the device the facts were read for, and a facts-first binder binds *that* device
57+
* from the token rather than taking one separately — so facts admitted for device A can never
58+
* bind device B.
59+
*/
5560
export type AdmittedRuntimePlan<Plan extends RuntimePlan> = Readonly<{
5661
admitted: true;
62+
device: DeviceInfo;
5763
plan: Plan;
5864
readonly [admissionProof]: true;
5965
}>;
@@ -81,7 +87,12 @@ export async function admitRuntimePlan<const Plan extends RuntimePlan>(
8187
const fact = facts.operations[operation];
8288
if (!fact.available) return { admitted: false, operation, fact };
8389
}
84-
return Object.freeze({ admitted: true, plan: params.plan, [admissionProof]: true as const });
90+
return Object.freeze({
91+
admitted: true,
92+
device: params.device,
93+
plan: params.plan,
94+
[admissionProof]: true as const,
95+
});
8596
}
8697

8798
export function requireRuntimeBinding(

src/daemon/snapshot-runtime-binding.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,16 @@ import {
1717
import { errorResponse } from './handlers/response.ts';
1818

1919
/**
20-
* Binds only an admitted plan: the token is minted by `admitRuntimePlan` alone, so a route
21-
* cannot reach the capture operations without the facts-first admission having run for the
22-
* very plan whose use is bound here.
20+
* Binds only an admitted plan, and only the device it was admitted for: the token is minted by
21+
* `admitRuntimePlan` alone and carries both, so a route cannot reach the capture operations
22+
* without the facts-first admission having run for the very plan and device bound here.
2323
*/
2424
export async function bindSnapshotCaptureRuntime(
2525
bindDevice: BindDeviceRuntime | undefined,
26-
device: SessionState['device'],
2726
admission: AdmittedRuntimePlan<SnapshotRuntimePlan>,
2827
): Promise<Readonly<{ captureSnapshot(input: CaptureSnapshotInput): Promise<SnapshotResult> }>> {
2928
const bind = requireRuntimeBinding(bindDevice);
30-
const { plan } = admission;
29+
const { device, plan } = admission;
3130
switch (plan.kind) {
3231
case 'active-app': {
3332
const runtime = await bind(device, plan.use);

src/daemon/snapshot-runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export async function dispatchSnapshotViaRuntime(params: {
7373
device,
7474
});
7575
}
76-
const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, device, admission);
76+
const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, admission);
7777
return await withSessionlessRunnerCleanup(
7878
session,
7979
device,

0 commit comments

Comments
 (0)