From 36083a91e52c959446606947ad94d09ba354d4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 12:20:24 +0200 Subject: [PATCH] fix: enforce device claims for sessionless device mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `boot` and `shutdown` never consulted the host-global device claim store, so a daemon in one state directory could terminate an emulator another daemon held a verified-live claim on and report success (#1799). Rather than adding a claim check to those two handlers, this makes the class unrepresentable: `CommandDescriptor` gains a REQUIRED `deviceClaimPolicy` trait (#1320's vocabulary), and the request-execution scope enforces it where the request runtime bindings create a device binding — the one seam through which any handler can obtain device operations, and already the place per-device deduplication lives. A `transient-exclusive` command acquires a command-scoped claim before operations reach the handler, refuses a foreign live claim with the existing DEVICE_IN_USE/DEVICE_CLAIM_LIVE_OWNER error, and releases in the scope's finally. Every other policy performs no claim-store I/O, so session-bound commands keep #1320's non-goal intact. --- CONTEXT.md | 7 + .../test-utils/device-claim-store.ts | 39 +++++ .../__tests__/device-claim-policy.test.ts | 97 +++++++++++ src/core/command-descriptor/registry.ts | 153 ++++++++++++++---- src/core/command-descriptor/types.ts | 36 +++++ .../__tests__/device-claim-admission.test.ts | 150 +++++++++++++++++ .../__tests__/request-runtime-binding.test.ts | 44 +++-- src/daemon/device-claim-admission.ts | 83 ++++++++++ src/daemon/device-claim-conflict.ts | 24 ++- src/daemon/device-claims.ts | 129 ++++++++++++--- ...ession-boot-shutdown-device-claims.test.ts | 151 +++++++++++++++++ .../__tests__/session-command-harness.ts | 12 ++ src/daemon/request-execution-scope.ts | 69 +++++++- src/daemon/request-runtime-binding.ts | 29 +++- src/platform-runtime-gateway.test.ts | 6 +- ...ale-provider-runtime-admission.fixtures.ts | 1 + 16 files changed, 947 insertions(+), 83 deletions(-) create mode 100644 src/__tests__/test-utils/device-claim-store.ts create mode 100644 src/core/command-descriptor/__tests__/device-claim-policy.test.ts create mode 100644 src/daemon/__tests__/device-claim-admission.test.ts create mode 100644 src/daemon/device-claim-admission.ts create mode 100644 src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 670e13535..321f8a4ef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,6 +63,13 @@ task touches: cloud bridge, or `limrun`. - Runner/process lease: backend helper mutual-exclusion guard for platform runners or tools; it is not the remote client ownership boundary. +- Device claim: host-global exclusive ownership of one local device, held by an open session or by a + single sessionless device-mutating command. Local only; remote targets use device leases instead. +- Device-claim policy: required command-descriptor trait declaring a command's relationship to the + claim store (`none`, `observe`, `require-owner`, `transient-exclusive`, `acquire-session`, + `release-session`). The request-execution scope enforces it at the device binding seam: + `transient-exclusive` takes a command-scoped claim and refuses a foreign one, and every other + policy performs no claim-store I/O. - iOS physical-device control: Apple-local module selected from discovery evidence. CoreDevice devices retain the `devicectl` controller; devices found only by `xctrace` use the XCTest controller for readiness, app activation/termination, and cable-bound usbmux runner transport diff --git a/src/__tests__/test-utils/device-claim-store.ts b/src/__tests__/test-utils/device-claim-store.ts new file mode 100644 index 000000000..189909acf --- /dev/null +++ b/src/__tests__/test-utils/device-claim-store.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach } from 'vitest'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import type { DeviceClaimReconciler } from '../../daemon/device-claims.ts'; + +export type IsolatedDeviceClaimStore = { + /** Temporary root holding both the claim store and the daemon state dir. */ + root: string; + stateDir: string; + claimsDir: string; +}; + +/** + * Device claims are host-global by design, so a test that exercises them must + * redirect the store. Registers cleanup once and returns a per-test factory. + */ +export function isolatedDeviceClaimStores(prefix: string): () => IsolatedDeviceClaimStore { + const roots: string[] = []; + afterEach(() => { + delete process.env.AGENT_DEVICE_CLAIMS_DIR; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + }); + return () => { + const root = mkdtempForTestSync(prefix); + roots.push(root); + const claimsDir = path.join(root, 'claims'); + process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + const stateDir = path.join(root, 'state'); + fs.mkdirSync(stateDir, { recursive: true }); + return { root, stateDir, claimsDir }; + }; +} + +/** Fail-closed reconciler: a test owner is never treated as recoverable. */ +export const retainOrphanedDeviceClaims: DeviceClaimReconciler = async () => ({ + status: 'retained', + reason: 'test-live-owner', +}); diff --git a/src/core/command-descriptor/__tests__/device-claim-policy.test.ts b/src/core/command-descriptor/__tests__/device-claim-policy.test.ts new file mode 100644 index 000000000..2cdeaf362 --- /dev/null +++ b/src/core/command-descriptor/__tests__/device-claim-policy.test.ts @@ -0,0 +1,97 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; +import { commandDescriptors, resolveCommandDeviceClaimPolicy } from '../registry.ts'; +import type { DeviceClaimPolicy } from '../types.ts'; + +// #1320 completeness gate. TypeScript already makes the trait required on every +// raw descriptor, so these tests only pin what types cannot: the CLASSIFICATION, +// and the structural precondition that makes it enforceable. + +function commandsByPolicy(): Partial> { + const grouped: Partial> = {}; + for (const descriptor of commandDescriptors) { + (grouped[descriptor.deviceClaimPolicy] ??= []).push(descriptor.name); + } + for (const names of Object.values(grouped)) names.sort(); + return grouped; +} + +test('every public command resolves the policy its descriptor declares', () => { + const byName = new Map(commandDescriptors.map((descriptor) => [descriptor.name, descriptor])); + for (const command of Object.values(PUBLIC_COMMANDS)) { + const descriptor = byName.get(command); + assert.ok(descriptor, `public command ${command} is missing from the descriptor registry`); + assert.equal(resolveCommandDeviceClaimPolicy(command), descriptor.deviceClaimPolicy); + } + // Command names outside the registry stay claim-free rather than fail closed + // into an acquisition no owner would ever release. + assert.equal(resolveCommandDeviceClaimPolicy(undefined), 'require-owner'); + assert.equal(resolveCommandDeviceClaimPolicy('not-a-registered-command'), 'require-owner'); +}); + +test('every command that deviates from require-owner is a reviewed, diffable set', () => { + // CONSERVATIVE: these lists may only change in the same PR that updates them + // here. A `transient-exclusive` command takes host-global exclusive ownership + // of its device for one request, so adding one changes cross-worktree + // behavior for everyone sharing that device (#1799); `none` is for + // host/config-only commands and pure delegators, whose device work runs inside + // the request scope of the command they dispatch. + const { 'require-owner': _sessionBound, ...deviating } = commandsByPolicy(); + assert.deepEqual(deviating, { + 'acquire-session': ['open'], + 'release-session': ['close'], + 'transient-exclusive': [ + 'boot', + 'install', + 'install_source', + 'prepare', + 'push', + 'reinstall', + 'shutdown', + ], + observe: ['apps', 'appstate', 'capabilities', 'device', 'devices', 'doctor'], + none: [ + 'artifacts', + 'auth', + 'batch', + 'cdp', + 'connect', + 'connection', + 'daemon', + 'debug', + 'disconnect', + 'install-from-source', + 'lease_allocate', + 'lease_heartbeat', + 'lease_release', + 'mcp', + 'metro', + 'proxy', + 'react-devtools', + 'release_materialized_paths', + 'session', + 'session_list', + 'session_save_script', + 'web', + ], + }); +}); + +test('a transient-exclusive command can actually reach the device binding seam', () => { + // The claim gate lives on the request scope's device binding, which only ADR + // 0019 `device-runtime` commands pass through: an unmigrated (`legacy`) + // command reaches its device through dispatch instead, so declaring + // `transient-exclusive` there would be a claim nobody ever acquires. Live + // verification of #1799 caught exactly that on `keyboard`. Those commands stay + // `require-owner` until their platform execution migrates. + for (const descriptor of commandDescriptors) { + if (descriptor.deviceClaimPolicy !== 'transient-exclusive') continue; + assert.ok(descriptor.daemon, `${descriptor.name}: transient-exclusive without a daemon route`); + assert.equal( + descriptor.platformExecution.kind, + 'device-runtime', + `${descriptor.name}: transient-exclusive cannot be enforced without device-runtime execution`, + ); + } +}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index f1366870f..a1c0f2858 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -35,6 +35,7 @@ import type { CommandCatalogGroup, CommandDescriptor, CommandFrameworkTier, + DeviceClaimPolicy, RecordingEffect, CommandResponseDataTransform, CommandTimeoutPolicy, @@ -239,6 +240,7 @@ const LEGACY_PLATFORM_EXECUTION = { kind: 'legacy' } as const; const GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS = { recordsSessionAction: true, recordingEffect: 'mutates-app', + deviceClaimPolicy: 'require-owner', daemon: { route: 'generic', refFrameEffect: 'may-invalidate', @@ -252,6 +254,7 @@ const GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS = { Extract, | 'recordsSessionAction' | 'recordingEffect' + | 'deviceClaimPolicy' | 'daemon' | 'dispatch' | 'capability' @@ -259,6 +262,36 @@ const GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS = { | 'batchable' >; +// click/fill/press/longpress differ only in their timeout budget and response +// shaping: same owner file, same pre-dispatch target identity, same interaction +// route and dialog guard, same device buckets, and the same session-bound claim +// policy. Sharing that here is what keeps them from drifting apart one field at +// a time. +const TARGETED_TOUCH_INTERACTION_TRAITS = { + targetIdentityVerification: 'pre-dispatch', + catalog: { group: 'public' }, + recordsSessionAction: true, + recordingEffect: 'mutates-app', + deviceClaimPolicy: 'require-owner', + daemon: { + route: 'interaction', + refFrameEffect: 'may-invalidate', + androidBlockingDialogGuard: true, + }, + dispatch: {}, + capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, +} as const satisfies Pick< + Extract, + | 'targetIdentityVerification' + | 'catalog' + | 'recordsSessionAction' + | 'recordingEffect' + | 'deviceClaimPolicy' + | 'daemon' + | 'dispatch' + | 'capability' +>; + // --------------------------------------------------------------------------- // Timeout policies — descriptor-owned request-envelope budget source and // on-timeout daemon policy (ADR 0008). This replaces the two deleted client @@ -359,6 +392,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // -- lease (route: lease) -- { name: 'lease_allocate', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseAllocate' }, recordsSessionAction: false, @@ -369,6 +403,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'lease_heartbeat', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseHeartbeat' }, recordsSessionAction: false, @@ -379,6 +414,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'lease_release', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseRelease' }, recordsSessionAction: false, @@ -389,6 +425,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'artifacts', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/artifacts.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -402,6 +439,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // -- session (route: session) -- { name: 'session_list', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/session-inventory.ts'] as const } : {}), @@ -419,6 +457,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'session_save_script', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/session-script-publication.ts'] as const } : {}), @@ -435,6 +474,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'devices', + deviceClaimPolicy: 'observe', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -452,6 +492,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'capabilities', + deviceClaimPolicy: 'observe', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -470,6 +511,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'doctor', + deviceClaimPolicy: 'observe', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/doctor.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -488,6 +530,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'apps', + deviceClaimPolicy: 'observe', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -505,6 +548,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'boot', + deviceClaimPolicy: 'transient-exclusive', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -516,6 +560,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'shutdown', + deviceClaimPolicy: 'transient-exclusive', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -527,6 +572,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'appstate', + deviceClaimPolicy: 'observe', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appState' }, frameworkTier: 'extended', @@ -538,6 +584,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'perf', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/perf/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -551,6 +598,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'logs', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -562,6 +610,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'events', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -579,6 +628,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'network', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -590,6 +640,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'audio', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -606,6 +657,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'replay', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/replay/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -624,6 +676,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'test', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/replay/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -642,6 +695,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'runtime', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: [ @@ -659,6 +713,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'clipboard', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -677,6 +732,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'keyboard', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -699,6 +755,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'install', + deviceClaimPolicy: 'transient-exclusive', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -711,6 +768,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'reinstall', + deviceClaimPolicy: 'transient-exclusive', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -723,6 +781,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'install_source', + deviceClaimPolicy: 'transient-exclusive', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/session-app-source-deployment.ts'] as const } : {}), @@ -736,6 +795,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'release_materialized_paths', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/session-app-source-deployment.ts'] as const } : {}), @@ -748,6 +808,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'push', + deviceClaimPolicy: 'transient-exclusive', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -760,6 +821,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'trigger-app-event', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public', key: 'triggerAppEvent' }, frameworkTier: 'extended', @@ -774,6 +836,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'open', + deviceClaimPolicy: 'acquire-session', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -791,6 +854,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'prepare', + deviceClaimPolicy: 'transient-exclusive', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/prepare.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -808,6 +872,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'batch', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/batch/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -819,6 +884,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'close', + deviceClaimPolicy: 'release-session', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -838,6 +904,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // -- snapshot (route: snapshot) -- { name: 'snapshot', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/snapshot.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -854,6 +921,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'diff', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/diff.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -867,6 +935,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'wait', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/wait.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -888,6 +957,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'alert', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/alert.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -905,6 +975,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'settings', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/settings.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -925,6 +996,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // -- specialized routes -- { name: 'react-native', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/react-native/index.ts'] as const } : {}), catalog: { group: 'public', key: 'reactNative' }, frameworkTier: 'extended', @@ -938,6 +1010,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'record', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -955,6 +1028,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'trace', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -967,6 +1041,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'find', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -988,6 +1063,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // stuck Apple runner work. { name: 'click', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), targetIdentityVerification: 'pre-dispatch', catalog: { group: 'public' }, @@ -1009,18 +1085,8 @@ export const RAW_COMMAND_DESCRIPTORS = [ { name: 'fill', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), - targetIdentityVerification: 'pre-dispatch', - catalog: { group: 'public' }, + ...TARGETED_TOUCH_INTERACTION_TRAITS, frameworkTier: 'core', - recordsSessionAction: true, - recordingEffect: 'mutates-app', - daemon: { - route: 'interaction', - refFrameEffect: 'may-invalidate', - androidBlockingDialogGuard: true, - }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: postActionObservationTimeoutPolicy('fill', PRESERVE_DAEMON_TIMEOUT_POLICY), postActionObservation: postActionObservation('fill'), responseDataTransform: FILL_INTERACTION_RESPONSE_DATA_TRANSFORM, @@ -1030,18 +1096,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ { name: 'longpress', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), - targetIdentityVerification: 'pre-dispatch', + ...TARGETED_TOUCH_INTERACTION_TRAITS, catalog: { group: 'public', key: 'longPress' }, frameworkTier: 'extended', - recordsSessionAction: true, - recordingEffect: 'mutates-app', - daemon: { - route: 'interaction', - refFrameEffect: 'may-invalidate', - androidBlockingDialogGuard: true, - }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: { ...SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY, // Android's cold path may inspect/install the helper, hand off a running @@ -1055,6 +1112,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'hover', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), targetIdentityVerification: 'pre-dispatch', catalog: { group: 'public' }, @@ -1079,18 +1137,8 @@ export const RAW_COMMAND_DESCRIPTORS = [ { name: 'press', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), - targetIdentityVerification: 'pre-dispatch', - catalog: { group: 'public' }, + ...TARGETED_TOUCH_INTERACTION_TRAITS, frameworkTier: 'core', - recordsSessionAction: true, - recordingEffect: 'mutates-app', - daemon: { - route: 'interaction', - refFrameEffect: 'may-invalidate', - androidBlockingDialogGuard: true, - }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: postActionObservationTimeoutPolicy('press', PRESERVE_DAEMON_TIMEOUT_POLICY), postActionObservation: postActionObservation('press'), responseDataTransform: TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM, @@ -1099,6 +1147,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'type', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -1117,6 +1166,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'get', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), targetIdentityVerification: 'pre-dispatch', catalog: { group: 'public' }, @@ -1131,6 +1181,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'read', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/interaction.ts'] as const } : {}), catalog: { group: 'dispatch-alias' }, recordsSessionAction: false, @@ -1141,6 +1192,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'is', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), targetIdentityVerification: 'pre-dispatch', catalog: { group: 'public' }, @@ -1171,6 +1223,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'gesture', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -1201,6 +1254,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'tv-remote', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'tvRemote' }, frameworkTier: 'extended', @@ -1224,6 +1278,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'orientation', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -1256,6 +1311,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'swipe', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -1281,6 +1337,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'screenshot', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/screenshot.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', @@ -1295,6 +1352,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'viewport', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/viewport.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', @@ -1316,6 +1374,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // -- capability/batch-only commands (no daemon route) -- { name: 'app-switcher', + deviceClaimPolicy: 'require-owner', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appSwitcher' }, frameworkTier: 'extended', @@ -1339,6 +1398,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'install-from-source', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public', key: 'installFromSource' }, frameworkTier: 'extended', @@ -1352,6 +1412,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // -- local client-backed CLI/MCP commands (no daemon route/capability) -- { name: 'debug', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/debugging/index.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1361,6 +1422,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'daemon', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/daemon.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1371,6 +1433,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'device', + deviceClaimPolicy: 'observe', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/device.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1381,6 +1444,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'metro', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/metro/index.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1390,6 +1454,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'session', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/session.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1399,6 +1464,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'cdp', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/agent-cdp.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1409,6 +1475,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'auth', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/auth.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1419,6 +1486,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'connect', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1429,6 +1497,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'connection', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1439,6 +1508,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'disconnect', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1449,6 +1519,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'mcp', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/bin.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1459,6 +1530,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'proxy', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/proxy.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1469,6 +1541,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'react-devtools', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/react-devtools.ts'] as const } : {}), catalog: { group: 'local-cli', key: 'reactDevtools' }, recordsSessionAction: false, @@ -1482,6 +1555,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'web', + deviceClaimPolicy: 'none', ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/web.ts'] as const } : {}), catalog: { group: 'local-cli' }, recordsSessionAction: false, @@ -1607,6 +1681,10 @@ const TIMEOUT_POLICY_BY_COMMAND: ReadonlyMap = new commandDescriptors.map((descriptor) => [descriptor.name, descriptor.timeoutPolicy]), ); +const DEVICE_CLAIM_POLICY_BY_COMMAND: ReadonlyMap = new Map( + commandDescriptors.map((descriptor) => [descriptor.name, descriptor.deviceClaimPolicy]), +); + const RESPONSE_DATA_TRANSFORM_BY_COMMAND: ReadonlyMap = new Map( Array.from(COMMAND_DESCRIPTOR_BY_NAME.values()).flatMap((descriptor) => @@ -1642,6 +1720,17 @@ export function resolveCommandTimeoutPolicy(command: string | undefined): Comman return TIMEOUT_POLICY_BY_COMMAND.get(command) ?? DEFAULT_TIMEOUT_POLICY; } +/** + * The declared #1320 device-claim policy for a command. Names outside the + * registry (internal probes, unknown commands) resolve to `require-owner`: the + * value that performs no claim-store I/O at the binding seam, so an + * unregistered name can neither acquire nor be refused a claim. + */ +export function resolveCommandDeviceClaimPolicy(command: string | undefined): DeviceClaimPolicy { + if (command === undefined) return 'require-owner'; + return DEVICE_CLAIM_POLICY_BY_COMMAND.get(command) ?? 'require-owner'; +} + export function resolveCommandResponseDataTransform( command: string | undefined, ): CommandResponseDataTransform | undefined { diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index d75320f07..9c8b53e52 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -73,6 +73,36 @@ export type CommandTimeoutPolicy = { onTimeout: 'preserve-daemon' | 'reset-daemon'; }; +/** + * #1320 "Command descriptor policy": what a command may do with the host-global + * device claim store. REQUIRED on every descriptor (no default), and read by the + * request-execution scope so enforcement is derived from the declaration rather + * than from a per-handler call a new author can forget. + * + * - `none` — host/config-only; never binds a device. + * - `observe` — device inventory/ownership projection; may report a + * claim, never mutates one. + * - `require-owner` — session-bound work; trusts the invariant `open` + * established and does NO claim-store I/O. + * - `transient-exclusive` — sessionless device mutation; acquires a + * command-scoped claim before device operations reach + * the handler, refuses a foreign claim, and releases + * in `finally`. Enforced at the request scope's + * device binding, so it is available only to ADR 0019 + * `device-runtime` commands. + * - `acquire-session` — `open`; acquires the session claim before platform + * preparation or mutation. + * - `release-session` — `close`; releases the session claim only after + * teardown reaches a safe terminal state. + */ +export type DeviceClaimPolicy = + | 'none' + | 'observe' + | 'require-owner' + | 'transient-exclusive' + | 'acquire-session' + | 'release-session'; + export type CommandCatalogGroup = 'public' | 'internal' | 'local-cli' | 'dispatch-alias'; /** @@ -179,6 +209,12 @@ type CommandDescriptorBase = { batchable: boolean; mcpExposed: boolean; timeoutPolicy: CommandTimeoutPolicy; + /** + * #1320 device-claim policy. REQUIRED with no default so a new command must + * classify itself; `transient-exclusive` is the only value that makes the + * request scope touch the host-global claim store. + */ + deviceClaimPolicy: DeviceClaimPolicy; postActionObservation?: PostActionObservationSupport; responseDataTransform?: CommandResponseDataTransform; catalog: CommandCatalogFacet; diff --git a/src/daemon/__tests__/device-claim-admission.test.ts b/src/daemon/__tests__/device-claim-admission.test.ts new file mode 100644 index 000000000..d39d0b9fe --- /dev/null +++ b/src/daemon/__tests__/device-claim-admission.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform'; +import { asAppError } from '@agent-device/kernel/errors'; +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { + isolatedDeviceClaimStores, + retainOrphanedDeviceClaims, +} from '../../__tests__/test-utils/device-claim-store.ts'; +import { createDeviceClaimAdmission } from '../device-claim-admission.ts'; +import { acquireDeviceClaim } from '../device-claims.ts'; +import { inspectDeviceClaims } from '../device-claim-inspection.ts'; +import { createRequestExecutionScope } from '../request-execution-scope.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { SessionStore } from '../session-store.ts'; +import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; +import type { DeviceClaimPolicy } from '../../core/command-descriptor/types.ts'; + +const setup = isolatedDeviceClaimStores('agent-device-claim-admission-'); +const localAndroid = localRuntimeOwner('android'); + +function makeAdmission(policy: DeviceClaimPolicy, stateDir: string, command = 'made-up-command') { + return createDeviceClaimAdmission({ + policy, + command, + workspace: '/worktrees/current', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); +} + +function claimedSessions(): (string | undefined)[] { + return inspectDeviceClaims({}).map((entry) => entry.claim?.session); +} + +// Enforcement is a pure function of the declared policy, not of a command name: +// an unregistered command declared `transient-exclusive` claims, and every other +// policy stays out of the claim store entirely. +const POLICY_CLAIMS: [DeviceClaimPolicy, string[]][] = [ + ['none', []], + ['observe', []], + ['require-owner', []], + ['acquire-session', []], + ['release-session', []], + ['transient-exclusive', ['transient:made-up-command']], +]; + +test.for(POLICY_CLAIMS)( + 'the %s policy reaches the claim store only when transient-exclusive', + async ([policy, held], { expect }) => { + const { stateDir, claimsDir } = setup(); + const admission = makeAdmission(policy, stateDir); + + await admission.admit(ANDROID_EMULATOR, localAndroid); + expect(claimedSessions()).toEqual(held); + + await admission[Symbol.asyncDispose](); + expect(inspectDeviceClaims({})).toEqual([]); + // A claim-free policy never even creates the store. + if (held.length === 0) expect(fs.existsSync(claimsDir)).toBe(false); + }, +); + +test('a foreign live claim refuses the command before it can reach device operations', async () => { + const { root, stateDir } = setup(); + const ownerStateDir = path.join(root, 'foreign'); + fs.mkdirSync(ownerStateDir, { recursive: true }); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'owner-session', + workspace: '/worktrees/foreign', + stateDir: ownerStateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + const admission = makeAdmission('transient-exclusive', stateDir, 'shutdown'); + const error = asAppError( + await admission.admit(ANDROID_EMULATOR, localAndroid).catch((thrown: unknown) => thrown), + ); + + expect(error.code).toBe('DEVICE_IN_USE'); + expect(error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + expect(error.details?.retriable).toBe(false); + expect(error.details?.hint).toBe( + 'Inspect the owner with: agent-device device status --platform android --serial emulator-5554', + ); + + await admission[Symbol.asyncDispose](); + // Disposal never touches a claim this command did not acquire. + expect(claimedSessions()).toEqual(['owner-session']); +}); + +test('a claim already held by this daemon covers the command instead of colliding with it', async () => { + const { stateDir } = setup(); + await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'open-session', + workspace: '/worktrees/current', + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + + const admission = makeAdmission('transient-exclusive', stateDir, 'install'); + await admission.admit(ANDROID_EMULATOR, localAndroid); + await admission[Symbol.asyncDispose](); + + // The session claim is untouched: the command neither replaced nor released it. + expect(claimedSessions()).toEqual(['open-session']); +}); + +test('a provider-owned device takes no host-local claim', async () => { + const { stateDir, claimsDir } = setup(); + const admission = makeAdmission('transient-exclusive', stateDir); + + await admission.admit(ANDROID_EMULATOR, providerRuntimeOwner('limrun', 'instance-1')); + await admission[Symbol.asyncDispose](); + + expect(fs.existsSync(claimsDir)).toBe(false); +}); + +/** Claims visible while one command holds a device binding from the real request scope. */ +async function claimsWhileBound(command: string, stateDir: string) { + const scope = await createRequestExecutionScope({ + req: { token: 't', session: 'default', command, positionals: [], flags: {} }, + sessionStore: new SessionStore(path.join(stateDir, 'sessions')), + leaseRegistry: new LeaseRegistry(), + deviceRuntimeGateway: unavailableDeviceRuntimeGateway, + platformRequestScope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + try { + await scope.bindDevice(ANDROID_EMULATOR, { required: [], preferred: [] }); + return claimedSessions(); + } finally { + await scope[Symbol.asyncDispose](); + } +} + +test('the request scope claims only for commands whose descriptor declares transient-exclusive', async () => { + const { stateDir } = setup(); + + // Same binding call, same device: the difference is the descriptor policy. + expect(await claimsWhileBound('shutdown', stateDir)).toEqual(['transient:shutdown']); + expect(await claimsWhileBound('snapshot', stateDir)).toEqual([]); + expect(inspectDeviceClaims({})).toEqual([]); +}); diff --git a/src/daemon/__tests__/request-runtime-binding.test.ts b/src/daemon/__tests__/request-runtime-binding.test.ts index c036d66d7..fc06b3b02 100644 --- a/src/daemon/__tests__/request-runtime-binding.test.ts +++ b/src/daemon/__tests__/request-runtime-binding.test.ts @@ -30,9 +30,15 @@ const scope = { progress: { report: () => {} }, }; +const admitDeviceClaim = async () => {}; + test('request runtime binding caches one broad owner and projects each declared use', async () => { const runtime = makeGateway(); - const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const bindings = createRequestRuntimeBindings({ + gateway: runtime.gateway, + scope, + admitDeviceClaim, + }); const admission = await bindings.bindDevice(device('one'), appLogAdmissionUse); const inspect = await bindings.bindDevice(device('one'), appLogInspectUse); @@ -51,7 +57,11 @@ test('request runtime binding caches one broad owner and projects each declared test('facts inspection answers admission without creating a request binding', async () => { const runtime = makeGateway(); - const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const bindings = createRequestRuntimeBindings({ + gateway: runtime.gateway, + scope, + admitDeviceClaim, + }); await expect(bindings.inspectFacts(device('one'))).resolves.toMatchObject({ operations: { ensureReady: { available: true } }, @@ -65,7 +75,11 @@ test('facts inspection answers admission without creating a request binding', as test('request binding disposes multiple owners in reverse adoption order', async () => { const runtime = makeGateway(); - const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const bindings = createRequestRuntimeBindings({ + gateway: runtime.gateway, + scope, + admitDeviceClaim, + }); await bindings.bindDevice(device('one'), appLogInspectUse); await bindings.bindDevice(device('two'), appLogInspectUse); await bindings[Symbol.asyncDispose](); @@ -74,7 +88,11 @@ test('request binding disposes multiple owners in reverse adoption order', async test('concurrent uses share one in-flight broad binding for the device', async () => { const runtime = makeGateway(); - const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const bindings = createRequestRuntimeBindings({ + gateway: runtime.gateway, + scope, + admitDeviceClaim, + }); const selected = device('one'); await Promise.all([ @@ -90,7 +108,11 @@ test('concurrent uses share one in-flight broad binding for the device', async ( test('preferred absence is visible without failing while required absence fails typed', async () => { const runtime = makeGateway({ inspectAvailable: false }); - const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const bindings = createRequestRuntimeBindings({ + gateway: runtime.gateway, + scope, + admitDeviceClaim, + }); const admission = await bindings.bindDevice(device('one'), appLogAdmissionUse); expect(admission.facts.appLogInspect).toMatchObject({ available: false }); expect(admission.operations.appLogInspect).toBeUndefined(); @@ -102,7 +124,11 @@ test('preferred absence is visible without failing while required absence fails test('exact-owner recovery binds the persisted owner and fence without ordinary arbitration', async () => { const runtime = makeGateway(); - const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const bindings = createRequestRuntimeBindings({ + gateway: runtime.gateway, + scope, + admitDeviceClaim, + }); const selected = device('one'); const owner = localRuntimeOwner('android'); const fence = { token: 'recording-fence', generation: 4 } as const; @@ -155,7 +181,7 @@ test('late exact-owner binding is rolled back when request cleanup already began ), shutdown: async () => {}, }; - const bindings = createRequestRuntimeBindings({ gateway, scope }); + const bindings = createRequestRuntimeBindings({ gateway, scope, admitDeviceClaim }); const binding = bindings.bindExactDevice( selected, owner, @@ -203,7 +229,7 @@ test('late exact-owner rollback failure is secondary diagnostic evidence', async diagnostics: { emit }, progress: { report: () => {} }, }; - const bindings = createRequestRuntimeBindings({ gateway, scope }); + const bindings = createRequestRuntimeBindings({ gateway, scope, admitDeviceClaim }); const binding = bindings.bindExactDevice( selected, owner, @@ -266,7 +292,7 @@ test('request cancellation aborts deferred exact recovery and late publication i diagnostics: { emit: vi.fn() }, progress: { report: () => {} }, }; - const bindings = createRequestRuntimeBindings({ gateway, scope: requestScope }); + const bindings = createRequestRuntimeBindings({ gateway, scope: requestScope, admitDeviceClaim }); const acquisition = acquireDurableCaptureRecoveryAuthorityBeforeDeadline({ displayName: 'screen recording', envelope, diff --git a/src/daemon/device-claim-admission.ts b/src/daemon/device-claim-admission.ts new file mode 100644 index 000000000..95e88783a --- /dev/null +++ b/src/daemon/device-claim-admission.ts @@ -0,0 +1,83 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { RuntimeOwnerRef } from '@agent-device/contracts/platform'; +import type { DeviceClaimPolicy } from '../core/command-descriptor/types.ts'; +import { emitDiagnostic } from '../utils/diagnostics.ts'; +import { deviceClaimConflictError } from './device-claim-conflict.ts'; +import { + acquireTransientDeviceClaim, + clearDeviceClaim, + isLocalDeviceClaimTarget, + type DeviceClaimReconciler, + type DeviceClaimSessionOwnership, +} from './device-claims.ts'; + +/** + * The #1320 claim gate a request passes on its way from a device binding to + * device operations. It is built from the executing command's declared + * {@link DeviceClaimPolicy}, so `transient-exclusive` enforcement cannot be + * forgotten by a handler: there is no other way to obtain device operations. + * + * `admit` is called once per device binding by the request runtime bindings, + * which is where per-device deduplication already lives. + */ +export type DeviceClaimAdmission = AsyncDisposable & + Readonly<{ + /** Throws `DEVICE_IN_USE` when a foreign live claim owns the device. */ + admit(device: DeviceInfo, owner: RuntimeOwnerRef): Promise; + }>; + +const NO_CLAIM_INTERACTION: DeviceClaimAdmission = Object.freeze({ + admit: async () => {}, + [Symbol.asyncDispose]: async () => {}, +}); + +export function createDeviceClaimAdmission(params: { + policy: DeviceClaimPolicy; + command: string; + workspace: string; + stateDir: string; + reconcileOrphanedDeviceClaim: DeviceClaimReconciler; +}): DeviceClaimAdmission { + // `none`/`observe`/`require-owner` never read or write the claim store, and + // `acquire-session`/`release-session` own the session claim through the open + // and close lifecycles instead. + if (params.policy !== 'transient-exclusive') return NO_CLAIM_INTERACTION; + + // The caller admits once per device binding, so this only has to remember what + // it took in order to give it back. + const acquired: DeviceClaimSessionOwnership[] = []; + + return { + admit: async (device, owner) => { + // Remote/provider devices are owned by their provider lease, exactly as + // `open` decides through the admitted runtime owner rather than flags. + if (!isLocalDeviceClaimTarget(owner)) return; + const result = await acquireTransientDeviceClaim({ + device, + command: params.command, + workspace: params.workspace, + stateDir: params.stateDir, + reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, + }); + if (result.status === 'conflict') throw deviceClaimConflictError(device, result.conflict); + if (result.status === 'acquired') acquired.push(result.ownership); + }, + [Symbol.asyncDispose]: async () => { + for (const ownership of acquired.splice(0)) { + try { + await clearDeviceClaim(ownership); + } catch (error) { + emitDiagnostic({ + level: 'error', + phase: 'transient_device_claim_release_failed', + data: { + command: params.command, + deviceKey: ownership.deviceKey, + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + }, + }; +} diff --git a/src/daemon/device-claim-conflict.ts b/src/daemon/device-claim-conflict.ts index 07471f5de..24efe8cb5 100644 --- a/src/daemon/device-claim-conflict.ts +++ b/src/daemon/device-claim-conflict.ts @@ -3,6 +3,7 @@ import { publicPlatformString, type DeviceInfo, } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { shellQuoteIfNeeded } from '../utils/shell-quote.ts'; import { deviceClaimRequiresStaleInspection, @@ -31,16 +32,21 @@ export function buildDeviceClaimInspectionCommand( ].join(' '); } -export function buildDeviceClaimConflictError( +/** + * The single construction of the foreign-claim refusal. `open` returns it as a + * response; the request-scope binding seam throws it, because a + * `transient-exclusive` command must never receive device operations at all. + */ +export function deviceClaimConflictError( device: DeviceInfo, conflict: InspectedDeviceClaim, -): DaemonResponse { +): AppError { const owner = conflict.claim; const recoveryCommand = buildDeviceClaimInspectionCommand(device, conflict); const publicPlatform = owner ? publicPlatformString({ platform: owner.device.family, appleOs: owner.device.appleOs }) : publicPlatformString(device); - return errorResponse( + return new AppError( 'DEVICE_IN_USE', owner ? `${publicPlatform} device ${device.id} is owned by session "${owner.session}" in workspace "${owner.workspace}".` @@ -59,11 +65,21 @@ export function buildDeviceClaimConflictError( } : {}), recovery: { command: recoveryCommand }, + hint: `Inspect the owner with: ${recoveryCommand}`, + retriable: false, }, - { hint: `Inspect the owner with: ${recoveryCommand}`, retriable: false }, ); } +export function buildDeviceClaimConflictError( + device: DeviceInfo, + conflict: InspectedDeviceClaim, +): DaemonResponse { + const error = deviceClaimConflictError(device, conflict); + const { hint, retriable, ...details } = error.details ?? {}; + return errorResponse(error.code, error.message, details, { hint, retriable }); +} + function conflictReason( classification: DeviceClaimClassification, ): 'DEVICE_CLAIM_LIVE_OWNER' | 'DEVICE_CLAIM_RECOVERY_PENDING' | 'DEVICE_CLAIM_OWNER_UNCERTAIN' { diff --git a/src/daemon/device-claims.ts b/src/daemon/device-claims.ts index ede39c178..0330100f4 100644 --- a/src/daemon/device-claims.ts +++ b/src/daemon/device-claims.ts @@ -55,11 +55,29 @@ export type DeviceClaimAcquireResult = | { status: 'acquired'; ownership: DeviceClaimSessionOwnership } | { status: 'conflict'; conflict: InspectedDeviceClaim }; +/** + * A `transient-exclusive` command may find the device already claimed by a + * session of the very daemon executing it. That session claim already carries + * the exclusion the command needs, so the command adds none of its own. + */ +export type TransientDeviceClaimResult = + | DeviceClaimAcquireResult + | { status: 'covered-by-owned-claim' }; + /** Claim policy follows the admitted runtime owner, never request metadata. */ export function isLocalDeviceClaimTarget(owner: RuntimeOwnerRef): boolean { return owner.kind === 'local-family'; } +/** + * The claim `session` recorded for a command-scoped claim. Claim records carry + * no separate kind discriminant, so the session field is what tells `device + * status` that the owner is a command in flight rather than an open session. + */ +function transientDeviceClaimSession(command: string): string { + return `transient:${command}`; +} + export async function acquireDeviceClaim(params: { device: DeviceInfo; session: string; @@ -67,41 +85,104 @@ export async function acquireDeviceClaim(params: { stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; }): Promise { + const identity = deviceClaimIdentity(params.device); + const deviceKey = canonicalLocalDeviceKey(identity); + return await withDeviceClaimLock( + deviceKey, + async () => await claimHeldDevice({ ...params, deviceKey, identity }), + ); +} + +/** + * #1320 `transient-exclusive`: exclusive ownership for the duration of one + * sessionless device mutation. Identical to a session claim except that a claim + * already held by this daemon process covers the command instead of colliding + * with it — the claim file itself, not the in-memory session table, is the + * authority for that, so a claim acquired earlier in the same request (`open`'s, + * for instance) can never lock the daemon out of its own device. + */ +export async function acquireTransientDeviceClaim(params: { + device: DeviceInfo; + command: string; + workspace: string; + stateDir: string; + reconcileOrphanedDeviceClaim: DeviceClaimReconciler; +}): Promise { const identity = deviceClaimIdentity(params.device); const deviceKey = canonicalLocalDeviceKey(identity); return await withDeviceClaimLock(deviceKey, async () => { - const owner = readCurrentOwnerIdentity(); - const existingResult = await resolveExistingClaim({ + const existing = inspectDeviceClaimFile(resolveDeviceClaimPath(deviceKey)); + if ( + existing?.claim && + isClaimOwnedByThisDaemon(existing.claim, params.stateDir, readCurrentOwnerIdentity()) + ) { + return { status: 'covered-by-owned-claim' }; + } + return await claimHeldDevice({ + device: params.device, deviceKey, - owner, - session: params.session, + identity, + session: transientDeviceClaimSession(params.command), workspace: params.workspace, stateDir: params.stateDir, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, }); - if (existingResult.status !== 'available') return existingResult; - const now = Date.now(); - const claim: DeviceClaim = { - schemaVersion: DEVICE_CLAIM_SCHEMA_VERSION, - deviceKey, - device: { - ...identity, - name: params.device.name, - }, - session: params.session, - workspace: params.workspace, - stateDir: params.stateDir, - ownerPid: owner.pid, - ownerStartTime: owner.startTime, - ownerToken: crypto.randomUUID(), - createdAtMs: now, - updatedAtMs: now, - }; - writeClaim(claim); - return { status: 'acquired', ownership: ownershipFromClaim(claim) }; }); } +/** Acquisition body shared by session and transient claims; the caller holds the claim lock. */ +async function claimHeldDevice(params: { + device: DeviceInfo; + deviceKey: string; + identity: DeviceIdentity; + session: string; + workspace: string; + stateDir: string; + reconcileOrphanedDeviceClaim: DeviceClaimReconciler; +}): Promise { + const { deviceKey, identity } = params; + const owner = readCurrentOwnerIdentity(); + const existingResult = await resolveExistingClaim({ + deviceKey, + owner, + session: params.session, + workspace: params.workspace, + stateDir: params.stateDir, + reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, + }); + if (existingResult.status !== 'available') return existingResult; + const now = Date.now(); + const claim: DeviceClaim = { + schemaVersion: DEVICE_CLAIM_SCHEMA_VERSION, + deviceKey, + device: { + ...identity, + name: params.device.name, + }, + session: params.session, + workspace: params.workspace, + stateDir: params.stateDir, + ownerPid: owner.pid, + ownerStartTime: owner.startTime, + ownerToken: crypto.randomUUID(), + createdAtMs: now, + updatedAtMs: now, + }; + writeClaim(claim); + return { status: 'acquired', ownership: ownershipFromClaim(claim) }; +} + +function isClaimOwnedByThisDaemon( + claim: DeviceClaim, + stateDir: string, + owner: ReturnType, +): boolean { + return ( + claim.stateDir === stateDir && + ownerIdentityMatches({ pid: claim.ownerPid, startTime: claim.ownerStartTime }, owner) + ); +} + function deviceClaimIdentity(device: DeviceInfo): DeviceIdentity { return deviceIdentity({ ...device, diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts new file mode 100644 index 000000000..219aa0ab6 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mockResolveTargetDevice } from './session-test-harness.ts'; +import { + mockEnsureReadyRuntime, + mockShutdownTargetRuntime, + readinessDeviceRuntimeGateway, +} from './session-command-harness.ts'; +import { normalizeError } from '@agent-device/kernel/errors'; +import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; +import { + isolatedDeviceClaimStores, + retainOrphanedDeviceClaims, +} from '../../../__tests__/test-utils/device-claim-store.ts'; +import { SessionStore } from '../../session-store.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; +import { acquireDeviceClaim } from '../../device-claims.ts'; +import { inspectDeviceClaims } from '../../device-claim-inspection.ts'; +import { createRequestExecutionScope } from '../../request-execution-scope.ts'; +import { handleSessionStateCommands } from '../session-state.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; + +// #1799: `boot`/`shutdown` route through a daemon that need not own the target +// device. Two daemons on one host each keep their own state directory, so the +// host-global claim store is their only shared authority. These tests are shaped +// like that pair: daemon A's claim is written directly, then daemon B (a second +// state directory) runs the command through the production request-execution +// scope — the seam every device-mutating handler must pass through. + +const setup = isolatedDeviceClaimStores('agent-device-boot-shutdown-claim-'); +const OWNER_STATE_DIR_NAME = 'daemon-a'; + +function daemonB(stateDir: string): SessionStore { + return new SessionStore(path.join(stateDir, 'sessions')); +} + +async function claimForeignDaemon(root: string): Promise { + const ownerStateDir = path.join(root, OWNER_STATE_DIR_NAME); + fs.mkdirSync(ownerStateDir, { recursive: true }); + const acquired = await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'owner-session', + workspace: '/worktrees/daemon-a', + stateDir: ownerStateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + expect(acquired.status).toBe('acquired'); + return ownerStateDir; +} + +/** Mirrors the router: the scope disposes, and a thrown request error normalizes. */ +async function runStateCommand( + command: 'boot' | 'shutdown', + store: SessionStore, +): Promise { + const req: DaemonRequest = { + token: 't', + session: 'default', + command, + positionals: [], + flags: { platform: 'android', serial: ANDROID_EMULATOR.id }, + meta: { cwd: '/worktrees/daemon-b' }, + }; + const scope = await createRequestExecutionScope({ + req, + sessionStore: store, + leaseRegistry: new LeaseRegistry(), + deviceRuntimeGateway: readinessDeviceRuntimeGateway, + platformRequestScope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + try { + return await handleSessionStateCommands({ + req, + sessionName: 'default', + sessionStore: store, + inspectFacts: scope.inspectFacts, + bindDevice: scope.bindDevice, + }); + } catch (error) { + return { ok: false, error: normalizeError(error) }; + } finally { + await scope[Symbol.asyncDispose](); + } +} + +test('shutdown refuses a device held by a foreign live claim and never reaches the device', async () => { + const { root, stateDir } = setup(); + const ownerStateDir = await claimForeignDaemon(root); + mockResolveTargetDevice.mockResolvedValue(ANDROID_EMULATOR); + + const response = await runStateCommand('shutdown', daemonB(stateDir)); + + expect(response?.ok).toBe(false); + if (!response || response.ok) return; + expect(response.error.code).toBe('DEVICE_IN_USE'); + expect(response.error.retriable).toBe(false); + expect(response.error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + expect(response.error.details?.owner).toEqual({ + session: 'owner-session', + workspace: '/worktrees/daemon-a', + stateDir: ownerStateDir, + }); + expect(response.error.hint).toBe( + 'Inspect the owner with: agent-device device status --platform android --serial emulator-5554', + ); + expect(mockShutdownTargetRuntime).not.toHaveBeenCalled(); + // The refusal leaves the owner's claim exactly as it was. + expect(inspectDeviceClaims({}).map((entry) => entry.claim?.session)).toEqual(['owner-session']); +}); + +test('boot refuses a device held by a foreign live claim and never reaches the device', async () => { + const { root, stateDir } = setup(); + await claimForeignDaemon(root); + mockResolveTargetDevice.mockResolvedValue({ ...ANDROID_EMULATOR, booted: false }); + + const response = await runStateCommand('boot', daemonB(stateDir)); + + expect(response?.ok).toBe(false); + if (!response || response.ok) return; + expect(response.error.code).toBe('DEVICE_IN_USE'); + expect(response.error.retriable).toBe(false); + expect(response.error.details?.reason).toBe('DEVICE_CLAIM_LIVE_OWNER'); + expect(mockEnsureReadyRuntime).not.toHaveBeenCalled(); +}); + +test('shutdown on an unclaimed device succeeds and releases its transient claim', async () => { + const { stateDir } = setup(); + mockResolveTargetDevice.mockResolvedValue(ANDROID_EMULATOR); + + const response = await runStateCommand('shutdown', daemonB(stateDir)); + + expect(response?.ok).toBe(true); + expect(mockShutdownTargetRuntime).toHaveBeenCalledOnce(); + expect(inspectDeviceClaims({})).toEqual([]); +}); + +test('a failing shutdown still releases its transient claim', async () => { + const { stateDir } = setup(); + mockResolveTargetDevice.mockResolvedValue(ANDROID_EMULATOR); + mockShutdownTargetRuntime.mockRejectedValue(new Error('adb emu kill failed')); + + const response = await runStateCommand('shutdown', daemonB(stateDir)); + + expect(response?.ok).toBe(false); + expect(inspectDeviceClaims({})).toEqual([]); +}); diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index 02a26160e..a429b406d 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -9,6 +9,7 @@ import { type AppDeploymentInput, type AppDeploymentResult, type DeviceBinding, + type DeviceRuntimeGateway, type DeployMaterializedAppInput, type EnsureReadyInput, type MaterializeAppSourceInput, @@ -105,6 +106,17 @@ export function handleSessionCommands( }); } +/** + * The harness bindings as a gateway, so a test can drive the real + * `createRequestExecutionScope` seam instead of injecting `bindDevice` directly. + */ +export const readinessDeviceRuntimeGateway: DeviceRuntimeGateway = + Object.freeze({ + inspectFacts: async (device: DeviceInfo) => readinessFacts(device), + bind: async ({ device }) => await readinessBinding(device), + shutdown: async () => {}, + }); + function readinessFacts(device: DeviceInfo): RuntimeFacts { const normalAvailable = supportsReadiness(device); const headlessAvailable = device.platform === 'android' && device.kind === 'emulator'; diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 543b73101..017f7cbdd 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -52,7 +52,11 @@ import { type BindDeviceRuntime, type BindExactDeviceRuntime, type InspectDeviceRuntimeFacts, + type RequestRuntimeBindings, } from './request-runtime-binding.ts'; +import { createDeviceClaimAdmission, type DeviceClaimAdmission } from './device-claim-admission.ts'; +import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; +import { resolveCommandDeviceClaimPolicy } from '../core/command-descriptor/registry.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -160,13 +164,13 @@ export async function createRequestExecutionScope(params: { locks: executionLocks, initialKeys: executionLockKeys, }); - const runtimeBindings = - params.deviceRuntimeGateway && params.platformRequestScope - ? createRequestRuntimeBindings({ - gateway: params.deviceRuntimeGateway, - scope: params.platformRequestScope, - }) - : undefined; + const { claimAdmission, runtimeBindings } = createRequestDeviceAccess({ + command, + workspace: scopedReq.meta?.cwd ?? process.cwd(), + stateDir: sessionStore.resolveDaemonStateDir(), + deviceRuntimeGateway: params.deviceRuntimeGateway, + platformRequestScope: params.platformRequestScope, + }); const scope: RequestExecutionScope = { req: scopedReq, @@ -233,7 +237,15 @@ export async function createRequestExecutionScope(params: { throwIfRequestCanceled(scopedReq.meta?.requestId); return await requestExecutionLocks.run(async () => await scope.runAdmitted(task)); }, - [Symbol.asyncDispose]: async () => await runtimeBindings?.[Symbol.asyncDispose](), + // Claims outlive the bindings they guard: release only once no device + // operation from this request can still run. + [Symbol.asyncDispose]: async () => { + try { + await runtimeBindings?.[Symbol.asyncDispose](); + } finally { + await claimAdmission?.[Symbol.asyncDispose](); + } + }, }; requestScopeFinalizers.set(scope, (response) => { if (shouldRecordRequestEvents) { @@ -270,6 +282,47 @@ export async function createRequestExecutionScope(params: { } } +/** + * The request's device access, gated by the executing command's #1320 claim + * policy: bindings hand out device operations only after the claim admission + * derived from that policy allows it, so `transient-exclusive` commands hold an + * exclusive claim for as long as they can reach a device and every other policy + * stays out of the claim store. + */ +function createRequestDeviceAccess(params: { + command: string; + workspace: string; + stateDir: string; + deviceRuntimeGateway: DeviceRuntimeGateway | undefined; + platformRequestScope: PlatformRequestScope | undefined; +}): { + claimAdmission: DeviceClaimAdmission | undefined; + runtimeBindings: RequestRuntimeBindings | undefined; +} { + const { deviceRuntimeGateway, platformRequestScope } = params; + if (!deviceRuntimeGateway || !platformRequestScope) { + return { claimAdmission: undefined, runtimeBindings: undefined }; + } + const claimAdmission = createDeviceClaimAdmission({ + policy: resolveCommandDeviceClaimPolicy(params.command), + command: params.command, + workspace: params.workspace, + stateDir: params.stateDir, + reconcileOrphanedDeviceClaim: createDeviceClaimReconciler({ + gateway: deviceRuntimeGateway, + scope: platformRequestScope, + }), + }); + return { + claimAdmission, + runtimeBindings: createRequestRuntimeBindings({ + gateway: deviceRuntimeGateway, + scope: platformRequestScope, + admitDeviceClaim: claimAdmission.admit, + }), + }; +} + async function teardownExpiredSession(params: { session: SessionState; sessionName: string; diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts index 9b7909ec3..a42dbee98 100644 --- a/src/daemon/request-runtime-binding.ts +++ b/src/daemon/request-runtime-binding.ts @@ -50,14 +50,32 @@ export type RequestRuntimeBindings = AsyncDisposable & bindExactDevice: BindExactDeviceRuntime; }>; -/** Private broad-binding cache; handlers receive only the selected projection. */ +/** + * Private broad-binding cache; handlers receive only the selected projection. + * + * `admitDeviceClaim` is the #1320 claim gate, and it runs as part of creating a + * binding, so the per-device cache below is also what makes it run once per + * device. Binding performs no device mutation — it composes the operation + * catalog — so a binding that has not been admitted is the last state before any + * device operation exists, and admitting here covers every handler by + * construction. A refusal rejects the cached promise, so a second `bindDevice` + * for the same device re-attempts rather than inheriting a rejected binding. + */ export function createRequestRuntimeBindings(params: { gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; + admitDeviceClaim: (device: DeviceInfo, owner: RuntimeOwnerRef) => Promise; }): RequestRuntimeBindings { const cleanups = new AsyncCleanupStack(); const bindings = new Map>>(); + const admitBinding = async ( + binding: DeviceBinding, + ): Promise> => { + await params.admitDeviceClaim(binding.device, binding.owner); + return binding; + }; + const bindDevice: BindDeviceRuntime = async (device, use) => { const key = deviceIdentityKey(deviceIdentity(device)); let bindingPromise = bindings.get(key); @@ -68,23 +86,24 @@ export function createRequestRuntimeBindings(params: { intent: { kind: 'ordinary' }, scope: params.scope, }) - .then((binding) => cleanups.use(binding)); + .then((binding) => cleanups.use(binding)) + .then(admitBinding); bindings.set(key, bindingPromise); void bindingPromise.catch(() => { if (bindings.get(key) === bindingPromise) bindings.delete(key); }); } - const binding = await bindingPromise; - return narrowDeviceBinding(binding, use); + return narrowDeviceBinding(await bindingPromise, use); }; + // Exact-owner bindings deliberately bypass the cache, so they admit their own. const bindExactDevice: BindExactDeviceRuntime = async (device, owner, fence, use, scope) => { const published = await params.gateway.bind({ device, intent: { kind: 'exact-owner', owner, fence }, scope, }); - const binding = await adoptExactBinding(cleanups, published, scope); + const binding = await admitBinding(await adoptExactBinding(cleanups, published, scope)); return narrowDeviceBinding(binding, use); }; diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index d98303261..f54ca369f 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -126,7 +126,11 @@ describe('composed platform runtime gateway', () => { providerModules: [{ runtime: registration.runtime, module: providerModule }], }); - const bindings = createRequestRuntimeBindings({ gateway: runtimeGateway, scope }); + const bindings = createRequestRuntimeBindings({ + gateway: runtimeGateway, + scope, + admitDeviceClaim: async () => {}, + }); try { const response = await withTestDeviceInventory( { local: async () => [staleDevice] }, diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts index b807f2b7a..78730899d 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts @@ -46,6 +46,7 @@ function createProviderDeploymentAdmission(params: { diagnostics: { emit: () => {} }, progress: { report: () => {} }, }, + admitDeviceClaim: async () => {}, }); return { bindings,