Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <platform>` to see what a target supports.

Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Run `agent-device device status` to inspect ownership without a daemon, and `agent-device device release --stale` to release claims whose owner is provably no longer running. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, TestMu AI, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds).
Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Run `agent-device device status` to inspect ownership without a daemon, and `agent-device device release --stale` to release claims whose owner is provably no longer running. For builds that install through the platform tools directly (`expo run:ios`, `xcodebuild`, `adb install`), pass matching device selectors before `--` and to the wrapped command — `agent-device exec --platform ios --udid <UDID> -- expo run:ios --device <UDID>` — 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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
16 changes: 16 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions packages/command-registry/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -104,6 +109,7 @@ export type DeviceClaimPolicy =
| 'observe'
| 'require-owner'
| 'transient-exclusive'
| 'wrapper-exclusive'
| 'acquire-session'
| 'release-session';

Expand Down
4 changes: 4 additions & 0 deletions packages/daemon-contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/daemon-contracts/src/device-claim-inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
24 changes: 21 additions & 3 deletions packages/daemon-contracts/src/device-claim-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
};

/**
Expand Down Expand Up @@ -122,12 +134,18 @@ function decodeClaimLocation(

function decodeClaimOwner(
raw: Record<string, unknown>,
): Pick<DeviceClaim, 'ownerPid' | 'ownerStartTime' | 'ownerToken'> | null {
const { ownerPid, ownerStartTime } = raw;
): Pick<DeviceClaim, 'ownerPid' | 'ownerStartTime' | 'ownerToken' | 'ownerKind'> | 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(
Expand Down
144 changes: 144 additions & 0 deletions packages/daemon-contracts/src/device-claims-wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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);
});
Loading
Loading