From 29476492ec2ef12b3cd2f68af6b0f28b66ad505d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 18:52:15 +0200 Subject: [PATCH 1/6] feat(exec): hold the device claim for an external command's lifetime `agent-device exec --platform ios --udid -- [args...]` takes the host-global device claim on the wrapper process's own identity, runs the child with stdio inherit, forwards SIGINT/SIGTERM to the child's process group, exits with the child's code (128+signum for signal exits, 127 for a missing command), and releases the claim on every exit path. A killed wrapper's claim carries an explicit owner kind so the supersession proof never steals a live wrapper claim and every later acquirer reconciles a dead one as stale. - device-claims: acquireWrapperDeviceClaim records ownerKind 'wrapper' under a dedicated /exec claim dir; the session label exec: is sanitized like session names. - device-claim-inspection: the daemon-supersession proof applies only to daemon-served claims (ownerKind absent); wrapper liveness stands. - device-claim-conflict moves to daemon-contracts so the wrapper throws the same typed refusal the daemon admission seam returns. - registry: new honest 'wrapper-exclusive' claim policy for the exec descriptor. - docs: commands.md and sessions.md claim sections, README claim paragraph. --- CONTEXT.md | 4 +- README.md | 2 +- .../registry-device-claim-policy.test.ts | 1 + packages/command-registry/src/registry.ts | 16 + packages/command-registry/src/types.ts | 6 + packages/daemon-contracts/package.json | 4 + .../src}/device-claim-conflict.test.ts | 4 +- .../src}/device-claim-conflict.ts | 6 +- .../src/device-claim-inspection.ts | 7 +- .../src/device-claim-record.ts | 24 +- .../src/device-claims-wrapper.test.ts | 144 ++++++++ .../daemon-contracts/src/device-claims.ts | 45 +++ packages/daemon/package.json | 4 - .../__tests__/device-claim-admission.test.ts | 3 + .../device/device-claim-admission.ts | 2 +- .../internal/session-open-execution.ts | 2 +- .../src/device-inventory-context.ts | 10 + .../help-conformance-sample-producers.ts | 2 +- src/cli.ts | 17 +- .../commands/__tests__/exec-cli-fixture.ts | 41 ++ src/cli/commands/__tests__/exec.test.ts | 349 ++++++++++++++++++ src/cli/commands/exec.ts | 225 +++++++++++ src/commands/schema/command-overrides.ts | 21 ++ website/docs/docs/commands.md | 24 ++ website/docs/docs/sessions.md | 19 + 25 files changed, 962 insertions(+), 20 deletions(-) rename packages/{daemon/src/resources/device/__tests__ => daemon-contracts/src}/device-claim-conflict.test.ts (94%) rename packages/{daemon/src/resources/device => daemon-contracts/src}/device-claim-conflict.ts (98%) create mode 100644 packages/daemon-contracts/src/device-claims-wrapper.test.ts create mode 100644 src/cli/commands/__tests__/exec-cli-fixture.ts create mode 100644 src/cli/commands/__tests__/exec.test.ts create mode 100644 src/cli/commands/exec.ts 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..f82157fdce 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. Wrap a build that installs through the platform tools directly (`expo run:ios`, `xcodebuild`, `adb install`) in `agent-device exec -- ` to hold its device claim for the whole build. The same commands 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..6a8bb54649 100644 --- a/packages/daemon-contracts/src/device-claims.ts +++ b/packages/daemon-contracts/src/device-claims.ts @@ -10,6 +10,7 @@ import { } from '@agent-device/kernel/device'; import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { safeSessionName } from '@agent-device/host-kit/session-paths'; import { ownerIdentityMatches, readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; import { @@ -30,6 +31,7 @@ import { DEVICE_CLAIM_SCHEMA_VERSION, ownershipFromClaim, type DeviceClaim, + type DeviceClaimOwnerKind, type DeviceClaimSessionOwnership, } from './device-claim-record.ts'; import { @@ -84,6 +86,18 @@ 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. + */ +function wrapperDeviceClaimSession(command: string): string { + return `exec:${safeSessionName(command)}`; +} + export async function acquireDeviceClaim(params: { device: DeviceInfo; session: string; @@ -104,6 +118,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: 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 +196,7 @@ async function claimHeldDevice(params: { stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; + ownerKind?: DeviceClaimOwnerKind; }): Promise { const { deviceKey, identity } = params; const owner = readCurrentOwnerIdentity(); @@ -178,6 +222,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/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.test.ts b/src/cli/commands/__tests__/exec.test.ts new file mode 100644 index 0000000000..8fd1d629a0 --- /dev/null +++ b/src/cli/commands/__tests__/exec.test.ts @@ -0,0 +1,349 @@ +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)); + } +} + +/** + * 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`; +} + +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('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('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..eb28fd91a3 --- /dev/null +++ b/src/cli/commands/exec.ts @@ -0,0 +1,225 @@ +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 .', + ); + } + 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; a signal received earlier killed it at attach. */ + attach(child: { pid?: number }): void; + readonly signal: NodeJS.Signals | null; + dispose(): void; +}>; + +function attachSignalForwarding(): SignalForwarding { + let signal: NodeJS.Signals | null = null; + let child: { pid?: number } | undefined; + const forward = (received: NodeJS.Signals) => { + if (signal) return; + signal = received; + if (child?.pid !== undefined) signalProcessGroupBestEffort(child.pid, received); + }; + const onSigint = () => forward('SIGINT'); + const onSigterm = () => forward('SIGTERM'); + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigterm); + return { + attach(next) { + child = next; + if (signal && next.pid !== undefined) signalProcessGroupBestEffort(next.pid, signal); + }, + get signal() { + return signal; + }, + dispose() { + process.off('SIGINT', onSigint); + process.off('SIGTERM', onSigterm); + }, + }; +} + +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) { + outcome = 'unattributable'; + process.stderr.write( + `agent-device exec: could not release the device claim: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + 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..f6eef70cce 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/SIGTERM to the command, exits with its exit code, and releases the claim on every exit path. A killed wrapper leaves a claim every later acquirer reconciles as stale. Claims cover local devices of every platform family, never provider-leased cloud devices.', + }, + usageOverride: + 'exec [--platform ] [--device ] [--udid ] [--serial ] -- [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..cdf93b82e3 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -199,6 +199,30 @@ 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. +- The command's exit code is `exec`'s exit code. Ctrl+C (or `SIGTERM`) is forwarded to the command + before `exec` exits, and the claim is released on every exit path. A killed `exec` leaves a claim + that `device status` reports as stale and the next `open` or `exec` reconciles. +- Claims exist only for local devices: `exec` refuses targets on a cloud provider lease. +- `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..ee62117c7a 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -182,6 +182,25 @@ 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 +``` + +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 to it, and releases the claim on every exit path. If `exec` is killed outright, its +claim shows up in `agent-device device status --stale` as a claim whose owner exited, and the next +`open` or `exec` on that device clears it. 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. From f1875063aa67f8b3cd2863080b1137823aefc8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 19:03:46 +0200 Subject: [PATCH 2/6] fix(exec): keep session-paths off the claim store's eager closure The wrapper-claim label sanitizing reached host-kit/session-paths statically, growing three budgeted eager closures by one module each. Take the same safeSessionName through a function-scoped dynamic import, as the closure guard prescribes. --- packages/daemon-contracts/src/device-claims.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/daemon-contracts/src/device-claims.ts b/packages/daemon-contracts/src/device-claims.ts index 6a8bb54649..a3342e9274 100644 --- a/packages/daemon-contracts/src/device-claims.ts +++ b/packages/daemon-contracts/src/device-claims.ts @@ -10,7 +10,6 @@ import { } from '@agent-device/kernel/device'; import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { safeSessionName } from '@agent-device/host-kit/session-paths'; import { ownerIdentityMatches, readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; import { @@ -92,9 +91,12 @@ function transientDeviceClaimSession(command: string): string { * 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. + * 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. */ -function wrapperDeviceClaimSession(command: string): string { +async function wrapperDeviceClaimSession(command: string): Promise { + const { safeSessionName } = await import('@agent-device/host-kit/session-paths'); return `exec:${safeSessionName(command)}`; } @@ -141,7 +143,7 @@ export async function acquireWrapperDeviceClaim(params: { ...params, deviceKey, identity, - session: wrapperDeviceClaimSession(params.command), + session: await wrapperDeviceClaimSession(params.command), ownerKind: 'wrapper', }), ); From b628fc2d9e176207c83974c72cbb9ad39b07d1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 19:32:56 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(exec):=20review=20round=201=20=E2=80=94?= =?UTF-8?q?=20repeated=20signals,=20Windows=20refusal,=20honest=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Forward every received signal to the child group; the first one still owns the exit code, so a child that traps or ignores SIGINT stays stoppable by a later SIGTERM (r4297377444). - Refuse to start on win32 with UNSUPPORTED_PLATFORM and reason unsupported-host-platform instead of half-supporting process-group signalling and backslash relative paths there (r4297377356, r4297377386); help and docs now say macOS and Linux hosts. - Help synopsis lists every accepted selector: --target and --ios-simulator-device-set were declared but missing (r4297377464). - Docs: README shows matching selectors fencing the same device the build targets (r4297377397); sessions.md names the local-only boundary (r4297377423), the attempted-release guarantee (r4297377435), the exact auto-reconcile condition with uncertain claims staying blocked (r4297377455); commands.md documents the reboot-takeover exception (r4297377408). --- README.md | 2 +- src/cli/commands/__tests__/exec.test.ts | 53 ++++++++++++++++++++++++ src/cli/commands/exec.ts | 20 ++++++++- src/commands/schema/command-overrides.ts | 4 +- website/docs/docs/commands.md | 12 ++++-- website/docs/docs/sessions.md | 13 ++++-- 6 files changed, 93 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f82157fdce..ff78db394e 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. Wrap a build that installs through the platform tools directly (`expo run:ios`, `xcodebuild`, `adb install`) in `agent-device exec -- ` to hold its device claim for the whole build. 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. The same commands 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/src/cli/commands/__tests__/exec.test.ts b/src/cli/commands/__tests__/exec.test.ts index 8fd1d629a0..b572b08fb6 100644 --- a/src/cli/commands/__tests__/exec.test.ts +++ b/src/cli/commands/__tests__/exec.test.ts @@ -317,6 +317,59 @@ test('forwards SIGTERM to the child and exits with the signal code after releasi assert.equal(storedClaim(dirs.claimsDir), undefined); }); +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('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 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); + 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 diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index eb28fd91a3..64173ba2a7 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -52,6 +52,20 @@ export async function runExecCommand( '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); @@ -172,8 +186,10 @@ function attachSignalForwarding(): SignalForwarding { let signal: NodeJS.Signals | null = null; let child: { pid?: number } | undefined; const forward = (received: NodeJS.Signals) => { - if (signal) return; - signal = received; + // The first signal owns the exit code; every received signal still reaches + // the group, so a child that traps or ignores one signal stays stoppable + // by the next. + if (!signal) signal = received; if (child?.pid !== undefined) signalProcessGroupBestEffort(child.pid, received); }; const onSigint = () => forward('SIGINT'); diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index f6eef70cce..cf368842dc 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -72,10 +72,10 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 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/SIGTERM to the command, exits with its exit code, and releases the claim on every exit path. A killed wrapper leaves a claim every later acquirer reconciles as stale. Claims cover local devices of every platform family, never provider-leased cloud devices.', + '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/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 ] [--device ] [--udid ] [--serial ] -- [args…]', + 'exec [--platform ] [--target mobile|tv|desktop] [--device ] [--udid ] [--serial ] [--ios-simulator-device-set ] -- [args…]', listUsageOverride: 'exec -- ', positionalArgs: ['command', 'args?'], allowsExtraPositionals: true, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index cdf93b82e3..cf4831bc71 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -213,10 +213,16 @@ agent-device exec --platform android --serial emulator-5554 -- adb install app-d - 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 (or `SIGTERM`) is forwarded to the command - before `exec` exits, and the claim is released on every exit path. A killed `exec` leaves a claim - that `device status` reports as stale and the next `open` or `exec` reconciles. -- Claims exist only for local devices: `exec` refuses targets on a cloud provider lease. + and its whole process group before `exec` exits, and `exec` attempts to release the claim on every + exit path; 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. diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index ee62117c7a..ecf07f6830 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -192,13 +192,20 @@ while the build runs. Wrap the command in `exec` to hold the claim for its whole 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 to it, and releases the claim on every exit path. If `exec` is killed outright, its -claim shows up in `agent-device device status --stale` as a claim whose owner exited, and the next -`open` or `exec` on that device clears it. See +forwards Ctrl+C to it, and attempts to release the claim on every exit path; 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 From 6318cb0795c1851f131a834dbf2a2dc4404af5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 19:42:49 +0200 Subject: [PATCH 4/6] =?UTF-8?q?fix(exec):=20review=20round=202=20=E2=80=94?= =?UTF-8?q?=20README=20local-only=20clause,=20SIGINT=20cleanup=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: the hosted-device sentence no longer covers `exec`; it holds claims for local devices only (r4297509439). - exec tests: the second-signal case pins listener restoration for both SIGINT and SIGTERM, so a leaked onSigint fails the run (r4297509474). --- README.md | 2 +- src/cli/commands/__tests__/exec.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff78db394e..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. 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. 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/src/cli/commands/__tests__/exec.test.ts b/src/cli/commands/__tests__/exec.test.ts index b572b08fb6..a6df05323d 100644 --- a/src/cli/commands/__tests__/exec.test.ts +++ b/src/cli/commands/__tests__/exec.test.ts @@ -344,6 +344,7 @@ test('forwards a second signal when the child ignored the first, and keeps the f 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( [ @@ -365,6 +366,8 @@ test('forwards a second signal when the child ignored the first, and keeps the f 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); From 2665364c40f52ef3bb9fd20790d79169eeae0d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 21:10:25 +0200 Subject: [PATCH 5/6] fix(exec): forward SIGHUP and SIGQUIT so a hung-up terminal stops the build too The child leads its own session, so terminal hangup reached only the wrapper and its default action killed it before the release: the claim named a dead pid while the build kept mutating the device. attachSignalForwarding now owns one list of forwarded signals (SIGINT, SIGTERM, SIGHUP, SIGQUIT), registers and disposes every handler from it, and replays all signals received before the child existed. The claim is released only after the child exits. - exec tests: real-process SIGHUP case (fails on the previous code: wrapper killed, claim stranded, child alive) asserting release-before-exit, the child group stopping, and exit 129. - help and docs name the full forwarded set. - delete the dead outcome assignment in releaseExecDeviceClaim. - fix the stale device-claim-conflict.ts path in the help-conformance sample. --- scripts/help-conformance-sample-outputs.mjs | 2 +- .../commands/__tests__/exec-hup-fixture.ts | 44 ++++++++++++++++ src/cli/commands/__tests__/exec.test.ts | 47 +++++++++++++++++ src/cli/commands/exec.ts | 52 ++++++++++++------- src/commands/schema/command-overrides.ts | 2 +- website/docs/docs/commands.md | 7 +-- website/docs/docs/sessions.md | 3 +- 7 files changed, 133 insertions(+), 24 deletions(-) create mode 100644 src/cli/commands/__tests__/exec-hup-fixture.ts 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/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 index a6df05323d..aab79ba39d 100644 --- a/src/cli/commands/__tests__/exec.test.ts +++ b/src/cli/commands/__tests__/exec.test.ts @@ -118,6 +118,16 @@ async function waitFor(condition: () => boolean | Promise): Promise { + 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; diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index 64173ba2a7..b0cbf96aff 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -176,37 +176,52 @@ async function runClaimedChild( } type SignalForwarding = Readonly<{ - /** Forwards to the child's process group once it exists; a signal received earlier killed it at attach. */ + /** 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 signal: NodeJS.Signals | null = null; + let first: NodeJS.Signals | null = null; let child: { pid?: number } | undefined; - const forward = (received: NodeJS.Signals) => { - // The first signal owns the exit code; every received signal still reaches - // the group, so a child that traps or ignores one signal stays stoppable - // by the next. - if (!signal) signal = received; - if (child?.pid !== undefined) signalProcessGroupBestEffort(child.pid, received); - }; - const onSigint = () => forward('SIGINT'); - const onSigterm = () => forward('SIGTERM'); - process.on('SIGINT', onSigint); - process.on('SIGTERM', onSigterm); + 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 (signal && next.pid !== undefined) signalProcessGroupBestEffort(next.pid, signal); + if (next.pid !== undefined) { + for (const name of received) signalProcessGroupBestEffort(next.pid, name); + } }, get signal() { - return signal; + return first; }, dispose() { - process.off('SIGINT', onSigint); - process.off('SIGTERM', onSigterm); + for (const [name, handler] of handlers) process.off(name, handler); }, }; } @@ -221,12 +236,13 @@ async function releaseExecDeviceClaim(ownership: DeviceClaimSessionOwnership): P try { outcome = await clearDeviceClaim(ownership); } catch (error) { - outcome = 'unattributable'; 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 diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index cf368842dc..f1b977fc99 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -72,7 +72,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 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/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.', + '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…]', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index cf4831bc71..ee23ad937a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -216,9 +216,10 @@ agent-device exec --platform android --serial emulator-5554 -- adb install app-d 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 (or `SIGTERM`) is forwarded to the command - and its whole process group before `exec` exits, and `exec` attempts to release the claim on every - exit path; a release that fails or can't attribute the record prints to stderr and leaves a claim +- 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 diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index ecf07f6830..cd3321d3f3 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -200,7 +200,8 @@ The claim is held by the `exec` process itself, not by a daemon, so it lasts exa 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 to it, and attempts to release the claim on every exit path; a release that fails +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 From 5509d9a7d214e5684cfd76ec6ce69a66989e4730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 21:23:58 +0200 Subject: [PATCH 6/6] test(exec): one shared Windows skip for the POSIX signal cases Signal disposition, process-group kill, and a real SIGHUP's default action are POSIX semantics, and exec refuses before claiming on a Windows host, so the signal cases would only time out waiting for the claim there. Gate all three from one IS_WINDOWS condition (r4298409165). --- src/cli/commands/__tests__/exec.test.ts | 229 +++++++++++++----------- 1 file changed, 122 insertions(+), 107 deletions(-) diff --git a/src/cli/commands/__tests__/exec.test.ts b/src/cli/commands/__tests__/exec.test.ts index aab79ba39d..41eb1c328e 100644 --- a/src/cli/commands/__tests__/exec.test.ts +++ b/src/cli/commands/__tests__/exec.test.ts @@ -137,6 +137,12 @@ 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'); @@ -287,82 +293,88 @@ test('a foreign session acquisition is refused while the wrapper holds the devic assert.equal(acquired.status, 'acquired'); }); -test('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('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. +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); - 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.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(); @@ -387,38 +399,41 @@ test('refuses to run on a Windows host before touching the device or the claim s assert.equal(storedClaim(dirs.claimsDir), undefined); }); -test('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.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();