diff --git a/CONTEXT.md b/CONTEXT.md index 45a63d0004..87dfc7f8b7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,8 +70,8 @@ A mutual-exclusion guard for a platform helper process. It is not remote client _Avoid_: Device lease, process lease **Device claim**: -Host-global exclusive ownership of one local device by an open session or a sessionless mutating -command. +Host-global exclusive ownership of one local device by an open session, a sessionless mutating +command, or an `exec` wrapper for the lifetime of its child process. **Device-claim policy**: A command's observation, ownership, or exclusive-mutation rule. diff --git a/README.md b/README.md index 60c0bac2c8..d0999e4e8e 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ The same session and evidence model works at every step: the agent explores the Support depth varies by target. Some backends, such as HarmonyOS and Vega OS, cover a subset of commands; run `agent-device capabilities --platform ` to see what a target supports. -Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Run `agent-device device status` to inspect ownership without a daemon, and `agent-device device release --stale` to release claims whose owner is provably no longer running. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, TestMu AI, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds). +Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Run `agent-device device status` to inspect ownership without a daemon, and `agent-device device release --stale` to release claims whose owner is provably no longer running. For builds that install through the platform tools directly (`expo run:ios`, `xcodebuild`, `adb install`), pass matching device selectors before `--` and to the wrapped command — `agent-device exec --platform ios --udid -- expo run:ios --device ` — so the claim guards the very device the build targets; mismatched selectors fence a different device and leave the build unprotected. `exec` holds claims for local devices only; the other commands also drive hosted devices on [BrowserStack, AWS Device Farm, TestMu AI, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds). `agent-device` uses the inspect-act-verify process from Vercel's [agent-browser](https://github.com/vercel-labs/agent-browser) for mobile, TV, and desktop apps. Basic `--platform web` support runs `agent-browser` in the same session and replay system. diff --git a/packages/command-registry/src/__tests__/registry-device-claim-policy.test.ts b/packages/command-registry/src/__tests__/registry-device-claim-policy.test.ts index 7fe7e1bca6..0b601a9ac0 100644 --- a/packages/command-registry/src/__tests__/registry-device-claim-policy.test.ts +++ b/packages/command-registry/src/__tests__/registry-device-claim-policy.test.ts @@ -52,6 +52,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set 'uninstall', ], observe: ['apps', 'appstate', 'capabilities', 'device', 'devices', 'doctor', 'takeover'], + 'wrapper-exclusive': ['exec'], none: [ 'artifacts', 'auth', diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 76de176176..63988778d3 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1692,6 +1692,22 @@ export const RAW_COMMAND_DESCRIPTORS = [ mcpExposed: false, platformExecution: NO_PLATFORM_EXECUTION, }, + { + name: 'exec', + // #3263: the wrapper takes its own host-global claim through the shared + // claim store, owns it on this CLI process's identity, and releases it on + // every exit path. No daemon request ever binds a device for it. + deviceClaimPolicy: 'wrapper-exclusive', + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/exec.ts'] as const } : {}), + catalog: { group: 'local-cli' }, + recordsSessionAction: false, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + mcpExposed: false, + // Device selection resolves the selector through the local device inventory; + // the wrapped command, not this process, touches the device. + platformExecution: { kind: 'inventory', use: inventoryUse }, + }, { name: 'metro', deviceClaimPolicy: 'none', diff --git a/packages/command-registry/src/types.ts b/packages/command-registry/src/types.ts index 934bd0bc38..8472a7be25 100644 --- a/packages/command-registry/src/types.ts +++ b/packages/command-registry/src/types.ts @@ -94,6 +94,11 @@ export type CommandTimeoutPolicy = { * in `finally`. Enforced at the request scope's * device binding, so it is available only to ADR 0019 * `device-runtime` commands. + * - `wrapper-exclusive` — `exec` (#3263); a local CLI claim wrapper that + * acquires one exclusive claim on its own process + * identity, runs a child command, and releases on + * every exit path. Never binds a daemon device + * binding, so the request-scope gate never reads it. * - `acquire-session` — `open`; acquires the session claim before platform * preparation or mutation. * - `release-session` — `close`; releases the session claim only after @@ -104,6 +109,7 @@ export type DeviceClaimPolicy = | 'observe' | 'require-owner' | 'transient-exclusive' + | 'wrapper-exclusive' | 'acquire-session' | 'release-session'; diff --git a/packages/daemon-contracts/package.json b/packages/daemon-contracts/package.json index bc4d0cf0af..7cd2a4ae9b 100644 --- a/packages/daemon-contracts/package.json +++ b/packages/daemon-contracts/package.json @@ -52,6 +52,10 @@ "types": "./src/device-claim-recovery.ts", "default": "./src/device-claim-recovery.ts" }, + "./device-claim-conflict": { + "types": "./src/device-claim-conflict.ts", + "default": "./src/device-claim-conflict.ts" + }, "./device-claims": { "types": "./src/device-claims.ts", "default": "./src/device-claims.ts" diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts b/packages/daemon-contracts/src/device-claim-conflict.test.ts similarity index 94% rename from packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts rename to packages/daemon-contracts/src/device-claim-conflict.test.ts index 037847ae0c..2fb575a059 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-conflict.test.ts +++ b/packages/daemon-contracts/src/device-claim-conflict.test.ts @@ -4,8 +4,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { buildDeviceClaimConflictError, buildDeviceClaimInspectionCommand, -} from '../device-claim-conflict.ts'; -import type { InspectedDeviceClaim } from '@agent-device/daemon-contracts/device-claims'; +} from './device-claim-conflict.ts'; +import type { InspectedDeviceClaim } from './device-claims.ts'; const device: DeviceInfo = { platform: 'android', diff --git a/packages/daemon/src/resources/device/device-claim-conflict.ts b/packages/daemon-contracts/src/device-claim-conflict.ts similarity index 98% rename from packages/daemon/src/resources/device/device-claim-conflict.ts rename to packages/daemon-contracts/src/device-claim-conflict.ts index 58cc4095fe..d1fd00f906 100644 --- a/packages/daemon/src/resources/device/device-claim-conflict.ts +++ b/packages/daemon-contracts/src/device-claim-conflict.ts @@ -6,14 +6,14 @@ import { } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; +import { errorResponse } from '@agent-device/kernel/contracts'; +import type { DeviceClaimConflictReason } from '@agent-device/contracts/device'; import { deviceClaimOwnerCannotRelease, deviceClaimRequiresStaleInspection, type DeviceClaimClassification, type InspectedDeviceClaim, -} from '@agent-device/daemon-contracts/device-claims'; -import { errorResponse } from '@agent-device/kernel/contracts'; -import type { DeviceClaimConflictReason } from '@agent-device/contracts/device'; +} from './device-claims.ts'; export function buildDeviceClaimInspectionCommand( device: DeviceInfo, diff --git a/packages/daemon-contracts/src/device-claim-inspection.ts b/packages/daemon-contracts/src/device-claim-inspection.ts index 00e83dc551..c0397eac94 100644 --- a/packages/daemon-contracts/src/device-claim-inspection.ts +++ b/packages/daemon-contracts/src/device-claim-inspection.ts @@ -142,8 +142,13 @@ function classifyInspectedClaim( // release it. #2031: a replaced daemon keeps running and keeps its claim, but // clients reach the successor published for its state dir instead, so the // claim names a session no `session list` reports and no `close` can reach. + // #3263: that proof reads the state dir's daemon registration, which describes + // daemons only. A wrapper claim's owner is a plain CLI process no registration + // can ever name, so supersession does not apply to it and its liveness stands. const superseded = - liveness === 'live' && isSupersededDaemonOwner({ ...owner, stateDir: claim.stateDir }); + liveness === 'live' && + claim.ownerKind === undefined && + isSupersededDaemonOwner({ ...owner, stateDir: claim.stateDir }); return { ...entry, classification: superseded ? 'owner-daemon-superseded' : liveness }; } diff --git a/packages/daemon-contracts/src/device-claim-record.ts b/packages/daemon-contracts/src/device-claim-record.ts index 974e1400fc..6a3edca595 100644 --- a/packages/daemon-contracts/src/device-claim-record.ts +++ b/packages/daemon-contracts/src/device-claim-record.ts @@ -11,6 +11,16 @@ import { canonicalLocalDeviceKey } from './device-claim-paths.ts'; /** Schema version of a process-owned claim record; v1 records migrate to it on read. */ export const DEVICE_CLAIM_SCHEMA_VERSION = 2; +/** + * Which process role the claim's principal is. Absent means a daemon-served + * session or transient command claim, which is every record written before the + * `exec` claim wrapper (#3263) existed. `wrapper` names a plain CLI process that + * claims a device on its own behalf for the lifetime of a child process it runs; + * it is not a daemon and no registration in its state dir can ever name it, so + * the daemon-supersession proof must not be read against its record. + */ +export type DeviceClaimOwnerKind = 'wrapper'; + /** * A claim owned by one process: the session or the sessionless command that took it. Its principal * is `ownerPid`/`ownerStartTime`/`ownerToken`, which is what every clearing surface matches on. @@ -29,6 +39,8 @@ export type DeviceClaim = { updatedAtMs: number; /** Set by `abandonDeviceClaim`; absent while the claim still holds the device for its owner. */ abandonedAtMs?: number; + /** Set for claims held by an `exec` wrapper process; absent for daemon-served claims. */ + ownerKind?: DeviceClaimOwnerKind; }; /** @@ -122,12 +134,18 @@ function decodeClaimLocation( function decodeClaimOwner( raw: Record, -): Pick | null { - const { ownerPid, ownerStartTime } = raw; +): Pick | null { + const { ownerPid, ownerStartTime, ownerKind } = raw; const ownerToken = readNonEmptyString(raw.ownerToken); if (!isPositiveInteger(ownerPid) || !ownerToken) return null; if (ownerStartTime !== null && !isNonEmptyString(ownerStartTime)) return null; - return { ownerPid, ownerStartTime, ownerToken }; + if (ownerKind !== undefined && ownerKind !== 'wrapper') return null; + return { + ownerPid, + ownerStartTime, + ownerToken, + ...(ownerKind === 'wrapper' ? { ownerKind } : {}), + }; } function decodeClaimTimestamps( diff --git a/packages/daemon-contracts/src/device-claims-wrapper.test.ts b/packages/daemon-contracts/src/device-claims-wrapper.test.ts new file mode 100644 index 0000000000..20a20e7c03 --- /dev/null +++ b/packages/daemon-contracts/src/device-claims-wrapper.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { + acquireDeviceClaim, + acquireWrapperDeviceClaim, + canonicalLocalDeviceKey, + inspectDeviceClaims, +} from './device-claims.ts'; +import { resolveDeviceClaimPath } from './device-claim-paths.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import { mkdtempForTestSync, publishDaemonRegistration } from '@agent-device/test-kit'; + +// #3263: the `exec` claim wrapper records its claims with `ownerKind: 'wrapper'` +// because a plain CLI process is never the daemon a state dir's registration can +// name. These tests pin that a live wrapper claim cannot be read as superseded +// (stealable) from a registration, and that the wrapper entry point records the +// kind and the command label. + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +const roots: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + delete process.env.AGENT_DEVICE_CLAIMS_DIR; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function useClaimsRoot(): string { + const root = mkdtempForTestSync('agent-device-wrapper-claims-'); + roots.push(root); + process.env.AGENT_DEVICE_CLAIMS_DIR = root; + return root; +} + +function storedClaimFile(): string { + return resolveDeviceClaimPath(canonicalLocalDeviceKey(device)); +} + +/** + * Moves the stored claim to another live pid (the test host's parent): a foreign + * caller then judges a live foreign owner, exactly as a second workspace's + * `open` judges a wrapper it cannot reach. + */ +function handStoredClaimToAnotherLiveProcess(): void { + const filePath = storedClaimFile(); + const stored = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + fs.writeFileSync( + filePath, + JSON.stringify({ ...stored, ownerPid: process.ppid, ownerStartTime: null }), + ); +} + +test('a wrapper claim records its owner kind and names the wrapped command', async () => { + const root = useClaimsRoot(); + const acquired = await acquireWrapperDeviceClaim({ + device, + command: 'expo', + workspace: '/worktrees/wrapper', + stateDir: root, + reconcileOrphanedDeviceClaim: async () => ({ status: 'reconciled' as const }), + }); + assert.equal(acquired.status, 'acquired'); + const stored = JSON.parse(fs.readFileSync(storedClaimFile(), 'utf8')) as Record; + assert.equal(stored.ownerKind, 'wrapper'); + const inspected = inspectDeviceClaims({ serial: device.id })[0]; + assert.equal(inspected?.claim?.session, 'exec:expo'); + assert.equal(inspected?.claim?.ownerPid, process.pid); + assert.equal(inspected?.classification, 'live'); +}); + +test('a live wrapper claim survives a daemon registration published for its state dir', async () => { + // #3263, the steal bug: the supersession proof reads the state dir's + // `daemon.json`, which answers "which daemon serves this state dir" only. A + // wrapper is never named there, so a registration must not make its live + // claim reclaimable. + const root = useClaimsRoot(); + const stateDir = path.join(root, 'exec-state'); + fs.mkdirSync(stateDir, { recursive: true }); + const wrapper = await acquireWrapperDeviceClaim({ + device, + command: 'expo', + workspace: '/worktrees/wrapper', + stateDir, + reconcileOrphanedDeviceClaim: async () => ({ status: 'reconciled' as const }), + }); + assert.equal(wrapper.status, 'acquired'); + handStoredClaimToAnotherLiveProcess(); + publishDaemonRegistration(stateDir, readCurrentOwnerIdentity()); + const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); + + const second = await acquireDeviceClaim({ + device, + session: 'foreign-session', + workspace: '/worktrees/foreign', + stateDir: path.join(root, 'foreign-state'), + reconcileOrphanedDeviceClaim: reconcile, + }); + + assert.equal(second.status, 'conflict'); + if (second.status !== 'conflict') return; + assert.equal(second.conflict.classification, 'live'); + assert.equal(reconcile.mock.calls.length, 0); +}); + +test('a daemon-served claim is still reclaimed from a superseded registration', async () => { + // The closest negative: the proof stays live for the claims it was written + // about. Same registration, same foreign live owner — only a daemon-served + // claim (no owner kind) is answerable by the registration. + const root = useClaimsRoot(); + const stateDir = path.join(root, 'daemon-state'); + fs.mkdirSync(stateDir, { recursive: true }); + const seeded = await acquireDeviceClaim({ + device, + session: 'superseded-daemon-session', + workspace: '/w', + stateDir, + reconcileOrphanedDeviceClaim: async () => ({ status: 'retained', reason: 'test-no-recovery' }), + }); + assert.equal(seeded.status, 'acquired'); + handStoredClaimToAnotherLiveProcess(); + publishDaemonRegistration(stateDir, readCurrentOwnerIdentity()); + const reconcile = vi.fn(async () => ({ status: 'reconciled' as const })); + + const second = await acquireDeviceClaim({ + device, + session: 'successor-session', + workspace: '/w', + stateDir, + reconcileOrphanedDeviceClaim: reconcile, + }); + + assert.equal(second.status, 'acquired'); + assert.equal(reconcile.mock.calls.length, 1); +}); diff --git a/packages/daemon-contracts/src/device-claims.ts b/packages/daemon-contracts/src/device-claims.ts index 2fbd71f0db..a3342e9274 100644 --- a/packages/daemon-contracts/src/device-claims.ts +++ b/packages/daemon-contracts/src/device-claims.ts @@ -30,6 +30,7 @@ import { DEVICE_CLAIM_SCHEMA_VERSION, ownershipFromClaim, type DeviceClaim, + type DeviceClaimOwnerKind, type DeviceClaimSessionOwnership, } from './device-claim-record.ts'; import { @@ -84,6 +85,21 @@ function transientDeviceClaimSession(command: string): string { return `transient:${command}`; } +/** + * The claim `session` recorded for an `exec` wrapper claim. As with a transient + * claim, the session field is what tells `device status` the owner is a command + * in flight; the `exec` prefix names the wrapper that holds it for the whole + * lifetime of its child process. The command is a raw argv token and this label + * is echoed into `device status` and conflict messages, so it is sanitized the + * way session names are before being recorded. `safeSessionName`'s module stays + * behind this dynamic edge: the claim store's eager closure is budgeted, and a + * wrapper claim is taken on a cold CLI path that must not pay for it. + */ +async function wrapperDeviceClaimSession(command: string): Promise { + const { safeSessionName } = await import('@agent-device/host-kit/session-paths'); + return `exec:${safeSessionName(command)}`; +} + export async function acquireDeviceClaim(params: { device: DeviceInfo; session: string; @@ -104,6 +120,35 @@ export async function acquireDeviceClaim(params: { ); } +/** + * #3263: the claim an `exec` wrapper takes on its own process identity for the + * lifetime of a child command. Same host-global exclusion as a session claim, + * recorded with `ownerKind: 'wrapper'` so the daemon-supersession proof never + * reads a `daemon.json` against a CLI process, and with the wrapped command in + * the session label so `device status` names who holds the device. + */ +export async function acquireWrapperDeviceClaim(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 () => + await claimHeldDevice({ + ...params, + deviceKey, + identity, + session: await wrapperDeviceClaimSession(params.command), + ownerKind: 'wrapper', + }), + ); +} + /** * #1320 `transient-exclusive`: exclusive ownership for the duration of one * sessionless device mutation. Identical to a session claim except that a claim @@ -153,6 +198,7 @@ async function claimHeldDevice(params: { stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; + ownerKind?: DeviceClaimOwnerKind; }): Promise { const { deviceKey, identity } = params; const owner = readCurrentOwnerIdentity(); @@ -178,6 +224,7 @@ async function claimHeldDevice(params: { ownerToken: crypto.randomUUID(), createdAtMs: now, updatedAtMs: now, + ...(params.ownerKind ? { ownerKind: params.ownerKind } : {}), }; writeDeviceClaim(claim); return { diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 3d7ffb08b1..e8f951da2f 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -132,10 +132,6 @@ "types": "./src/core/deferred-interaction-outcome.ts", "default": "./src/core/deferred-interaction-outcome.ts" }, - "./testing/device/device-claim-conflict": { - "types": "./src/resources/device/device-claim-conflict.ts", - "default": "./src/resources/device/device-claim-conflict.ts" - }, "./testing/device/device-ready": { "types": "./src/core/device/device-ready.ts", "default": "./src/core/device/device-ready.ts" diff --git a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts index 57830bb24d..eab6c47813 100644 --- a/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts +++ b/packages/daemon/src/resources/device/__tests__/device-claim-admission.test.ts @@ -52,6 +52,9 @@ const POLICY_CLAIMS: [DeviceClaimPolicy, string[]][] = [ ['acquire-session', []], ['release-session', []], ['transient-exclusive', ['transient:made-up-command']], + // The wrapper policy is never taken through daemon admission; only the `exec` + // CLI process records such a claim, on its own identity. + ['wrapper-exclusive', []], ]; test.for(POLICY_CLAIMS)( diff --git a/packages/daemon/src/resources/device/device-claim-admission.ts b/packages/daemon/src/resources/device/device-claim-admission.ts index 3dd98c5728..e0f8c5dea8 100644 --- a/packages/daemon/src/resources/device/device-claim-admission.ts +++ b/packages/daemon/src/resources/device/device-claim-admission.ts @@ -2,7 +2,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; import type { DeviceClaimPolicy } from '@agent-device/command-registry/types'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { deviceClaimConflictError } from './device-claim-conflict.ts'; +import { deviceClaimConflictError } from '@agent-device/daemon-contracts/device-claim-conflict'; import { deviceClaimRuleForOwner } from './device-claim-rule.ts'; import { acquireTransientDeviceClaim, diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts index 5cd67bf7ef..88f1bf2475 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution.ts @@ -61,7 +61,7 @@ import { type TakenOverDeviceClaim, } from '@agent-device/daemon-contracts/device-claims'; import { appendResponseWarning } from './session-open-warnings.ts'; -import { buildDeviceClaimConflictError } from '../../../resources/device/device-claim-conflict.ts'; +import { buildDeviceClaimConflictError } from '@agent-device/daemon-contracts/device-claim-conflict'; import { deviceClaimRuleForOwner } from '../../../resources/device/device-claim-rule.ts'; import { errorResponse, diff --git a/packages/device-selection/src/device-inventory-context.ts b/packages/device-selection/src/device-inventory-context.ts index 0311ce21aa..2e354bc55d 100644 --- a/packages/device-selection/src/device-inventory-context.ts +++ b/packages/device-selection/src/device-inventory-context.ts @@ -15,6 +15,16 @@ type DeviceInventoryContext = ComposedDeviceInventoryGateways & const deviceInventoryContext = new AsyncLocalStorage(); +/** + * Whether a device inventory gateway composition is already installed for this + * async scope. The daemon installs one per request; a process-owned local + * command composes one only when none was installed, so its test seams and the + * daemon's request path share the same entry point. + */ +export function hasDeviceInventoryContext(): boolean { + return deviceInventoryContext.getStore() !== undefined; +} + export async function withDeviceInventoryContext( context: DeviceInventoryContext, task: () => Promise, diff --git a/scripts/__tests__/help-conformance-sample-producers.ts b/scripts/__tests__/help-conformance-sample-producers.ts index 4014451abf..13dbe03ff5 100644 --- a/scripts/__tests__/help-conformance-sample-producers.ts +++ b/scripts/__tests__/help-conformance-sample-producers.ts @@ -23,7 +23,7 @@ import { NEVER_SETTLED_HINT } from '../../src/commands/interaction/runtime/settl import { buildAmbiguousMatchError } from '@agent-device/daemon/testing/selector-match-errors'; import { refMutationAdmissionResponse } from '@agent-device/daemon/testing/interaction/index'; import { buildDeviceInUseBySessionError } from '@agent-device/daemon/testing/session-recovery-hints'; -import { buildDeviceClaimConflictError } from '@agent-device/daemon/testing/device/device-claim-conflict'; +import { buildDeviceClaimConflictError } from '@agent-device/daemon-contracts/device-claim-conflict'; import { activateCompleteRefFrame, readRefMutationFrame, diff --git a/scripts/help-conformance-sample-outputs.mjs b/scripts/help-conformance-sample-outputs.mjs index 3f53547f30..8d1e5a211a 100644 --- a/scripts/help-conformance-sample-outputs.mjs +++ b/scripts/help-conformance-sample-outputs.mjs @@ -91,7 +91,7 @@ Hint: Run agent-device session list to inspect active sessions. To reuse this de }; // Enforced cross-daemon device claim conflict from -// packages/daemon/src/resources/device/device-claim-conflict.ts. This path is deliberately non-retriable: +// packages/daemon-contracts/src/device-claim-conflict.ts. This path is deliberately non-retriable: // the exact status command is the only safe next step while ownership is live. export const DEVICE_CLAIM_IN_USE_SAMPLE = { command: 'agent-device open Demo --platform android --serial emulator-5554', diff --git a/src/cli.ts b/src/cli.ts index 8c0e8bd4c3..6d6c1cda5c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -151,6 +151,18 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): ); return; } + if (command === 'exec') { + // The claim wrapper runs to the child's own finish and exits with the + // child's code; it never needs the daemon, a lease, or a client. + const { runExecCommand } = await import('./cli/commands/exec.ts'); + await exitAfterFlush( + await runExecCommand(positionals, { + flags: ctx.effectiveFlags, + stateDir: ctx.daemonPaths.baseDir, + }), + ); + return; + } maybeRunUpgradeNotifier({ command, currentVersion: version, @@ -700,7 +712,10 @@ function resolveActiveConnectionDefaults(options: { options.command === 'connection' || options.command === 'daemon' || options.command === 'plugins' || - options.command === 'proxy' + options.command === 'proxy' || + // exec claims local devices only; an active remote connection's platform and + // device selectors must not leak into its local device resolution. + options.command === 'exec' ) { return null; } diff --git a/src/cli/commands/__tests__/exec-cli-fixture.ts b/src/cli/commands/__tests__/exec-cli-fixture.ts new file mode 100644 index 0000000000..37861305df --- /dev/null +++ b/src/cli/commands/__tests__/exec-cli-fixture.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { ANDROID_EMULATOR } from '@agent-device/test-kit'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { runExecCommand } from '../exec.ts'; + +// The child-process half of the SIGKILL case in exec.test.ts: a real `exec` +// wrapper whose device selection resolves through the stub inventory, so the +// test can kill the wrapper mid-child without a simulator. Paths arrive via +// EXEC_TEST_DIRS; the claim root is exported so the wrapper's own child +// inherits it. +const dirs = JSON.parse(process.env.EXEC_TEST_DIRS ?? '{}') as { + claimsDir?: string; + stateDir?: string; +}; +const claimsDir = dirs.claimsDir; +const stateDir = dirs.stateDir; +if (!claimsDir || !stateDir) { + process.stderr.write('exec-cli-fixture: EXEC_TEST_DIRS missing\n'); + process.exit(2); +} +process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + +const flags: CliFlags = { + json: false, + help: false, + version: false, + platform: 'android', + serial: ANDROID_EMULATOR.id, +}; + +fs.mkdirSync(path.join(stateDir, 'exec'), { recursive: true }); +// A short child bounds the leak when the wrapper is SIGKILLed: the claim goes +// stale the moment the wrapper's pid is gone, and a `sleep` descendant that +// outlives the direct child is exactly what must not keep the device fenced. +const exitCode = await withTestDeviceInventory( + { local: async () => [ANDROID_EMULATOR] }, + async () => await runExecCommand(['sh', '-c', 'sleep 10'], { flags, stateDir }), +); +process.exit(exitCode); diff --git a/src/cli/commands/__tests__/exec-hup-fixture.ts b/src/cli/commands/__tests__/exec-hup-fixture.ts new file mode 100644 index 0000000000..7e13380aeb --- /dev/null +++ b/src/cli/commands/__tests__/exec-hup-fixture.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { ANDROID_EMULATOR } from '@agent-device/test-kit'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { runExecCommand } from '../exec.ts'; + +// The child-process half of the SIGHUP case in exec.test.ts: a real `exec` +// wrapper receiving a real SIGHUP, exactly as a closed terminal delivers it. +// Only a real signal exercises the wrapper process's own default disposition, +// so the stub inventory runs in the wrapper's process like exec-cli-fixture.ts. +// The child records its pid and replaces the shell with `exec sleep`, keeping +// that pid, so the test can prove the group — not just the wrapper — stopped. +const dirs = JSON.parse(process.env.EXEC_TEST_DIRS ?? '{}') as { + claimsDir?: string; + stateDir?: string; +}; +const claimsDir = dirs.claimsDir; +const stateDir = dirs.stateDir; +if (!claimsDir || !stateDir) { + process.stderr.write('exec-hup-fixture: EXEC_TEST_DIRS missing\n'); + process.exit(2); +} +process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + +const flags: CliFlags = { + json: false, + help: false, + version: false, + platform: 'android', + serial: ANDROID_EMULATOR.id, +}; + +const childPidFile = path.join(stateDir, 'hup-child.pid'); +fs.mkdirSync(path.join(stateDir, 'exec'), { recursive: true }); +const exitCode = await withTestDeviceInventory( + { local: async () => [ANDROID_EMULATOR] }, + async () => + await runExecCommand(['sh', '-c', `echo $$ > ${JSON.stringify(childPidFile)}; exec sleep 8`], { + flags, + stateDir, + }), +); +process.exit(exitCode); diff --git a/src/cli/commands/__tests__/exec.test.ts b/src/cli/commands/__tests__/exec.test.ts new file mode 100644 index 0000000000..41eb1c328e --- /dev/null +++ b/src/cli/commands/__tests__/exec.test.ts @@ -0,0 +1,467 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test } from 'vitest'; +import { ANDROID_EMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { + acquireDeviceClaim, + canonicalLocalDeviceKey, + clearDeviceClaim, + inspectDeviceClaims, +} from '@agent-device/daemon-contracts/device-claims'; +import { runCmdBackground } from '@agent-device/host-kit/command'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { runExecCommand } from '../exec.ts'; + +// #3263: `exec` takes the host-global device claim on its own process identity, +// runs the child, and releases on every exit path. The claim assertions read the +// shared store directly; the device-selection seam is the same inventory context +// the daemon request path installs, so a real wrapper process can be spawned with +// a stubbed inventory. + +const deviceKey = canonicalLocalDeviceKey(ANDROID_EMULATOR); +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 }); +}); + +type ExecDirs = Readonly<{ claimsDir: string; stateDir: string }>; + +function useDirs(): ExecDirs { + const root = mkdtempForTestSync('exec-command-'); + roots.push(root); + const dirs = { claimsDir: path.join(root, 'claims'), stateDir: path.join(root, 'state') }; + fs.mkdirSync(dirs.stateDir, { recursive: true }); + process.env.AGENT_DEVICE_CLAIMS_DIR = dirs.claimsDir; + return dirs; +} + +function execFlags(): CliFlags { + return { + json: false, + help: false, + version: false, + platform: 'android', + serial: ANDROID_EMULATOR.id, + }; +} + +type RunningExec = Readonly<{ + promise: Promise; + /** Resolves once device selection has consulted the test inventory. */ + inventoryReady: () => Promise; +}>; + +function runExec(args: readonly string[], dirs: ExecDirs): RunningExec { + let releaseInventory: () => void = () => {}; + const inventoryReady = new Promise((resolve) => { + releaseInventory = resolve; + }); + const promise = withTestDeviceInventory( + { + local: async () => { + releaseInventory(); + return [ANDROID_EMULATOR]; + }, + }, + async () => await runExecCommand(args, { flags: execFlags(), stateDir: dirs.stateDir }), + ); + return { promise, inventoryReady: () => inventoryReady }; +} + +function claimFilePath(claimsDir: string): string { + return path.join( + claimsDir, + `${crypto.createHash('sha256').update(deviceKey).digest('hex')}.json`, + ); +} + +function storedClaim(claimsDir: string): Record | undefined { + try { + return JSON.parse(fs.readFileSync(claimFilePath(claimsDir), 'utf8')) as Record; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +function claimEntry(): ReturnType[number] | undefined { + return inspectDeviceClaims({ serial: ANDROID_EMULATOR.id })[0]; +} + +/** + * A foreign workspace's acquisition of the same device — the acquisition a + * daemon-side `install` or session `open` performs through this same store. + */ +async function foreignAcquireAttempt(dirs: ExecDirs): Promise { + return await withTestDeviceInventory({ local: async () => [] }, async () => + acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'other-workspace', + workspace: '/worktrees/other', + stateDir: path.join(dirs.stateDir, 'other'), + reconcileOrphanedDeviceClaim: async () => ({ status: 'reconciled' as const }), + }), + ); +} + +async function waitFor(condition: () => boolean | Promise): Promise { + const deadline = Date.now() + 10_000; + for (;;) { + if (await condition()) return; + if (Date.now() > deadline) throw new Error('condition not reached within 10s'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +/** Liveness probe by pid; signal 0 sends nothing, ESRCH means the process is gone. */ +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * A child that runs until the test releases it: the loop is bounded so a failed + * assertion ends the child instead of hanging the run, and every release path + * still reports the gate's removal to the wrapper. + */ +function gatedChildCommand(gate: string): string { + return `until [ -e ${JSON.stringify(gate)} ]; do i=$((i+1)); [ $i -ge 200 ] && exit 0; sleep 0.05; done`; +} + +// One condition for every POSIX signal case below: dispatch disposition, +// process-group `kill`, and a real SIGHUP's default action are POSIX semantics, +// and `exec` refuses to start on a Windows host before claiming anything, so +// these tests would only time out waiting for the claim there. +const IS_WINDOWS = process.platform === 'win32'; + +test('holds the claim with the command label while the child runs and releases it on exit', async () => { + const dirs = useDirs(); + const observed = path.join(dirs.stateDir, 'child-saw-claim.txt'); + // The child resolves the claim path from its own environment through the same + // claim root: the wrapper's claim must fence the device for the whole child. + const exitCode = await runExec( + [ + 'node', + '-e', + `const fs=require('fs'),path=require('path'),crypto=require('crypto'); +const key='local:android:none:emulator-5554'; +const file=path.join(process.env.AGENT_DEVICE_CLAIMS_DIR,crypto.createHash('sha256').update(key).digest('hex')+'.json'); +let session='none'; +try{session=JSON.parse(fs.readFileSync(file,'utf8')).session}catch{} +fs.writeFileSync(${JSON.stringify(observed)},session)`, + ], + dirs, + ).promise; + assert.equal(exitCode, 0); + assert.equal(fs.readFileSync(observed, 'utf8'), 'exec:node'); + assert.equal(storedClaim(dirs.claimsDir), undefined); +}); + +test('records the wrapper claim with its owner kind under an exec state dir', async () => { + const dirs = useDirs(); + // The ordinary state dir serves a daemon; the wrapper records its claim under + // a directory of its own. Planting a registration inside that dir and still + // classifying the live claim as live pins that the supersession proof — a + // daemon-only proof — never reads a wrapper claim. + fs.mkdirSync(path.join(dirs.stateDir, 'exec'), { recursive: true }); + fs.writeFileSync( + path.join(dirs.stateDir, 'exec', 'daemon.json'), + '{"pid":4242,"processStartTime":"x"}', + ); + const gate = path.join(dirs.stateDir, 'release-child.flag'); + const running = runExec(['sh', '-c', gatedChildCommand(gate)], dirs); + try { + await running.inventoryReady(); + await waitFor(() => claimEntry()?.claim?.session === 'exec:sh'); + const stored = storedClaim(dirs.claimsDir); + assert.equal(stored?.ownerKind, 'wrapper'); + assert.equal(stored?.stateDir, path.join(dirs.stateDir, 'exec')); + assert.equal(claimEntry()?.classification, 'live'); + } finally { + fs.writeFileSync(gate, ''); + } + assert.equal(await running.promise, 0); + assert.equal(storedClaim(dirs.claimsDir), undefined); +}); + +test('propagates the child exit code', async () => { + const dirs = useDirs(); + assert.equal(await runExec(['sh', '-c', 'exit 7'], dirs).promise, 7); + assert.equal(storedClaim(dirs.claimsDir), undefined); +}); + +test('runs a command named by relative path and reports a missing command as shell code 127', async () => { + const dirs = useDirs(); + // `./gradlew`-style commands are the common shape; the wrapper resolves them + // against the cwd the way a shell does. + const script = path.join(dirs.stateDir, 'build.sh'); + fs.writeFileSync(script, '#!/bin/sh\nexit 3\n'); + fs.chmodSync(script, 0o755); + const previousCwd = process.cwd(); + const exitCode = await withTestDeviceInventory( + { local: async () => [ANDROID_EMULATOR] }, + async () => { + process.chdir(dirs.stateDir); + try { + return await runExecCommand(['./build.sh'], { + flags: execFlags(), + stateDir: dirs.stateDir, + }); + } finally { + process.chdir(previousCwd); + } + }, + ); + assert.equal(exitCode, 3); + assert.equal(storedClaim(dirs.claimsDir), undefined); + + // A command that cannot start reports what a shell would: 127, not a stack. + assert.equal(await runExec(['definitely-not-a-command-3263'], dirs).promise, 127); + assert.equal(storedClaim(dirs.claimsDir), undefined); +}); + +test('reports a child killed by a signal as a signal exit code and releases', async () => { + const dirs = useDirs(); + const exitCode = await runExec(['sh', '-c', 'kill -TERM $$; wait'], dirs).promise; + assert.equal(exitCode, 128 + 15); + assert.equal(storedClaim(dirs.claimsDir), undefined); +}); + +test('refuses to start while a foreign live claim holds the device, and runs after release', async () => { + const dirs = useDirs(); + const foreignStateDir = path.join(dirs.stateDir, 'other'); + fs.mkdirSync(foreignStateDir, { recursive: true }); + const foreign = await withTestDeviceInventory({ local: async () => [] }, async () => + acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'other-workspace', + workspace: '/worktrees/other', + stateDir: foreignStateDir, + reconcileOrphanedDeviceClaim: async () => ({ + status: 'retained', + reason: 'test-no-recovery', + }), + }), + ); + assert.equal(foreign.status, 'acquired'); + if (foreign.status !== 'acquired') return; + + await assert.rejects(runExec(['true'], dirs).promise, (error: unknown) => { + const appError = error as { code?: string; details?: { reason?: string } }; + return ( + appError.code === 'DEVICE_IN_USE' && appError.details?.reason === 'DEVICE_CLAIM_LIVE_OWNER' + ); + }); + assert.equal(claimEntry()?.claim?.session, 'other-workspace'); + + await clearDeviceClaim(foreign.ownership); + assert.equal(await runExec(['true'], dirs).promise, 0); + assert.equal(storedClaim(dirs.claimsDir), undefined); +}); + +test('a foreign session acquisition is refused while the wrapper holds the device', async () => { + const dirs = useDirs(); + const gate = path.join(dirs.stateDir, 'release-child.flag'); + const running = runExec(['sh', '-c', gatedChildCommand(gate)], dirs); + await running.inventoryReady(); + await waitFor(() => claimEntry()?.claim?.session === 'exec:sh'); + + // The mirror of the live check: a parallel `install` from another session + // meets the same conflict the daemon admission seam turns into the typed + // refusal, on a claim classified live, with no reconciliation. + const conflicted = (await foreignAcquireAttempt(dirs)) as { + status?: string; + conflict?: { classification?: string }; + }; + assert.equal(conflicted.status, 'conflict'); + assert.equal(conflicted.conflict?.classification, 'live'); + + fs.writeFileSync(gate, ''); + assert.equal(await running.promise, 0); + assert.equal(storedClaim(dirs.claimsDir), undefined); + // And once the wrapper released, the same foreign acquirer gets the device. + const acquired = (await foreignAcquireAttempt(dirs)) as { status?: string }; + assert.equal(acquired.status, 'acquired'); +}); + +test.skipIf(IS_WINDOWS)( + 'forwards SIGTERM to the child and exits with the signal code after releasing', + async () => { + const dirs = useDirs(); + const gotSignal = path.join(dirs.stateDir, 'child-got-term.txt'); + const grandchildGotSignal = path.join(dirs.stateDir, 'grandchild-got-term.txt'); + const childStarted = path.join(dirs.stateDir, 'child-started.txt'); + // An in-place emit reaches every SIGTERM listener in this worker, so pin that + // the wrapper starts from the worker's own baseline and returns to it. + const sigtermListenersBefore = process.listenerCount('SIGTERM'); + const running = runExec( + [ + 'sh', + '-c', + // Arms the trap before announcing itself, then reports its own receipt of + // the forwarded signal, so the test only signals a child that can catch it. + // The background grandchild proves the forward reaches the whole process + // group: a build's toolchain, not just its driver, must die with the claim. + // Both loops are bounded so a lost forward ends them instead of leaking. + `trap 'echo got > ${JSON.stringify(gotSignal)}; exit 0' TERM; echo started > ${JSON.stringify( + childStarted, + )}; { trap 'echo got > ${JSON.stringify(grandchildGotSignal)}' TERM; j=0; while [ $j -lt 60 ]; do sleep 0.05; j=$((j+1)); done; } & i=0; while [ $i -lt 60 ]; do sleep 0.05; i=$((i+1)); done`, + ], + dirs, + ); + await running.inventoryReady(); + await waitFor(() => fs.existsSync(childStarted)); + assert.equal(claimEntry()?.claim?.session, 'exec:sh'); + // Same in-place signal pattern as the takeover command's tests. The child + // exits 0, so only a forwarded SIGTERM can write the receipt, and only the + // wrapper's own signal bookkeeping can report 143 rather than 0. A swallowed + // group-kill failure would leave both receipts missing, so their presence + // also proves the group signal left this worker. + process.emit('SIGTERM'); + assert.equal(await running.promise, 128 + 15); + assert.equal(process.listenerCount('SIGTERM'), sigtermListenersBefore); + assert.equal(fs.readFileSync(gotSignal, 'utf8').trim(), 'got'); + await waitFor(() => fs.existsSync(grandchildGotSignal)); + assert.equal(fs.readFileSync(grandchildGotSignal, 'utf8').trim(), 'got'); + assert.equal(storedClaim(dirs.claimsDir), undefined); + }, +); + +test.skipIf(IS_WINDOWS)( + 'a real SIGHUP stops the child group and releases the claim before the wrapper exits', + async () => { + const dirs = useDirs(); + // Only a real signal exercises the wrapper's own default disposition, so this + // runs the wrapper as a real process like the SIGKILL case. Before the + // SIGHUP handler existed the wrapper died on its default action, leaving the + // claim pointing at a dead pid while the child kept running — so the release + // and child-liveness assertions below are the fail-before, not the exit code + // (a default-action death also reports failure there). + const hupFixture = path.join(import.meta.dirname, 'exec-hup-fixture.ts'); + const child = runCmdBackground(process.execPath, ['--experimental-strip-types', hupFixture], { + cwd: process.cwd(), + env: { ...process.env, EXEC_TEST_DIRS: JSON.stringify(dirs) }, + captureOutput: false, + stdio: 'ignore', + allowFailure: true, + }); + try { + await waitFor(() => claimEntry()?.claim?.session === 'exec:sh'); + const childPidFile = path.join(dirs.stateDir, 'hup-child.pid'); + await waitFor(() => fs.existsSync(childPidFile)); + const childPid = Number(fs.readFileSync(childPidFile, 'utf8')); + assert.ok(Number.isInteger(childPid) && childPid > 0); + + child.child.kill('SIGHUP'); + const exited = await child.wait; + // The rule: the wrapper outlives the signal, waits for the child, and the + // claim is already gone once its exit is observable. + assert.equal(storedClaim(dirs.claimsDir), undefined); + await waitFor(() => !processAlive(childPid)); + assert.equal(exited.exitCode, 128 + 1); + } finally { + // The wrapper's child leads its own session, so no test-side group kill can + // reach it; the fixture bounds it with `sleep 8` instead. + child.child.kill('SIGKILL'); + } + }, +); + +test('refuses to run on a Windows host before touching the device or the claim store', async () => { + const dirs = useDirs(); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + try { + await assert.rejects(runExec(['true'], dirs).promise, (error: unknown) => { + const appError = error as { code?: string; details?: { reason?: string } }; + return ( + appError.code === 'UNSUPPORTED_PLATFORM' && + appError.details?.reason === 'unsupported-host-platform' + ); + }); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + } + // The refusal throws before device resolution or claim acquisition: nothing + // was written to the claim store. + assert.equal(storedClaim(dirs.claimsDir), undefined); +}); + +test.skipIf(IS_WINDOWS)( + 'forwards a second signal when the child ignored the first, and keeps the first exit code', + async () => { + const dirs = useDirs(); + const gotSignal = path.join(dirs.stateDir, 'child-got-second-term.txt'); + const childStarted = path.join(dirs.stateDir, 'child-started.txt'); + const sigintListenersBefore = process.listenerCount('SIGINT'); + const sigtermListenersBefore = process.listenerCount('SIGTERM'); + const running = runExec( + [ + 'sh', + '-c', + // Traps INT without exiting, so the first signal is survivable and only + // the forwarded second one can end the child. + `trap '' INT; trap 'echo got > ${JSON.stringify(gotSignal)}; exit 0' TERM; echo started > ${JSON.stringify( + childStarted, + )}; i=0; while [ $i -lt 60 ]; do sleep 0.05; i=$((i+1)); done`, + ], + dirs, + ); + await running.inventoryReady(); + await waitFor(() => fs.existsSync(childStarted)); + process.emit('SIGINT'); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(fs.existsSync(gotSignal), false); + process.emit('SIGTERM'); + // The first signal owns the exit code even though the second ended the child. + assert.equal(await running.promise, 128 + 2); + // Both signals were emitted, so both handlers must be gone again. + assert.equal(process.listenerCount('SIGINT'), sigintListenersBefore); + assert.equal(process.listenerCount('SIGTERM'), sigtermListenersBefore); + assert.equal(fs.readFileSync(gotSignal, 'utf8').trim(), 'got'); + assert.equal(storedClaim(dirs.claimsDir), undefined); + }, +); + +test('a SIGKILLed wrapper leaves a stale claim the next acquirer reconciles', async () => { + const dirs = useDirs(); + // Spawn a real wrapper process through the CLI entry with a stubbed + // inventory, then SIGKILL it: the claim record names a process that exited, + // and the `sleep 120` descendant that outlives the direct child keeps the + // device fenced for nobody. + const cliFixture = path.join(import.meta.dirname, 'exec-cli-fixture.ts'); + // No test-side stdio variant can exercise the wrapper's stdout/pipe cleanup + // across the real CLI entry and spawn; the claim fence itself is asserted + // against the same store through a real child of the real spawn. + const child = runCmdBackground(process.execPath, ['--experimental-strip-types', cliFixture], { + cwd: process.cwd(), + env: { ...process.env, EXEC_TEST_DIRS: JSON.stringify(dirs) }, + captureOutput: false, + stdio: 'ignore', + allowFailure: true, + }); + try { + await waitFor(() => claimEntry()?.claim?.session === 'exec:sh'); + child.child.kill('SIGKILL'); + await child.wait; + await waitFor(() => claimEntry()?.classification === 'owner-process-dead'); + + const acquired = (await foreignAcquireAttempt(dirs)) as { status?: string }; + assert.equal(acquired.status, 'acquired'); + assert.equal(claimEntry()?.claim?.session, 'other-workspace'); + } finally { + child.child.kill('SIGKILL'); + } +}); diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts new file mode 100644 index 0000000000..b0cbf96aff --- /dev/null +++ b/src/cli/commands/exec.ts @@ -0,0 +1,257 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import type { CliFlags } from '@agent-device/contracts/command'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { + acquireWrapperDeviceClaim, + clearDeviceClaim, + type DeviceClaimClearOutcome, + type DeviceClaimSessionOwnership, +} from '@agent-device/daemon-contracts/device-claims'; +import { deviceClaimConflictError } from '@agent-device/daemon-contracts/device-claim-conflict'; +import { createRecoveryPlatformScope } from '@agent-device/daemon-contracts/device-claim-recovery'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { runCmdBackground } from '@agent-device/host-kit/command'; +import { signalProcessGroupBestEffort } from '@agent-device/host-kit/process'; +import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; +import { + hasDeviceInventoryContext, + withDeviceInventoryContext, +} from '@agent-device/device-selection/device-inventory-context'; + +const EXEC_CLAIM_STATE_DIR_NAME = 'exec'; + +export type ExecCommandOptions = Readonly<{ + /** Parsed CLI flags; the device selectors (`--platform`, `--udid`, ...) select the claimed device. */ + flags: CliFlags; + /** The CLI's resolved daemon state dir; the wrapper's claim lives under it. */ + stateDir: string; +}>; + +/** + * `exec` (#3263): holds the host-global device claim for the lifetime of one + * external command, so a build system that installs and launches through the + * platform tools directly (`expo run:ios --device `, `xcodebuild`, + * `adb install`) is fenced against agent-device sessions on the same device. + * + * The claim is taken on this CLI process's own identity, never through daemon + * admission: a build can outlast any request envelope, and a claim owned by the + * wrapper dies with the wrapper, where the existing owner-liveness proofs + * reconcile it as stale. The child's exit code is the command's exit code. + */ +export async function runExecCommand( + positionals: readonly string[], + options: ExecCommandOptions, +): Promise { + const [command, ...args] = positionals; + if (!command) { + throw new AppError( + 'INVALID_ARGS', + 'exec requires a command after `--`, e.g. agent-device exec --platform ios --udid -- expo run:ios --device .', + ); + } + // Signal forwarding reaches the child's process group, which Windows does + // not model, and claims are host-global over the local devices of POSIX + // hosts only. Refuse the whole command up front rather than run a wrapper + // whose fence a stuck build could silently outlive. + if (process.platform === 'win32') { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + 'exec holds and forwards POSIX process-group signals and supports macOS and Linux hosts only.', + { + reason: 'unsupported-host-platform', + hostPlatform: process.platform, + }, + ); + } + const termination = attachSignalForwarding(); + try { + const device = await resolveExecDevice(options.flags); + if (termination.signal) return signalExitCode(termination.signal); + const ownership = await acquireExecDeviceClaim(device, command, options); + try { + if (termination.signal) return signalExitCode(termination.signal); + return await runClaimedChild(command, args, termination); + } catch (error) { + // A command missing from PATH is the child failing to start; shells report + // that as 127, and `exec` promises the command's exit code. + if (error instanceof AppError && error.code === 'TOOL_MISSING') { + process.stderr.write(`agent-device exec: ${error.message}\n`); + return 127; + } + throw error; + } finally { + await releaseExecDeviceClaim(ownership); + } + } finally { + termination.dispose(); + } +} + +async function resolveExecDevice(flags: CliFlags): Promise { + if (hasDeviceInventoryContext()) return await resolveTargetDevice(flags); + // Claims exist only for local devices, so the wrapper composes the local + // inventory itself; a scope installed by a host (a test or embedding caller) + // is reused as-is. Platform modules are named by the gateway composition, so + // it is imported only on this path. + const { createPlatformDeviceInventoryGateways } = await import('../../platform-runtime.ts'); + const gateways = await createPlatformDeviceInventoryGateways(); + return await withDeviceInventoryContext( + { ...gateways, requestScope: createRecoveryPlatformScope() }, + async () => await resolveTargetDevice(flags), + ); +} + +async function acquireExecDeviceClaim( + device: DeviceInfo, + command: string, + options: ExecCommandOptions, +): Promise { + // Exact-owner reconciliation of a stale claim composes the dead owner's + // recovery gateway, which names platform modules; import it lazily so the + // common claim-free path stays as light as `device release --stale` keeps it. + const { createOwnerScopedDeviceClaimReconciler } = + await import('@agent-device/daemon-contracts/device-claim-recovery'); + const { createClaimRecoveryGateway } = await import('../../platform-runtime-claim-recovery.ts'); + const result = await acquireWrapperDeviceClaim({ + device, + command, + workspace: process.cwd(), + stateDir: prepareExecClaimStateDir(options.stateDir), + reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler({ + scope: createRecoveryPlatformScope(), + composeGateway: createClaimRecoveryGateway, + }), + }); + if (result.status === 'conflict') throw deviceClaimConflictError(device, result.conflict); + return result.ownership; +} + +/** + * The wrapper's claims are recorded under a state dir of their own: a + * `daemon.json` published for the ordinary state dir proves what a DAEMON can + * be reached through, and proving that about a wrapper would misread its live + * claim as unreachable and hand the device to the next acquirer. + */ +function prepareExecClaimStateDir(stateDir: string): string { + const execStateDir = path.join(resolveDaemonPaths(stateDir).baseDir, EXEC_CLAIM_STATE_DIR_NAME); + fs.mkdirSync(execStateDir, { recursive: true, mode: 0o700 }); + return execStateDir; +} + +async function runClaimedChild( + command: string, + args: readonly string[], + termination: SignalForwarding, +): Promise { + // A build tool named with a path (`./gradlew`, `./scripts/build.sh`) is how the + // fence is used day to day; resolve it against the cwd the way a shell would, + // because the spawn helper keeps bare PATH names and absolute paths only. + const executable = + !path.isAbsolute(command) && command.includes('/') + ? path.resolve(process.cwd(), command) + : command; + // The child leads its own process group so a forwarded signal reaches the + // build tools it spawns — the grandchildren the fence exists to cover — not + // just its direct process. + const child = runCmdBackground(executable, args, { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit', + captureOutput: false, + allowFailure: true, + detached: true, + }); + let exitSignal: NodeJS.Signals | null = null; + child.child.once('exit', (_code, signal) => { + exitSignal = signal; + }); + termination.attach(child.child); + const result = await child.wait; + if (termination.signal) return signalExitCode(termination.signal); + if (exitSignal) return signalExitCode(exitSignal); + return result.exitCode; +} + +type SignalForwarding = Readonly<{ + /** Forwards to the child's process group once it exists; signals received earlier are replayed at attach. */ + attach(child: { pid?: number }): void; + readonly signal: NodeJS.Signals | null; + dispose(): void; +}>; + +// Every terminating signal the wrapper can catch. The child leads its own +// session, so terminal-generated signals reach only the wrapper: forwarding +// them all is what keeps the claim and the build dying together. SIGHUP is the +// closed terminal or dropped SSH connection, SIGQUIT the Ctrl-\; without a +// handler either one would take the wrapper down before the release, leaving a +// dead owner's claim while the build keeps mutating the device. SIGTSTP is +// deliberately absent: stopping the wrapper with the child left running is the +// shell's own semantics, and job control here would need a controlling +// terminal the detached child does not share. +const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const; + +function attachSignalForwarding(): SignalForwarding { + let first: NodeJS.Signals | null = null; + let child: { pid?: number } | undefined; + const received: NodeJS.Signals[] = []; + const handlers = new Map void>(); + for (const name of FORWARDED_SIGNALS) { + const handler = () => { + // The first signal owns the exit code; every received signal still reaches + // the group, so a child that traps or ignores one stays stoppable by the + // next, and the wrapper always lives to await the child and release. + if (!first) first = name; + received.push(name); + if (child?.pid !== undefined) signalProcessGroupBestEffort(child.pid, name); + }; + handlers.set(name, handler); + process.on(name, handler); + } + return { + attach(next) { + child = next; + if (next.pid !== undefined) { + for (const name of received) signalProcessGroupBestEffort(next.pid, name); + } + }, + get signal() { + return first; + }, + dispose() { + for (const [name, handler] of handlers) process.off(name, handler); + }, + }; +} + +function signalExitCode(signal: NodeJS.Signals): number { + const signum = os.constants.signals[signal]; + return 128 + (signum ?? 0); +} + +async function releaseExecDeviceClaim(ownership: DeviceClaimSessionOwnership): Promise { + let outcome: DeviceClaimClearOutcome; + try { + outcome = await clearDeviceClaim(ownership); + } catch (error) { + process.stderr.write( + `agent-device exec: could not release the device claim: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + // The wrapper is about to exit, so a claim left behind names a process that + // is gone and every acquirer reconciles it as stale. + return; + } + // The wrapper is about to exit, so a claim left behind names a process that is + // gone and every acquirer reconciles it as stale. Report it; never mask the + // child's exit code with it. + if (outcome === 'unattributable') { + process.stderr.write( + 'agent-device exec: the device claim could not be attributed on release; ' + + 'it becomes reclaimable as stale when this process exits.\n', + ); + } +} diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 1d3fa753c5..f1b977fc99 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -68,6 +68,27 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { allowedFlags: ['stale'], supportedFlags: ['platform', 'device', 'udid', 'serial'], }, + exec: { + text: { + summary: 'Run a command while holding the device claim', + description: + 'Hold the host-local exclusive device claim for the selected local device for the whole lifetime of an external command, so a build that installs or reinstalls through the platform tools directly (expo run:ios, xcodebuild, adb install) is fenced against agent-device sessions on that device. Refuses to start while another session or wrapper holds the claim, forwards SIGINT, SIGHUP, SIGQUIT and SIGTERM to the command, exits with its exit code, and attempts to release the claim on every exit path; a release that fails leaves a claim that device release --stale reports. A killed wrapper leaves a claim that the next acquirer reconciles once its owner is proven dead. Claims cover local devices of every platform family, never provider-leased cloud devices. macOS and Linux hosts only.', + }, + usageOverride: + 'exec [--platform ] [--target mobile|tv|desktop] [--device ] [--udid ] [--serial ] [--ios-simulator-device-set ] -- [args…]', + listUsageOverride: 'exec -- ', + positionalArgs: ['command', 'args?'], + allowsExtraPositionals: true, + supportedFlags: [ + 'platform', + 'target', + 'device', + 'udid', + 'serial', + 'iosSimulatorDeviceSet', + 'stateDir', + ], + }, connect: { text: { summary: 'Configure remote or provider state', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index e0014faa7f..ee23ad937a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -199,6 +199,37 @@ agent-device close --platform web - `audio probe start [durationSeconds] [bucketMs]` samples HTML media elements into RMS/peak dBFS buckets; see [Debugging & Profiling](/docs/debugging-profiling#audio-probes). - Not supported: tab/window/devtools control, network routing/interception/HAR, cookies/storage, downloads/uploads, arbitrary page scripting, multi-page orchestration, and raw browser diagnostics. Use `agent-browser` directly for those. +## Run a build or external command under a device claim + +`exec` runs any external command while holding the exclusive claim on the selected local device, so a +build that installs through the platform tools directly is fenced against `agent-device` sessions on +that device: + +```bash +agent-device exec --platform ios --udid -- expo run:ios --device +agent-device exec --platform android --serial emulator-5554 -- adb install app-debug.apk +``` + +- While `exec` runs, another workspace's `open`, `boot`, or `install` on that device fails with + `DEVICE_IN_USE` and names the holder (`session exec:`); it works again once `exec` exits. + `exec` itself refuses to start with the same error while another session or `exec` holds the device. + One exception, shared with every other claim: if the device reboots while `exec` holds the claim, + the reboot already ended the build, so `open` settles the holder's durable resources, takes the + claim, and reports the release in `warnings`. +- The command's exit code is `exec`'s exit code. Ctrl+C, terminal hangup, and Ctrl-\ (`SIGINT`, + `SIGHUP`, `SIGQUIT`, `SIGTERM`) are forwarded to the command and its whole process group, `exec` + waits for it to exit, and only then attempts to release the claim; a release that fails or can't + attribute the record prints to stderr and leaves a claim + that `device release --stale` reports. A killed `exec` leaves a claim that the next `open` or `exec` + reconciles once its owner process is proven dead. +- Claims exist only for local devices: `exec` refuses targets on a cloud provider lease. `exec` runs + on macOS and Linux hosts only; it refuses to start on Windows. +- `exec` doesn't boot the device or launch your app; it only selects the device, holds the claim, and + runs the command. Boot the device first, or let the build boot it while no session holds the claim. + +See [Sessions](/docs/sessions#run-an-external-command-on-a-claimed-device) for how this claim fits the +session and daemon claims. + ## Device isolation scopes ```bash diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index b419bac96d..cd3321d3f3 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -182,6 +182,33 @@ To free a device: claim, and reports the release in `warnings`. `device release --stale` never checks for a reboot, so it keeps refusing a claim whose owner is live. +## Run an external command on a claimed device + +A build you run yourself (`expo run:ios --device `, `xcodebuild`, `adb install`) installs +directly and doesn't take a device claim, so a parallel `agent-device` session can open that device +while the build runs. Wrap the command in `exec` to hold the claim for its whole lifetime: + +```bash +agent-device exec --platform ios --udid -- expo run:ios --device +``` + +Pass selectors before `--` that name the same device the wrapped command targets, or the claim +guards a different device and the build stays unprotected. `exec` holds claims for local devices +only; provider-leased cloud targets are rejected. + +The claim is held by the `exec` process itself, not by a daemon, so it lasts exactly as long as your +command: while `exec` runs, another workspace's `open`, `boot`, or `install` on that device fails with +`DEVICE_IN_USE` naming the holder, and succeeds again once `exec` exits. `exec` refuses to start the +same way while another session holds the device. `exec` exits with your command's exit code, +forwards Ctrl+C, terminal hangup, and Ctrl-\ (`SIGINT`, `SIGHUP`, `SIGQUIT`, `SIGTERM`) to it, +and attempts to release the claim only after the command exits; a release that fails +leaves a claim `device release --stale` reports. If `exec` is killed outright, its claim appears in +`agent-device device status --stale`. The next `open` or `exec` reconciles it automatically only +when the recorded owner process is proven dead; a claim whose owner is uncertain — its PID now +belongs to another process, or its state directory was deleted — stays blocked until you inspect it, +as with any other claim. See +[Commands](/docs/commands#run-a-build-or-external-command-under-a-device-claim) for the flags. + ## Notes - `open ` in an existing session switches the active app and updates the session's bundle id.