Skip to content
8 changes: 5 additions & 3 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,9 @@
},
{
"comment": "Daemon route handlers are reached only through the dynamic `import()` table in request-handler-chain.ts, which --production analysis cannot follow to a consumer.",
"file": "src/daemon/handlers/{lease,session,snapshot,react-native,record-trace,find,interaction}.ts",
"file": "src/daemon/handlers/{human-control,lease,session,snapshot,react-native,record-trace,find,interaction}.ts",
"exports": [
"handleHumanControlCommand",
"handleLeaseCommands",
"handleSessionCommands",
"handleSnapshotCommands",
Expand All @@ -307,7 +308,7 @@
},
{
"comment": "Dedicated CLI command handlers are reached only through the dynamic `import()` table `dedicatedCliCommandHandlerLoaders` in src/cli/commands/router.ts, which --production analysis cannot follow to a consumer. Same shape as the daemon route-handler entry above; that table is what enumerates this list, so add/remove here whenever a loader is added/removed.",
"file": "src/cli/commands/{auth,connection,daemon,device,proxy,replay,screenshot}.ts",
"file": "src/cli/commands/{auth,connection,daemon,device,proxy,replay,screenshot,takeover}.ts",
"exports": [
"authCommand",
"connectCommand",
Expand All @@ -318,7 +319,8 @@
"proxyCommand",
"replayCommand",
"screenshotCommand",
"diffCommand"
"diffCommand",
"takeoverCommand"
]
},
{
Expand Down
6 changes: 4 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ Host-global exclusive ownership of one local device by an open session or a sess
command.

**Device-claim policy**:
A command's declared relationship to local device ownership, including observation, acquisition,
release, and exclusive mutation.
A command's observation, ownership, or exclusive-mutation rule.

**Human-control hold**:
A device-scoped pause on agent mutations during human operation.

### Commands and routing

Expand Down
26 changes: 26 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,29 @@ owning lease expiry.

Backend-only leases remain valid for older remote clients, while provider-aware
clients get device-level contention and clearer recovery.

## Human control

Human-control holds coexist with an open remote session. They belong to `LeaseRegistry` and use
the same backend/provider/device contention key as device-aware leases. Hold heartbeat, expiry,
lease preservation, and release refresh are one registry-owned lifecycle. Releasing or expiring the
last hold gives the lease its existing inactivity TTL again; expiry uses the hold's expiry instant.

Tenant hold operations are ordinary daemon RPCs admitted through `request-admission.ts`. Their
device comes only from the admitted lease. Host administration is a distinct loopback capability
authenticated with the daemon token, never a tenant credential; tenants cannot modify host holds.

Mutation admission derives from existing recording effects, observation-class inventory, and
observability semantics. Takeover and lease heartbeats are exempt from the mutation fence, not from
their ownership checks. Unknown effects are treated as mutations. A pending activation fences new
mutations and drains those already admitted before reporting active; advisory execution locks alone
do not establish this guarantee for fresh sessions.

Activation follows the calling RPC or host HTTP request's cancellation signal. A disconnect while
draining removes only that request's pending hold, leaving successor and unrelated holds intact;
canceling activation does not cancel the mutations being drained. Completed holds use their TTL or
explicit release lifecycle.

Holds, like leases, are in-memory and do not survive daemon restart. Controllers must reconnect and
re-establish them; no persisted hold store is used. Local takeover is deferred: a future host-global
human-control fence must coexist with the local session's device claim, not acquire it exclusively.
21 changes: 21 additions & 0 deletions packages/contracts/src/client-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,24 @@ export type CloudArtifactsOptions = AgentDeviceRequestOverrides & {
provider?: string;
providerSessionId?: string;
};

export type HumanControlHoldScope = {
backend: LeaseBackend;
leaseProvider?: string;
deviceKey: string;
};

export type HumanControlHold = {
id: string;
scope: HumanControlHoldScope;
reason?: string;
state: 'activating' | 'active';
createdAt: number;
updatedAt: number;
expiresAt?: number;
};

export type HumanControlHoldOptions = {
reason?: string;
ttlMs?: number;
};
3 changes: 3 additions & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export type {
} from '../client-gesture.ts';
export type {
CloudArtifactsOptions,
HumanControlHold,
HumanControlHoldOptions,
HumanControlHoldScope,
Lease,
LeaseAllocateOptions,
LeaseOptions,
Expand Down
2 changes: 1 addition & 1 deletion scripts/__tests__/test-file-size-ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const PINNED_TEST_FILE_LINES: Readonly<Record<string, number>> = Object.freeze({
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1556,
'src/utils/__tests__/daemon-client-lifecycle.test.ts': 1413,
'packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts': 1325,
'src/__tests__/cli-client-commands.test.ts': 1317,
'src/__tests__/cli-client-commands.test.ts': 1304,
'src/__tests__/cli-config.test.ts': 1282,
'src/daemon/handlers/__tests__/find.test.ts': 1199,
'packages/platform-apple/src/core/__tests__/perf.test.ts': 1222,
Expand Down
17 changes: 2 additions & 15 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { SettingsUpdateOptions } from '@agent-device/contracts/client';
import { AppError } from '@agent-device/kernel/errors';
import { resolveCliOptions } from '../cli/resolve-cli-options.ts';
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
import { createStubClientLeases } from './test-utils/client-lease-fixtures.ts';

// #1802: the replay client reads the script it names, so CLI-level replay cases need a real file.
const REPLAY_SCRIPT_ROOT = mkdtempForTestSync('agent-device-cli-replay-scripts-');
Expand Down Expand Up @@ -1214,21 +1215,7 @@ function createStubClient(params: {
identifiers: { session: options.session ?? 'default' },
}),
},
leases: {
allocate: async (options) => ({
leaseId: 'lease-1',
tenantId: options.tenant,
runId: options.runId,
backend: options.leaseBackend ?? 'ios-simulator',
}),
heartbeat: async (options) => ({
leaseId: options.leaseId,
tenantId: options.tenant ?? 'tenant',
runId: options.runId ?? 'run',
backend: options.leaseBackend ?? 'ios-simulator',
}),
release: async () => ({ released: true }),
},
leases: createStubClientLeases(),
metro: {
prepare:
params.prepareMetro ??
Expand Down
28 changes: 28 additions & 0 deletions src/__tests__/daemon-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,34 @@ test('daemon proxy rejects unauthenticated rpc requests', async (t) => {
}
});

test('daemon proxy does not expose local human-control administration', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;

let upstreamCalled = false;
const upstream = http.createServer((_req, res) => {
upstreamCalled = true;
res.end('{}');
});
const proxy = createDaemonProxyServer({
upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`,
upstreamToken: 'daemon-secret',
clientToken: 'proxy-secret',
});

try {
const proxyPort = await listenOnLoopback(proxy);
const response = await fetch(
`http://127.0.0.1:${String(proxyPort)}/agent-device/admin/human-control/holds`,
{ headers: { authorization: 'Bearer proxy-secret' } },
);
assert.equal(response.status, 404);
assert.equal(upstreamCalled, false);
} finally {
await closeLoopbackServer(proxy);
await closeLoopbackServer(upstream);
}
});

test('daemon proxy leaves health endpoint unauthenticated', async (t) => {
if (await skipWhenLoopbackUnavailable(t)) return;

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/eager-closure-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ export const HUB_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
// readers), `input-audience.ts` (who may write a key), and `common-input-fields.ts` (the table
// itself). Every command schema already evaluated all three concerns; the growth is three more
// module records for the same code, with no new subtree behind any of them.
'src/cli.ts': 381,
'src/cli.ts': 382,
'src/platform-runtime.ts': 47,
'src/core/command-descriptor/registry.ts': 71,
'src/core/command-descriptor/platform-execution-entry.ts': 3,
Expand Down
115 changes: 115 additions & 0 deletions src/__tests__/takeover-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test, vi } from 'vitest';
import { createAgentDeviceClient } from '../agent-device-client.ts';
import {
renderTakeoverStarted,
renderTakeoverStatus,
takeoverCommand,
} from '../cli/commands/takeover.ts';
import {
HUMAN_CONTROL_HOLD,
createControlLatch,
} from '../daemon/__tests__/human-control-fixtures.ts';
import type { DaemonRequest } from '@agent-device/kernel/contracts';
import { TAKEOVER_CLI_FLAGS } from './test-utils/client-lease-fixtures.ts';

const mocks = vi.hoisted(() => ({ writeCommandOutput: vi.fn() }));
vi.mock('../cli/commands/shared.ts', () => ({ writeCommandOutput: mocks.writeCommandOutput }));
beforeEach(() => mocks.writeCommandOutput.mockReset());
afterEach(() => vi.useRealTimers());

function clientForTest() {
const transport = vi.fn(async (req: Omit<DaemonRequest, 'token'>) => ({
ok: true as const,
data:
req.positionals?.[0] === 'list'
? { holds: [HUMAN_CONTROL_HOLD] }
: req.positionals?.[0] === 'remove'
? { released: true }
: { hold: { ...HUMAN_CONTROL_HOLD, id: req.positionals?.[1] } },
}));
const client = createAgentDeviceClient(
{ session: 'remote-session', leaseId: 'lease-1', tenant: 'tenant-a', runId: 'run-a' },
{ transport },
);
return { client, transport };
}

test('takeover output explains the hold and release gesture', () => {
assert.match(
renderTakeoverStarted(HUMAN_CONTROL_HOLD),
/Human control active for ios:mobile:sim-1/,
);
assert.match(renderTakeoverStarted(HUMAN_CONTROL_HOLD), /Press Ctrl\+C/);
assert.equal(renderTakeoverStatus([]), 'No active human-control holds.');
assert.match(renderTakeoverStatus([HUMAN_CONTROL_HOLD]), /operator-1: ios:mobile:sim-1/);
});

test('takeover status and release use the configured lease client', async () => {
const { client, transport } = clientForTest();
await takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: ['status'] });
await takeoverCommand({
client,
flags: TAKEOVER_CLI_FLAGS,
positionals: ['release', 'operator-1'],
});
assert.deepEqual(
transport.mock.calls.map(([req]) => req.positionals),
[['list'], ['remove', 'operator-1']],
);
for (const [req] of transport.mock.calls) {
assert.equal(req.command, 'human_control');
assert.equal(req.session, 'remote-session');
assert.equal(req.meta?.leaseId, 'lease-1');
}
});

test('takeover rejects malformed actions without contacting the daemon', async () => {
const { client, transport } = clientForTest();
for (const positionals of [['release'], ['status', 'extra'], ['unknown']]) {
await assert.rejects(takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals }), {
code: 'INVALID_ARGS',
});
}
assert.equal(transport.mock.calls.length, 0);
});

test('foreground takeover renews its admitted lease hold and releases it on Ctrl+C', async () => {
vi.useFakeTimers();
const { client, transport } = clientForTest();
const started = createControlLatch();
mocks.writeCommandOutput.mockImplementationOnce(() => started.resolve());
const pending = takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: [] });
await started.promise;
await vi.advanceTimersByTimeAsync(5_000);
process.emit('SIGINT');
assert.equal(await pending, true);
assert.deepEqual(
transport.mock.calls.map(([req]) => req.positionals?.[0]),
['put', 'put', 'remove'],
);
assert.equal(
transport.mock.calls[0]?.[0].positionals?.[1],
transport.mock.calls[2]?.[0].positionals?.[1],
);
const input = JSON.parse(transport.mock.calls[0]?.[0].positionals?.[2] ?? '{}') as Record<
string,
unknown
>;
assert.equal(input.ttlMs, 15_000);
assert.equal(input.scope, undefined);
});

test('a failed heartbeat stops foreground takeover and attempts release', async () => {
vi.useFakeTimers();
const { client, transport } = clientForTest();
const started = createControlLatch();
mocks.writeCommandOutput.mockImplementationOnce(() => started.resolve());
const pending = takeoverCommand({ client, flags: TAKEOVER_CLI_FLAGS, positionals: [] });
const rejected = assert.rejects(pending, /heartbeat failed/);
await started.promise;
transport.mockRejectedValueOnce(new Error('heartbeat failed'));
await vi.advanceTimersByTimeAsync(5_000);
await rejected;
assert.equal(transport.mock.calls.at(-1)?.[0].positionals?.[0], 'remove');
});
29 changes: 29 additions & 0 deletions src/__tests__/test-utils/client-lease-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { AgentDeviceClient } from '../../agent-device-client.ts';
import type { CliFlags } from '@agent-device/contracts/command';

export const TAKEOVER_CLI_FLAGS: CliFlags = { json: true, help: false, version: false };

export function createStubClientLeases(): AgentDeviceClient['leases'] {
return {
allocate: async (options) => ({
leaseId: 'lease-1',
tenantId: options.tenant,
runId: options.runId,
backend: options.leaseBackend ?? 'ios-simulator',
}),
heartbeat: async (options) => ({
leaseId: options.leaseId,
tenantId: options.tenant ?? 'tenant',
runId: options.runId ?? 'run',
backend: options.leaseBackend ?? 'ios-simulator',
}),
release: async () => ({ released: true }),
humanControl: {
list: async () => [],
put: async () => {
throw new Error('Unexpected takeover in this fixture');
},
remove: async () => false,
},
};
}
1 change: 1 addition & 0 deletions src/__tests__/test-utils/property-arbitraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ const REPLAY_SCRIPT_LINE_PLANS = {
'trigger-app-event': GENERIC_REPLAY_LINE,
'tv-remote': GENERIC_REPLAY_LINE,
viewport: GENERIC_REPLAY_LINE,
human_control: { waived: 'host-local control commands are never recorded in replay scripts' },
install_source: GENERIC_REPLAY_LINE,
lease_allocate: GENERIC_REPLAY_LINE,
lease_heartbeat: GENERIC_REPLAY_LINE,
Expand Down
Loading
Loading