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 @@ -69,8 +69,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: open session, sessionless command, or exec
wrapper.

**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
2 changes: 1 addition & 1 deletion oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const LEGACY_FILE_LINE_CAPS = {
'src/daemon-client/__tests__/daemon-client.test.ts': 1340,
'src/mcp/__tests__/command-tools.test.ts': 1029,
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1227,
'packages/command-registry/src/registry.ts': 2179,
'packages/command-registry/src/registry.ts': 2159,
'packages/host-kit/src/internal/exec.ts': 1032,
'packages/react-native/src/metro/client-metro.ts': 1123,
'src/cli/commands/connection-runtime.ts': 1019,
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
100 changes: 40 additions & 60 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,18 @@ const GENERIC_MUTATING_COMMAND_TRAITS = {
| 'batchable'
>;

// Local client-backed CLI commands have no daemon route, record no session
// action, and cannot run inside a batch.
const LOCAL_CLI_COMMAND_TRAITS = {
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
} as const satisfies Pick<
Extract<CommandDescriptor, { recordsSessionAction: false }>,
'catalog' | 'recordsSessionAction' | 'timeoutPolicy' | 'batchable'
>;

// click/fill/press/longpress differ only in their timeout budget and response
// shaping: same owner file, same pre-dispatch target identity, same interaction
// route and dialog guard, same device buckets, and the same session-bound claim
Expand Down Expand Up @@ -1658,10 +1670,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
name: 'debug',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/debugging/index.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
// Wave 6 residue: this route reads local diagnostics files and has no daemon route, no
// platform import, and no injected dispatch — it was `legacy` only because the discriminator
// pass had nothing better to say about it.
Expand All @@ -1671,10 +1680,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
name: 'daemon',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/daemon.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
// `stop --clean` reconciles owner-scoped Apple runner resources through the neutral
// daemon-owner cleanup service. It is host execution, not a request-bound device operation.
Expand All @@ -1684,129 +1690,106 @@ export const RAW_COMMAND_DESCRIPTORS = [
name: 'device',
deviceClaimPolicy: 'observe',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/device.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
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 } : {}),
...LOCAL_CLI_COMMAND_TRAITS,
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',
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/metro/index.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'session',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/session.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'cdp',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/agent-cdp.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'auth',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/auth.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'plugins',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/plugins.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'connect',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'connection',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'disconnect',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'mcp',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/bin.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'proxy',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/proxy.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: NO_PLATFORM_EXECUTION,
},
{
name: 'takeover',
deviceClaimPolicy: 'observe',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/takeover.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
platformExecution: { kind: 'inventory', use: inventoryUse },
},
Expand All @@ -1825,10 +1808,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
name: 'web',
deviceClaimPolicy: 'none',
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/web.ts'] as const } : {}),
catalog: { group: 'local-cli' },
recordsSessionAction: false,
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
...LOCAL_CLI_COMMAND_TRAITS,
mcpExposed: false,
// setup/doctor mutate and inspect the managed browser installed on this host through the
// neutral managed-web-backend service. Live web sessions remain device-runtime owned.
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 @@ -56,6 +56,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 @@ -146,8 +146,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.claimOwnerKind !== 'wrapper' &&
isSupersededDaemonOwner({ ...owner, stateDir: claim.stateDir });
return { ...entry, classification: superseded ? 'owner-daemon-superseded' : liveness };
}

Expand Down
5 changes: 4 additions & 1 deletion packages/daemon-contracts/src/device-claim-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ export const DEVICE_CLAIM_SCHEMA_VERSION = 3;
* schemas wrote a command-scoped claim and an open session *named* `transient:<command>`
* identically; the session label is never re-read as the discriminant. A claim that states no kind
* can hold and fence a device but can never cover a command.
* `wrapper` names a plain CLI process holding the device for the lifetime of a child it runs
* (#3263); it is not a daemon, so the daemon-supersession proof never reads against its record.
*/
export type DeviceClaimOwnerKind = 'open-session' | 'transient-command' | 'unstated';
export type DeviceClaimOwnerKind = 'open-session' | 'transient-command' | 'wrapper' | 'unstated';

const DEVICE_CLAIM_OWNER_KINDS: readonly DeviceClaimOwnerKind[] = [
'open-session',
'transient-command',
'wrapper',
'unstated',
];

Expand Down
Loading
Loading