From 1ad90f08b9e605a0668322188af62599ec8f1fb4 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 27 Aug 2026 13:05:24 +0200 Subject: [PATCH 01/10] feat: add human takeover controls --- .fallowrc.json | 8 +- CONTEXT.md | 6 +- src/__tests__/daemon-proxy.test.ts | 28 ++ src/__tests__/takeover-command.test.ts | 88 +++++ .../test-utils/property-arbitraries.ts | 1 + src/cli-schema/cli-help-command-usage.test.ts | 10 + src/cli-schema/cli-help-topics.test.ts | 8 + src/cli-schema/cli-help.ts | 7 + src/cli-schema/command-overrides.ts | 21 ++ src/cli.ts | 7 +- src/cli/commands/router.ts | 1 + src/cli/commands/takeover.ts | 297 +++++++++++++++++ .../__tests__/device-claim-policy.test.ts | 3 +- .../daemon-command-descriptor.ts | 3 + src/core/command-descriptor/registry.ts | 208 ++++++++++-- .../__tests__/daemon-command-registry.test.ts | 40 +++ .../__tests__/human-control-http.test.ts | 142 +++++++++ .../__tests__/human-control-request.test.ts | 100 ++++++ src/daemon/__tests__/human-control.test.ts | 82 +++++ src/daemon/__tests__/lease-registry.test.ts | 30 ++ .../__tests__/request-handler-catalog.test.ts | 3 + src/daemon/daemon-command-registry.ts | 8 + src/daemon/handlers/human-control.ts | 59 ++++ src/daemon/human-control-contract.ts | 68 ++++ src/daemon/human-control-http.ts | 132 ++++++++ src/daemon/human-control-request.ts | 72 +++++ src/daemon/human-control.ts | 300 ++++++++++++++++++ src/daemon/lease-registry.ts | 21 +- src/daemon/request-execution-scope.ts | 49 +-- src/daemon/request-handler-chain.ts | 19 ++ src/daemon/request-router.ts | 10 + src/daemon/route-owner-files.ts | 1 + src/daemon/server/daemon-runtime.ts | 26 ++ src/daemon/server/http-server.ts | 40 ++- website/docs/docs/commands.md | 22 ++ website/docs/docs/remote-proxy.md | 44 +++ website/docs/docs/security-trust.md | 8 +- 37 files changed, 1906 insertions(+), 66 deletions(-) create mode 100644 src/__tests__/takeover-command.test.ts create mode 100644 src/cli/commands/takeover.ts create mode 100644 src/daemon/__tests__/human-control-http.test.ts create mode 100644 src/daemon/__tests__/human-control-request.test.ts create mode 100644 src/daemon/__tests__/human-control.test.ts create mode 100644 src/daemon/handlers/human-control.ts create mode 100644 src/daemon/human-control-contract.ts create mode 100644 src/daemon/human-control-http.ts create mode 100644 src/daemon/human-control-request.ts create mode 100644 src/daemon/human-control.ts diff --git a/.fallowrc.json b/.fallowrc.json index ab2831e29c..04b4320345 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -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", @@ -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", @@ -318,7 +319,8 @@ "proxyCommand", "replayCommand", "screenshotCommand", - "diffCommand" + "diffCommand", + "takeoverCommand" ] }, { diff --git a/CONTEXT.md b/CONTEXT.md index 695050ebbb..f81904baf7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/src/__tests__/daemon-proxy.test.ts b/src/__tests__/daemon-proxy.test.ts index 6d66d96264..6ae2ed85bb 100644 --- a/src/__tests__/daemon-proxy.test.ts +++ b/src/__tests__/daemon-proxy.test.ts @@ -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; diff --git a/src/__tests__/takeover-command.test.ts b/src/__tests__/takeover-command.test.ts new file mode 100644 index 0000000000..7013c06984 --- /dev/null +++ b/src/__tests__/takeover-command.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vitest'; +import type { AgentDeviceClient } from '../agent-device-client.ts'; +import { + renderTakeoverStarted, + renderTakeoverStatus, + takeoverCommand, +} from '../cli/commands/takeover.ts'; +import type { HumanControlHold } from '../daemon/human-control-contract.ts'; + +const mocks = vi.hoisted(() => ({ + sendRequest: vi.fn(), + writeCommandOutput: vi.fn(), +})); + +vi.mock('../daemon/client/daemon-client-lifecycle.ts', () => ({ + ensureDaemon: async () => ({ info: { port: 1234, token: 'daemon-token' } }), + resolveClientSettings: () => ({ paths: { socketPath: '/tmp/daemon.sock' } }), +})); + +vi.mock('../daemon/client/daemon-client-transport.ts', () => ({ + sendRequest: mocks.sendRequest, +})); + +vi.mock('../cli/commands/shared.ts', () => ({ + writeCommandOutput: mocks.writeCommandOutput, +})); + +const HOLD: HumanControlHold = { + id: 'takeover-1', + scope: { deviceKey: 'sim-1', deviceName: 'iPhone 17 Pro', platform: 'ios' }, + reason: 'Human is interacting with the simulator.', + createdAt: 1_000, + updatedAt: 1_000, + expiresAt: 16_000, +}; + +beforeEach(() => { + mocks.sendRequest.mockReset(); + mocks.writeCommandOutput.mockReset(); +}); + +test('takeover output explains the active hold and release gesture', () => { + assert.equal( + renderTakeoverStarted(HOLD), + [ + 'Human control active for iPhone 17 Pro (sim-1).', + 'Agent interactions are paused. Press Ctrl+C to return control.', + 'Hold: takeover-1', + ].join('\n'), + ); + assert.equal(renderTakeoverStatus([]), 'No active human-control holds.'); + assert.match(renderTakeoverStatus([HOLD]), /takeover-1: iPhone 17 Pro \(sim-1\)/); +}); + +test('takeover status lists holds through the local daemon command', async () => { + mocks.sendRequest.mockResolvedValue({ ok: true, data: { holds: [HOLD] } }); + + assert.equal(await runTakeover(['status']), true); + assert.deepEqual(mocks.sendRequest.mock.calls[0]?.[1].positionals, ['list']); + assert.deepEqual(mocks.writeCommandOutput.mock.calls[0]?.[1], { holds: [HOLD] }); +}); + +test('takeover release removes the named hold through the local daemon command', async () => { + mocks.sendRequest.mockResolvedValue({ ok: true, data: { released: true } }); + + assert.equal(await runTakeover(['release', 'takeover-1']), true); + assert.deepEqual(mocks.sendRequest.mock.calls[0]?.[1].positionals, ['remove', 'takeover-1']); + assert.deepEqual(mocks.writeCommandOutput.mock.calls[0]?.[1], { + holdId: 'takeover-1', + released: true, + }); +}); + +test('takeover rejects malformed actions before contacting the daemon', async () => { + await assert.rejects(runTakeover(['status', 'extra']), /does not accept additional arguments/); + await assert.rejects(runTakeover(['release']), /requires a hold id/); + await assert.rejects(runTakeover(['unknown']), /accepts only/); + assert.equal(mocks.sendRequest.mock.calls.length, 0); +}); + +async function runTakeover(positionals: string[]): Promise { + return await takeoverCommand({ + positionals, + flags: { json: false, help: false, version: false }, + client: {} as AgentDeviceClient, + }); +} diff --git a/src/__tests__/test-utils/property-arbitraries.ts b/src/__tests__/test-utils/property-arbitraries.ts index 4dada8f5f0..fa3d521525 100644 --- a/src/__tests__/test-utils/property-arbitraries.ts +++ b/src/__tests__/test-utils/property-arbitraries.ts @@ -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, diff --git a/src/cli-schema/cli-help-command-usage.test.ts b/src/cli-schema/cli-help-command-usage.test.ts index eb504a483b..e1a6346419 100644 --- a/src/cli-schema/cli-help-command-usage.test.ts +++ b/src/cli-schema/cli-help-command-usage.test.ts @@ -174,6 +174,16 @@ test('proxy command help describes tunnel usage', async () => { assert.doesNotMatch(help, /agent-device-proxy/); }); +test('takeover command help documents local foreground and VM API flows', async () => { + const help = await usageForCommand('takeover'); + if (help === null) throw new Error('Expected command help text'); + assert.match(help, /Usage:\s+agent-device takeover/); + assert.match(help, /foreground command pauses state-changing agent commands/); + assert.match(help, /until Ctrl\+C/); + assert.match(help, /\/admin\/human-control\/holds/); + assert.match(help, /always targets the local daemon/); +}); + test('connect command help lists lease id in usage and flags', async () => { const help = await usageForCommand('connect'); if (help === null) throw new Error('Expected command help text'); diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index c59c6fb4ea..5f8213cba0 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -167,6 +167,14 @@ test('usageForCommand resolves Maestro compatibility help topic', async () => { assert.doesNotMatch(help, /issues\/558/); }); +test('remote help documents host-local takeover controls', async () => { + const help = await usageForCommand('remote'); + if (help === null) throw new Error('Expected remote help text'); + assert.match(help, /agent-device takeover --platform ios/); + assert.match(help, /authenticated GET\/PUT\/DELETE requests/); + assert.match(help, /not forwarded by agent-device proxy/); +}); + test('usageForCommand resolves workflow help topic', async () => { const help = await usageForCommand('workflow'); if (help === null) throw new Error('Expected workflow help text'); diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index 6a4b4a11a3..a56d72e7e2 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -758,6 +758,13 @@ Direct proxy flow for a remote Mac/simulator: agent-device close agent-device disconnect +Human takeover on the device host: + Run agent-device takeover with the same device selector on the machine or VM that owns the target. It pauses state-changing agent commands until Ctrl+C while snapshots and other read-only diagnostics remain available. takeover always controls the local daemon, even when that CLI has a saved remote connection. + agent-device takeover --platform ios --device "iPhone 17 Pro" + agent-device takeover status + agent-device takeover release + An HTTP-mode daemon also accepts authenticated GET/PUT/DELETE requests at /admin/human-control/holds on its loopback listener. Use the daemon token from the same host. This host-admin route is intentionally not forwarded by agent-device proxy. + Cloud profile flow: agent-device connect agent-device open com.example.app diff --git a/src/cli-schema/command-overrides.ts b/src/cli-schema/command-overrides.ts index 29181b5cb9..78a7a8914a 100644 --- a/src/cli-schema/command-overrides.ts +++ b/src/cli-schema/command-overrides.ts @@ -142,6 +142,27 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { listUsageOverride: 'proxy', allowedFlags: ['proxyHost', 'proxyPort', 'daemonAuthToken', 'stateDir'], }, + takeover: { + text: { + summary: 'Pause agent interactions while a person controls a device', + description: + 'Temporarily hand control of a locally attached simulator or device to a person. Run this on the host that owns the target. The foreground command pauses state-changing agent commands, renews the hold until Ctrl+C, and then releases it. Read-only diagnostics remain available. status and release inspect or recover local holds. HTTP-mode daemons also expose an authenticated loopback API under /admin/human-control/holds for host-side automation; the external proxy does not forward this route. This command always targets the local daemon, even when a remote connection is active.', + }, + usageOverride: + 'takeover [status | release ] [--platform ] [--device ] [--udid ] [--serial ]', + listUsageOverride: 'takeover [status|release]', + positionalArgs: ['status|release?', 'hold-id?'], + supportedFlags: [ + 'stateDir', + 'platform', + 'target', + 'device', + 'udid', + 'serial', + 'iosSimulatorDeviceSet', + 'androidDeviceAllowlist', + ], + }, 'react-devtools': { text: { summary: 'Inspect components, hooks, and render profiles', diff --git a/src/cli.ts b/src/cli.ts index c0c96773bb..8416edc4c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -91,6 +91,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([ 'metro', 'proxy', 'session', + 'takeover', ]); export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise { @@ -702,7 +703,8 @@ function resolveActiveConnectionDefaults(options: { options.command === 'connect' || options.command === 'connection' || options.command === 'daemon' || - options.command === 'proxy' + options.command === 'proxy' || + options.command === 'takeover' ) { return null; } @@ -730,7 +732,8 @@ function shouldResolveRemoteAuth(command: string): boolean { command !== 'connection' && command !== 'daemon' && command !== 'device' && - command !== 'proxy' + command !== 'proxy' && + command !== 'takeover' ); } diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 1d8408e0aa..da787a1ab2 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -16,6 +16,7 @@ const dedicatedCliCommandHandlerLoaders = { daemon: async () => (await import('./daemon.ts')).daemonCommand, device: async () => (await import('./device.ts')).deviceCommand, proxy: async () => (await import('./proxy.ts')).proxyCommand, + takeover: async () => (await import('./takeover.ts')).takeoverCommand, replay: async () => (await import('./replay.ts')).replayCommand, screenshot: async () => (await import('./screenshot.ts')).screenshotCommand, diff: async () => (await import('./screenshot.ts')).diffCommand, diff --git a/src/cli/commands/takeover.ts b/src/cli/commands/takeover.ts new file mode 100644 index 0000000000..19c36e1c30 --- /dev/null +++ b/src/cli/commands/takeover.ts @@ -0,0 +1,297 @@ +import { randomUUID } from 'node:crypto'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError, throwDaemonError, toAppErrorCode } from '@agent-device/kernel/errors'; +import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; +import { resolveTargetDevice } from '../../core/dispatch-resolve.ts'; +import { + ensureDaemon, + resolveClientSettings, +} from '../../daemon/client/daemon-client-lifecycle.ts'; +import { sendRequest } from '../../daemon/client/daemon-client-transport.ts'; +import { buildDaemonHttpAuthHeaders } from '../../daemon/http-contract.ts'; +import type { + HumanControlHold, + HumanControlHoldInput, +} from '../../daemon/human-control-contract.ts'; +import { HUMAN_CONTROL_HTTP_PREFIX } from '../../daemon/human-control.ts'; +import { writeCommandOutput } from './shared.ts'; +import type { ClientCommandHandler } from './router-types.ts'; + +const FOREGROUND_HOLD_TTL_MS = 15_000; +const FOREGROUND_HEARTBEAT_MS = 5_000; +const HUMAN_CONTROL_REQUEST_TIMEOUT_MS = 20_000; + +type HumanControlListResponse = { + ok: boolean; + holds?: HumanControlHold[]; + error?: string; + code?: string; +}; + +type HumanControlMutationResponse = { + ok: boolean; + hold?: HumanControlHold; + released?: boolean; + error?: string; + code?: string; +}; + +type LocalHumanControlClient = { + list(): Promise; + put(holdId: string, input: HumanControlHoldInput): Promise; + remove(holdId: string): Promise; +}; + +export const takeoverCommand: ClientCommandHandler = async ({ positionals, flags }) => { + const action = positionals[0]?.toLowerCase(); + if (action === 'status') { + if (positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'takeover status does not accept additional arguments.'); + } + await showTakeoverStatus(flags); + return true; + } + if (action === 'release') { + if (positionals.length !== 2 || !positionals[1]) { + throw new AppError('INVALID_ARGS', 'takeover release requires a hold id.'); + } + await releaseTakeover(flags, positionals[1]); + return true; + } + if (positionals.length > 0) { + throw new AppError('INVALID_ARGS', 'takeover accepts only: status or release .'); + } + + await runForegroundTakeover(flags); + return true; +}; + +async function runForegroundTakeover(flags: CliFlags): Promise { + const device = await resolveTargetDevice(flags); + const holdId = `takeover-${randomUUID()}`; + const input = buildForegroundHoldInput(device); + const client = await createLocalHumanControlClient(flags); + const hold = await client.put(holdId, input); + writeCommandOutput(flags, { hold, state: 'active' }, () => renderTakeoverStarted(hold)); + + let heartbeatError: unknown; + let heartbeatRequest: Promise | undefined; + let released = false; + let finish: (() => void) | undefined; + const stopped = new Promise((resolve) => { + finish = resolve; + }); + const stop = () => finish?.(); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + const heartbeat = setInterval(() => { + if (heartbeatRequest) return; + heartbeatRequest = client + .put(holdId, input) + .then(() => undefined) + .catch((error: unknown) => { + heartbeatError = error; + finish?.(); + }) + .finally(() => { + heartbeatRequest = undefined; + }); + }, FOREGROUND_HEARTBEAT_MS); + + try { + await stopped; + } finally { + clearInterval(heartbeat); + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + await heartbeatRequest; + released = await client.remove(holdId).catch(() => false); + } + if (heartbeatError) throw heartbeatError; + if (!flags.json) { + process.stdout.write( + released + ? 'Human control released. Agent interactions are enabled.\n' + : 'Release could not be confirmed. The safety TTL will re-enable agent interactions automatically.\n', + ); + } +} + +async function showTakeoverStatus(flags: CliFlags): Promise { + const holds = await (await createLocalHumanControlClient(flags)).list(); + writeCommandOutput(flags, { holds }, () => renderTakeoverStatus(holds)); +} + +async function releaseTakeover(flags: CliFlags, holdId: string): Promise { + const released = await (await createLocalHumanControlClient(flags)).remove(holdId); + writeCommandOutput(flags, { holdId, released }, () => + released ? `Released human-control hold ${holdId}.` : `No active hold found for ${holdId}.`, + ); +} + +function buildForegroundHoldInput(device: DeviceInfo): HumanControlHoldInput { + return { + scope: { + deviceKey: device.id, + deviceName: device.name, + platform: publicPlatformString(device), + kind: device.kind, + }, + reason: 'Human is interacting with the simulator or device.', + ttlMs: FOREGROUND_HOLD_TTL_MS, + }; +} + +async function createLocalHumanControlClient(flags: CliFlags): Promise { + const settings = resolveClientSettings({ + session: 'default', + command: 'takeover', + positionals: [], + flags: { + stateDir: flags.stateDir, + daemonBaseUrl: '', + daemonTransport: 'auto', + }, + }); + const daemon = await ensureDaemon(settings); + if (daemon.info.port) { + const run = async (positionals: string[]): Promise> => { + const response = await sendRequest( + daemon.info, + { + token: daemon.info.token, + session: 'default', + command: INTERNAL_COMMANDS.humanControl, + positionals, + flags: { stateDir: flags.stateDir }, + }, + 'socket', + settings.paths, + HUMAN_CONTROL_REQUEST_TIMEOUT_MS, + ); + if (!response.ok) throwDaemonError(response.error); + return response.data ?? {}; + }; + return { + list: async () => readHolds(await run(['list'])), + put: async (holdId, input) => readHold(await run(['put', holdId, JSON.stringify(input)])), + remove: async (holdId) => (await run(['remove', holdId])).released === true, + }; + } + if (!daemon.info.httpPort) { + throw new AppError('COMMAND_FAILED', 'Local daemon management endpoint is unavailable.'); + } + return createHttpHumanControlClient(daemon.info.httpPort, daemon.info.token); +} + +function createHttpHumanControlClient(httpPort: number, token: string): LocalHumanControlClient { + const baseUrl = `http://127.0.0.1:${String(httpPort)}`; + const headers = { + ...buildDaemonHttpAuthHeaders(token), + 'content-type': 'application/json', + }; + return { + list: async () => { + const response = await requestHumanControl( + `${baseUrl}${HUMAN_CONTROL_HTTP_PREFIX}`, + { headers }, + ); + return response.holds ?? []; + }, + put: async (holdId, input) => { + const response = await requestHumanControl( + holdUrl(baseUrl, holdId), + { method: 'PUT', headers, body: JSON.stringify(input) }, + ); + if (!response.hold) { + throw new AppError('COMMAND_FAILED', 'Daemon did not return the human-control hold.'); + } + return response.hold; + }, + remove: async (holdId) => { + const response = await requestHumanControl( + holdUrl(baseUrl, holdId), + { method: 'DELETE', headers }, + ); + return response.released === true; + }, + }; +} + +function readHolds(data: Record): HumanControlHold[] { + return Array.isArray(data.holds) ? (data.holds as HumanControlHold[]) : []; +} + +function readHold(data: Record): HumanControlHold { + if (!data.hold || typeof data.hold !== 'object' || Array.isArray(data.hold)) { + throw new AppError('COMMAND_FAILED', 'Daemon did not return the human-control hold.'); + } + return data.hold as HumanControlHold; +} + +async function requestHumanControl( + url: string, + init: RequestInit, +): Promise { + let response: Response; + try { + response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(HUMAN_CONTROL_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Failed to reach the local daemon human-control endpoint.', + undefined, + error, + ); + } + let payload: T; + try { + payload = (await response.json()) as T; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Local daemon returned an invalid human-control response (${String(response.status)}).`, + undefined, + error, + ); + } + if (!response.ok || !payload.ok) { + throw new AppError( + toAppErrorCode(payload.code), + payload.error ?? `Human-control request failed (${String(response.status)}).`, + ); + } + return payload; +} + +function holdUrl(baseUrl: string, holdId: string): string { + return `${baseUrl}${HUMAN_CONTROL_HTTP_PREFIX}/${encodeURIComponent(holdId)}`; +} + +export function renderTakeoverStarted(hold: HumanControlHold): string { + const target = hold.scope.deviceName + ? `${hold.scope.deviceName} (${hold.scope.deviceKey})` + : hold.scope.deviceKey; + return [ + `Human control active for ${target}.`, + 'Agent interactions are paused. Press Ctrl+C to return control.', + `Hold: ${hold.id}`, + ].join('\n'); +} + +export function renderTakeoverStatus(holds: HumanControlHold[]): string { + if (holds.length === 0) return 'No active human-control holds.'; + return [ + 'Active human-control holds:', + ...holds.map((hold) => { + const target = hold.scope.deviceName + ? `${hold.scope.deviceName} (${hold.scope.deviceKey})` + : hold.scope.deviceKey; + return ` ${hold.id}: ${target}`; + }), + ].join('\n'); +} diff --git a/src/core/command-descriptor/__tests__/device-claim-policy.test.ts b/src/core/command-descriptor/__tests__/device-claim-policy.test.ts index 2cdeaf3626..74ed323918 100644 --- a/src/core/command-descriptor/__tests__/device-claim-policy.test.ts +++ b/src/core/command-descriptor/__tests__/device-claim-policy.test.ts @@ -50,7 +50,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set 'reinstall', 'shutdown', ], - observe: ['apps', 'appstate', 'capabilities', 'device', 'devices', 'doctor'], + observe: ['apps', 'appstate', 'capabilities', 'device', 'devices', 'doctor', 'takeover'], none: [ 'artifacts', 'auth', @@ -61,6 +61,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set 'daemon', 'debug', 'disconnect', + 'human_control', 'install-from-source', 'lease_allocate', 'lease_heartbeat', diff --git a/src/core/command-descriptor/daemon-command-descriptor.ts b/src/core/command-descriptor/daemon-command-descriptor.ts index 713fb39ff7..00d69ac42a 100644 --- a/src/core/command-descriptor/daemon-command-descriptor.ts +++ b/src/core/command-descriptor/daemon-command-descriptor.ts @@ -2,12 +2,14 @@ import type { DispatchedCommand } from '@agent-device/contracts/command'; import type { RefFrameEffect } from '@agent-device/contracts/replay'; export type SessionCommandKind = 'inventory' | 'state' | 'observability' | 'publication' | 'replay'; +export type HumanControlEffect = 'read' | 'mutate' | 'control'; /** * Routes a daemon command to its handler family. The handler table in * `request-handler-chain.ts` must cover every member (`satisfies Record<…>`). */ export type DaemonCommandRoute = + | 'humanControl' | 'lease' | 'session' | 'snapshot' @@ -28,6 +30,7 @@ export type DaemonRefFrameEffect = */ export type DaemonCommandDescriptor = { command: string; + humanControlEffect: HumanControlEffect | ((req: TRequest) => HumanControlEffect); route: DaemonCommandRoute; sessionKind?: SessionCommandKind; refFrameEffect?: DaemonRefFrameEffect; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index c86dd28b00..c59d3fcb01 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -162,6 +162,10 @@ const REQUEST_EXECUTION_EXEMPT = { selectorValidationExempt: true, } as const; +const HUMAN_CONTROL_READ = { humanControlEffect: 'read' } as const; +const HUMAN_CONTROL_MUTATE = { humanControlEffect: 'mutate' } as const; +const HUMAN_CONTROL_CONTROL = { humanControlEffect: 'control' } as const; + const allowAnyDeviceSessionless = (): boolean => true; const isRecordingStartRequest = (req: DispatchedCommand): boolean => @@ -214,6 +218,21 @@ const findRecordingEffect = (req: DispatchedCommand): RecordingEffect => { } }; +const humanControlEffectFromRecording = (effect: RecordingEffect): 'read' | 'mutate' => + effect === 'observes-app' ? 'read' : 'mutate'; + +const clipboardHumanControlEffect = (req: DispatchedCommand): 'read' | 'mutate' => + req.positionals?.[0]?.toLowerCase() === 'read' ? 'read' : 'mutate'; + +const keyboardHumanControlEffect = (req: DispatchedCommand): 'read' | 'mutate' => + humanControlEffectFromRecording(keyboardRecordingEffect(req)); + +const alertHumanControlEffect = (req: DispatchedCommand): 'read' | 'mutate' => + humanControlEffectFromRecording(alertRecordingEffect(req)); + +const findHumanControlEffect = (req: DispatchedCommand): 'read' | 'mutate' => + humanControlEffectFromRecording(findRecordingEffect(req)); + function readOnlySubactionRefFrameEffect( req: DispatchedCommand, readOnlyActions: ReadonlySet, @@ -252,6 +271,7 @@ const GENERIC_MUTATING_COMMAND_TRAITS = { route: 'generic', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, + ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -280,6 +300,7 @@ const TARGETED_TOUCH_INTERACTION_TRAITS = { route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, + ...HUMAN_CONTROL_MUTATE, }, } as const satisfies Pick< Extract, @@ -401,6 +422,24 @@ function postActionObservation(command: string): PostActionObservationSupport { const ownerFilesEnabled = typeof __OWNER_FILES__ === 'undefined' || __OWNER_FILES__; export const RAW_COMMAND_DESCRIPTORS = [ + // -- host-local human control (route: humanControl) -- + { + name: 'human_control', + deviceClaimPolicy: 'none', + ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/human-control.ts'] as const } : {}), + catalog: { group: 'internal', key: 'humanControl' }, + recordsSessionAction: false, + daemon: { + route: 'humanControl', + refFrameEffect: 'preserve', + ...REQUEST_EXECUTION_EXEMPT, + ...HUMAN_CONTROL_CONTROL, + }, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + platformExecution: NO_PLATFORM_EXECUTION, + }, + // -- lease (route: lease) -- { name: 'lease_allocate', @@ -408,7 +447,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseAllocate' }, recordsSessionAction: false, - daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { + route: 'lease', + refFrameEffect: 'preserve', + ...ADMISSION_AND_LOCK_EXEMPT, + ...HUMAN_CONTROL_MUTATE, + }, timeoutPolicy: LEASE_ALLOCATE_TIMEOUT_POLICY, batchable: false, platformExecution: NO_PLATFORM_EXECUTION, @@ -419,7 +463,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseHeartbeat' }, recordsSessionAction: false, - daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { + route: 'lease', + refFrameEffect: 'preserve', + ...ADMISSION_AND_LOCK_EXEMPT, + ...HUMAN_CONTROL_CONTROL, + }, timeoutPolicy: LEASE_TIMEOUT_POLICY, batchable: false, platformExecution: NO_PLATFORM_EXECUTION, @@ -430,7 +479,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseRelease' }, recordsSessionAction: false, - daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { + route: 'lease', + refFrameEffect: 'preserve', + ...ADMISSION_AND_LOCK_EXEMPT, + ...HUMAN_CONTROL_MUTATE, + }, timeoutPolicy: LEASE_TIMEOUT_POLICY, batchable: false, platformExecution: NO_PLATFORM_EXECUTION, @@ -442,7 +496,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT }, + daemon: { + route: 'lease', + refFrameEffect: 'preserve', + ...ADMISSION_AND_LOCK_EXEMPT, + ...HUMAN_CONTROL_READ, + }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, platformExecution: NO_PLATFORM_EXECUTION, @@ -462,6 +521,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'preserve', sessionKind: 'inventory', ...REQUEST_EXECUTION_EXEMPT, + ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -479,6 +539,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'preserve', sessionKind: 'publication', + ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -497,6 +558,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, ...REQUEST_EXECUTION_EXEMPT, + ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -516,6 +578,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, ...REQUEST_EXECUTION_EXEMPT, + ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -540,6 +603,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ lockPolicySelectorOverride: true, allowSessionlessDefaultDevice: allowAnyDeviceSessionless, ...REQUEST_EXECUTION_EXEMPT, + ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -558,6 +622,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, + ...HUMAN_CONTROL_READ, }, platformExecution: { kind: 'device-runtime', uses: [appsRuntimeUse] as const }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -570,7 +635,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state' }, + daemon: { + route: 'session', + refFrameEffect: 'may-invalidate', + sessionKind: 'state', + ...HUMAN_CONTROL_MUTATE, + }, platformExecution: { kind: 'device-runtime', uses: deviceBootRuntimeUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -582,7 +652,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state' }, + daemon: { + route: 'session', + refFrameEffect: 'may-invalidate', + sessionKind: 'state', + ...HUMAN_CONTROL_MUTATE, + }, platformExecution: { kind: 'device-runtime', use: shutdownTargetUse }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -594,7 +669,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public', key: 'appState' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'state' }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + sessionKind: 'state', + ...HUMAN_CONTROL_READ, + }, platformExecution: { kind: 'device-runtime', uses: appStateRuntimeUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -607,7 +687,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + sessionKind: 'observability', + ...HUMAN_CONTROL_READ, + }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: perfRuntimePlanUses }, @@ -619,7 +704,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + sessionKind: 'observability', + ...HUMAN_CONTROL_READ, + }, platformExecution: { kind: 'device-runtime', uses: appLogRuntimePlanUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -637,6 +727,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'observability', allowInvalidRecording: true, ...REQUEST_EXECUTION_EXEMPT, + ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -651,7 +742,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + sessionKind: 'observability', + ...HUMAN_CONTROL_READ, + }, platformExecution: { kind: 'device-runtime', use: networkDumpUse }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -663,7 +759,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + sessionKind: 'observability', + ...HUMAN_CONTROL_READ, + }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: audioRuntimePlanUses }, @@ -681,6 +782,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'replay', skipSessionlessProviderDevice: isShardedTestRequest, saveScriptFlagOwner: true, + ...HUMAN_CONTROL_MUTATE, }, // Replay durations are script-dependent; --timeout bounds the envelope. timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag' } }, @@ -702,6 +804,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'delegated', sessionKind: 'replay', skipSessionlessProviderDevice: isShardedTestRequest, + ...HUMAN_CONTROL_MUTATE, }, // Test runs stream per-scenario progress and are budgeted downstream; no // client envelope at all. @@ -724,7 +827,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ : {}), catalog: { group: 'internal' }, recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve' }, + daemon: { route: 'session', refFrameEffect: 'preserve', ...HUMAN_CONTROL_MUTATE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, platformExecution: { @@ -743,7 +846,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ // names, and the only execution is that one bound operation (ADR 0019 §9). recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'session', refFrameEffect: 'preserve' }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + humanControlEffect: clipboardHumanControlEffect, + }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: clipboardRuntimePlanUses }, @@ -763,6 +870,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: keyboardRefFrameEffect, androidBlockingDialogGuard: true, + humanControlEffect: keyboardHumanControlEffect, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -776,7 +884,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, platformExecution: { kind: 'device-runtime', use: deployAppUse }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, @@ -789,7 +897,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, platformExecution: { kind: 'device-runtime', use: deployAppUse }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, @@ -803,7 +911,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'internal', key: 'installSource' }, recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, platformExecution: { kind: 'device-runtime', use: readyMaterializeAndDeployAppUse }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: false, @@ -816,7 +924,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ : {}), catalog: { group: 'internal', key: 'releaseMaterializedPaths' }, recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve', ...REQUEST_EXECUTION_EXEMPT }, + daemon: { + route: 'session', + refFrameEffect: 'preserve', + ...REQUEST_EXECUTION_EXEMPT, + ...HUMAN_CONTROL_CONTROL, + }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, platformExecution: NO_PLATFORM_EXECUTION, @@ -829,7 +942,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, platformExecution: { kind: 'device-runtime', use: readySendPushNotificationUse }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -846,7 +959,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // no command, request, or CLI flag), so the owner receives a URL to open. recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: [appEventRuntimeUse] }, @@ -864,6 +977,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', allowSessionlessDefaultDevice: allowAnyDeviceSessionless, saveScriptFlagOwner: true, + ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -876,7 +990,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve' }, + daemon: { route: 'session', refFrameEffect: 'preserve', ...HUMAN_CONTROL_MUTATE }, // Runner warm-up builds are the longest fixed envelope; --timeout overrides. timeoutPolicy: { budget: { source: 'flag' }, @@ -894,7 +1008,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'delegated' }, + daemon: { route: 'session', refFrameEffect: 'delegated', ...HUMAN_CONTROL_MUTATE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, // Wave 6 residue: every step runs as its own daemon request under its own descriptor, which @@ -915,6 +1029,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ allowInvalidRecording: true, saveScriptFlagOwner: true, sessionlessPlainCloseAdmissionExempt: isPlainCloseRequest, + ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -930,7 +1045,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, // First Apple snapshot on a device can sit behind runner startup; --timeout // widens the envelope, and a timeout must not tear down the daemon. timeoutPolicy: { ...PRESERVE_DAEMON_TIMEOUT_POLICY, budget: { source: 'flag' } }, @@ -945,7 +1060,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: snapshotRuntimePlanUses }, @@ -961,7 +1076,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // #1349: a wait's landmark may legitimately be absent when the step // starts, so identity verification runs inside its polling resolution. targetIdentityVerification: 'post-resolution', - daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, // The wait budget travels as a positional, not a flag; parse it the same // way the daemon will so the request envelope extends past it (#1075). timeoutPolicy: { @@ -996,7 +1111,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ // how long a transient sheet takes to appear is family mechanics, not request policy. recordsSessionAction: true, recordingEffect: alertRecordingEffect, - daemon: { route: 'snapshot', refFrameEffect: alertRefFrameEffect }, + daemon: { + route: 'snapshot', + refFrameEffect: alertRefFrameEffect, + humanControlEffect: alertHumanControlEffect, + }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: alertRuntimePlanUses }, @@ -1013,7 +1132,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // it keys on the requested setting, which is not a device fact. recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: [settingsRuntimeUse] }, @@ -1031,7 +1150,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // verification capture are daemon policy over an already-migrated snapshot route. recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: [tapPointUse] }, @@ -1049,6 +1168,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'preserve', allowInvalidRecording: true, allowSessionlessDefaultDevice: isRecordingStartRequest, + ...HUMAN_CONTROL_MUTATE, }, platformExecution: { kind: 'device-runtime', uses: screenRecordingRuntimePlanUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -1062,7 +1182,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'recordTrace', refFrameEffect: 'preserve' }, + daemon: { route: 'recordTrace', refFrameEffect: 'preserve', ...HUMAN_CONTROL_MUTATE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: NO_PLATFORM_EXECUTION, @@ -1075,7 +1195,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: findRecordingEffect, - daemon: { route: 'find', refFrameEffect: 'may-invalidate' }, + daemon: { + route: 'find', + refFrameEffect: 'may-invalidate', + humanControlEffect: findHumanControlEffect, + }, timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: findRuntimePlanUses }, @@ -1101,6 +1225,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, + ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: postActionObservationTimeoutPolicy('click', PRESERVE_DAEMON_TIMEOUT_POLICY), postActionObservation: postActionObservation('click'), @@ -1148,6 +1273,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', + ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: postActionObservationTimeoutPolicy('hover', PRESERVE_DAEMON_TIMEOUT_POLICY), postActionObservation: postActionObservation('hover'), @@ -1177,6 +1303,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, + ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: postActionObservationTimeoutPolicy('type', PRESERVE_DAEMON_TIMEOUT_POLICY), batchable: true, @@ -1191,7 +1318,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'interaction', refFrameEffect: 'preserve' }, + daemon: { route: 'interaction', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, timeoutPolicy: postActionObservationTimeoutPolicy('get', PRESERVE_DAEMON_TIMEOUT_POLICY), batchable: true, platformExecution: { kind: 'device-runtime', uses: selectorTextCaptureRuntimePlanUses }, @@ -1205,7 +1332,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'interaction', refFrameEffect: 'preserve' }, + daemon: { route: 'interaction', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, timeoutPolicy: postActionObservationTimeoutPolicy('is', PRESERVE_DAEMON_TIMEOUT_POLICY), batchable: true, platformExecution: { kind: 'device-runtime', uses: selectorCaptureRuntimePlanUses }, @@ -1238,6 +1365,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, + ...HUMAN_CONTROL_MUTATE, }, // R52 retires this command's capability bucket: admission is the owner's gesture-tier facts, // which the retired `requireGestureSupported` used to decide inside the daemon. The declared @@ -1301,6 +1429,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, + ...HUMAN_CONTROL_MUTATE, }, // R54 retires this command's capability bucket. A swipe always normalizes to a coordinate // fling, so it declares only the one-contact plan it can select. @@ -1333,7 +1462,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'generic', refFrameEffect: 'preserve' }, + daemon: { route: 'generic', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: screenshotRuntimePlanUses }, @@ -1346,7 +1475,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'generic', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_READ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, platformExecution: { kind: 'device-runtime', uses: [viewportRuntimeUse] }, @@ -1368,7 +1497,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // classified. Add the facet (route unchanged) so its device mutation is // covered by the completeness gate; this is the escape hatch the ADR calls // out, not a new specialized route. - daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, + daemon: { route: 'generic', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: [appSwitcherRuntimeUse] }, @@ -1521,6 +1650,17 @@ export const RAW_COMMAND_DESCRIPTORS = [ 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, + mcpExposed: false, + platformExecution: { kind: 'inventory', use: inventoryUse }, + }, { name: 'react-devtools', deviceClaimPolicy: 'none', diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index dce44a4933..f6ef9ffc44 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -6,6 +6,7 @@ import { canRunReplayScopedAction, getDaemonCommandRoute, getSessionCommandKind, + humanControlEffectForRequest, isLeaseAdmissionExempt, shouldBlockForInvalidRecording, shouldGuardAndroidBlockingDialog, @@ -18,6 +19,7 @@ import { import type { DaemonRequest } from '../types.ts'; test('daemon command registry owns specialized handler routes', () => { + assert.equal(getDaemonCommandRoute(INTERNAL_COMMANDS.humanControl), 'humanControl'); for (const command of [ INTERNAL_COMMANDS.leaseAllocate, INTERNAL_COMMANDS.leaseHeartbeat, @@ -250,6 +252,44 @@ test('every lease-route command skips sessionless provider-device resolution', ( } }); +test('daemon command registry owns human-control effects and fails closed', () => { + for (const command of [ + PUBLIC_COMMANDS.snapshot, + PUBLIC_COMMANDS.screenshot, + PUBLIC_COMMANDS.get, + PUBLIC_COMMANDS.is, + PUBLIC_COMMANDS.logs, + PUBLIC_COMMANDS.devices, + ]) { + assert.equal(humanControlEffectForRequest(makeRequest(command)), 'read', `${command} effect`); + } + + assert.equal( + humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.clipboard, ['read'])), + 'read', + ); + assert.equal( + humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.clipboard, ['write', 'value'])), + 'mutate', + ); + assert.equal( + humanControlEffectForRequest( + makeRequest(PUBLIC_COMMANDS.find, ['text', 'Save', 'get', 'text']), + ), + 'read', + ); + assert.equal( + humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.find, ['text', 'Save', 'click'])), + 'mutate', + ); + assert.equal(humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.click)), 'mutate'); + assert.equal(humanControlEffectForRequest(makeRequest('future-command')), 'mutate'); + assert.equal( + humanControlEffectForRequest(makeRequest(INTERNAL_COMMANDS.leaseHeartbeat)), + 'control', + ); +}); + function makeRequest(command: string, positionals: string[] = []): DaemonRequest { return { command, diff --git a/src/daemon/__tests__/human-control-http.test.ts b/src/daemon/__tests__/human-control-http.test.ts new file mode 100644 index 0000000000..b2b14edb19 --- /dev/null +++ b/src/daemon/__tests__/human-control-http.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { HUMAN_CONTROL_HTTP_PREFIX, HumanControlRegistry } from '../human-control.ts'; +import { createDaemonHttpServer } from '../server/http-server.ts'; + +test('daemon human-control API authenticates and manages persistent holds', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + const registry = new HumanControlRegistry(); + let releasedHoldId: string | undefined; + let handlerCalls = 0; + const server = await createDaemonHttpServer({ + token: 'daemon-secret', + humanControlRegistry: registry, + onHumanControlHoldReleased: (hold) => { + releasedHoldId = hold.id; + }, + handleRequest: async (request) => { + handlerCalls += 1; + if (request.command === 'click') { + return { + ok: false, + error: { + code: 'DEVICE_IN_USE', + message: + 'A human is interacting with this simulator or device; agent interactions are temporarily disabled.', + details: { reason: 'human_control_active' }, + }, + }; + } + return { ok: true, data: {} }; + }, + }); + + try { + const port = await listenOnLoopback(server); + const baseUrl = `http://127.0.0.1:${String(port)}${HUMAN_CONTROL_HTTP_PREFIX}`; + const unauthorized = await fetch(baseUrl); + assert.equal(unauthorized.status, 401); + assert.deepEqual(await unauthorized.json(), { + ok: false, + error: 'Invalid token', + code: 'UNAUTHORIZED', + }); + + const malformedHoldId = await fetch(`${baseUrl}/%`, { + method: 'PUT', + headers: { + authorization: 'Bearer daemon-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ scope: { deviceKey: 'sim-1' } }), + }); + assert.equal(malformedHoldId.status, 400); + assert.equal(((await malformedHoldId.json()) as { code?: string }).code, 'INVALID_ARGS'); + + const socketOnlyRpc = await fetch(`http://127.0.0.1:${String(port)}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'human-control-rpc', + method: 'agent_device.command', + params: { + token: 'daemon-secret', + command: 'human_control', + positionals: ['list'], + }, + }), + }); + assert.equal(socketOnlyRpc.status, 404); + assert.match(JSON.stringify(await socketOnlyRpc.json()), /socket-only/); + + const created = await fetch(`${baseUrl}/vm-console`, { + method: 'PUT', + headers: { + authorization: 'Bearer daemon-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + scope: { + deviceKey: 'sim-1', + deviceName: 'iPhone 17 Pro', + platform: 'ios', + kind: 'simulator', + }, + reason: 'Human is using the VM console.', + }), + }); + assert.equal(created.status, 200); + const createdBody = (await created.json()) as { + hold?: { id?: string; expiresAt?: number }; + state?: string; + }; + assert.equal(createdBody.hold?.id, 'vm-console'); + assert.equal(createdBody.hold?.expiresAt, undefined); + assert.equal(createdBody.state, 'active'); + + const blockedRpc = await fetch(`http://127.0.0.1:${String(port)}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'blocked-click', + method: 'agent_device.command', + params: { + token: 'daemon-secret', + command: 'click', + positionals: ['10', '10'], + }, + }), + }); + assert.equal(blockedRpc.status, 423); + assert.match(JSON.stringify(await blockedRpc.json()), /DEVICE_IN_USE/); + + const listed = await fetch(baseUrl, { + headers: { 'x-agent-device-token': 'daemon-secret' }, + }); + assert.equal(listed.status, 200); + const listedBody = (await listed.json()) as { holds?: Array<{ id?: string }> }; + assert.deepEqual( + listedBody.holds?.map((hold) => hold.id), + ['vm-console'], + ); + + const removed = await fetch(`${baseUrl}/vm-console`, { + method: 'DELETE', + headers: { authorization: 'Bearer daemon-secret' }, + }); + assert.equal(removed.status, 200); + assert.equal(((await removed.json()) as { released?: boolean }).released, true); + assert.equal(releasedHoldId, 'vm-console'); + assert.equal(handlerCalls, 1); + } finally { + await closeLoopbackServer(server); + } +}); diff --git a/src/daemon/__tests__/human-control-request.test.ts b/src/daemon/__tests__/human-control-request.test.ts new file mode 100644 index 0000000000..9481093d77 --- /dev/null +++ b/src/daemon/__tests__/human-control-request.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; +import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { HumanControlRegistry } from '../human-control.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { createRequestExecutionScope } from '../request-execution-scope.ts'; +import { createRequestHandler } from '../request-router.ts'; +import type { DaemonRequest } from '../types.ts'; +import { lifecycleDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; + +test('request execution blocks mutations but permits read-only commands during human control', async () => { + const sessionName = 'human-control-request'; + const sessionStore = makeSessionStore('agent-device-human-control-request-'); + sessionStore.set(sessionName, makeIosSession(sessionName)); + const registry = new HumanControlRegistry(); + registry.upsert('operator-1', { scope: { deviceKey: 'sim-1' } }); + + let mutationRan = false; + const mutationScope = await createRequestExecutionScope({ + req: makeRequest(sessionName, 'click'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + humanControlRegistry: registry, + }); + await assert.rejects( + mutationScope.runLocked(async () => { + mutationRan = true; + }), + (error: unknown) => (error as { code?: string }).code === 'DEVICE_IN_USE', + ); + assert.equal(mutationRan, false); + + const readScope = await createRequestExecutionScope({ + req: makeRequest(sessionName, 'snapshot'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + humanControlRegistry: registry, + }); + assert.equal(await readScope.runLocked(async () => 'read-completed'), 'read-completed'); +}); + +test('socket management command activates the production request gate and releases it', async () => { + const sessionName = 'human-control-router'; + const sessionStore = makeSessionStore('agent-device-human-control-router-'); + sessionStore.set(sessionName, makeIosSession(sessionName)); + const registry = new HumanControlRegistry(); + let releasedHoldId: string | undefined; + const handleRequest = createRequestHandler({ + logPath: '/tmp/agent-device-human-control-router.log', + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + humanControlRegistry: registry, + onHumanControlHoldReleased: (hold) => { + releasedHoldId = hold.id; + }, + trackDownloadableArtifact: () => 'artifact-1', + }); + + const activated = await handleRequest({ + ...makeRequest(sessionName, INTERNAL_COMMANDS.humanControl), + positionals: [ + 'put', + 'operator-1', + JSON.stringify({ scope: { deviceKey: 'sim-1' }, reason: 'Manual inspection' }), + ], + }); + assert.equal(activated.ok, true); + + const blocked = await handleRequest(makeRequest(sessionName, 'click')); + assert.equal(blocked.ok, false); + if (blocked.ok) throw new Error('Expected click to be blocked'); + assert.equal(blocked.error.code, 'DEVICE_IN_USE'); + assert.equal(blocked.error.details?.reason, 'human_control_active'); + assert.equal(blocked.error.retriable, true); + assert.match(blocked.error.message, /agent interactions are temporarily disabled/i); + + const released = await handleRequest({ + ...makeRequest(sessionName, INTERNAL_COMMANDS.humanControl), + positionals: ['remove', 'operator-1'], + }); + assert.equal(released.ok, true); + assert.equal(releasedHoldId, 'operator-1'); + assert.deepEqual(registry.list(), []); +}); + +function makeRequest(session: string, command: string): DaemonRequest { + return { + token: 'test-token', + session, + command, + positionals: [], + flags: {}, + }; +} diff --git a/src/daemon/__tests__/human-control.test.ts b/src/daemon/__tests__/human-control.test.ts new file mode 100644 index 0000000000..443aac3cda --- /dev/null +++ b/src/daemon/__tests__/human-control.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; +import { HumanControlRegistry } from '../human-control.ts'; + +test('human-control holds persist and expire by ttl', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-human-control-')); + const statePath = path.join(root, 'human-control.json'); + let now = 1_000; + + try { + const registry = new HumanControlRegistry({ statePath, now: () => now }); + const hold = registry.upsert('operator-1', { + scope: { deviceKey: 'SIM-1', deviceName: 'iPhone 17 Pro', platform: 'ios' }, + reason: 'Manual inspection', + ttlMs: 5_000, + }); + + assert.equal(hold.createdAt, 1_000); + assert.equal(hold.expiresAt, 6_000); + assert.equal(fs.statSync(statePath).mode & 0o777, 0o600); + + const restored = new HumanControlRegistry({ statePath, now: () => now }); + assert.deepEqual(restored.list(), [hold]); + assert.equal(restored.isDeviceControlled('sim-1'), true); + + now = 6_000; + assert.deepEqual(restored.list(), []); + assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')).holds, []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('human-control activation waits for an active mutation and blocks later mutations', async () => { + const registry = new HumanControlRegistry(); + let finishMutation: (() => void) | undefined; + let markMutationStarted: (() => void) | undefined; + const mutationStarted = new Promise((resolve) => { + markMutationStarted = resolve; + }); + const mutationFinished = new Promise((resolve) => { + finishMutation = resolve; + }); + const mutation = registry.runDeviceMutation(['SIM-1', 'iPhone 17 Pro'], async () => { + markMutationStarted?.(); + await mutationFinished; + }); + await mutationStarted; + + registry.upsert('operator-1', { + scope: { deviceKey: 'sim-1' }, + reason: 'Human is interacting with the simulator.', + }); + let idle = false; + const waitForIdle = registry.waitForDeviceIdle('sim-1').then(() => { + idle = true; + }); + await Promise.resolve(); + assert.equal(idle, false); + + finishMutation?.(); + await mutation; + await waitForIdle; + assert.equal(idle, true); + + await assert.rejects( + registry.runDeviceMutation(['SIM-1'], async () => undefined), + (error: unknown) => { + assert.equal((error as { code?: string }).code, 'DEVICE_IN_USE'); + assert.equal( + (error as { details?: { reason?: string } }).details?.reason, + 'human_control_active', + ); + assert.match((error as Error).message, /agent interactions are temporarily disabled/i); + assert.equal((error as { details?: { holdId?: string } }).details?.holdId, 'operator-1'); + return true; + }, + ); +}); diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index 1fc4c0820f..74c2fd8557 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -88,6 +88,36 @@ test('expired leases are cleaned before admission checks', () => { ); }); +test('human-controlled device leases survive expiry and refresh when control is released', () => { + let now = 1_000; + let protectedByHumanControl = true; + const registry = new LeaseRegistry({ + now: () => now, + defaultLeaseTtlMs: 5_000, + isDeviceLeaseProtected: (lease) => protectedByHumanControl && lease.deviceKey === 'device-1', + }); + const lease = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'device-1', + }); + + now = 7_000; + assert.deepEqual(registry.consumeExpiredLeases(), []); + assert.equal(registry.listActiveLeases()[0]?.leaseId, lease.leaseId); + + now = 8_000; + const [refreshed] = registry.refreshLeasesForDeviceKey('DEVICE-1'); + assert.equal(refreshed?.expiresAt, 13_000); + protectedByHumanControl = false; + now = 12_000; + assert.equal(registry.listActiveLeases()[0]?.leaseId, lease.leaseId); + now = 14_000; + assert.deepEqual(registry.listActiveLeases(), []); +}); + test('capacity limits reject additional simulator leases', () => { const registry = new LeaseRegistry({ maxActiveSimulatorLeases: 1, diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index e58c966fb6..c1fc9e83e2 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -10,6 +10,7 @@ import { getDaemonCommandRoute, type DaemonCommandRoute } from '../daemon-comman import { cleanupDownloadableArtifact, trackDownloadableArtifact } from '../artifact-tracking.ts'; import { contextFromFlags } from '../context.ts'; import { handleLeaseCommands } from '../handlers/lease.ts'; +import { HumanControlRegistry } from '../human-control.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { runRequestHandlerChain } from '../request-handler-chain.ts'; import { @@ -29,6 +30,7 @@ import { createAudioProbeAdmissionLedger } from '../audio-probe-admission-ledger import { createPerfCaptureAdmissionLedger } from '../perf-capture-admission-ledger.ts'; const SPECIALIZED_ROUTES = [ + 'humanControl', 'lease', 'session', 'snapshot', @@ -475,6 +477,7 @@ async function runCatalogCommandThroughHandlerChain( logPath: '/tmp/agent-device-catalog-route.log', sessionStore, leaseRegistry, + humanControlRegistry: new HumanControlRegistry(), invoke: async () => ({ ok: true, data: {} }), providerScope: { androidAdbExecutor: async () => ({ stdout: '', stderr: '', exitCode: 0 }), diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index 7c97d2221c..87b7e21ff9 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -2,6 +2,7 @@ import { type DaemonCommandDescriptor, type DaemonCommandRoute, type SessionCommandKind, + type HumanControlEffect, } from '../core/command-descriptor/daemon-command-descriptor.ts'; import { deriveDaemonCommandDescriptors } from '../core/command-descriptor/derive.ts'; import { commandDescriptors } from '../core/command-descriptor/registry.ts'; @@ -74,6 +75,13 @@ export function shouldGuardAndroidBlockingDialog(command: string): boolean { return getDaemonCommandDescriptor(command)?.androidBlockingDialogGuard === true; } +export function humanControlEffectForRequest(req: DaemonRequest): HumanControlEffect { + const effect = getDaemonCommandDescriptor(req.command)?.humanControlEffect; + // Unknown wire commands still fail closed even though every known descriptor + // must declare an effect at compile time. + return typeof effect === 'function' ? effect(req) : (effect ?? 'mutate'); +} + export function shouldPreferExplicitDeviceOverExistingSession(req: DaemonRequest): boolean { return getDaemonCommandDescriptor(req.command)?.preferExplicitDeviceOverExistingSession === true; } diff --git a/src/daemon/handlers/human-control.ts b/src/daemon/handlers/human-control.ts new file mode 100644 index 0000000000..58a04949de --- /dev/null +++ b/src/daemon/handlers/human-control.ts @@ -0,0 +1,59 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { parseHumanControlHoldInput, type HumanControlHold } from '../human-control-contract.ts'; +import { releaseHumanControlHold, type HumanControlRegistry } from '../human-control.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; + +export async function handleHumanControlCommand(params: { + req: DaemonRequest; + registry: HumanControlRegistry | undefined; + onHoldReleased?: (hold: HumanControlHold) => void; +}): Promise { + const { req, registry, onHoldReleased } = params; + if (!registry) { + throw new AppError('COMMAND_FAILED', 'Human-control registry is unavailable.'); + } + const [action, holdId, rawInput] = req.positionals ?? []; + if (action === 'list') return { ok: true, data: { holds: registry.list() } }; + if (action === 'put') return await putHold(registry, holdId, rawInput); + if (action === 'remove') return removeHold(registry, holdId, onHoldReleased); + throw new AppError('INVALID_ARGS', 'human_control requires list, put, or remove.'); +} + +async function putHold( + registry: HumanControlRegistry, + holdId: string | undefined, + rawInput: string | undefined, +): Promise { + if (!holdId || rawInput === undefined) { + throw new AppError('INVALID_ARGS', 'human_control put requires a hold id and payload.'); + } + const hold = registry.upsert(holdId, parseHumanControlHoldInput(parsePayload(rawInput))); + await registry.waitForDeviceIdle(hold.scope.deviceKey); + return { ok: true, data: { hold, state: 'active' } }; +} + +function removeHold( + registry: HumanControlRegistry, + holdId: string | undefined, + onHoldReleased: ((hold: HumanControlHold) => void) | undefined, +): DaemonResponse { + if (!holdId) { + throw new AppError('INVALID_ARGS', 'human_control remove requires a hold id.'); + } + const hold = releaseHumanControlHold(registry, holdId); + if (hold) onHoldReleased?.(hold); + return { ok: true, data: { released: Boolean(hold), ...(hold ? { hold } : {}) } }; +} + +function parsePayload(rawInput: string): unknown { + try { + return JSON.parse(rawInput) as unknown; + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + 'Human-control payload must be valid JSON.', + undefined, + error, + ); + } +} diff --git a/src/daemon/human-control-contract.ts b/src/daemon/human-control-contract.ts new file mode 100644 index 0000000000..ed817d14ae --- /dev/null +++ b/src/daemon/human-control-contract.ts @@ -0,0 +1,68 @@ +import { AppError } from '@agent-device/kernel/errors'; + +export type HumanControlHoldScope = { + deviceKey: string; + deviceName?: string; + platform?: string; + kind?: string; +}; + +export type HumanControlHold = { + id: string; + scope: HumanControlHoldScope; + reason?: string; + createdAt: number; + updatedAt: number; + expiresAt?: number; +}; + +export type HumanControlHoldInput = { + scope: HumanControlHoldScope; + reason?: string; + ttlMs?: number; +}; + +export function parseHumanControlHoldInput(value: unknown): HumanControlHoldInput { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', 'Human-control request body must be an object.'); + } + const record = value as Record; + const scope = record.scope; + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) { + throw new AppError('INVALID_ARGS', 'Human-control request requires scope.deviceKey.'); + } + const scopeRecord = scope as Record; + return { + scope: { + deviceKey: readRequiredString(scopeRecord.deviceKey, 'scope.deviceKey'), + ...readOptionalStringField(scopeRecord, 'deviceName'), + ...readOptionalStringField(scopeRecord, 'platform'), + ...readOptionalStringField(scopeRecord, 'kind'), + }, + ...(record.reason === undefined ? {} : { reason: readRequiredString(record.reason, 'reason') }), + ...(record.ttlMs === undefined ? {} : { ttlMs: readInteger(record.ttlMs, 'ttlMs') }), + }; +} + +function readRequiredString(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new AppError('INVALID_ARGS', `Human-control ${field} must be a non-empty string.`); + } + return value; +} + +function readOptionalStringField( + record: Record, + key: 'deviceName' | 'platform' | 'kind', +): Partial> { + const value = record[key]; + if (value === undefined) return {}; + return { [key]: readRequiredString(value, `scope.${key}`) }; +} + +function readInteger(value: unknown, field: string): number { + if (!Number.isInteger(value)) { + throw new AppError('INVALID_ARGS', `Human-control ${field} must be an integer.`); + } + return Number(value); +} diff --git a/src/daemon/human-control-http.ts b/src/daemon/human-control-http.ts new file mode 100644 index 0000000000..9be8317f10 --- /dev/null +++ b/src/daemon/human-control-http.ts @@ -0,0 +1,132 @@ +import type http from 'node:http'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { readNodeHttpRequestBody } from '../utils/node-http.ts'; +import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; +import { sendRestJsonError } from './http-errors.ts'; +import { + parseHumanControlHoldInput, + type HumanControlHold, + type HumanControlHoldInput, +} from './human-control-contract.ts'; +import { + HUMAN_CONTROL_HTTP_PREFIX, + releaseHumanControlHold, + type HumanControlRegistry, +} from './human-control.ts'; + +const MAX_HUMAN_CONTROL_BODY_BYTES = 16 * 1024; + +type HumanControlHttpRoute = + | { kind: 'list' } + | { kind: 'upsert'; holdId: string } + | { kind: 'remove'; holdId: string } + | { kind: 'unsupported' }; + +export function tryHandleHumanControlHttpRoute(params: { + req: http.IncomingMessage; + res: http.ServerResponse; + expectedToken: string; + registry: HumanControlRegistry; + onHoldReleased?: (hold: HumanControlHold) => void; +}): boolean { + const route = resolveHumanControlRoute(params.req); + if (!route) return false; + void handleHumanControlRoute(route, params); + return true; +} + +async function handleHumanControlRoute( + route: HumanControlHttpRoute, + params: { + req: http.IncomingMessage; + res: http.ServerResponse; + expectedToken: string; + registry: HumanControlRegistry; + onHoldReleased?: (hold: HumanControlHold) => void; + }, +): Promise { + const { req, res, expectedToken, registry, onHoldReleased } = params; + try { + assertAuthorized(req, expectedToken); + switch (route.kind) { + case 'list': + sendJson(res, { ok: true, holds: registry.list() }); + return; + case 'upsert': { + const input = await readHoldInput(req); + const hold = registry.upsert(route.holdId, input); + await registry.waitForDeviceIdle(hold.scope.deviceKey); + sendJson(res, { ok: true, hold, state: 'active' }); + return; + } + case 'remove': { + const hold = releaseHumanControlHold(registry, route.holdId); + if (hold) onHoldReleased?.(hold); + sendJson(res, { ok: true, released: Boolean(hold), ...(hold ? { hold } : {}) }); + return; + } + case 'unsupported': + res.statusCode = 405; + res.setHeader('allow', 'GET, PUT, DELETE'); + sendJson(res, { ok: false, error: 'Method not allowed', code: 'INVALID_ARGS' }); + return; + } + } catch (error) { + sendRestJsonError(res, normalizeError(error)); + } +} + +function resolveHumanControlRoute(req: http.IncomingMessage): HumanControlHttpRoute | null { + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; + if (pathname === HUMAN_CONTROL_HTTP_PREFIX) { + return req.method === 'GET' ? { kind: 'list' } : { kind: 'unsupported' }; + } + if (!pathname.startsWith(`${HUMAN_CONTROL_HTTP_PREFIX}/`)) return null; + const holdId = pathname.slice(HUMAN_CONTROL_HTTP_PREFIX.length + 1); + if (!holdId || holdId.includes('/')) return { kind: 'unsupported' }; + if (req.method === 'PUT') return { kind: 'upsert', holdId }; + if (req.method === 'DELETE') return { kind: 'remove', holdId }; + return { kind: 'unsupported' }; +} + +async function readHoldInput(req: http.IncomingMessage): Promise { + const raw = await readNodeHttpRequestBody( + req, + MAX_HUMAN_CONTROL_BODY_BYTES, + 'Human-control request body is too large.', + ); + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString('utf8')); + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + 'Human-control request body must be valid JSON.', + undefined, + error, + ); + } + return parseHumanControlHoldInput(parsed); +} + +function assertAuthorized(req: http.IncomingMessage, expectedToken: string): void { + const authorization = + typeof req.headers.authorization === 'string' ? req.headers.authorization : ''; + const bearer = authorization.toLowerCase().startsWith('bearer ') + ? authorization.slice('bearer '.length) + : ''; + const headerToken = + typeof req.headers['x-agent-device-token'] === 'string' + ? req.headers['x-agent-device-token'] + : ''; + const token = headerToken || bearer; + if (!token || !timingSafeStringEqual(token, expectedToken)) { + throw new AppError('UNAUTHORIZED', 'Invalid token'); + } +} + +function sendJson(res: http.ServerResponse, body: Record): void { + res.statusCode ||= 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(body)); +} diff --git a/src/daemon/human-control-request.ts b/src/daemon/human-control-request.ts new file mode 100644 index 0000000000..7428f90675 --- /dev/null +++ b/src/daemon/human-control-request.ts @@ -0,0 +1,72 @@ +import { resolveTargetDevice, type ResolveDeviceFlags } from '../core/dispatch-resolve.ts'; +import { uniqueStrings } from '@agent-device/kernel/collections'; +import { humanControlEffectForRequest } from './daemon-command-registry.ts'; +import type { HumanControlRegistry } from './human-control.ts'; +import type { SessionStore } from './session-store.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; + +export async function runRequestWithHumanControl(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + registry: HumanControlRegistry | undefined; + task: () => Promise; +}): Promise { + const { req, sessionName, sessionStore, registry, task } = params; + if (!registry || humanControlEffectForRequest(req) !== 'mutate') return await task(); + + const aliases = await resolveRequestDeviceAliases(req, sessionStore.get(sessionName)); + return await registry.runDeviceMutation(aliases, task); +} + +async function resolveRequestDeviceAliases( + req: DaemonRequest, + session: SessionState | undefined, +): Promise { + const requestAliases = readRequestDeviceAliases(req); + if (session) { + return uniqueDefinedStrings([ + session.device.id, + session.device.name, + session.lease?.deviceKey, + ...requestAliases, + ]); + } + + const directHoldAliases = uniqueDefinedStrings(requestAliases); + try { + const device = await resolveTargetDevice(resolveDeviceFlags(req)); + return uniqueDefinedStrings([device.id, device.name, ...directHoldAliases]); + } catch { + // Preserve the command's normal device-resolution error. Requests carrying a + // remote deviceKey or explicit UDID/serial are still gated by those aliases. + return directHoldAliases; + } +} + +function resolveDeviceFlags(req: DaemonRequest): ResolveDeviceFlags { + return { + ...(req.flags ?? {}), + leaseProvider: req.meta?.leaseProvider, + deviceKey: req.meta?.deviceKey, + clientId: req.meta?.clientId, + }; +} + +function readRequestDeviceAliases(req: DaemonRequest): Array { + return [ + req.meta?.deviceKey, + req.internal?.admittedLease?.deviceKey, + req.flags?.udid, + req.flags?.serial, + req.flags?.device, + ].map((value) => (typeof value === 'string' ? value : undefined)); +} + +function uniqueDefinedStrings(values: Array): string[] { + return uniqueStrings( + values + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .map((value) => value.trim()), + ); +} diff --git a/src/daemon/human-control.ts b/src/daemon/human-control.ts new file mode 100644 index 0000000000..7939cfbb2a --- /dev/null +++ b/src/daemon/human-control.ts @@ -0,0 +1,300 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + HumanControlHold, + HumanControlHoldInput, + HumanControlHoldScope, +} from './human-control-contract.ts'; + +export const HUMAN_CONTROL_HTTP_PREFIX = '/admin/human-control/holds'; + +const MIN_HOLD_TTL_MS = 1_000; +const MAX_HOLD_TTL_MS = 24 * 60 * 60_000; + +export class HumanControlRegistry { + private readonly holds = new Map(); + private readonly activeMutations = new Map(); + private readonly idleWaiters = new Map void>>(); + private readonly statePath: string | undefined; + private readonly now: () => number; + + constructor(options: { statePath?: string; now?: () => number } = {}) { + this.statePath = options.statePath; + this.now = options.now ?? (() => Date.now()); + this.load(); + } + + list(): HumanControlHold[] { + this.cleanupExpired(); + return Array.from(this.holds.values(), (hold) => cloneHold(hold)).sort((left, right) => + left.id.localeCompare(right.id), + ); + } + + upsert(id: string, input: HumanControlHoldInput): HumanControlHold { + const normalizedId = normalizeHoldId(id); + const scope = normalizeScope(input.scope); + const reason = normalizeReason(input.reason); + const ttlMs = normalizeTtlMs(input.ttlMs); + const now = this.now(); + const existing = this.holds.get(normalizedId); + const hold: HumanControlHold = { + id: normalizedId, + scope, + ...(reason ? { reason } : {}), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + ...(ttlMs === undefined ? {} : { expiresAt: now + ttlMs }), + }; + this.holds.set(normalizedId, hold); + this.persist(); + return cloneHold(hold); + } + + remove(id: string): HumanControlHold | undefined { + const normalizedId = normalizeHoldId(id); + const hold = this.holds.get(normalizedId); + if (!hold) return undefined; + this.holds.delete(normalizedId); + this.persist(); + return cloneHold(hold); + } + + isDeviceControlled(deviceKey: string | undefined): boolean { + if (!deviceKey) return false; + return this.findMatchingHold([deviceKey]) !== undefined; + } + + findMatchingHold(deviceKeys: readonly string[]): HumanControlHold | undefined { + this.cleanupExpired(); + const keys = normalizeDeviceAliases(deviceKeys); + if (keys.length === 0) return undefined; + for (const hold of this.holds.values()) { + if (keys.includes(normalizeDeviceAlias(hold.scope.deviceKey))) return cloneHold(hold); + } + return undefined; + } + + async runDeviceMutation(deviceKeys: readonly string[], task: () => Promise): Promise { + const keys = normalizeDeviceAliases(deviceKeys); + const hold = this.findMatchingHold(keys); + if (hold) throw humanControlActiveError(hold); + if (keys.length === 0) return await task(); + + for (const key of keys) { + this.activeMutations.set(key, (this.activeMutations.get(key) ?? 0) + 1); + } + try { + return await task(); + } finally { + for (const key of keys) this.finishMutation(key); + } + } + + async waitForDeviceIdle(deviceKey: string): Promise { + const key = normalizeDeviceAlias(normalizeDeviceKey(deviceKey)); + if ((this.activeMutations.get(key) ?? 0) === 0) return; + await new Promise((resolve) => { + const waiters = this.idleWaiters.get(key) ?? new Set<() => void>(); + waiters.add(resolve); + this.idleWaiters.set(key, waiters); + }); + } + + private finishMutation(key: string): void { + const remaining = (this.activeMutations.get(key) ?? 1) - 1; + if (remaining > 0) { + this.activeMutations.set(key, remaining); + return; + } + this.activeMutations.delete(key); + const waiters = this.idleWaiters.get(key); + if (!waiters) return; + this.idleWaiters.delete(key); + for (const resolve of waiters) resolve(); + } + + private cleanupExpired(): void { + const now = this.now(); + let changed = false; + for (const [id, hold] of this.holds) { + if (hold.expiresAt === undefined || hold.expiresAt > now) continue; + this.holds.delete(id); + changed = true; + } + if (changed) this.persist(); + } + + private load(): void { + if (!this.statePath || !fs.existsSync(this.statePath)) return; + let parsed: { version: 1; holds: HumanControlHold[] }; + try { + parsed = JSON.parse(fs.readFileSync(this.statePath, 'utf8')) as typeof parsed; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Failed to read persisted human-control state.', + { path: this.statePath }, + error, + ); + } + if (parsed.version !== 1 || !Array.isArray(parsed.holds)) { + throw new AppError('COMMAND_FAILED', 'Persisted human-control state is invalid.', { + path: this.statePath, + }); + } + for (const rawHold of parsed.holds) { + const hold = normalizeStoredHold(rawHold); + this.holds.set(hold.id, hold); + } + this.cleanupExpired(); + } + + private persist(): void { + if (!this.statePath) return; + fs.mkdirSync(path.dirname(this.statePath), { recursive: true }); + const temporaryPath = `${this.statePath}.${String(process.pid)}.tmp`; + const state = { + version: 1, + holds: Array.from(this.holds.values(), (hold) => cloneHold(hold)), + }; + fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2), { mode: 0o600 }); + fs.renameSync(temporaryPath, this.statePath); + fs.chmodSync(this.statePath, 0o600); + } +} + +function humanControlActiveError(hold: HumanControlHold): AppError { + return new AppError( + 'DEVICE_IN_USE', + 'A human is interacting with this simulator or device; agent interactions are temporarily disabled.', + { + reason: 'human_control_active', + blockedBy: 'human_control', + holdId: hold.id, + deviceKey: hold.scope.deviceKey, + pausedAt: new Date(hold.createdAt).toISOString(), + ...(hold.expiresAt === undefined + ? {} + : { expiresAt: new Date(hold.expiresAt).toISOString() }), + ...(hold.reason ? { humanReason: hold.reason } : {}), + hint: 'Wait until the human-control hold is released before retrying.', + }, + ); +} + +export function releaseHumanControlHold( + registry: HumanControlRegistry, + holdId: string, +): HumanControlHold | undefined { + return registry.remove(holdId); +} + +function normalizeStoredHold(raw: HumanControlHold): HumanControlHold { + if (!raw || typeof raw !== 'object') { + throw new AppError('COMMAND_FAILED', 'Persisted human-control hold is invalid.'); + } + const createdAt = normalizeTimestamp(raw.createdAt, 'createdAt'); + const updatedAt = normalizeTimestamp(raw.updatedAt, 'updatedAt'); + const expiresAt = + raw.expiresAt === undefined ? undefined : normalizeTimestamp(raw.expiresAt, 'expiresAt'); + const reason = normalizeReason(raw.reason); + return { + id: normalizeHoldId(raw.id), + scope: normalizeScope(raw.scope), + ...(reason ? { reason } : {}), + createdAt, + updatedAt, + ...(expiresAt === undefined ? {} : { expiresAt }), + }; +} + +function normalizeScope(scope: HumanControlHoldScope): HumanControlHoldScope { + if (!scope || typeof scope !== 'object') { + throw new AppError('INVALID_ARGS', 'Human-control hold requires a device scope.'); + } + const deviceName = normalizeOptionalLabel(scope.deviceName, 'device name'); + const platform = normalizeOptionalLabel(scope.platform, 'platform'); + const kind = normalizeOptionalLabel(scope.kind, 'device kind'); + return { + deviceKey: normalizeDeviceKey(scope.deviceKey), + ...(deviceName ? { deviceName } : {}), + ...(platform ? { platform } : {}), + ...(kind ? { kind } : {}), + }; +} + +function normalizeHoldId(id: string): string { + const value = typeof id === 'string' ? id.trim() : ''; + if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid human-control hold id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return value; +} + +function normalizeDeviceKey(deviceKey: string): string { + const value = typeof deviceKey === 'string' ? deviceKey.trim() : ''; + if (!value || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) { + throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.'); + } + return value; +} + +function normalizeDeviceAliases(deviceKeys: readonly string[]): string[] { + return Array.from( + new Set( + deviceKeys + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .map((value) => normalizeDeviceAlias(value)), + ), + ); +} + +function normalizeDeviceAlias(value: string): string { + return value.trim().toLocaleLowerCase('en-US'); +} + +function normalizeOptionalLabel(value: string | undefined, label: string): string | undefined { + if (value === undefined) return undefined; + const normalized = value.trim(); + if (!normalized || normalized.length > 256) { + throw new AppError('INVALID_ARGS', `Invalid ${label}. Use 1-256 characters.`); + } + return normalized; +} + +function normalizeReason(reason: string | undefined): string | undefined { + if (reason === undefined) return undefined; + const value = reason.trim(); + if (!value) return undefined; + if (value.length > 512) { + throw new AppError('INVALID_ARGS', 'Human-control reason must be at most 512 characters.'); + } + return value; +} + +function normalizeTtlMs(ttlMs: number | undefined): number | undefined { + if (ttlMs === undefined) return undefined; + if (!Number.isInteger(ttlMs) || ttlMs < MIN_HOLD_TTL_MS || ttlMs > MAX_HOLD_TTL_MS) { + throw new AppError( + 'INVALID_ARGS', + `Human-control ttlMs must be between ${String(MIN_HOLD_TTL_MS)} and ${String(MAX_HOLD_TTL_MS)}.`, + ); + } + return ttlMs; +} + +function normalizeTimestamp(value: number, field: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new AppError('COMMAND_FAILED', `Persisted human-control ${field} is invalid.`); + } + return value; +} + +function cloneHold(hold: HumanControlHold): HumanControlHold { + return { ...hold, scope: { ...hold.scope } }; +} diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index d25b345b6e..162009b232 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -18,6 +18,7 @@ export type LeaseRegistryOptions = { providerSessionRetentionMs?: number; now?: () => number; onLeaseExpired?: (lease: DeviceLease) => void; + isDeviceLeaseProtected?: (lease: DeviceLease) => boolean; }; export type AllocateLeaseRequest = { @@ -210,6 +211,7 @@ export class LeaseRegistry { private readonly now: () => number; private readonly onLeaseExpired?: (lease: DeviceLease) => void; private readonly providerSessionOwnership: ProviderSessionOwnershipRegistry; + private readonly isDeviceLeaseProtected: (lease: DeviceLease) => boolean; constructor(options: LeaseRegistryOptions = {}) { this.maxActiveSimulatorLeases = Number.isInteger(options.maxActiveSimulatorLeases) @@ -230,6 +232,7 @@ export class LeaseRegistry { now: this.now, retentionMs: options.providerSessionRetentionMs, }); + this.isDeviceLeaseProtected = options.isDeviceLeaseProtected ?? (() => false); } allocateLease(request: AllocateLeaseRequest): DeviceLease { @@ -371,11 +374,23 @@ export class LeaseRegistry { return this.providerSessionOwnership.resolve(params); } + refreshLeasesForDeviceKey(deviceKey: string): DeviceLease[] { + const normalizedDeviceKey = normalizeDeviceKey(deviceKey); + if (!normalizedDeviceKey) return []; + const comparisonKey = normalizedDeviceKey.toLocaleLowerCase('en-US'); + const refreshed: DeviceLease[] = []; + for (const lease of this.leases.values()) { + if (lease.deviceKey?.toLocaleLowerCase('en-US') !== comparisonKey) continue; + refreshed.push(this.refreshLease(lease, this.defaultLeaseTtlMs)); + } + return refreshed; + } + consumeExpiredLeases(): DeviceLease[] { const now = this.now(); const expired: DeviceLease[] = []; for (const lease of this.leases.values()) { - if (lease.expiresAt > now) continue; + if (lease.expiresAt > now || this.isDeviceLeaseProtected(lease)) continue; this.leases.delete(lease.leaseId); this.unbindLease(lease, lease.expiresAt); const expiredLease = { ...lease }; @@ -389,7 +404,9 @@ export class LeaseRegistry { const normalizedLeaseId = normalizeLeaseId(leaseId); if (!normalizedLeaseId) return undefined; const lease = this.leases.get(normalizedLeaseId); - if (!lease || lease.expiresAt > this.now()) return undefined; + if (!lease || lease.expiresAt > this.now() || this.isDeviceLeaseProtected(lease)) { + return undefined; + } this.leases.delete(lease.leaseId); this.unbindLease(lease, lease.expiresAt); const expiredLease = { ...lease }; diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index b66fe746e4..d082b19633 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -58,6 +58,8 @@ import { createDeviceClaimAdmission, type DeviceClaimAdmission } from './device- import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; import { resolveCommandDeviceClaimPolicy } from '../core/command-descriptor/registry.ts'; import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import { runRequestWithHumanControl } from './human-control-request.ts'; +import type { HumanControlRegistry } from './human-control.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -116,6 +118,7 @@ export async function createRequestExecutionScope(params: { deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; + humanControlRegistry?: HumanControlRegistry; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -215,29 +218,37 @@ export async function createRequestExecutionScope(params: { }), throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runAdmitted: async (task) => { - throwIfRequestCanceled(scopedReq.meta?.requestId); - await cleanupExpiredLeasedSession({ - sessionName, - sessionStore, - leaseRegistry, - teardownSession: async (session, expiredSessionName) => - await teardownExpiredSession({ - session, - sessionName: expiredSessionName, - sessionStore, - inspectFacts: scope.inspectFacts, - bindDevice: scope.bindDevice, - platformCleanup: requirePlatformCleanup(params.platformResourceCleanup), - }), - }); - scopedReq = admitRequestLeaseForLockedScope({ + return await runRequestWithHumanControl({ req: scopedReq, sessionName, sessionStore, - leaseRegistry, + registry: params.humanControlRegistry, + task: async () => { + throwIfRequestCanceled(scopedReq.meta?.requestId); + await cleanupExpiredLeasedSession({ + sessionName, + sessionStore, + leaseRegistry, + teardownSession: async (session, expiredSessionName) => + await teardownExpiredSession({ + session, + sessionName: expiredSessionName, + sessionStore, + inspectFacts: scope.inspectFacts, + bindDevice: scope.bindDevice, + platformCleanup: requirePlatformCleanup(params.platformResourceCleanup), + }), + }); + scopedReq = admitRequestLeaseForLockedScope({ + req: scopedReq, + sessionName, + sessionStore, + leaseRegistry, + }); + scope.req = scopedReq; + return await task(); + }, }); - scope.req = scopedReq; - return await task(); }, runLocked: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 3cb39940a6..0c55e2022d 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -24,6 +24,8 @@ import type { PlatformRequestScope } from '@agent-device/contracts/platform-runt import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { HumanControlHold } from './human-control-contract.ts'; +import type { HumanControlRegistry } from './human-control.ts'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -35,6 +37,8 @@ type RequestHandlerChainParams = { providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; + humanControlRegistry?: HumanControlRegistry; + onHumanControlHoldReleased?: (hold: HumanControlHold) => void; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; /** @@ -66,6 +70,10 @@ type RequestHandlerChainParams = { }; const DAEMON_ROUTE_HANDLERS = { + humanControl: defineDaemonRoute({ + load: () => import('./handlers/human-control.ts'), + run: runHumanControlHandler, + }), lease: defineDaemonRoute({ load: () => import('./handlers/lease.ts'), run: runLeaseHandler, @@ -121,6 +129,17 @@ export async function loadGenericRequestHandlerModule(): Promise< return await DAEMON_ROUTE_HANDLERS.generic.loadModule(); } +async function runHumanControlHandler( + { handleHumanControlCommand }: typeof import('./handlers/human-control.ts'), + params: RequestHandlerChainParams, +): Promise { + return await handleHumanControlCommand({ + req: params.req, + registry: params.humanControlRegistry, + onHoldReleased: params.onHumanControlHoldReleased, + }); +} + async function runLeaseHandler( { handleLeaseCommands }: typeof import('./handlers/lease.ts'), params: RequestHandlerChainParams, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 4228a4b80e..daf0908120 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -68,6 +68,8 @@ import { import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import type { HumanControlHold } from './human-control-contract.ts'; +import type { HumanControlRegistry } from './human-control.ts'; // --------------------------------------------------------------------------- // Request handler API @@ -92,6 +94,8 @@ export type RequestRouterDeps = { cloudArtifactProvider?: CloudArtifactProvider; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; + humanControlRegistry?: HumanControlRegistry; + onHumanControlHoldReleased?: (hold: HumanControlHold) => void; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; trackDownloadableArtifact: (opts: { artifactPath: string; @@ -152,6 +156,8 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { cloudArtifactProvider, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, + humanControlRegistry, + onHumanControlHoldReleased, providerDeviceRuntimeScope, trackDownloadableArtifact, } = deps; @@ -216,6 +222,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope, platformResourceCleanup, + humanControlRegistry, }); return await executeRequestScope(scope); }), @@ -285,6 +292,8 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeIds, providerRuntimeRequiredIds, cloudArtifactProvider, + humanControlRegistry, + onHumanControlHoldReleased, invoke: handleRequest, invokeReplayAction: allowReplayActions ? createReplayScopedActionInvoker(lockedScope, providerScope) @@ -341,6 +350,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, + humanControlRegistry, }); // The outer replay keeps its stable session lock plus the device lock // from the first device binding through response projection and ref diff --git a/src/daemon/route-owner-files.ts b/src/daemon/route-owner-files.ts index bd39a3bc2c..15923d522d 100644 --- a/src/daemon/route-owner-files.ts +++ b/src/daemon/route-owner-files.ts @@ -17,6 +17,7 @@ import type { DaemonCommandRoute } from './request-handler-chain.ts'; * each path still points at the module that route's loader imports. */ const DAEMON_ROUTE_OWNER_FILES = { + humanControl: 'src/daemon/handlers/human-control.ts', lease: 'src/daemon/handlers/lease.ts', session: 'src/daemon/handlers/session.ts', snapshot: 'src/daemon/handlers/snapshot.ts', diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 82b838a895..a800f62bdf 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import path from 'node:path'; import { asAppError, AppError } from '@agent-device/kernel/errors'; import { resolveSessionRequestLogPath, SessionStore } from '../session-store.ts'; import { resolveDaemonPaths, resolveDaemonServerMode } from '../config.ts'; @@ -76,6 +77,8 @@ import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts' import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; import { createAudioProbeAdmissionLedger } from '../audio-probe-admission-ledger.ts'; import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import type { HumanControlHold } from '../human-control-contract.ts'; +import { HumanControlRegistry } from '../human-control.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; export const SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS = 11_000; @@ -255,6 +258,9 @@ export async function startDaemonRuntime( await configureAppleRunnerLeaseOwnerStateDir(baseDir); const sessionStore = new SessionStore(sessionsDir); + const humanControlRegistry = new HumanControlRegistry({ + statePath: path.join(baseDir, 'human-control.json'), + }); const ownedProcessRecords = createOwnedProcessRecordStore({ stateDir: baseDir, sessionsDir, @@ -319,7 +325,23 @@ export async function startDaemonRuntime( onLeaseExpired: (lease) => { void expiredProviderLeaseReleaser.release(lease); }, + isDeviceLeaseProtected: (lease) => humanControlRegistry.isDeviceControlled(lease.deviceKey), }); + const refreshReleasedHumanControlLeases = (hold: HumanControlHold): void => { + const refreshedLeases = leaseRegistry.refreshLeasesForDeviceKey(hold.scope.deviceKey); + const expiresAtByLeaseId = new Map( + refreshedLeases.map((lease) => [lease.leaseId, lease.expiresAt]), + ); + for (const session of sessionStore.values()) { + const leaseId = session.lease?.leaseId; + const expiresAt = leaseId ? expiresAtByLeaseId.get(leaseId) : undefined; + if (!session.lease || expiresAt === undefined) continue; + sessionStore.set(session.name, { + ...session, + lease: { ...session.lease, expiresAt }, + }); + } + }; const cloudArtifactProvider = providerRuntimeProviders.cloudArtifactProvider; const deviceInventoryGateways = createPlatformDeviceInventoryGateways( providerRuntimeProviders.deviceInventorySource, @@ -342,6 +364,8 @@ export async function startDaemonRuntime( requestPlatformProviders, androidObservation, platformResourceCleanup, + humanControlRegistry, + onHumanControlHoldReleased: refreshReleasedHumanControlLeases, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, @@ -447,6 +471,8 @@ export async function startDaemonRuntime( token, retainArtifacts, env, + humanControlRegistry, + onHumanControlHoldReleased: refreshReleasedHumanControlLeases, // #1801: the same record `DaemonError.logPath` names, addressed by its // locator so a remote caller can fetch what it cannot read by path. resolveRequestDiagnosticsPath: (ref) => diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index 52b3317c44..b63ac633c3 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -43,6 +43,10 @@ import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; import { tryHandleRequestDiagnosticsHttpRoute } from '../request-diagnostics-http.ts'; import { resolveTrustedTenant, tenantTrustRejectionError } from './tenant-trust.ts'; +import { tryHandleHumanControlHttpRoute } from '../human-control-http.ts'; +import type { HumanControlHold } from '../human-control-contract.ts'; +import type { HumanControlRegistry } from '../human-control.ts'; +import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; type JsonRpcRequest = JsonRpcRequestEnvelope; @@ -555,6 +559,8 @@ export async function createDaemonHttpServer(options: { token?: string; retainArtifacts?: boolean; env?: NodeJS.ProcessEnv; + humanControlRegistry?: HumanControlRegistry; + onHumanControlHoldReleased?: (hold: HumanControlHold) => void; /** * Resolves a request diagnostics record path for the `/sessions/.../requests/...` * route (#1801). Omitted by embedded servers with no session store; the route @@ -574,6 +580,20 @@ export async function createDaemonHttpServer(options: { return; } + if ( + token && + options.humanControlRegistry && + tryHandleHumanControlHttpRoute({ + req, + res, + expectedToken: token, + registry: options.humanControlRegistry, + onHoldReleased: options.onHumanControlHoldReleased, + }) + ) { + return; + } + if ( tryHandleUploadHttpRoute({ req, @@ -745,6 +765,14 @@ export async function createDaemonHttpServer(options: { authHook !== null, req.headers[DAEMON_HTTP_NETWORK_ACCESS_HEADER], ); + if (daemonRequest.command === INTERNAL_COMMANDS.humanControl) { + sendJson( + res, + createRpcError(rpcRequest.id ?? null, -32601, 'Human-control RPC is socket-only'), + 404, + ); + return; + } let canceledInFlight = false; // Request-scoped cancellation: mark this request canceled whenever its client @@ -813,7 +841,7 @@ export async function createDaemonHttpServer(options: { daemonResponse.error.message, daemonResponse.error, ), - statusCodeForNormalizedError(daemonResponse.error.code), + statusCodeForDaemonError(daemonResponse.error), ); } catch (error) { handlerCompleted = true; @@ -838,6 +866,16 @@ export async function createDaemonHttpServer(options: { }); } +function statusCodeForDaemonError(error: { + code: string; + details?: Record; +}): number { + if (error.code === 'DEVICE_IN_USE' && error.details?.reason === 'human_control_active') { + return 423; + } + return statusCodeForNormalizedError(error.code); +} + async function authorizeAuxiliaryHttpRequest(params: { req: http.IncomingMessage; res: http.ServerResponse; diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 454b6f8045..83c7260321 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -108,6 +108,28 @@ agent-device open com.example.myapp --platform android --serial emulator-5554 -- agent-device metro reload ``` +## Human Takeover + +Use `takeover` on the machine or VM that owns the simulator/device when a person needs to interact +with it without racing the agent: + +```bash +agent-device takeover --platform ios +agent-device takeover --platform android --serial emulator-5554 +``` + +The command resolves the local target, installs a short-lived device-scoped hold, keeps it alive in +the foreground, and releases it on Ctrl+C. While held, state-changing commands fail with +`DEVICE_IN_USE` and `details.reason: "human_control_active"`, explaining that agent interactions are +temporarily disabled. Snapshots, screenshots, selector reads, logs, and other read-only diagnostics +remain available. The hold also +protects an existing remote device lease from inactivity expiry so the human does not accidentally +hand the simulator to a different agent. + +Use `agent-device takeover status` to list holds. A foreground hold expires automatically if its +process disappears; `agent-device takeover release ` is available for explicit recovery. +`takeover` always controls the local daemon, even when the CLI has an active remote connection. + ## Web Automation Minimal `--platform web` support reuses [agent-browser](https://github.com/vercel-labs/agent-browser). `agent-device` owns command/session/replay integration, refs/selectors, and artifact routing; `agent-browser` owns browser launch, page control, screenshots, and browser-specific mechanics. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index bf97512b63..2b14a90022 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -53,12 +53,56 @@ Do not put proxy endpoint, token, tenant, or provider fields in `./agent-device. configuration is intentionally limited to project-safe automation defaults. Use `connect proxy`, user config, an explicit `--config` file, or protected CI environment variables for the endpoint and token. +## Human Takeover on the Host + +If a person needs the simulator or device, run this on the host—not on the remote agent client: + +```bash +agent-device takeover --platform ios +``` + +The local daemon pauses state-changing agent commands for the selected device until Ctrl+C. Read-only +diagnostics remain available, and an existing remote lease is preserved during the hold. + +VM-side automation can use the same feature without a foreground CLI process. Read the local +daemon's `httpPort` and `token` from `daemon.json` in the effective state directory, then call the +loopback-only API with either `Authorization: Bearer ` or +`X-Agent-Device-Token: `: + +The API is available when the daemon runs with an HTTP listener, including remote-mode daemons. A +default socket-only local daemon should use the `takeover` CLI command instead. + +```text +PUT /admin/human-control/holds/ +GET /admin/human-control/holds +DELETE /admin/human-control/holds/ +``` + +A PUT body has the shape below. Omitting `ttlMs` creates a persistent hold that must be deleted; +including it creates an expiring hold. + +```json +{ + "scope": { + "deviceKey": "", + "deviceName": "iPhone 17 Pro", + "platform": "ios", + "kind": "simulator" + }, + "reason": "Human is using the VM console.", + "ttlMs": 15000 +} +``` + ## What Is Exposed The proxy allows only the daemon HTTP contract: `/health`, `/rpc`, `/upload` plus resumable `/upload/*` routes, and `/artifacts/*`, with the same routes also available under `/agent-device/*`. Health checks are unauthenticated; command, upload, and artifact routes require the bearer token. The proxy validates the client token and rewrites authorized upstream requests to the local daemon token. The local daemon still validates its own token, so the daemon token is not exposed to remote clients. +The proxy deliberately does not forward `/admin/*`, including human-control holds. A caller inside +the device-host VM must use the daemon's loopback port and local daemon token. + ## Compatibility Remote clients read `/health` before issuing commands and compare the daemon RPC protocol version. Keep the client and proxy versions reasonably close; patch-level differences should normally work, but incompatible RPC protocol versions fail before commands run. diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index 5df52dcef5..dbcc8d21f4 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -18,11 +18,15 @@ description: Security and trust guidance for agent-device local app automation, CLI commands run through a per-user background daemon: - The daemon binds to `127.0.0.1` only, on ephemeral ports, for both its socket and HTTP transports. It is never reachable from the network unless you deliberately front it with your own proxy. -- Command (RPC), upload, and artifact-download requests must present a token generated fresh on each daemon boot (24 random bytes). The only unauthenticated endpoint is `GET /health`, which intentionally returns a bare liveness response and nothing else; like the rest of the server it is reachable only via loopback. The token is stored in `daemon.json` inside the daemon state directory (`~/.agent-device` for packaged installs; source checkouts use a worktree-scoped directory under `~/.agent-device/dev/`) with `0600` permissions; whoever can read that file already has your user account. +- Command (RPC), upload, artifact-download, and `/admin/human-control/*` requests must present a token generated fresh on each daemon boot (24 random bytes). The only unauthenticated endpoint is `GET /health`, which intentionally returns a bare liveness response and nothing else; like the rest of the server it is reachable only via loopback. The token is stored in `daemon.json` inside the daemon state directory (`~/.agent-device` for packaged installs; source checkouts use a worktree-scoped directory under `~/.agent-device/dev/`) with `0600` permissions; whoever can read that file already has your user account. - A client only reuses a running daemon when the daemon's version and binary code signature match its own; otherwise the daemon is restarted. This prevents a stale or tampered daemon from silently serving new clients. - Artifact uploads are size-capped, filenames are sanitized, and archive extraction rejects path-traversal entries. Artifact downloads resolve through server-side IDs, never client-supplied paths. -For remote or cloud deployments, the daemon supports a custom auth hook: `AGENT_DEVICE_HTTP_AUTH_HOOK` names a module path that is dynamically imported and invoked for each HTTP request (with `AGENT_DEVICE_HTTP_AUTH_EXPORT` selecting the export). The hook runs with the daemon's full privileges, so treat it as part of your trusted computing base: point it only at a read-only path you control, never at a location writable by less-trusted users or processes. Whoever controls the daemon's environment controls the hook. +For remote or cloud deployments, the daemon supports a custom auth hook for remotely consumable HTTP routes: `AGENT_DEVICE_HTTP_AUTH_HOOK` names a module path that is dynamically imported (with `AGENT_DEVICE_HTTP_AUTH_EXPORT` selecting the export). The host-local `/admin/human-control/*` route uses the daemon token instead. The hook runs with the daemon's full privileges, so treat it as part of your trusted computing base: point it only at a read-only path you control, never at a location writable by less-trusted users or processes. Whoever controls the daemon's environment controls the hook. + +Human-control administration is host-local. The daemon accepts it only on its loopback listener with +the local daemon token, and `agent-device proxy` does not forward `/admin/*`. Persistent holds are +stored as `human-control.json` in the daemon state directory with `0600` permissions. If a hook is configured and its result does not attest a `tenantId`, the daemon refuses the request (401) outright — it never falls back to a tenant the client declares itself (RPC body `meta.tenantId` or `flags.tenant`, or the `x-agent-device-tenant` header on the upload/artifact-download/diagnostics routes), and it never admits the request unscoped either: a shared token must not let one caller claim another tenant's identity, nor read a tenant-owned session or artifact by simply declaring none. A hook must attest `tenantId` on every request it wants admitted; a deployment with no hook configured is unaffected. From 0de245a42a95f4a1fdd7b9ba375e51b04fa6b2a8 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 27 Aug 2026 18:50:31 +0200 Subject: [PATCH 02/10] fix: harden human takeover controls --- .github/workflows/ios.yml | 3 + .../RunnerTests.swift | 41 +++++++ src/__tests__/takeover-command.test.ts | 38 ++++++ src/cli/commands/connection-runtime.ts | 43 +------ src/cli/commands/takeover.ts | 21 +++- src/core/command-descriptor/registry.ts | 40 +++---- src/core/device-selection-resolver.ts | 37 ++++++ src/core/lease-scope.ts | 29 +++++ .../__tests__/daemon-command-registry.test.ts | 2 + .../__tests__/human-control-http.test.ts | 36 ++++++ .../__tests__/human-control-request.test.ts | 2 +- src/daemon/__tests__/human-control.test.ts | 74 +++++++++--- src/daemon/__tests__/lease-registry.test.ts | 28 +++++ src/daemon/daemon-command-registry.ts | 2 - src/daemon/handlers/human-control.ts | 3 +- src/daemon/human-control-http.ts | 108 ++++++++++++------ src/daemon/human-control-request.ts | 2 - src/daemon/human-control.ts | 40 +++++-- src/daemon/lease-registry.ts | 11 +- 19 files changed, 426 insertions(+), 134 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index fe52efd26d..fc1ae4f65f 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -136,6 +136,9 @@ jobs: xcodebuild test-without-building \ -xctestrun "$XCTESTRUN_PATH" \ -destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \ + -retry-tests-on-failure \ + -test-iterations 2 \ + -test-repetition-relaunch-enabled YES \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden \ diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index d34ca183d6..9465c8c114 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -194,8 +194,49 @@ final class RunnerTests: XCTestCase { override func setUp() { continueAfterFailure = true + #if os(macOS) + addUIInterruptionMonitor(withDescription: "Host local-network permission") { alert in + let text = alert.staticTexts.allElementsBoundByIndex.map(\.label) + guard let button = alert.buttons.allElementsBoundByIndex.first(where: { button in + Self.shouldDismissHostLocalNetworkPermission(text: text, buttonLabel: button.label) + }) else { + return false + } + button.tap() + return true + } + #endif } + static func shouldDismissHostLocalNetworkPermission( + text: [String], + buttonLabel: String + ) -> Bool { + let isLocalNetworkPrompt = text.contains { value in + value.localizedCaseInsensitiveContains("local network") + } + let normalizedButton = buttonLabel.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return isLocalNetworkPrompt && ["don't allow", "don’t allow"].contains(normalizedButton) + } + + #if AGENT_DEVICE_RUNNER_UNIT_TESTS + func testHostLocalNetworkPermissionMonitorSelectsOnlyTheDenialAction() { + let prompt = ["Allow hosted compute to find devices on local networks?"] + XCTAssertTrue( + Self.shouldDismissHostLocalNetworkPermission(text: prompt, buttonLabel: "Don’t Allow") + ) + XCTAssertFalse( + Self.shouldDismissHostLocalNetworkPermission(text: prompt, buttonLabel: "Allow") + ) + XCTAssertFalse( + Self.shouldDismissHostLocalNetworkPermission( + text: ["System Settings wants to make changes"], + buttonLabel: "Don’t Allow" + ) + ) + } + #endif + /// True for the one recorded-issue class the runner deliberately mutes: an AX-server error /// (`kAXError*`) inside a "Failed to get matching snapshot" fetch. The kAXError token /// intentionally covers kAXErrorIllegalArgument and its sibling AX server codes (e.g. diff --git a/src/__tests__/takeover-command.test.ts b/src/__tests__/takeover-command.test.ts index 7013c06984..f4362e0a55 100644 --- a/src/__tests__/takeover-command.test.ts +++ b/src/__tests__/takeover-command.test.ts @@ -79,6 +79,44 @@ test('takeover rejects malformed actions before contacting the daemon', async () assert.equal(mocks.sendRequest.mock.calls.length, 0); }); +test('foreground takeover resolves the device through the public client inventory', async () => { + const list = vi.fn().mockResolvedValue([ + { + platform: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'sim-1', + name: 'iPhone 17 Pro', + booted: true, + identifiers: { udid: 'sim-1' }, + }, + ]); + mocks.sendRequest.mockImplementation(async (_daemon, request) => { + if (request.positionals[0] === 'put') { + setTimeout(() => process.emit('SIGINT'), 0); + return { ok: true, data: { hold: HOLD } }; + } + return { ok: true, data: { released: true } }; + }); + + await takeoverCommand({ + positionals: [], + flags: { json: true, help: false, version: false, platform: 'ios', udid: 'sim-1' }, + client: { devices: { list } } as unknown as AgentDeviceClient, + }); + + assert.equal(list.mock.calls[0]?.[0].platform, 'ios'); + assert.equal(list.mock.calls[0]?.[0].udid, 'sim-1'); + const putRequest = mocks.sendRequest.mock.calls.find( + (call) => call[1].positionals[0] === 'put', + )?.[1]; + assert.equal(JSON.parse(putRequest.positionals[2]).scope.deviceKey, 'sim-1'); + assert.equal( + mocks.sendRequest.mock.calls.some((call) => call[1].positionals[0] === 'remove'), + true, + ); +}); + async function runTakeover(positionals: string[]): Promise { return await takeoverCommand({ positionals, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 51a769b367..e78ccee570 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -7,13 +7,8 @@ import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; // see resolvePreviousOwnDaemonAuthToken below for why this must not be // resolveRemoteConfigProfile. import { readRemoteConfigFile } from '../../remote/remote-config-core.ts'; -import { - deviceFieldsFromPublicPlatform, - isIosFamily, - publicPlatformString, - resolveDevice, - type DeviceInfo, -} from '@agent-device/kernel/device'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { proxyLeaseDeviceKey } from '../../core/lease-scope.ts'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import { buildRemoteConnectionDaemonState, @@ -846,7 +841,7 @@ async function resolveProxyLeaseState(options: { ); } const device = await resolveSelectedDevice(options.client, options.flags); - const deviceKey = buildProxyDeviceKey(device); + const deviceKey = proxyLeaseDeviceKey(device); return { state: { ...options.state, @@ -877,36 +872,8 @@ async function resolveSelectedDevice( client: AgentDeviceClient, flags: CliFlags, ): Promise { - const devices = await client.devices.list({ - platform: flags.platform, - target: flags.target, - device: flags.device, - udid: flags.udid, - serial: flags.serial, - iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, - androidDeviceAllowlist: flags.androidDeviceAllowlist, - }); - return await resolveDevice( - devices.map((device) => ({ - ...deviceFieldsFromPublicPlatform(device.platform), - id: device.id, - name: device.name, - kind: device.kind, - target: device.target, - booted: device.booted, - })), - { - platform: flags.platform, - target: flags.target, - deviceName: flags.device, - udid: flags.udid, - serial: flags.serial, - }, - ); -} - -function buildProxyDeviceKey(device: DeviceInfo): string { - return `${publicPlatformString(device)}:${device.target ?? 'mobile'}:${device.id}`; + const { resolvePublicInventoryDevice } = await import('../../core/device-selection-resolver.ts'); + return await resolvePublicInventoryDevice(client.devices, flags); } function leaseBackendForDevice(device: DeviceInfo): LeaseBackend | undefined { diff --git a/src/cli/commands/takeover.ts b/src/cli/commands/takeover.ts index 19c36e1c30..5f09c6d842 100644 --- a/src/cli/commands/takeover.ts +++ b/src/cli/commands/takeover.ts @@ -2,8 +2,9 @@ import { randomUUID } from 'node:crypto'; import type { CliFlags } from '@agent-device/contracts/command'; import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError, throwDaemonError, toAppErrorCode } from '@agent-device/kernel/errors'; +import type { AgentDeviceClient } from '../../agent-device-client.ts'; import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; -import { resolveTargetDevice } from '../../core/dispatch-resolve.ts'; +import { resolvePublicInventoryDevice } from '../../core/device-selection-resolver.ts'; import { ensureDaemon, resolveClientSettings, @@ -43,7 +44,7 @@ type LocalHumanControlClient = { remove(holdId: string): Promise; }; -export const takeoverCommand: ClientCommandHandler = async ({ positionals, flags }) => { +export const takeoverCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const action = positionals[0]?.toLowerCase(); if (action === 'status') { if (positionals.length !== 1) { @@ -63,12 +64,15 @@ export const takeoverCommand: ClientCommandHandler = async ({ positionals, flags throw new AppError('INVALID_ARGS', 'takeover accepts only: status or release .'); } - await runForegroundTakeover(flags); + await runForegroundTakeover(flags, client); return true; }; -async function runForegroundTakeover(flags: CliFlags): Promise { - const device = await resolveTargetDevice(flags); +async function runForegroundTakeover( + flags: CliFlags, + agentDeviceClient: AgentDeviceClient, +): Promise { + const device = await resolveTakeoverDevice(agentDeviceClient, flags); const holdId = `takeover-${randomUUID()}`; const input = buildForegroundHoldInput(device); const client = await createLocalHumanControlClient(flags); @@ -118,6 +122,13 @@ async function runForegroundTakeover(flags: CliFlags): Promise { } } +async function resolveTakeoverDevice( + client: AgentDeviceClient, + flags: CliFlags, +): Promise { + return await resolvePublicInventoryDevice(client.devices, flags); +} + async function showTakeoverStatus(flags: CliFlags): Promise { const holds = await (await createLocalHumanControlClient(flags)).list(); writeCommandOutput(flags, { holds }, () => renderTakeoverStatus(holds)); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index c59d3fcb01..c6e9b8bda6 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -421,8 +421,20 @@ function postActionObservation(command: string): PostActionObservationSupport { const ownerFilesEnabled = typeof __OWNER_FILES__ === 'undefined' || __OWNER_FILES__; +const DEPLOY_APP_COMMAND_DESCRIPTOR = { + deviceClaimPolicy: 'transient-exclusive', + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), + catalog: { group: 'public' }, + frameworkTier: 'extended', + recordsSessionAction: true, + recordingEffect: 'mutates-app', + daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + platformExecution: { kind: 'device-runtime', use: deployAppUse }, + timeoutPolicy: INSTALL_TIMEOUT_POLICY, + batchable: true, +} as const; + export const RAW_COMMAND_DESCRIPTORS = [ - // -- host-local human control (route: humanControl) -- { name: 'human_control', deviceClaimPolicy: 'none', @@ -878,29 +890,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'install', - deviceClaimPolicy: 'transient-exclusive', - ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), - catalog: { group: 'public' }, - frameworkTier: 'extended', - recordsSessionAction: true, - recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, - platformExecution: { kind: 'device-runtime', use: deployAppUse }, - timeoutPolicy: INSTALL_TIMEOUT_POLICY, - batchable: true, + ...DEPLOY_APP_COMMAND_DESCRIPTOR, }, { name: 'reinstall', - deviceClaimPolicy: 'transient-exclusive', - ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), - catalog: { group: 'public' }, - frameworkTier: 'extended', - recordsSessionAction: true, - recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, - platformExecution: { kind: 'device-runtime', use: deployAppUse }, - timeoutPolicy: INSTALL_TIMEOUT_POLICY, - batchable: true, + ...DEPLOY_APP_COMMAND_DESCRIPTOR, }, { name: 'install_source', @@ -1182,7 +1176,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'recordTrace', refFrameEffect: 'preserve', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'recordTrace', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: NO_PLATFORM_EXECUTION, @@ -1475,7 +1469,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'generic', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_READ }, + daemon: { route: 'generic', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, platformExecution: { kind: 'device-runtime', uses: [viewportRuntimeUse] }, diff --git a/src/core/device-selection-resolver.ts b/src/core/device-selection-resolver.ts index ed7e878e99..2103ae33bb 100644 --- a/src/core/device-selection-resolver.ts +++ b/src/core/device-selection-resolver.ts @@ -1,9 +1,12 @@ import type { + AgentDeviceDevice, + AgentDeviceSelectionOptions, DeviceSelectionMetadata, DeviceSelectionReason, DeviceSelectionSource, } from '@agent-device/contracts/client'; import { + deviceFieldsFromPublicPlatform, hasExplicitDeviceIdentitySelector, isIosFamily, isSerialAddressablePlatform, @@ -31,6 +34,40 @@ export type InventoryDeviceSelectionParams = { appleSimulatorAppTarget?: string; }; +export async function resolvePublicInventoryDevice( + source: { + list(options?: AgentDeviceSelectionOptions): Promise; + }, + options: AgentDeviceSelectionOptions, +): Promise { + const devices = await source.list({ + platform: options.platform, + target: options.target, + device: options.device, + udid: options.udid, + serial: options.serial, + iosSimulatorDeviceSet: options.iosSimulatorDeviceSet, + androidDeviceAllowlist: options.androidDeviceAllowlist, + }); + return await resolveDevice( + devices.map((device) => ({ + ...deviceFieldsFromPublicPlatform(device.platform), + id: device.id, + name: device.name, + kind: device.kind, + target: device.target, + booted: device.booted, + })), + { + platform: options.platform, + target: options.target, + deviceName: options.device, + udid: options.udid, + serial: options.serial, + }, + ); +} + export async function resolveInventoryDeviceSelection( params: InventoryDeviceSelectionParams, ): Promise { diff --git a/src/core/lease-scope.ts b/src/core/lease-scope.ts index 530fa97c10..5e8c7d299a 100644 --- a/src/core/lease-scope.ts +++ b/src/core/lease-scope.ts @@ -1,9 +1,38 @@ import type { LeaseBackend } from '@agent-device/kernel/contracts'; import { stripUndefined } from '@agent-device/kernel/record'; +import { + DEVICE_TARGETS, + isPublicPlatform, + publicPlatformString, + type DeviceInfo, +} from '@agent-device/kernel/device'; const PROXY_LEASE_PROVIDER = 'proxy'; export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; +export function proxyLeaseDeviceKey(device: DeviceInfo): string { + return `${publicPlatformString(device)}:${device.target ?? 'mobile'}:${device.id}`; +} + +export function deviceIdentityAliases(deviceKeys: readonly string[]): string[] { + const aliases = new Set(); + for (const rawKey of deviceKeys) { + const deviceKey = rawKey.trim(); + if (!deviceKey) continue; + aliases.add(deviceKey); + const [platform, target, ...identityParts] = deviceKey.split(':'); + if ( + isPublicPlatform(platform) && + (DEVICE_TARGETS as readonly string[]).includes(target ?? '') && + identityParts.length > 0 + ) { + const identity = identityParts.join(':').trim(); + if (identity) aliases.add(identity); + } + } + return [...aliases]; +} + const REQUIRED_PROXY_LEASE_FIELDS = [ 'leaseId', 'tenantId', diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index f6ef9ffc44..6507ed71af 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -260,6 +260,7 @@ test('daemon command registry owns human-control effects and fails closed', () = PUBLIC_COMMANDS.is, PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.trace, ]) { assert.equal(humanControlEffectForRequest(makeRequest(command)), 'read', `${command} effect`); } @@ -283,6 +284,7 @@ test('daemon command registry owns human-control effects and fails closed', () = 'mutate', ); assert.equal(humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.click)), 'mutate'); + assert.equal(humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.viewport)), 'mutate'); assert.equal(humanControlEffectForRequest(makeRequest('future-command')), 'mutate'); assert.equal( humanControlEffectForRequest(makeRequest(INTERNAL_COMMANDS.leaseHeartbeat)), diff --git a/src/daemon/__tests__/human-control-http.test.ts b/src/daemon/__tests__/human-control-http.test.ts index b2b14edb19..98cd526365 100644 --- a/src/daemon/__tests__/human-control-http.test.ts +++ b/src/daemon/__tests__/human-control-http.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import type http from 'node:http'; import { test } from 'vitest'; import { closeLoopbackServer, @@ -6,8 +7,43 @@ import { skipWhenLoopbackUnavailable, } from '../../__tests__/test-utils/loopback.ts'; import { HUMAN_CONTROL_HTTP_PREFIX, HumanControlRegistry } from '../human-control.ts'; +import { tryHandleHumanControlHttpRoute } from '../human-control-http.ts'; import { createDaemonHttpServer } from '../server/http-server.ts'; +test('malformed request URLs return a normalized error', async () => { + let responseBody = ''; + let finishResponse: (() => void) | undefined; + const responseFinished = new Promise((resolve) => { + finishResponse = resolve; + }); + const req = { + url: 'http://[', + method: 'PUT', + headers: { authorization: 'Bearer daemon-secret' }, + } as http.IncomingMessage; + const res = { + statusCode: 0, + setHeader: () => undefined, + end: (body: string) => { + responseBody = body; + finishResponse?.(); + }, + } as unknown as http.ServerResponse; + + assert.equal( + tryHandleHumanControlHttpRoute({ + req, + res, + expectedToken: 'daemon-secret', + registry: new HumanControlRegistry(), + }), + true, + ); + await responseFinished; + assert.equal(res.statusCode, 400); + assert.equal((JSON.parse(responseBody) as { code?: string }).code, 'INVALID_ARGS'); +}); + test('daemon human-control API authenticates and manages persistent holds', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; diff --git a/src/daemon/__tests__/human-control-request.test.ts b/src/daemon/__tests__/human-control-request.test.ts index 9481093d77..c7fd0949f9 100644 --- a/src/daemon/__tests__/human-control-request.test.ts +++ b/src/daemon/__tests__/human-control-request.test.ts @@ -16,7 +16,7 @@ test('request execution blocks mutations but permits read-only commands during h const sessionStore = makeSessionStore('agent-device-human-control-request-'); sessionStore.set(sessionName, makeIosSession(sessionName)); const registry = new HumanControlRegistry(); - registry.upsert('operator-1', { scope: { deviceKey: 'sim-1' } }); + await registry.upsert('operator-1', { scope: { deviceKey: 'sim-1' } }); let mutationRan = false; const mutationScope = await createRequestExecutionScope({ diff --git a/src/daemon/__tests__/human-control.test.ts b/src/daemon/__tests__/human-control.test.ts index 443aac3cda..53b5e7499e 100644 --- a/src/daemon/__tests__/human-control.test.ts +++ b/src/daemon/__tests__/human-control.test.ts @@ -5,14 +5,14 @@ import path from 'node:path'; import { test } from 'vitest'; import { HumanControlRegistry } from '../human-control.ts'; -test('human-control holds persist and expire by ttl', () => { +test('human-control holds persist and expire by ttl', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-human-control-')); const statePath = path.join(root, 'human-control.json'); let now = 1_000; try { const registry = new HumanControlRegistry({ statePath, now: () => now }); - const hold = registry.upsert('operator-1', { + const hold = await registry.upsert('operator-1', { scope: { deviceKey: 'SIM-1', deviceName: 'iPhone 17 Pro', platform: 'ios' }, reason: 'Manual inspection', ttlMs: 5_000, @@ -50,21 +50,22 @@ test('human-control activation waits for an active mutation and blocks later mut }); await mutationStarted; - registry.upsert('operator-1', { - scope: { deviceKey: 'sim-1' }, - reason: 'Human is interacting with the simulator.', - }); - let idle = false; - const waitForIdle = registry.waitForDeviceIdle('sim-1').then(() => { - idle = true; - }); + let activated = false; + const activation = registry + .upsert('operator-1', { + scope: { deviceKey: 'sim-1' }, + reason: 'Human is interacting with the simulator.', + }) + .then(() => { + activated = true; + }); await Promise.resolve(); - assert.equal(idle, false); + assert.equal(activated, false); finishMutation?.(); await mutation; - await waitForIdle; - assert.equal(idle, true); + await activation; + assert.equal(activated, true); await assert.rejects( registry.runDeviceMutation(['SIM-1'], async () => undefined), @@ -80,3 +81,50 @@ test('human-control activation waits for an active mutation and blocks later mut }, ); }); + +test('human-control ttl starts after active mutations drain', async () => { + let now = 1_000; + const registry = new HumanControlRegistry({ now: () => now }); + let finishMutation: (() => void) | undefined; + let markMutationStarted: (() => void) | undefined; + const mutationStarted = new Promise((resolve) => { + markMutationStarted = resolve; + }); + const mutationFinished = new Promise((resolve) => { + finishMutation = resolve; + }); + const mutation = registry.runDeviceMutation(['ios:mobile:SIM-1'], async () => { + markMutationStarted?.(); + await mutationFinished; + }); + await mutationStarted; + + let activated = false; + const activation = registry + .upsert('operator-1', { + scope: { deviceKey: 'SIM-1' }, + ttlMs: 1_000, + }) + .then((hold) => { + activated = true; + return hold; + }); + await Promise.resolve(); + assert.equal(activated, false); + + now = 5_000; + assert.equal(registry.isDeviceControlled('ios:mobile:SIM-1'), true); + await assert.rejects( + registry.runDeviceMutation(['SIM-1'], async () => undefined), + (error: unknown) => (error as { code?: string }).code === 'DEVICE_IN_USE', + ); + + finishMutation?.(); + await mutation; + const hold = await activation; + assert.equal(hold.expiresAt, 6_000); + assert.equal(registry.isDeviceControlled('SIM-1'), true); + + now = 6_000; + assert.equal(registry.isDeviceControlled('SIM-1'), false); +}); diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index 74c2fd8557..d42d364df4 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import { HumanControlRegistry } from '../human-control.ts'; import { LeaseRegistry } from '../lease-registry.ts'; test('allocateLease creates lease and enforces tenant/run validation', () => { @@ -118,6 +119,33 @@ test('human-controlled device leases survive expiry and refresh when control is assert.deepEqual(registry.listActiveLeases(), []); }); +test('bare takeover identity protects and refreshes a composite proxy lease key', async () => { + let now = 1_000; + const humanControl = new HumanControlRegistry({ now: () => now }); + const registry = new LeaseRegistry({ + now: () => now, + defaultLeaseTtlMs: 5_000, + isDeviceLeaseProtected: (lease) => humanControl.isDeviceControlled(lease.deviceKey), + }); + const lease = registry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:mobile:SIM-1', + }); + const hold = await humanControl.upsert('operator-1', { + scope: { deviceKey: 'SIM-1' }, + }); + + now = 7_000; + assert.deepEqual(registry.consumeExpiredLeases(), []); + humanControl.remove(hold.id); + const [refreshed] = registry.refreshLeasesForDeviceKey(hold.scope.deviceKey); + assert.equal(refreshed?.leaseId, lease.leaseId); + assert.equal(refreshed?.expiresAt, 12_000); +}); + test('capacity limits reject additional simulator leases', () => { const registry = new LeaseRegistry({ maxActiveSimulatorLeases: 1, diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index 87b7e21ff9..c2daf62d83 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -77,8 +77,6 @@ export function shouldGuardAndroidBlockingDialog(command: string): boolean { export function humanControlEffectForRequest(req: DaemonRequest): HumanControlEffect { const effect = getDaemonCommandDescriptor(req.command)?.humanControlEffect; - // Unknown wire commands still fail closed even though every known descriptor - // must declare an effect at compile time. return typeof effect === 'function' ? effect(req) : (effect ?? 'mutate'); } diff --git a/src/daemon/handlers/human-control.ts b/src/daemon/handlers/human-control.ts index 58a04949de..4309aed55c 100644 --- a/src/daemon/handlers/human-control.ts +++ b/src/daemon/handlers/human-control.ts @@ -27,8 +27,7 @@ async function putHold( if (!holdId || rawInput === undefined) { throw new AppError('INVALID_ARGS', 'human_control put requires a hold id and payload.'); } - const hold = registry.upsert(holdId, parseHumanControlHoldInput(parsePayload(rawInput))); - await registry.waitForDeviceIdle(hold.scope.deviceKey); + const hold = await registry.upsert(holdId, parseHumanControlHoldInput(parsePayload(rawInput))); return { ok: true, data: { hold, state: 'active' } }; } diff --git a/src/daemon/human-control-http.ts b/src/daemon/human-control-http.ts index 9be8317f10..d694e719f5 100644 --- a/src/daemon/human-control-http.ts +++ b/src/daemon/human-control-http.ts @@ -20,15 +20,18 @@ type HumanControlHttpRoute = | { kind: 'list' } | { kind: 'upsert'; holdId: string } | { kind: 'remove'; holdId: string } + | { kind: 'invalid' } | { kind: 'unsupported' }; -export function tryHandleHumanControlHttpRoute(params: { +type HumanControlHttpParams = { req: http.IncomingMessage; res: http.ServerResponse; expectedToken: string; registry: HumanControlRegistry; onHoldReleased?: (hold: HumanControlHold) => void; -}): boolean { +}; + +export function tryHandleHumanControlHttpRoute(params: HumanControlHttpParams): boolean { const route = resolveHumanControlRoute(params.req); if (!route) return false; void handleHumanControlRoute(route, params); @@ -37,58 +40,89 @@ export function tryHandleHumanControlHttpRoute(params: { async function handleHumanControlRoute( route: HumanControlHttpRoute, - params: { - req: http.IncomingMessage; - res: http.ServerResponse; - expectedToken: string; - registry: HumanControlRegistry; - onHoldReleased?: (hold: HumanControlHold) => void; - }, + params: HumanControlHttpParams, ): Promise { - const { req, res, expectedToken, registry, onHoldReleased } = params; + const { req, res, expectedToken } = params; try { assertAuthorized(req, expectedToken); - switch (route.kind) { - case 'list': - sendJson(res, { ok: true, holds: registry.list() }); - return; - case 'upsert': { - const input = await readHoldInput(req); - const hold = registry.upsert(route.holdId, input); - await registry.waitForDeviceIdle(hold.scope.deviceKey); - sendJson(res, { ok: true, hold, state: 'active' }); - return; - } - case 'remove': { - const hold = releaseHumanControlHold(registry, route.holdId); - if (hold) onHoldReleased?.(hold); - sendJson(res, { ok: true, released: Boolean(hold), ...(hold ? { hold } : {}) }); - return; - } - case 'unsupported': - res.statusCode = 405; - res.setHeader('allow', 'GET, PUT, DELETE'); - sendJson(res, { ok: false, error: 'Method not allowed', code: 'INVALID_ARGS' }); - return; - } + await executeHumanControlRoute(route, params); } catch (error) { sendRestJsonError(res, normalizeError(error)); } } +async function executeHumanControlRoute( + route: HumanControlHttpRoute, + params: HumanControlHttpParams, +): Promise { + switch (route.kind) { + case 'list': + sendJson(params.res, { ok: true, holds: params.registry.list() }); + return; + case 'upsert': + await upsertHumanControlHold(route.holdId, params); + return; + case 'remove': + removeHumanControlHold(route.holdId, params); + return; + case 'unsupported': + sendMethodNotAllowed(params.res); + return; + case 'invalid': + throw new AppError('INVALID_ARGS', 'Invalid request URL.'); + } +} + +async function upsertHumanControlHold( + holdId: string, + params: HumanControlHttpParams, +): Promise { + const input = await readHoldInput(params.req); + const hold = await params.registry.upsert(holdId, input); + sendJson(params.res, { ok: true, hold, state: 'active' }); +} + +function removeHumanControlHold(holdId: string, params: HumanControlHttpParams): void { + const hold = releaseHumanControlHold(params.registry, holdId); + if (hold) params.onHoldReleased?.(hold); + sendJson(params.res, { ok: true, released: Boolean(hold), ...(hold ? { hold } : {}) }); +} + +function sendMethodNotAllowed(res: http.ServerResponse): void { + res.statusCode = 405; + res.setHeader('allow', 'GET, PUT, DELETE'); + sendJson(res, { ok: false, error: 'Method not allowed', code: 'INVALID_ARGS' }); +} + function resolveHumanControlRoute(req: http.IncomingMessage): HumanControlHttpRoute | null { - const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; + const pathname = parseRequestPathname(req.url); + if (pathname === null) return { kind: 'invalid' }; + return resolveHumanControlPathRoute(pathname, req.method); +} + +function resolveHumanControlPathRoute( + pathname: string, + method: string | undefined, +): HumanControlHttpRoute | null { if (pathname === HUMAN_CONTROL_HTTP_PREFIX) { - return req.method === 'GET' ? { kind: 'list' } : { kind: 'unsupported' }; + return method === 'GET' ? { kind: 'list' } : { kind: 'unsupported' }; } if (!pathname.startsWith(`${HUMAN_CONTROL_HTTP_PREFIX}/`)) return null; const holdId = pathname.slice(HUMAN_CONTROL_HTTP_PREFIX.length + 1); if (!holdId || holdId.includes('/')) return { kind: 'unsupported' }; - if (req.method === 'PUT') return { kind: 'upsert', holdId }; - if (req.method === 'DELETE') return { kind: 'remove', holdId }; + if (method === 'PUT') return { kind: 'upsert', holdId }; + if (method === 'DELETE') return { kind: 'remove', holdId }; return { kind: 'unsupported' }; } +function parseRequestPathname(url: string | undefined): string | null { + try { + return new URL(url ?? '/', 'http://127.0.0.1').pathname; + } catch { + return null; + } +} + async function readHoldInput(req: http.IncomingMessage): Promise { const raw = await readNodeHttpRequestBody( req, diff --git a/src/daemon/human-control-request.ts b/src/daemon/human-control-request.ts index 7428f90675..8be01e0b34 100644 --- a/src/daemon/human-control-request.ts +++ b/src/daemon/human-control-request.ts @@ -38,8 +38,6 @@ async function resolveRequestDeviceAliases( const device = await resolveTargetDevice(resolveDeviceFlags(req)); return uniqueDefinedStrings([device.id, device.name, ...directHoldAliases]); } catch { - // Preserve the command's normal device-resolution error. Requests carrying a - // remote deviceKey or explicit UDID/serial are still gated by those aliases. return directHoldAliases; } } diff --git a/src/daemon/human-control.ts b/src/daemon/human-control.ts index 7939cfbb2a..8502cddcf3 100644 --- a/src/daemon/human-control.ts +++ b/src/daemon/human-control.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import { deviceIdentityAliases } from '../core/lease-scope.ts'; import type { HumanControlHold, HumanControlHoldInput, @@ -32,24 +33,39 @@ export class HumanControlRegistry { ); } - upsert(id: string, input: HumanControlHoldInput): HumanControlHold { + async upsert(id: string, input: HumanControlHoldInput): Promise { const normalizedId = normalizeHoldId(id); const scope = normalizeScope(input.scope); const reason = normalizeReason(input.reason); const ttlMs = normalizeTtlMs(input.ttlMs); const now = this.now(); const existing = this.holds.get(normalizedId); - const hold: HumanControlHold = { + const pendingHold: HumanControlHold = { id: normalizedId, scope, ...(reason ? { reason } : {}), createdAt: existing?.createdAt ?? now, updatedAt: now, - ...(ttlMs === undefined ? {} : { expiresAt: now + ttlMs }), }; - this.holds.set(normalizedId, hold); + this.holds.set(normalizedId, pendingHold); this.persist(); - return cloneHold(hold); + await this.waitForDeviceIdle([scope.deviceKey]); + if (this.holds.get(normalizedId) !== pendingHold) { + throw new AppError( + 'COMMAND_FAILED', + 'Human-control hold changed before activation completed.', + { holdId: normalizedId }, + ); + } + const activatedAt = this.now(); + const activeHold: HumanControlHold = { + ...pendingHold, + updatedAt: activatedAt, + ...(ttlMs === undefined ? {} : { expiresAt: activatedAt + ttlMs }), + }; + this.holds.set(normalizedId, activeHold); + this.persist(); + return cloneHold(activeHold); } remove(id: string): HumanControlHold | undefined { @@ -71,7 +87,8 @@ export class HumanControlRegistry { const keys = normalizeDeviceAliases(deviceKeys); if (keys.length === 0) return undefined; for (const hold of this.holds.values()) { - if (keys.includes(normalizeDeviceAlias(hold.scope.deviceKey))) return cloneHold(hold); + const holdKeys = normalizeDeviceAliases([hold.scope.deviceKey]); + if (holdKeys.some((key) => keys.includes(key))) return cloneHold(hold); } return undefined; } @@ -92,8 +109,13 @@ export class HumanControlRegistry { } } - async waitForDeviceIdle(deviceKey: string): Promise { - const key = normalizeDeviceAlias(normalizeDeviceKey(deviceKey)); + private async waitForDeviceIdle(deviceKeys: readonly string[]): Promise { + await Promise.all( + normalizeDeviceAliases(deviceKeys).map(async (key) => this.waitForKeyIdle(key)), + ); + } + + private async waitForKeyIdle(key: string): Promise { if ((this.activeMutations.get(key) ?? 0) === 0) return; await new Promise((resolve) => { const waiters = this.idleWaiters.get(key) ?? new Set<() => void>(); @@ -247,7 +269,7 @@ function normalizeDeviceKey(deviceKey: string): string { function normalizeDeviceAliases(deviceKeys: readonly string[]): string[] { return Array.from( new Set( - deviceKeys + deviceIdentityAliases(deviceKeys) .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .map((value) => normalizeDeviceAlias(value)), ), diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 162009b232..b5be59f0e4 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -2,6 +2,7 @@ import type { DeviceLease } from '@agent-device/contracts/device'; import crypto from 'node:crypto'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; +import { deviceIdentityAliases } from '../core/lease-scope.ts'; import { normalizeTenantId } from './config.ts'; import { ProviderSessionOwnershipRegistry, @@ -377,10 +378,12 @@ export class LeaseRegistry { refreshLeasesForDeviceKey(deviceKey: string): DeviceLease[] { const normalizedDeviceKey = normalizeDeviceKey(deviceKey); if (!normalizedDeviceKey) return []; - const comparisonKey = normalizedDeviceKey.toLocaleLowerCase('en-US'); + const comparisonKeys = normalizedDeviceAliases([normalizedDeviceKey]); const refreshed: DeviceLease[] = []; for (const lease of this.leases.values()) { - if (lease.deviceKey?.toLocaleLowerCase('en-US') !== comparisonKey) continue; + if (!lease.deviceKey) continue; + const leaseKeys = normalizedDeviceAliases([lease.deviceKey]); + if (!leaseKeys.some((key) => comparisonKeys.includes(key))) continue; refreshed.push(this.refreshLease(lease, this.defaultLeaseTtlMs)); } return refreshed; @@ -656,3 +659,7 @@ export class LeaseRegistry { }); } } + +function normalizedDeviceAliases(deviceKeys: readonly string[]): string[] { + return deviceIdentityAliases(deviceKeys).map((key) => key.toLocaleLowerCase('en-US')); +} From a08b62152e8b15c6eff67521d620c7de7f64bc88 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 27 Aug 2026 19:08:45 +0200 Subject: [PATCH 03/10] fix: align host XCTest selection count --- .../RunnerTests.swift | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 9465c8c114..f291bb5767 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -220,21 +220,21 @@ final class RunnerTests: XCTestCase { } #if AGENT_DEVICE_RUNNER_UNIT_TESTS - func testHostLocalNetworkPermissionMonitorSelectsOnlyTheDenialAction() { - let prompt = ["Allow hosted compute to find devices on local networks?"] - XCTAssertTrue( - Self.shouldDismissHostLocalNetworkPermission(text: prompt, buttonLabel: "Don’t Allow") - ) - XCTAssertFalse( - Self.shouldDismissHostLocalNetworkPermission(text: prompt, buttonLabel: "Allow") - ) - XCTAssertFalse( - Self.shouldDismissHostLocalNetworkPermission( - text: ["System Settings wants to make changes"], - buttonLabel: "Don’t Allow" - ) + func testHostLocalNetworkPermissionMonitorSelectsOnlyTheDenialAction() { + let prompt = ["Allow hosted compute to find devices on local networks?"] + XCTAssertTrue( + Self.shouldDismissHostLocalNetworkPermission(text: prompt, buttonLabel: "Don’t Allow") + ) + XCTAssertFalse( + Self.shouldDismissHostLocalNetworkPermission(text: prompt, buttonLabel: "Allow") + ) + XCTAssertFalse( + Self.shouldDismissHostLocalNetworkPermission( + text: ["System Settings wants to make changes"], + buttonLabel: "Don’t Allow" ) - } + ) + } #endif /// True for the one recorded-issue class the runner deliberately mutes: an AX-server error From e4f2a0743f12bb339b7b2542a74f51c30410b520 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 27 Aug 2026 19:31:58 +0200 Subject: [PATCH 04/10] fix: address takeover readiness feedback --- .../RunnerTests.swift | 2 +- src/cli/commands/takeover-client.ts | 168 +++++++++++++++++ src/cli/commands/takeover.ts | 163 +---------------- src/daemon/human-control-contract.ts | 95 ++++++++++ src/daemon/human-control-store.ts | 53 ++++++ src/daemon/human-control.ts | 173 +++--------------- 6 files changed, 341 insertions(+), 313 deletions(-) create mode 100644 src/cli/commands/takeover-client.ts create mode 100644 src/daemon/human-control-store.ts diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index f291bb5767..197bc9c5fc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -219,7 +219,7 @@ final class RunnerTests: XCTestCase { return isLocalNetworkPrompt && ["don't allow", "don’t allow"].contains(normalizedButton) } - #if AGENT_DEVICE_RUNNER_UNIT_TESTS + #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS) func testHostLocalNetworkPermissionMonitorSelectsOnlyTheDenialAction() { let prompt = ["Allow hosted compute to find devices on local networks?"] XCTAssertTrue( diff --git a/src/cli/commands/takeover-client.ts b/src/cli/commands/takeover-client.ts new file mode 100644 index 0000000000..00101662f9 --- /dev/null +++ b/src/cli/commands/takeover-client.ts @@ -0,0 +1,168 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import { AppError, throwDaemonError, toAppErrorCode } from '@agent-device/kernel/errors'; +import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; +import { + ensureDaemon, + resolveClientSettings, +} from '../../daemon/client/daemon-client-lifecycle.ts'; +import { sendRequest } from '../../daemon/client/daemon-client-transport.ts'; +import { buildDaemonHttpAuthHeaders } from '../../daemon/http-contract.ts'; +import type { + HumanControlHold, + HumanControlHoldInput, +} from '../../daemon/human-control-contract.ts'; +import { HUMAN_CONTROL_HTTP_PREFIX } from '../../daemon/human-control.ts'; + +const HUMAN_CONTROL_REQUEST_TIMEOUT_MS = 20_000; + +type HumanControlListResponse = { + ok: boolean; + holds?: HumanControlHold[]; + error?: string; + code?: string; +}; + +type HumanControlMutationResponse = { + ok: boolean; + hold?: HumanControlHold; + released?: boolean; + error?: string; + code?: string; +}; + +export type LocalHumanControlClient = { + list(): Promise; + put(holdId: string, input: HumanControlHoldInput): Promise; + remove(holdId: string): Promise; +}; + +export async function createLocalHumanControlClient( + flags: CliFlags, +): Promise { + const settings = resolveClientSettings({ + session: 'default', + command: 'takeover', + positionals: [], + flags: { + stateDir: flags.stateDir, + daemonBaseUrl: '', + daemonTransport: 'auto', + }, + }); + const daemon = await ensureDaemon(settings); + if (daemon.info.port) { + const run = async (positionals: string[]): Promise> => { + const response = await sendRequest( + daemon.info, + { + token: daemon.info.token, + session: 'default', + command: INTERNAL_COMMANDS.humanControl, + positionals, + flags: { stateDir: flags.stateDir }, + }, + 'socket', + settings.paths, + HUMAN_CONTROL_REQUEST_TIMEOUT_MS, + ); + if (!response.ok) throwDaemonError(response.error); + return response.data ?? {}; + }; + return { + list: async () => readHolds(await run(['list'])), + put: async (holdId, input) => readHold(await run(['put', holdId, JSON.stringify(input)])), + remove: async (holdId) => (await run(['remove', holdId])).released === true, + }; + } + if (!daemon.info.httpPort) { + throw new AppError('COMMAND_FAILED', 'Local daemon management endpoint is unavailable.'); + } + return createHttpHumanControlClient(daemon.info.httpPort, daemon.info.token); +} + +function createHttpHumanControlClient(httpPort: number, token: string): LocalHumanControlClient { + const baseUrl = `http://127.0.0.1:${String(httpPort)}`; + const headers = { + ...buildDaemonHttpAuthHeaders(token), + 'content-type': 'application/json', + }; + return { + list: async () => { + const response = await requestHumanControl( + `${baseUrl}${HUMAN_CONTROL_HTTP_PREFIX}`, + { headers }, + ); + return response.holds ?? []; + }, + put: async (holdId, input) => { + const response = await requestHumanControl( + holdUrl(baseUrl, holdId), + { method: 'PUT', headers, body: JSON.stringify(input) }, + ); + if (!response.hold) { + throw new AppError('COMMAND_FAILED', 'Daemon did not return the human-control hold.'); + } + return response.hold; + }, + remove: async (holdId) => { + const response = await requestHumanControl( + holdUrl(baseUrl, holdId), + { method: 'DELETE', headers }, + ); + return response.released === true; + }, + }; +} + +function readHolds(data: Record): HumanControlHold[] { + return Array.isArray(data.holds) ? (data.holds as HumanControlHold[]) : []; +} + +function readHold(data: Record): HumanControlHold { + if (!data.hold || typeof data.hold !== 'object' || Array.isArray(data.hold)) { + throw new AppError('COMMAND_FAILED', 'Daemon did not return the human-control hold.'); + } + return data.hold as HumanControlHold; +} + +async function requestHumanControl( + url: string, + init: RequestInit, +): Promise { + let response: Response; + try { + response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(HUMAN_CONTROL_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Failed to reach the local daemon human-control endpoint.', + undefined, + error, + ); + } + let payload: T; + try { + payload = (await response.json()) as T; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Local daemon returned an invalid human-control response (${String(response.status)}).`, + undefined, + error, + ); + } + if (!response.ok || !payload.ok) { + throw new AppError( + toAppErrorCode(payload.code), + payload.error ?? `Human-control request failed (${String(response.status)}).`, + ); + } + return payload; +} + +function holdUrl(baseUrl: string, holdId: string): string { + return `${baseUrl}${HUMAN_CONTROL_HTTP_PREFIX}/${encodeURIComponent(holdId)}`; +} diff --git a/src/cli/commands/takeover.ts b/src/cli/commands/takeover.ts index 5f09c6d842..25c1752c94 100644 --- a/src/cli/commands/takeover.ts +++ b/src/cli/commands/takeover.ts @@ -1,49 +1,19 @@ import { randomUUID } from 'node:crypto'; import type { CliFlags } from '@agent-device/contracts/command'; import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; -import { AppError, throwDaemonError, toAppErrorCode } from '@agent-device/kernel/errors'; +import { AppError } from '@agent-device/kernel/errors'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; -import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; import { resolvePublicInventoryDevice } from '../../core/device-selection-resolver.ts'; -import { - ensureDaemon, - resolveClientSettings, -} from '../../daemon/client/daemon-client-lifecycle.ts'; -import { sendRequest } from '../../daemon/client/daemon-client-transport.ts'; -import { buildDaemonHttpAuthHeaders } from '../../daemon/http-contract.ts'; import type { HumanControlHold, HumanControlHoldInput, } from '../../daemon/human-control-contract.ts'; -import { HUMAN_CONTROL_HTTP_PREFIX } from '../../daemon/human-control.ts'; import { writeCommandOutput } from './shared.ts'; +import { createLocalHumanControlClient } from './takeover-client.ts'; import type { ClientCommandHandler } from './router-types.ts'; const FOREGROUND_HOLD_TTL_MS = 15_000; const FOREGROUND_HEARTBEAT_MS = 5_000; -const HUMAN_CONTROL_REQUEST_TIMEOUT_MS = 20_000; - -type HumanControlListResponse = { - ok: boolean; - holds?: HumanControlHold[]; - error?: string; - code?: string; -}; - -type HumanControlMutationResponse = { - ok: boolean; - hold?: HumanControlHold; - released?: boolean; - error?: string; - code?: string; -}; - -type LocalHumanControlClient = { - list(): Promise; - put(holdId: string, input: HumanControlHoldInput): Promise; - remove(holdId: string): Promise; -}; - export const takeoverCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const action = positionals[0]?.toLowerCase(); if (action === 'status') { @@ -154,135 +124,6 @@ function buildForegroundHoldInput(device: DeviceInfo): HumanControlHoldInput { }; } -async function createLocalHumanControlClient(flags: CliFlags): Promise { - const settings = resolveClientSettings({ - session: 'default', - command: 'takeover', - positionals: [], - flags: { - stateDir: flags.stateDir, - daemonBaseUrl: '', - daemonTransport: 'auto', - }, - }); - const daemon = await ensureDaemon(settings); - if (daemon.info.port) { - const run = async (positionals: string[]): Promise> => { - const response = await sendRequest( - daemon.info, - { - token: daemon.info.token, - session: 'default', - command: INTERNAL_COMMANDS.humanControl, - positionals, - flags: { stateDir: flags.stateDir }, - }, - 'socket', - settings.paths, - HUMAN_CONTROL_REQUEST_TIMEOUT_MS, - ); - if (!response.ok) throwDaemonError(response.error); - return response.data ?? {}; - }; - return { - list: async () => readHolds(await run(['list'])), - put: async (holdId, input) => readHold(await run(['put', holdId, JSON.stringify(input)])), - remove: async (holdId) => (await run(['remove', holdId])).released === true, - }; - } - if (!daemon.info.httpPort) { - throw new AppError('COMMAND_FAILED', 'Local daemon management endpoint is unavailable.'); - } - return createHttpHumanControlClient(daemon.info.httpPort, daemon.info.token); -} - -function createHttpHumanControlClient(httpPort: number, token: string): LocalHumanControlClient { - const baseUrl = `http://127.0.0.1:${String(httpPort)}`; - const headers = { - ...buildDaemonHttpAuthHeaders(token), - 'content-type': 'application/json', - }; - return { - list: async () => { - const response = await requestHumanControl( - `${baseUrl}${HUMAN_CONTROL_HTTP_PREFIX}`, - { headers }, - ); - return response.holds ?? []; - }, - put: async (holdId, input) => { - const response = await requestHumanControl( - holdUrl(baseUrl, holdId), - { method: 'PUT', headers, body: JSON.stringify(input) }, - ); - if (!response.hold) { - throw new AppError('COMMAND_FAILED', 'Daemon did not return the human-control hold.'); - } - return response.hold; - }, - remove: async (holdId) => { - const response = await requestHumanControl( - holdUrl(baseUrl, holdId), - { method: 'DELETE', headers }, - ); - return response.released === true; - }, - }; -} - -function readHolds(data: Record): HumanControlHold[] { - return Array.isArray(data.holds) ? (data.holds as HumanControlHold[]) : []; -} - -function readHold(data: Record): HumanControlHold { - if (!data.hold || typeof data.hold !== 'object' || Array.isArray(data.hold)) { - throw new AppError('COMMAND_FAILED', 'Daemon did not return the human-control hold.'); - } - return data.hold as HumanControlHold; -} - -async function requestHumanControl( - url: string, - init: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(url, { - ...init, - signal: AbortSignal.timeout(HUMAN_CONTROL_REQUEST_TIMEOUT_MS), - }); - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - 'Failed to reach the local daemon human-control endpoint.', - undefined, - error, - ); - } - let payload: T; - try { - payload = (await response.json()) as T; - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - `Local daemon returned an invalid human-control response (${String(response.status)}).`, - undefined, - error, - ); - } - if (!response.ok || !payload.ok) { - throw new AppError( - toAppErrorCode(payload.code), - payload.error ?? `Human-control request failed (${String(response.status)}).`, - ); - } - return payload; -} - -function holdUrl(baseUrl: string, holdId: string): string { - return `${baseUrl}${HUMAN_CONTROL_HTTP_PREFIX}/${encodeURIComponent(holdId)}`; -} - export function renderTakeoverStarted(hold: HumanControlHold): string { const target = hold.scope.deviceName ? `${hold.scope.deviceName} (${hold.scope.deviceKey})` diff --git a/src/daemon/human-control-contract.ts b/src/daemon/human-control-contract.ts index ed817d14ae..1fd181b19a 100644 --- a/src/daemon/human-control-contract.ts +++ b/src/daemon/human-control-contract.ts @@ -1,5 +1,8 @@ import { AppError } from '@agent-device/kernel/errors'; +const MIN_HOLD_TTL_MS = 1_000; +const MAX_HOLD_TTL_MS = 24 * 60 * 60_000; + export type HumanControlHoldScope = { deviceKey: string; deviceName?: string; @@ -44,6 +47,74 @@ export function parseHumanControlHoldInput(value: unknown): HumanControlHoldInpu }; } +export function normalizeHumanControlHoldId(id: string): string { + const value = typeof id === 'string' ? id.trim() : ''; + if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid human-control hold id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return value; +} + +export function normalizeHumanControlHoldScope( + scope: HumanControlHoldScope, +): HumanControlHoldScope { + if (!scope || typeof scope !== 'object') { + throw new AppError('INVALID_ARGS', 'Human-control hold requires a device scope.'); + } + const deviceName = normalizeOptionalLabel(scope.deviceName, 'device name'); + const platform = normalizeOptionalLabel(scope.platform, 'platform'); + const kind = normalizeOptionalLabel(scope.kind, 'device kind'); + return { + deviceKey: normalizeDeviceKey(scope.deviceKey), + ...(deviceName ? { deviceName } : {}), + ...(platform ? { platform } : {}), + ...(kind ? { kind } : {}), + }; +} + +export function normalizeHumanControlReason(reason: string | undefined): string | undefined { + if (reason === undefined) return undefined; + const value = reason.trim(); + if (!value) return undefined; + if (value.length > 512) { + throw new AppError('INVALID_ARGS', 'Human-control reason must be at most 512 characters.'); + } + return value; +} + +export function normalizeHumanControlTtlMs(ttlMs: number | undefined): number | undefined { + if (ttlMs === undefined) return undefined; + if (!Number.isInteger(ttlMs) || ttlMs < MIN_HOLD_TTL_MS || ttlMs > MAX_HOLD_TTL_MS) { + throw new AppError( + 'INVALID_ARGS', + `Human-control ttlMs must be between ${String(MIN_HOLD_TTL_MS)} and ${String(MAX_HOLD_TTL_MS)}.`, + ); + } + return ttlMs; +} + +export function normalizeStoredHumanControlHold(raw: HumanControlHold): HumanControlHold { + if (!raw || typeof raw !== 'object') { + throw new AppError('COMMAND_FAILED', 'Persisted human-control hold is invalid.'); + } + const createdAt = normalizeTimestamp(raw.createdAt, 'createdAt'); + const updatedAt = normalizeTimestamp(raw.updatedAt, 'updatedAt'); + const expiresAt = + raw.expiresAt === undefined ? undefined : normalizeTimestamp(raw.expiresAt, 'expiresAt'); + const reason = normalizeHumanControlReason(raw.reason); + return { + id: normalizeHumanControlHoldId(raw.id), + scope: normalizeHumanControlHoldScope(raw.scope), + ...(reason ? { reason } : {}), + createdAt, + updatedAt, + ...(expiresAt === undefined ? {} : { expiresAt }), + }; +} + function readRequiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { throw new AppError('INVALID_ARGS', `Human-control ${field} must be a non-empty string.`); @@ -66,3 +137,27 @@ function readInteger(value: unknown, field: string): number { } return Number(value); } + +function normalizeDeviceKey(deviceKey: string): string { + const value = typeof deviceKey === 'string' ? deviceKey.trim() : ''; + if (!value || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) { + throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.'); + } + return value; +} + +function normalizeOptionalLabel(value: string | undefined, label: string): string | undefined { + if (value === undefined) return undefined; + const normalized = value.trim(); + if (!normalized || normalized.length > 256) { + throw new AppError('INVALID_ARGS', `Invalid ${label}. Use 1-256 characters.`); + } + return normalized; +} + +function normalizeTimestamp(value: number, field: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new AppError('COMMAND_FAILED', `Persisted human-control ${field} is invalid.`); + } + return value; +} diff --git a/src/daemon/human-control-store.ts b/src/daemon/human-control-store.ts new file mode 100644 index 0000000000..33a6ba30b2 --- /dev/null +++ b/src/daemon/human-control-store.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { + type HumanControlHold, + normalizeStoredHumanControlHold, +} from './human-control-contract.ts'; + +export class HumanControlStore { + private readonly statePath: string | undefined; + + constructor(statePath: string | undefined) { + this.statePath = statePath; + } + + load(): HumanControlHold[] { + if (!this.statePath || !fs.existsSync(this.statePath)) return []; + let parsed: { version: 1; holds: HumanControlHold[] }; + try { + parsed = JSON.parse(fs.readFileSync(this.statePath, 'utf8')) as typeof parsed; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Failed to read persisted human-control state.', + { path: this.statePath }, + error, + ); + } + if (parsed.version !== 1 || !Array.isArray(parsed.holds)) { + throw new AppError('COMMAND_FAILED', 'Persisted human-control state is invalid.', { + path: this.statePath, + }); + } + return parsed.holds.map((hold) => normalizeStoredHumanControlHold(hold)); + } + + persist(holds: Iterable): void { + if (!this.statePath) return; + fs.mkdirSync(path.dirname(this.statePath), { recursive: true }); + const temporaryPath = `${this.statePath}.${String(process.pid)}.tmp`; + const state = { + version: 1, + holds: Array.from(holds, (hold) => cloneHumanControlHold(hold)), + }; + fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2), { mode: 0o600 }); + fs.renameSync(temporaryPath, this.statePath); + fs.chmodSync(this.statePath, 0o600); + } +} + +export function cloneHumanControlHold(hold: HumanControlHold): HumanControlHold { + return { ...hold, scope: { ...hold.scope } }; +} diff --git a/src/daemon/human-control.ts b/src/daemon/human-control.ts index 8502cddcf3..324fb805a1 100644 --- a/src/daemon/human-control.ts +++ b/src/daemon/human-control.ts @@ -1,43 +1,42 @@ -import fs from 'node:fs'; -import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { deviceIdentityAliases } from '../core/lease-scope.ts'; -import type { - HumanControlHold, - HumanControlHoldInput, - HumanControlHoldScope, +import type { HumanControlHold, HumanControlHoldInput } from './human-control-contract.ts'; +import { + normalizeHumanControlHoldId, + normalizeHumanControlHoldScope, + normalizeHumanControlReason, + normalizeHumanControlTtlMs, } from './human-control-contract.ts'; +import { cloneHumanControlHold, HumanControlStore } from './human-control-store.ts'; export const HUMAN_CONTROL_HTTP_PREFIX = '/admin/human-control/holds'; -const MIN_HOLD_TTL_MS = 1_000; -const MAX_HOLD_TTL_MS = 24 * 60 * 60_000; - export class HumanControlRegistry { private readonly holds = new Map(); private readonly activeMutations = new Map(); private readonly idleWaiters = new Map void>>(); - private readonly statePath: string | undefined; + private readonly store: HumanControlStore; private readonly now: () => number; constructor(options: { statePath?: string; now?: () => number } = {}) { - this.statePath = options.statePath; + this.store = new HumanControlStore(options.statePath); this.now = options.now ?? (() => Date.now()); - this.load(); + for (const hold of this.store.load()) this.holds.set(hold.id, hold); + this.cleanupExpired(); } list(): HumanControlHold[] { this.cleanupExpired(); - return Array.from(this.holds.values(), (hold) => cloneHold(hold)).sort((left, right) => - left.id.localeCompare(right.id), + return Array.from(this.holds.values(), (hold) => cloneHumanControlHold(hold)).sort( + (left, right) => left.id.localeCompare(right.id), ); } async upsert(id: string, input: HumanControlHoldInput): Promise { - const normalizedId = normalizeHoldId(id); - const scope = normalizeScope(input.scope); - const reason = normalizeReason(input.reason); - const ttlMs = normalizeTtlMs(input.ttlMs); + const normalizedId = normalizeHumanControlHoldId(id); + const scope = normalizeHumanControlHoldScope(input.scope); + const reason = normalizeHumanControlReason(input.reason); + const ttlMs = normalizeHumanControlTtlMs(input.ttlMs); const now = this.now(); const existing = this.holds.get(normalizedId); const pendingHold: HumanControlHold = { @@ -65,16 +64,16 @@ export class HumanControlRegistry { }; this.holds.set(normalizedId, activeHold); this.persist(); - return cloneHold(activeHold); + return cloneHumanControlHold(activeHold); } remove(id: string): HumanControlHold | undefined { - const normalizedId = normalizeHoldId(id); + const normalizedId = normalizeHumanControlHoldId(id); const hold = this.holds.get(normalizedId); if (!hold) return undefined; this.holds.delete(normalizedId); this.persist(); - return cloneHold(hold); + return cloneHumanControlHold(hold); } isDeviceControlled(deviceKey: string | undefined): boolean { @@ -88,7 +87,7 @@ export class HumanControlRegistry { if (keys.length === 0) return undefined; for (const hold of this.holds.values()) { const holdKeys = normalizeDeviceAliases([hold.scope.deviceKey]); - if (holdKeys.some((key) => keys.includes(key))) return cloneHold(hold); + if (holdKeys.some((key) => keys.includes(key))) return cloneHumanControlHold(hold); } return undefined; } @@ -148,42 +147,8 @@ export class HumanControlRegistry { if (changed) this.persist(); } - private load(): void { - if (!this.statePath || !fs.existsSync(this.statePath)) return; - let parsed: { version: 1; holds: HumanControlHold[] }; - try { - parsed = JSON.parse(fs.readFileSync(this.statePath, 'utf8')) as typeof parsed; - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - 'Failed to read persisted human-control state.', - { path: this.statePath }, - error, - ); - } - if (parsed.version !== 1 || !Array.isArray(parsed.holds)) { - throw new AppError('COMMAND_FAILED', 'Persisted human-control state is invalid.', { - path: this.statePath, - }); - } - for (const rawHold of parsed.holds) { - const hold = normalizeStoredHold(rawHold); - this.holds.set(hold.id, hold); - } - this.cleanupExpired(); - } - private persist(): void { - if (!this.statePath) return; - fs.mkdirSync(path.dirname(this.statePath), { recursive: true }); - const temporaryPath = `${this.statePath}.${String(process.pid)}.tmp`; - const state = { - version: 1, - holds: Array.from(this.holds.values(), (hold) => cloneHold(hold)), - }; - fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2), { mode: 0o600 }); - fs.renameSync(temporaryPath, this.statePath); - fs.chmodSync(this.statePath, 0o600); + this.store.persist(this.holds.values()); } } @@ -213,59 +178,6 @@ export function releaseHumanControlHold( return registry.remove(holdId); } -function normalizeStoredHold(raw: HumanControlHold): HumanControlHold { - if (!raw || typeof raw !== 'object') { - throw new AppError('COMMAND_FAILED', 'Persisted human-control hold is invalid.'); - } - const createdAt = normalizeTimestamp(raw.createdAt, 'createdAt'); - const updatedAt = normalizeTimestamp(raw.updatedAt, 'updatedAt'); - const expiresAt = - raw.expiresAt === undefined ? undefined : normalizeTimestamp(raw.expiresAt, 'expiresAt'); - const reason = normalizeReason(raw.reason); - return { - id: normalizeHoldId(raw.id), - scope: normalizeScope(raw.scope), - ...(reason ? { reason } : {}), - createdAt, - updatedAt, - ...(expiresAt === undefined ? {} : { expiresAt }), - }; -} - -function normalizeScope(scope: HumanControlHoldScope): HumanControlHoldScope { - if (!scope || typeof scope !== 'object') { - throw new AppError('INVALID_ARGS', 'Human-control hold requires a device scope.'); - } - const deviceName = normalizeOptionalLabel(scope.deviceName, 'device name'); - const platform = normalizeOptionalLabel(scope.platform, 'platform'); - const kind = normalizeOptionalLabel(scope.kind, 'device kind'); - return { - deviceKey: normalizeDeviceKey(scope.deviceKey), - ...(deviceName ? { deviceName } : {}), - ...(platform ? { platform } : {}), - ...(kind ? { kind } : {}), - }; -} - -function normalizeHoldId(id: string): string { - const value = typeof id === 'string' ? id.trim() : ''; - if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid human-control hold id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } - return value; -} - -function normalizeDeviceKey(deviceKey: string): string { - const value = typeof deviceKey === 'string' ? deviceKey.trim() : ''; - if (!value || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) { - throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.'); - } - return value; -} - function normalizeDeviceAliases(deviceKeys: readonly string[]): string[] { return Array.from( new Set( @@ -279,44 +191,3 @@ function normalizeDeviceAliases(deviceKeys: readonly string[]): string[] { function normalizeDeviceAlias(value: string): string { return value.trim().toLocaleLowerCase('en-US'); } - -function normalizeOptionalLabel(value: string | undefined, label: string): string | undefined { - if (value === undefined) return undefined; - const normalized = value.trim(); - if (!normalized || normalized.length > 256) { - throw new AppError('INVALID_ARGS', `Invalid ${label}. Use 1-256 characters.`); - } - return normalized; -} - -function normalizeReason(reason: string | undefined): string | undefined { - if (reason === undefined) return undefined; - const value = reason.trim(); - if (!value) return undefined; - if (value.length > 512) { - throw new AppError('INVALID_ARGS', 'Human-control reason must be at most 512 characters.'); - } - return value; -} - -function normalizeTtlMs(ttlMs: number | undefined): number | undefined { - if (ttlMs === undefined) return undefined; - if (!Number.isInteger(ttlMs) || ttlMs < MIN_HOLD_TTL_MS || ttlMs > MAX_HOLD_TTL_MS) { - throw new AppError( - 'INVALID_ARGS', - `Human-control ttlMs must be between ${String(MIN_HOLD_TTL_MS)} and ${String(MAX_HOLD_TTL_MS)}.`, - ); - } - return ttlMs; -} - -function normalizeTimestamp(value: number, field: string): number { - if (!Number.isFinite(value) || value < 0) { - throw new AppError('COMMAND_FAILED', `Persisted human-control ${field} is invalid.`); - } - return value; -} - -function cloneHold(hold: HumanControlHold): HumanControlHold { - return { ...hold, scope: { ...hold.scope } }; -} From 8beb4a62cc6c65bdaabe59e2dddaac83bd4c4041 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 27 Aug 2026 20:17:42 +0200 Subject: [PATCH 05/10] fix: handle macos runner permission prompt in smoke tests --- .github/workflows/macos.yml | 48 +++++++++++++++++++ .../Sources/AgentDeviceMacOSHelper/main.swift | 48 ++++++++++++++++++- .../RunnerTests.swift | 41 ---------------- 3 files changed, 94 insertions(+), 43 deletions(-) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 64e59ce5ff..856005bb9c 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -130,6 +130,54 @@ jobs: uses: ./.github/actions/run-gate with: { gate: macos-helper } + # The hosted runner can present macOS's Local Network permission sheet the first time + # the XCTest transport connects over loopback. Warm the runner and resolve that sheet + # before replay timing begins; the daemon and runner stay warm in this job's state dir. + - name: Resolve runner Local Network permission + shell: bash + run: | + set -euo pipefail + session='macos-local-network-warmup' + screenshot_path="$RUNNER_TEMP/macos-local-network-warmup.png" + helper='apple/macos-helper/.build/release/agent-device-macos-helper' + + close_session() { + node --experimental-strip-types src/bin.ts close \ + --platform macos \ + --session "$session" \ + --state-dir "$AGENT_DEVICE_STATE_DIR" >/dev/null 2>&1 || true + } + trap close_session EXIT + + node --experimental-strip-types src/bin.ts open 'System Settings' \ + --relaunch \ + --platform macos \ + --session "$session" \ + --state-dir "$AGENT_DEVICE_STATE_DIR" + node --experimental-strip-types src/bin.ts screenshot "$screenshot_path" \ + --platform macos \ + --session "$session" \ + --state-dir "$AGENT_DEVICE_STATE_DIR" & + screenshot_pid=$! + + attempts_after_capture=5 + for attempt in {1..60}; do + if "$helper" alert dismiss --surface frontmost-app; then + break + fi + if [ -f "$screenshot_path" ]; then + attempts_after_capture=$((attempts_after_capture - 1)) + if [ "$attempts_after_capture" -eq 0 ]; then + break + fi + fi + sleep 1 + done + wait "$screenshot_pid" + + close_session + trap - EXIT + - name: Run macOS integration test uses: ./.github/actions/run-gate with: diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift index 6b77e18683..f8d969d558 100644 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift @@ -286,7 +286,13 @@ struct AgentDeviceMacOSHelper { let bundleId = optionValue(arguments: Array(arguments.dropFirst()), name: "--bundle-id") let surface = optionValue(arguments: Array(arguments.dropFirst()), name: "--surface") let app = try resolveTargetApplication(bundleId: bundleId, surface: surface) - guard let alertElement = findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) else { + let normalizedSurface = surface?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let alertElement = + normalizedSurface == "frontmost-app" + ? findFocusedAlertElement() + ?? findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) + : findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) + guard let alertElement else { // `reason` is the typed channel the host retries on; the message is for humans only. throw HelperError.commandFailed( "alert not found", @@ -585,6 +591,9 @@ func resolveTargetApplication(bundleId: String?, surface: String?) throws -> NSR ) } if normalizedSurface == "frontmost-app" { + if let focused = focusedApplication() { + return focused + } if let frontmost = NSWorkspace.shared.frontmostApplication { return frontmost } @@ -603,6 +612,20 @@ func resolveTargetApplication(bundleId: String?, surface: String?) throws -> NSR throw HelperError.commandFailed("unable to resolve target app") } +private func focusedApplication() -> NSRunningApplication? { + guard let appElement = elementAttribute( + AXUIElementCreateSystemWide(), + attribute: kAXFocusedApplicationAttribute as String + ) else { + return nil + } + var processIdentifier: pid_t = 0 + guard AXUIElementGetPid(appElement, &processIdentifier) == .success else { + return nil + } + return NSRunningApplication(processIdentifier: processIdentifier) +} + private func validatedBundleId(_ rawBundleId: String) throws -> String { let bundleId = rawBundleId.trimmingCharacters(in: .whitespacesAndNewlines) let pattern = #"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$"# @@ -626,6 +649,27 @@ private func findAlertElement(appElement: AXUIElement) -> AXUIElement? { return nil } +private func findFocusedAlertElement() -> AXUIElement? { + guard var element = elementAttribute( + AXUIElementCreateSystemWide(), + attribute: kAXFocusedUIElementAttribute as String + ) else { + return nil + } + for _ in 0..<8 { + if let role = stringAttribute(element, attribute: kAXRoleAttribute as String), + role == "AXSheet" || role == "AXDialog" + { + return element + } + guard let parent = elementAttribute(element, attribute: kAXParentAttribute as String) else { + return nil + } + element = parent + } + return nil +} + private func findAlertElementRecursively(root: AXUIElement, depth: Int) -> AXUIElement? { if depth > 4 { return nil @@ -685,7 +729,7 @@ private func resolveAlertActionButton(root: AXUIElement, buttons: [AXUIElement], let preferredLabels = action == "accept" ? ["allow", "ok", "open", "continue", "yes", "save", "install", "trust", "enable"] - : ["don't allow", "deny", "cancel", "not now", "no", "close", "later", "ignore"] + : ["don't allow", "don’t allow", "deny", "cancel", "not now", "no", "close", "later", "ignore"] for preferredLabel in preferredLabels { if let match = buttonEntries.first(where: { $0.label.contains(preferredLabel) }) { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 197bc9c5fc..d34ca183d6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -194,49 +194,8 @@ final class RunnerTests: XCTestCase { override func setUp() { continueAfterFailure = true - #if os(macOS) - addUIInterruptionMonitor(withDescription: "Host local-network permission") { alert in - let text = alert.staticTexts.allElementsBoundByIndex.map(\.label) - guard let button = alert.buttons.allElementsBoundByIndex.first(where: { button in - Self.shouldDismissHostLocalNetworkPermission(text: text, buttonLabel: button.label) - }) else { - return false - } - button.tap() - return true - } - #endif } - static func shouldDismissHostLocalNetworkPermission( - text: [String], - buttonLabel: String - ) -> Bool { - let isLocalNetworkPrompt = text.contains { value in - value.localizedCaseInsensitiveContains("local network") - } - let normalizedButton = buttonLabel.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return isLocalNetworkPrompt && ["don't allow", "don’t allow"].contains(normalizedButton) - } - - #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS) - func testHostLocalNetworkPermissionMonitorSelectsOnlyTheDenialAction() { - let prompt = ["Allow hosted compute to find devices on local networks?"] - XCTAssertTrue( - Self.shouldDismissHostLocalNetworkPermission(text: prompt, buttonLabel: "Don’t Allow") - ) - XCTAssertFalse( - Self.shouldDismissHostLocalNetworkPermission(text: prompt, buttonLabel: "Allow") - ) - XCTAssertFalse( - Self.shouldDismissHostLocalNetworkPermission( - text: ["System Settings wants to make changes"], - buttonLabel: "Don’t Allow" - ) - ) - } - #endif - /// True for the one recorded-issue class the runner deliberately mutes: an AX-server error /// (`kAXError*`) inside a "Failed to get matching snapshot" fetch. The kAXError token /// intentionally covers kAXErrorIllegalArgument and its sibling AX server codes (e.g. From a1d0fa8d2b8c1bf2aa50df07844dcf6b97646694 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 27 Aug 2026 20:40:54 +0200 Subject: [PATCH 06/10] fix: detect background macos permission dialog --- .../Sources/AgentDeviceMacOSHelper/main.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift index f8d969d558..2afcb05660 100644 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift @@ -291,6 +291,7 @@ struct AgentDeviceMacOSHelper { normalizedSurface == "frontmost-app" ? findFocusedAlertElement() ?? findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) + ?? findBlockingLocalNetworkPermissionAlert() : findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) guard let alertElement else { // `reason` is the typed channel the host retries on; the message is for humans only. @@ -670,6 +671,62 @@ private func findFocusedAlertElement() -> AXUIElement? { return nil } +private func findBlockingLocalNetworkPermissionAlert() -> AXUIElement? { + guard let windowInfoList = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] else { + return nil + } + + var inspectedProcessIdentifiers: Set = [] + for windowInfo in windowInfoList { + guard let processIdentifier = (windowInfo[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value, + inspectedProcessIdentifiers.insert(processIdentifier).inserted + else { + continue + } + let appElement = AXUIElementCreateApplication(processIdentifier) + for window in windows(of: appElement) { + var remainingNodes = 200 + guard elementTreeContainsLocalNetworkText(window, remainingNodes: &remainingNodes) else { + continue + } + let hasDenialAction = collectButtons(root: window).contains { button in + let label = resolveElementLabel(button) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return label == "don't allow" || label == "don’t allow" + } + if hasDenialAction { + return window + } + } + } + return nil +} + +private func elementTreeContainsLocalNetworkText( + _ element: AXUIElement, + remainingNodes: inout Int +) -> Bool { + guard remainingNodes > 0 else { + return false + } + remainingNodes -= 1 + if let text = readableText(for: element), + text.localizedCaseInsensitiveContains("local network") + { + return true + } + for child in children(of: element) { + if elementTreeContainsLocalNetworkText(child, remainingNodes: &remainingNodes) { + return true + } + } + return false +} + private func findAlertElementRecursively(root: AXUIElement, depth: Int) -> AXUIElement? { if depth > 4 { return nil From 51a065068b67b13847d6f1ad5652659545fdac1e Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Thu, 27 Aug 2026 21:12:15 +0200 Subject: [PATCH 07/10] fix: dismiss inaccessible macos privacy sheet visually --- .github/workflows/macos.yml | 5 +- .../VisualAlertFallback.swift | 116 ++++++++++++++++++ .../Sources/AgentDeviceMacOSHelper/main.swift | 76 +++--------- 3 files changed, 139 insertions(+), 58 deletions(-) create mode 100644 apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 856005bb9c..ce535baf69 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -162,7 +162,10 @@ jobs: attempts_after_capture=5 for attempt in {1..60}; do - if "$helper" alert dismiss --surface frontmost-app; then + if "$helper" alert dismiss \ + --surface frontmost-app \ + --screenshot "$screenshot_path"; then + sleep 1 break fi if [ -f "$screenshot_path" ]; then diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift new file mode 100644 index 0000000000..6104d80f81 --- /dev/null +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift @@ -0,0 +1,116 @@ +import AppKit +import ApplicationServices +import CoreGraphics +import Foundation +import ImageIO +import Vision + +struct VisualAlertMatch { + let buttonLabel: String +} + +func dismissLocalNetworkPermissionAlertVisually( + screenshotPath: String, + app: NSRunningApplication +) throws -> VisualAlertMatch? { + guard FileManager.default.fileExists(atPath: screenshotPath) else { + return nil + } + let screenshotUrl = URL(fileURLWithPath: screenshotPath) + guard let source = CGImageSourceCreateWithURL(screenshotUrl as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { + return nil + } + + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.recognitionLanguages = ["en-US"] + request.usesLanguageCorrection = false + do { + try VNImageRequestHandler(cgImage: image).perform([request]) + } catch { + throw HelperError.commandFailed( + "alert screenshot recognition failed", + details: ["error": error.localizedDescription] + ) + } + + let recognized = (request.results ?? []).compactMap { observation -> (String, CGRect)? in + guard let candidate = observation.topCandidates(1).first else { + return nil + } + return (candidate.string, observation.boundingBox) + } + let recognizedText = recognized.map(\.0).joined(separator: " ") + guard recognizedText.localizedCaseInsensitiveContains("local network"), + let denial = recognized.first(where: { text, _ in + normalizeVisualAlertText(text).contains("don't allow") + }), + let windowRect = bestMatchingWindowRect( + app: app, + screenshotWidth: image.width, + screenshotHeight: image.height + ) + else { + return nil + } + + let point = CGPoint( + x: windowRect.x + Double(denial.1.midX) * windowRect.width, + y: windowRect.y + (1 - Double(denial.1.midY)) * windowRect.height + ) + guard let move = CGEvent( + mouseEventSource: nil, + mouseType: .mouseMoved, + mouseCursorPosition: point, + mouseButton: .left + ), + let down = CGEvent( + mouseEventSource: nil, + mouseType: .leftMouseDown, + mouseCursorPosition: point, + mouseButton: .left + ), + let up = CGEvent( + mouseEventSource: nil, + mouseType: .leftMouseUp, + mouseCursorPosition: point, + mouseButton: .left + ) + else { + throw HelperError.commandFailed( + "alert action failed", + details: ["reason": "event_creation_failed"] + ) + } + move.post(tap: .cghidEventTap) + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + return VisualAlertMatch(buttonLabel: denial.0) +} + +private func normalizeVisualAlertText(_ value: String) -> String { + value + .replacingOccurrences(of: "’", with: "'") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() +} + +private func bestMatchingWindowRect( + app: NSRunningApplication, + screenshotWidth: Int, + screenshotHeight: Int +) -> RectResponse? { + guard screenshotWidth > 0, screenshotHeight > 0 else { + return nil + } + let screenshotAspectRatio = Double(screenshotWidth) / Double(screenshotHeight) + return windows(of: AXUIElementCreateApplication(app.processIdentifier)) + .compactMap(rectAttribute) + .filter { $0.width > 0 && $0.height > 0 } + .min { left, right in + abs(left.width / left.height - screenshotAspectRatio) + < abs(right.width / right.height - screenshotAspectRatio) + } +} diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift index 2afcb05660..3034e8ba96 100644 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift @@ -291,8 +291,26 @@ struct AgentDeviceMacOSHelper { normalizedSurface == "frontmost-app" ? findFocusedAlertElement() ?? findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) - ?? findBlockingLocalNetworkPermissionAlert() : findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) + if alertElement == nil, + action == "dismiss", + normalizedSurface == "frontmost-app", + let screenshotPath = optionValue(arguments: Array(arguments.dropFirst()), name: "--screenshot"), + let visualMatch = try dismissLocalNetworkPermissionAlertVisually( + screenshotPath: screenshotPath, + app: app + ) + { + return SuccessEnvelope( + data: AlertResponse( + title: "Local Network", + role: "visual", + buttons: [visualMatch.buttonLabel], + action: action, + bundleId: app.bundleIdentifier + ) + ) + } guard let alertElement else { // `reason` is the typed channel the host retries on; the message is for humans only. throw HelperError.commandFailed( @@ -671,62 +689,6 @@ private func findFocusedAlertElement() -> AXUIElement? { return nil } -private func findBlockingLocalNetworkPermissionAlert() -> AXUIElement? { - guard let windowInfoList = CGWindowListCopyWindowInfo( - [.optionOnScreenOnly, .excludeDesktopElements], - kCGNullWindowID - ) as? [[String: Any]] else { - return nil - } - - var inspectedProcessIdentifiers: Set = [] - for windowInfo in windowInfoList { - guard let processIdentifier = (windowInfo[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value, - inspectedProcessIdentifiers.insert(processIdentifier).inserted - else { - continue - } - let appElement = AXUIElementCreateApplication(processIdentifier) - for window in windows(of: appElement) { - var remainingNodes = 200 - guard elementTreeContainsLocalNetworkText(window, remainingNodes: &remainingNodes) else { - continue - } - let hasDenialAction = collectButtons(root: window).contains { button in - let label = resolveElementLabel(button) - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - return label == "don't allow" || label == "don’t allow" - } - if hasDenialAction { - return window - } - } - } - return nil -} - -private func elementTreeContainsLocalNetworkText( - _ element: AXUIElement, - remainingNodes: inout Int -) -> Bool { - guard remainingNodes > 0 else { - return false - } - remainingNodes -= 1 - if let text = readableText(for: element), - text.localizedCaseInsensitiveContains("local network") - { - return true - } - for child in children(of: element) { - if elementTreeContainsLocalNetworkText(child, remainingNodes: &remainingNodes) { - return true - } - } - return false -} - private func findAlertElementRecursively(root: AXUIElement, depth: Int) -> AXUIElement? { if depth > 4 { return nil From 1ad89a5c9167fc2991e9db4ead9a5a4f79f8b46c Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Fri, 28 Aug 2026 11:36:20 +0200 Subject: [PATCH 08/10] fix: map macos privacy prompt without accessibility --- .../VisualAlertFallback.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift index 6104d80f81..4ad61d60d5 100644 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift @@ -106,8 +106,28 @@ private func bestMatchingWindowRect( return nil } let screenshotAspectRatio = Double(screenshotWidth) / Double(screenshotHeight) - return windows(of: AXUIElementCreateApplication(app.processIdentifier)) - .compactMap(rectAttribute) + guard let windowInfoList = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] else { + return nil + } + return windowInfoList + .compactMap { windowInfo -> RectResponse? in + guard (windowInfo[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value + == app.processIdentifier, + let boundsDictionary = windowInfo[kCGWindowBounds as String] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: boundsDictionary) + else { + return nil + } + return RectResponse( + x: Double(bounds.origin.x), + y: Double(bounds.origin.y), + width: Double(bounds.width), + height: Double(bounds.height) + ) + } .filter { $0.width > 0 && $0.height > 0 } .min { left, right in abs(left.width / left.height - screenshotAspectRatio) From 96fd2f9c9bdcb20c8fdf35c83a327c1bef8b1570 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Fri, 28 Aug 2026 22:10:11 +0200 Subject: [PATCH 09/10] refactor: own human-control holds in lease registry --- .github/workflows/ios.yml | 3 - .github/workflows/macos.yml | 51 -- .../VisualAlertFallback.swift | 136 ---- .../Sources/AgentDeviceMacOSHelper/main.swift | 67 +- docs/adr/0007-remote-device-leases.md | 21 + packages/contracts/src/client-lease.ts | 21 + packages/contracts/src/facades/client.ts | 3 + .../__tests__/test-file-size-ratchet.test.ts | 2 +- src/__tests__/cli-client-commands.test.ts | 17 +- src/__tests__/eager-closure-budgets.ts | 2 +- src/__tests__/takeover-command.test.ts | 201 +++--- .../test-utils/client-lease-fixtures.ts | 29 + src/agent-device-client.ts | 37 +- src/cli-schema/cli-help-command-usage.test.ts | 5 +- src/cli-schema/cli-help-topics.test.ts | 4 +- src/cli-schema/cli-help.ts | 8 +- src/cli-schema/command-overrides.ts | 22 +- src/cli.ts | 6 +- src/cli/commands/connection-runtime.ts | 43 +- src/cli/commands/takeover-client.ts | 168 ----- src/cli/commands/takeover.ts | 60 +- src/client/client-types.ts | 11 + src/client/lease-client.test.ts | 75 ++ src/client/lease-client.ts | 93 +++ .../__tests__/parity.test.ts | 12 + .../daemon-command-descriptor.ts | 2 - src/core/command-descriptor/registry.ts | 97 +-- src/core/device-selection-resolver.ts | 37 - src/core/lease-scope.ts | 29 - .../__tests__/daemon-command-registry.test.ts | 80 ++- .../__tests__/device-mutation-drain.test.ts | 26 + .../__tests__/human-control-fixtures.ts | 72 ++ .../__tests__/human-control-http.test.ts | 166 ++--- .../__tests__/human-control-request.test.ts | 100 --- .../__tests__/human-control-router-fixture.ts | 32 + src/daemon/__tests__/human-control.test.ts | 130 ---- .../__tests__/lease-registry-scope.test.ts | 22 + src/daemon/__tests__/lease-registry.test.ts | 214 ++++-- .../__tests__/request-handler-catalog.test.ts | 2 - src/daemon/daemon-command-registry.ts | 16 +- src/daemon/device-mutation-drain.ts | 30 + .../handlers/__tests__/human-control.test.ts | 137 ++++ .../__tests__/lease-artifacts.test.ts | 10 +- src/daemon/handlers/__tests__/lease.test.ts | 95 +++ src/daemon/handlers/human-control.ts | 79 +- src/daemon/handlers/lease.ts | 68 +- src/daemon/human-control-contract.ts | 196 ++--- src/daemon/human-control-http.ts | 21 +- src/daemon/human-control-request.ts | 70 -- src/daemon/human-control-store.ts | 53 -- src/daemon/human-control.ts | 193 ----- src/daemon/lease-registry-scope.ts | 319 +++++++++ src/daemon/lease-registry.ts | 672 +++++++----------- src/daemon/request-admission.ts | 13 +- src/daemon/request-execution-scope.ts | 52 +- src/daemon/request-handler-chain.ts | 7 +- src/daemon/request-router.ts | 10 - src/daemon/server/daemon-runtime.ts | 27 +- src/daemon/server/http-server.ts | 20 +- website/docs/docs/commands.md | 24 +- website/docs/docs/remote-proxy.md | 48 +- website/docs/docs/security-trust.md | 8 +- 62 files changed, 1970 insertions(+), 2304 deletions(-) delete mode 100644 apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift create mode 100644 src/__tests__/test-utils/client-lease-fixtures.ts delete mode 100644 src/cli/commands/takeover-client.ts create mode 100644 src/client/lease-client.test.ts create mode 100644 src/client/lease-client.ts create mode 100644 src/daemon/__tests__/device-mutation-drain.test.ts create mode 100644 src/daemon/__tests__/human-control-fixtures.ts delete mode 100644 src/daemon/__tests__/human-control-request.test.ts create mode 100644 src/daemon/__tests__/human-control-router-fixture.ts delete mode 100644 src/daemon/__tests__/human-control.test.ts create mode 100644 src/daemon/__tests__/lease-registry-scope.test.ts create mode 100644 src/daemon/device-mutation-drain.ts create mode 100644 src/daemon/handlers/__tests__/human-control.test.ts create mode 100644 src/daemon/handlers/__tests__/lease.test.ts delete mode 100644 src/daemon/human-control-request.ts delete mode 100644 src/daemon/human-control-store.ts delete mode 100644 src/daemon/human-control.ts create mode 100644 src/daemon/lease-registry-scope.ts diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index fc1ae4f65f..fe52efd26d 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -136,9 +136,6 @@ jobs: xcodebuild test-without-building \ -xctestrun "$XCTESTRUN_PATH" \ -destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \ - -retry-tests-on-failure \ - -test-iterations 2 \ - -test-repetition-relaunch-enabled YES \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden \ diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index ce535baf69..64e59ce5ff 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -130,57 +130,6 @@ jobs: uses: ./.github/actions/run-gate with: { gate: macos-helper } - # The hosted runner can present macOS's Local Network permission sheet the first time - # the XCTest transport connects over loopback. Warm the runner and resolve that sheet - # before replay timing begins; the daemon and runner stay warm in this job's state dir. - - name: Resolve runner Local Network permission - shell: bash - run: | - set -euo pipefail - session='macos-local-network-warmup' - screenshot_path="$RUNNER_TEMP/macos-local-network-warmup.png" - helper='apple/macos-helper/.build/release/agent-device-macos-helper' - - close_session() { - node --experimental-strip-types src/bin.ts close \ - --platform macos \ - --session "$session" \ - --state-dir "$AGENT_DEVICE_STATE_DIR" >/dev/null 2>&1 || true - } - trap close_session EXIT - - node --experimental-strip-types src/bin.ts open 'System Settings' \ - --relaunch \ - --platform macos \ - --session "$session" \ - --state-dir "$AGENT_DEVICE_STATE_DIR" - node --experimental-strip-types src/bin.ts screenshot "$screenshot_path" \ - --platform macos \ - --session "$session" \ - --state-dir "$AGENT_DEVICE_STATE_DIR" & - screenshot_pid=$! - - attempts_after_capture=5 - for attempt in {1..60}; do - if "$helper" alert dismiss \ - --surface frontmost-app \ - --screenshot "$screenshot_path"; then - sleep 1 - break - fi - if [ -f "$screenshot_path" ]; then - attempts_after_capture=$((attempts_after_capture - 1)) - if [ "$attempts_after_capture" -eq 0 ]; then - break - fi - fi - sleep 1 - done - wait "$screenshot_pid" - - close_session - trap - EXIT - - name: Run macOS integration test uses: ./.github/actions/run-gate with: diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift deleted file mode 100644 index 4ad61d60d5..0000000000 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/VisualAlertFallback.swift +++ /dev/null @@ -1,136 +0,0 @@ -import AppKit -import ApplicationServices -import CoreGraphics -import Foundation -import ImageIO -import Vision - -struct VisualAlertMatch { - let buttonLabel: String -} - -func dismissLocalNetworkPermissionAlertVisually( - screenshotPath: String, - app: NSRunningApplication -) throws -> VisualAlertMatch? { - guard FileManager.default.fileExists(atPath: screenshotPath) else { - return nil - } - let screenshotUrl = URL(fileURLWithPath: screenshotPath) - guard let source = CGImageSourceCreateWithURL(screenshotUrl as CFURL, nil), - let image = CGImageSourceCreateImageAtIndex(source, 0, nil) - else { - return nil - } - - let request = VNRecognizeTextRequest() - request.recognitionLevel = .accurate - request.recognitionLanguages = ["en-US"] - request.usesLanguageCorrection = false - do { - try VNImageRequestHandler(cgImage: image).perform([request]) - } catch { - throw HelperError.commandFailed( - "alert screenshot recognition failed", - details: ["error": error.localizedDescription] - ) - } - - let recognized = (request.results ?? []).compactMap { observation -> (String, CGRect)? in - guard let candidate = observation.topCandidates(1).first else { - return nil - } - return (candidate.string, observation.boundingBox) - } - let recognizedText = recognized.map(\.0).joined(separator: " ") - guard recognizedText.localizedCaseInsensitiveContains("local network"), - let denial = recognized.first(where: { text, _ in - normalizeVisualAlertText(text).contains("don't allow") - }), - let windowRect = bestMatchingWindowRect( - app: app, - screenshotWidth: image.width, - screenshotHeight: image.height - ) - else { - return nil - } - - let point = CGPoint( - x: windowRect.x + Double(denial.1.midX) * windowRect.width, - y: windowRect.y + (1 - Double(denial.1.midY)) * windowRect.height - ) - guard let move = CGEvent( - mouseEventSource: nil, - mouseType: .mouseMoved, - mouseCursorPosition: point, - mouseButton: .left - ), - let down = CGEvent( - mouseEventSource: nil, - mouseType: .leftMouseDown, - mouseCursorPosition: point, - mouseButton: .left - ), - let up = CGEvent( - mouseEventSource: nil, - mouseType: .leftMouseUp, - mouseCursorPosition: point, - mouseButton: .left - ) - else { - throw HelperError.commandFailed( - "alert action failed", - details: ["reason": "event_creation_failed"] - ) - } - move.post(tap: .cghidEventTap) - down.post(tap: .cghidEventTap) - up.post(tap: .cghidEventTap) - return VisualAlertMatch(buttonLabel: denial.0) -} - -private func normalizeVisualAlertText(_ value: String) -> String { - value - .replacingOccurrences(of: "’", with: "'") - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() -} - -private func bestMatchingWindowRect( - app: NSRunningApplication, - screenshotWidth: Int, - screenshotHeight: Int -) -> RectResponse? { - guard screenshotWidth > 0, screenshotHeight > 0 else { - return nil - } - let screenshotAspectRatio = Double(screenshotWidth) / Double(screenshotHeight) - guard let windowInfoList = CGWindowListCopyWindowInfo( - [.optionOnScreenOnly, .excludeDesktopElements], - kCGNullWindowID - ) as? [[String: Any]] else { - return nil - } - return windowInfoList - .compactMap { windowInfo -> RectResponse? in - guard (windowInfo[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value - == app.processIdentifier, - let boundsDictionary = windowInfo[kCGWindowBounds as String] as? NSDictionary, - let bounds = CGRect(dictionaryRepresentation: boundsDictionary) - else { - return nil - } - return RectResponse( - x: Double(bounds.origin.x), - y: Double(bounds.origin.y), - width: Double(bounds.width), - height: Double(bounds.height) - ) - } - .filter { $0.width > 0 && $0.height > 0 } - .min { left, right in - abs(left.width / left.height - screenshotAspectRatio) - < abs(right.width / right.height - screenshotAspectRatio) - } -} diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift index 3034e8ba96..6b77e18683 100644 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift @@ -286,32 +286,7 @@ struct AgentDeviceMacOSHelper { let bundleId = optionValue(arguments: Array(arguments.dropFirst()), name: "--bundle-id") let surface = optionValue(arguments: Array(arguments.dropFirst()), name: "--surface") let app = try resolveTargetApplication(bundleId: bundleId, surface: surface) - let normalizedSurface = surface?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let alertElement = - normalizedSurface == "frontmost-app" - ? findFocusedAlertElement() - ?? findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) - : findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) - if alertElement == nil, - action == "dismiss", - normalizedSurface == "frontmost-app", - let screenshotPath = optionValue(arguments: Array(arguments.dropFirst()), name: "--screenshot"), - let visualMatch = try dismissLocalNetworkPermissionAlertVisually( - screenshotPath: screenshotPath, - app: app - ) - { - return SuccessEnvelope( - data: AlertResponse( - title: "Local Network", - role: "visual", - buttons: [visualMatch.buttonLabel], - action: action, - bundleId: app.bundleIdentifier - ) - ) - } - guard let alertElement else { + guard let alertElement = findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) else { // `reason` is the typed channel the host retries on; the message is for humans only. throw HelperError.commandFailed( "alert not found", @@ -610,9 +585,6 @@ func resolveTargetApplication(bundleId: String?, surface: String?) throws -> NSR ) } if normalizedSurface == "frontmost-app" { - if let focused = focusedApplication() { - return focused - } if let frontmost = NSWorkspace.shared.frontmostApplication { return frontmost } @@ -631,20 +603,6 @@ func resolveTargetApplication(bundleId: String?, surface: String?) throws -> NSR throw HelperError.commandFailed("unable to resolve target app") } -private func focusedApplication() -> NSRunningApplication? { - guard let appElement = elementAttribute( - AXUIElementCreateSystemWide(), - attribute: kAXFocusedApplicationAttribute as String - ) else { - return nil - } - var processIdentifier: pid_t = 0 - guard AXUIElementGetPid(appElement, &processIdentifier) == .success else { - return nil - } - return NSRunningApplication(processIdentifier: processIdentifier) -} - private func validatedBundleId(_ rawBundleId: String) throws -> String { let bundleId = rawBundleId.trimmingCharacters(in: .whitespacesAndNewlines) let pattern = #"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$"# @@ -668,27 +626,6 @@ private func findAlertElement(appElement: AXUIElement) -> AXUIElement? { return nil } -private func findFocusedAlertElement() -> AXUIElement? { - guard var element = elementAttribute( - AXUIElementCreateSystemWide(), - attribute: kAXFocusedUIElementAttribute as String - ) else { - return nil - } - for _ in 0..<8 { - if let role = stringAttribute(element, attribute: kAXRoleAttribute as String), - role == "AXSheet" || role == "AXDialog" - { - return element - } - guard let parent = elementAttribute(element, attribute: kAXParentAttribute as String) else { - return nil - } - element = parent - } - return nil -} - private func findAlertElementRecursively(root: AXUIElement, depth: Int) -> AXUIElement? { if depth > 4 { return nil @@ -748,7 +685,7 @@ private func resolveAlertActionButton(root: AXUIElement, buttons: [AXUIElement], let preferredLabels = action == "accept" ? ["allow", "ok", "open", "continue", "yes", "save", "install", "trust", "enable"] - : ["don't allow", "don’t allow", "deny", "cancel", "not now", "no", "close", "later", "ignore"] + : ["don't allow", "deny", "cancel", "not now", "no", "close", "later", "ignore"] for preferredLabel in preferredLabels { if let match = buttonEntries.first(where: { $0.label.contains(preferredLabel) }) { diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 6864093c92..c3117841ea 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -50,3 +50,24 @@ 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. + +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. diff --git a/packages/contracts/src/client-lease.ts b/packages/contracts/src/client-lease.ts index fb27fba661..6f5e6e6656 100644 --- a/packages/contracts/src/client-lease.ts +++ b/packages/contracts/src/client-lease.ts @@ -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; +}; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index bd9c90a5b2..51c964ad08 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -66,6 +66,9 @@ export type { } from '../client-gesture.ts'; export type { CloudArtifactsOptions, + HumanControlHold, + HumanControlHoldOptions, + HumanControlHoldScope, Lease, LeaseAllocateOptions, LeaseOptions, diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index 442b970c78..a4874ba62e 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -46,7 +46,7 @@ const PINNED_TEST_FILE_LINES: Readonly> = 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, diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 42866b11bd..b3382c6ba9 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -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-'); @@ -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 ?? diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index c5e1e414bb..2f7061f35b 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -393,7 +393,7 @@ export const HUB_BUDGETS: Readonly> = 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, diff --git a/src/__tests__/takeover-command.test.ts b/src/__tests__/takeover-command.test.ts index f4362e0a55..b52830a176 100644 --- a/src/__tests__/takeover-command.test.ts +++ b/src/__tests__/takeover-command.test.ts @@ -1,126 +1,115 @@ import assert from 'node:assert/strict'; -import { beforeEach, test, vi } from 'vitest'; -import type { AgentDeviceClient } from '../agent-device-client.ts'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; import { renderTakeoverStarted, renderTakeoverStatus, takeoverCommand, } from '../cli/commands/takeover.ts'; -import type { HumanControlHold } from '../daemon/human-control-contract.ts'; - -const mocks = vi.hoisted(() => ({ - sendRequest: vi.fn(), - writeCommandOutput: vi.fn(), -})); - -vi.mock('../daemon/client/daemon-client-lifecycle.ts', () => ({ - ensureDaemon: async () => ({ info: { port: 1234, token: 'daemon-token' } }), - resolveClientSettings: () => ({ paths: { socketPath: '/tmp/daemon.sock' } }), -})); - -vi.mock('../daemon/client/daemon-client-transport.ts', () => ({ - sendRequest: mocks.sendRequest, -})); - -vi.mock('../cli/commands/shared.ts', () => ({ - writeCommandOutput: mocks.writeCommandOutput, -})); - -const HOLD: HumanControlHold = { - id: 'takeover-1', - scope: { deviceKey: 'sim-1', deviceName: 'iPhone 17 Pro', platform: 'ios' }, - reason: 'Human is interacting with the simulator.', - createdAt: 1_000, - updatedAt: 1_000, - expiresAt: 16_000, -}; - -beforeEach(() => { - mocks.sendRequest.mockReset(); - mocks.writeCommandOutput.mockReset(); -}); +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) => ({ + 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 active hold and release gesture', () => { - assert.equal( - renderTakeoverStarted(HOLD), - [ - 'Human control active for iPhone 17 Pro (sim-1).', - 'Agent interactions are paused. Press Ctrl+C to return control.', - 'Hold: takeover-1', - ].join('\n'), +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([HOLD]), /takeover-1: iPhone 17 Pro \(sim-1\)/); -}); - -test('takeover status lists holds through the local daemon command', async () => { - mocks.sendRequest.mockResolvedValue({ ok: true, data: { holds: [HOLD] } }); - - assert.equal(await runTakeover(['status']), true); - assert.deepEqual(mocks.sendRequest.mock.calls[0]?.[1].positionals, ['list']); - assert.deepEqual(mocks.writeCommandOutput.mock.calls[0]?.[1], { holds: [HOLD] }); + assert.match(renderTakeoverStatus([HUMAN_CONTROL_HOLD]), /operator-1: ios:mobile:sim-1/); }); -test('takeover release removes the named hold through the local daemon command', async () => { - mocks.sendRequest.mockResolvedValue({ ok: true, data: { released: true } }); - - assert.equal(await runTakeover(['release', 'takeover-1']), true); - assert.deepEqual(mocks.sendRequest.mock.calls[0]?.[1].positionals, ['remove', 'takeover-1']); - assert.deepEqual(mocks.writeCommandOutput.mock.calls[0]?.[1], { - holdId: 'takeover-1', - released: true, +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 before contacting the daemon', async () => { - await assert.rejects(runTakeover(['status', 'extra']), /does not accept additional arguments/); - await assert.rejects(runTakeover(['release']), /requires a hold id/); - await assert.rejects(runTakeover(['unknown']), /accepts only/); - assert.equal(mocks.sendRequest.mock.calls.length, 0); +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 resolves the device through the public client inventory', async () => { - const list = vi.fn().mockResolvedValue([ - { - platform: 'ios', - target: 'mobile', - kind: 'simulator', - id: 'sim-1', - name: 'iPhone 17 Pro', - booted: true, - identifiers: { udid: 'sim-1' }, - }, - ]); - mocks.sendRequest.mockImplementation(async (_daemon, request) => { - if (request.positionals[0] === 'put') { - setTimeout(() => process.emit('SIGINT'), 0); - return { ok: true, data: { hold: HOLD } }; - } - return { ok: true, data: { released: true } }; - }); - - await takeoverCommand({ - positionals: [], - flags: { json: true, help: false, version: false, platform: 'ios', udid: 'sim-1' }, - client: { devices: { list } } as unknown as AgentDeviceClient, - }); - - assert.equal(list.mock.calls[0]?.[0].platform, 'ios'); - assert.equal(list.mock.calls[0]?.[0].udid, 'sim-1'); - const putRequest = mocks.sendRequest.mock.calls.find( - (call) => call[1].positionals[0] === 'put', - )?.[1]; - assert.equal(JSON.parse(putRequest.positionals[2]).scope.deviceKey, 'sim-1'); +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( - mocks.sendRequest.mock.calls.some((call) => call[1].positionals[0] === 'remove'), - true, + 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); }); -async function runTakeover(positionals: string[]): Promise { - return await takeoverCommand({ - positionals, - flags: { json: false, help: false, version: false }, - client: {} as AgentDeviceClient, - }); -} +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'); +}); diff --git a/src/__tests__/test-utils/client-lease-fixtures.ts b/src/__tests__/test-utils/client-lease-fixtures.ts new file mode 100644 index 0000000000..0b48559d67 --- /dev/null +++ b/src/__tests__/test-utils/client-lease-fixtures.ts @@ -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, + }, + }; +} diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 52fe665730..a06d10418a 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -19,7 +19,6 @@ import type { DragOptions, FlingOptions, InternalRequestOptions, - Lease, MaterializationReleaseOptions, PanOptions, PinchOptions, @@ -79,6 +78,7 @@ import { type MetroSessionHints, } from './metro/metro-session-hints.ts'; import { isRecord } from '@agent-device/kernel/record'; +import { createLeaseClient } from './client/lease-client.ts'; import { readScreenshotResultData } from './utils/screenshot-result.ts'; type ProjectedSystemCommandClient = ProjectedNavigationCommandClient & @@ -316,21 +316,7 @@ export function createAgentDeviceClient( }), ), }, - leases: { - allocate: async (options) => - normalizeLease( - await execute(INTERNAL_COMMANDS.leaseAllocate, [], { - ...options, - leaseId: undefined, - }), - ), - heartbeat: async (options) => - normalizeLease(await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], options)), - release: async (options) => { - const data = await execute(INTERNAL_COMMANDS.leaseRelease, [], options); - return { released: data.released === true, provider: readObject(data.provider) }; - }, - }, + leases: createLeaseClient(execute), metro: { prepare: async (options: MetroPrepareOptions) => { const result = await prepareMetroRuntime({ @@ -677,23 +663,4 @@ function clearMetroSessionHintsQuietly( } } -function normalizeLease(data: Record): Lease { - const rawLease = data.lease; - if (!isRecord(rawLease)) { - throw new Error('Invalid lease response from daemon'); - } - return { - leaseId: readRequiredString(rawLease, 'leaseId'), - tenantId: readRequiredString(rawLease, 'tenantId'), - runId: readRequiredString(rawLease, 'runId'), - backend: readRequiredString(rawLease, 'backend') as Lease['backend'], - leaseProvider: readOptionalString(rawLease, 'leaseProvider'), - clientId: readOptionalString(rawLease, 'clientId'), - deviceKey: readOptionalString(rawLease, 'deviceKey'), - createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined, - heartbeatAt: typeof rawLease.heartbeatAt === 'number' ? rawLease.heartbeatAt : undefined, - expiresAt: typeof rawLease.expiresAt === 'number' ? rawLease.expiresAt : undefined, - }; -} - export type * from './client/client-types.ts'; diff --git a/src/cli-schema/cli-help-command-usage.test.ts b/src/cli-schema/cli-help-command-usage.test.ts index e1a6346419..1e13d67c82 100644 --- a/src/cli-schema/cli-help-command-usage.test.ts +++ b/src/cli-schema/cli-help-command-usage.test.ts @@ -174,14 +174,15 @@ test('proxy command help describes tunnel usage', async () => { assert.doesNotMatch(help, /agent-device-proxy/); }); -test('takeover command help documents local foreground and VM API flows', async () => { +test('takeover command help documents lease-bound foreground and host API flows', async () => { const help = await usageForCommand('takeover'); if (help === null) throw new Error('Expected command help text'); assert.match(help, /Usage:\s+agent-device takeover/); assert.match(help, /foreground command pauses state-changing agent commands/); assert.match(help, /until Ctrl\+C/); assert.match(help, /\/admin\/human-control\/holds/); - assert.match(help, /always targets the local daemon/); + assert.match(help, /active remote lease device/); + assert.match(help, /do not survive daemon restart/); }); test('connect command help lists lease id in usage and flags', async () => { diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index 5f8213cba0..86f51af71e 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -167,10 +167,10 @@ test('usageForCommand resolves Maestro compatibility help topic', async () => { assert.doesNotMatch(help, /issues\/558/); }); -test('remote help documents host-local takeover controls', async () => { +test('remote help documents lease-bound takeover and distinct host administration', async () => { const help = await usageForCommand('remote'); if (help === null) throw new Error('Expected remote help text'); - assert.match(help, /agent-device takeover --platform ios/); + assert.match(help, /agent-device takeover --session remote-session/); assert.match(help, /authenticated GET\/PUT\/DELETE requests/); assert.match(help, /not forwarded by agent-device proxy/); }); diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index a56d72e7e2..9d7754a388 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -758,12 +758,12 @@ Direct proxy flow for a remote Mac/simulator: agent-device close agent-device disconnect -Human takeover on the device host: - Run agent-device takeover with the same device selector on the machine or VM that owns the target. It pauses state-changing agent commands until Ctrl+C while snapshots and other read-only diagnostics remain available. takeover always controls the local daemon, even when that CLI has a saved remote connection. - agent-device takeover --platform ios --device "iPhone 17 Pro" +Human takeover of a leased remote device: + Run agent-device takeover using the active remote connection and session. It pauses state-changing agent commands until Ctrl+C while snapshots and other read-only diagnostics remain available. Tenant requests can control only their admitted lease device. Local takeover without a remote device lease is not supported. + agent-device takeover --session remote-session agent-device takeover status agent-device takeover release - An HTTP-mode daemon also accepts authenticated GET/PUT/DELETE requests at /admin/human-control/holds on its loopback listener. Use the daemon token from the same host. This host-admin route is intentionally not forwarded by agent-device proxy. + An HTTP-mode daemon also accepts authenticated GET/PUT/DELETE requests at /admin/human-control/holds on its loopback listener. Host administrators supply the exact lease backend/provider/device key and use the local daemon token, not a tenant credential. This host-admin route is intentionally not forwarded by agent-device proxy. Holds do not survive daemon restart; re-establish them after reconnecting. Cloud profile flow: agent-device connect diff --git a/src/cli-schema/command-overrides.ts b/src/cli-schema/command-overrides.ts index 78a7a8914a..8f10b4da74 100644 --- a/src/cli-schema/command-overrides.ts +++ b/src/cli-schema/command-overrides.ts @@ -146,21 +146,23 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { text: { summary: 'Pause agent interactions while a person controls a device', description: - 'Temporarily hand control of a locally attached simulator or device to a person. Run this on the host that owns the target. The foreground command pauses state-changing agent commands, renews the hold until Ctrl+C, and then releases it. Read-only diagnostics remain available. status and release inspect or recover local holds. HTTP-mode daemons also expose an authenticated loopback API under /admin/human-control/holds for host-side automation; the external proxy does not forward this route. This command always targets the local daemon, even when a remote connection is active.', + 'Temporarily hand control of the active remote lease device to a person. The foreground command pauses state-changing agent commands, renews the hold until Ctrl+C, and then releases it. Read-only diagnostics remain available. Uses the active connection and --session, with normal tenant and lease admission. status lists holds on that device; release removes a hold owned by the admitted lease. The host-only /admin/human-control/holds API uses the separate local daemon token. Holds do not survive daemon restart. Local takeover without a remote device lease is not supported.', }, - usageOverride: - 'takeover [status | release ] [--platform ] [--device ] [--udid ] [--serial ]', + usageOverride: 'takeover [status | release ] [--session ]', listUsageOverride: 'takeover [status|release]', positionalArgs: ['status|release?', 'hold-id?'], supportedFlags: [ 'stateDir', - 'platform', - 'target', - 'device', - 'udid', - 'serial', - 'iosSimulatorDeviceSet', - 'androidDeviceAllowlist', + 'session', + 'remoteConfig', + 'daemonBaseUrl', + 'daemonAuthToken', + 'daemonTransport', + 'tenant', + 'runId', + 'leaseId', + 'leaseBackend', + 'sessionIsolation', ], }, 'react-devtools': { diff --git a/src/cli.ts b/src/cli.ts index 8416edc4c7..62ce545631 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -703,8 +703,7 @@ function resolveActiveConnectionDefaults(options: { options.command === 'connect' || options.command === 'connection' || options.command === 'daemon' || - options.command === 'proxy' || - options.command === 'takeover' + options.command === 'proxy' ) { return null; } @@ -732,8 +731,7 @@ function shouldResolveRemoteAuth(command: string): boolean { command !== 'connection' && command !== 'daemon' && command !== 'device' && - command !== 'proxy' && - command !== 'takeover' + command !== 'proxy' ); } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index e78ccee570..51a769b367 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -7,8 +7,13 @@ import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; // see resolvePreviousOwnDaemonAuthToken below for why this must not be // resolveRemoteConfigProfile. import { readRemoteConfigFile } from '../../remote/remote-config-core.ts'; -import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; -import { proxyLeaseDeviceKey } from '../../core/lease-scope.ts'; +import { + deviceFieldsFromPublicPlatform, + isIosFamily, + publicPlatformString, + resolveDevice, + type DeviceInfo, +} from '@agent-device/kernel/device'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import { buildRemoteConnectionDaemonState, @@ -841,7 +846,7 @@ async function resolveProxyLeaseState(options: { ); } const device = await resolveSelectedDevice(options.client, options.flags); - const deviceKey = proxyLeaseDeviceKey(device); + const deviceKey = buildProxyDeviceKey(device); return { state: { ...options.state, @@ -872,8 +877,36 @@ async function resolveSelectedDevice( client: AgentDeviceClient, flags: CliFlags, ): Promise { - const { resolvePublicInventoryDevice } = await import('../../core/device-selection-resolver.ts'); - return await resolvePublicInventoryDevice(client.devices, flags); + const devices = await client.devices.list({ + platform: flags.platform, + target: flags.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }); + return await resolveDevice( + devices.map((device) => ({ + ...deviceFieldsFromPublicPlatform(device.platform), + id: device.id, + name: device.name, + kind: device.kind, + target: device.target, + booted: device.booted, + })), + { + platform: flags.platform, + target: flags.target, + deviceName: flags.device, + udid: flags.udid, + serial: flags.serial, + }, + ); +} + +function buildProxyDeviceKey(device: DeviceInfo): string { + return `${publicPlatformString(device)}:${device.target ?? 'mobile'}:${device.id}`; } function leaseBackendForDevice(device: DeviceInfo): LeaseBackend | undefined { diff --git a/src/cli/commands/takeover-client.ts b/src/cli/commands/takeover-client.ts deleted file mode 100644 index 00101662f9..0000000000 --- a/src/cli/commands/takeover-client.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { CliFlags } from '@agent-device/contracts/command'; -import { AppError, throwDaemonError, toAppErrorCode } from '@agent-device/kernel/errors'; -import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; -import { - ensureDaemon, - resolveClientSettings, -} from '../../daemon/client/daemon-client-lifecycle.ts'; -import { sendRequest } from '../../daemon/client/daemon-client-transport.ts'; -import { buildDaemonHttpAuthHeaders } from '../../daemon/http-contract.ts'; -import type { - HumanControlHold, - HumanControlHoldInput, -} from '../../daemon/human-control-contract.ts'; -import { HUMAN_CONTROL_HTTP_PREFIX } from '../../daemon/human-control.ts'; - -const HUMAN_CONTROL_REQUEST_TIMEOUT_MS = 20_000; - -type HumanControlListResponse = { - ok: boolean; - holds?: HumanControlHold[]; - error?: string; - code?: string; -}; - -type HumanControlMutationResponse = { - ok: boolean; - hold?: HumanControlHold; - released?: boolean; - error?: string; - code?: string; -}; - -export type LocalHumanControlClient = { - list(): Promise; - put(holdId: string, input: HumanControlHoldInput): Promise; - remove(holdId: string): Promise; -}; - -export async function createLocalHumanControlClient( - flags: CliFlags, -): Promise { - const settings = resolveClientSettings({ - session: 'default', - command: 'takeover', - positionals: [], - flags: { - stateDir: flags.stateDir, - daemonBaseUrl: '', - daemonTransport: 'auto', - }, - }); - const daemon = await ensureDaemon(settings); - if (daemon.info.port) { - const run = async (positionals: string[]): Promise> => { - const response = await sendRequest( - daemon.info, - { - token: daemon.info.token, - session: 'default', - command: INTERNAL_COMMANDS.humanControl, - positionals, - flags: { stateDir: flags.stateDir }, - }, - 'socket', - settings.paths, - HUMAN_CONTROL_REQUEST_TIMEOUT_MS, - ); - if (!response.ok) throwDaemonError(response.error); - return response.data ?? {}; - }; - return { - list: async () => readHolds(await run(['list'])), - put: async (holdId, input) => readHold(await run(['put', holdId, JSON.stringify(input)])), - remove: async (holdId) => (await run(['remove', holdId])).released === true, - }; - } - if (!daemon.info.httpPort) { - throw new AppError('COMMAND_FAILED', 'Local daemon management endpoint is unavailable.'); - } - return createHttpHumanControlClient(daemon.info.httpPort, daemon.info.token); -} - -function createHttpHumanControlClient(httpPort: number, token: string): LocalHumanControlClient { - const baseUrl = `http://127.0.0.1:${String(httpPort)}`; - const headers = { - ...buildDaemonHttpAuthHeaders(token), - 'content-type': 'application/json', - }; - return { - list: async () => { - const response = await requestHumanControl( - `${baseUrl}${HUMAN_CONTROL_HTTP_PREFIX}`, - { headers }, - ); - return response.holds ?? []; - }, - put: async (holdId, input) => { - const response = await requestHumanControl( - holdUrl(baseUrl, holdId), - { method: 'PUT', headers, body: JSON.stringify(input) }, - ); - if (!response.hold) { - throw new AppError('COMMAND_FAILED', 'Daemon did not return the human-control hold.'); - } - return response.hold; - }, - remove: async (holdId) => { - const response = await requestHumanControl( - holdUrl(baseUrl, holdId), - { method: 'DELETE', headers }, - ); - return response.released === true; - }, - }; -} - -function readHolds(data: Record): HumanControlHold[] { - return Array.isArray(data.holds) ? (data.holds as HumanControlHold[]) : []; -} - -function readHold(data: Record): HumanControlHold { - if (!data.hold || typeof data.hold !== 'object' || Array.isArray(data.hold)) { - throw new AppError('COMMAND_FAILED', 'Daemon did not return the human-control hold.'); - } - return data.hold as HumanControlHold; -} - -async function requestHumanControl( - url: string, - init: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(url, { - ...init, - signal: AbortSignal.timeout(HUMAN_CONTROL_REQUEST_TIMEOUT_MS), - }); - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - 'Failed to reach the local daemon human-control endpoint.', - undefined, - error, - ); - } - let payload: T; - try { - payload = (await response.json()) as T; - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - `Local daemon returned an invalid human-control response (${String(response.status)}).`, - undefined, - error, - ); - } - if (!response.ok || !payload.ok) { - throw new AppError( - toAppErrorCode(payload.code), - payload.error ?? `Human-control request failed (${String(response.status)}).`, - ); - } - return payload; -} - -function holdUrl(baseUrl: string, holdId: string): string { - return `${baseUrl}${HUMAN_CONTROL_HTTP_PREFIX}/${encodeURIComponent(holdId)}`; -} diff --git a/src/cli/commands/takeover.ts b/src/cli/commands/takeover.ts index 25c1752c94..b5743742a5 100644 --- a/src/cli/commands/takeover.ts +++ b/src/cli/commands/takeover.ts @@ -1,15 +1,9 @@ import { randomUUID } from 'node:crypto'; import type { CliFlags } from '@agent-device/contracts/command'; -import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { AgentDeviceClient } from '../../agent-device-client.ts'; -import { resolvePublicInventoryDevice } from '../../core/device-selection-resolver.ts'; -import type { - HumanControlHold, - HumanControlHoldInput, -} from '../../daemon/human-control-contract.ts'; +import type { HumanControlHold } from '@agent-device/contracts/client'; import { writeCommandOutput } from './shared.ts'; -import { createLocalHumanControlClient } from './takeover-client.ts'; import type { ClientCommandHandler } from './router-types.ts'; const FOREGROUND_HOLD_TTL_MS = 15_000; @@ -20,14 +14,14 @@ export const takeoverCommand: ClientCommandHandler = async ({ positionals, flags if (positionals.length !== 1) { throw new AppError('INVALID_ARGS', 'takeover status does not accept additional arguments.'); } - await showTakeoverStatus(flags); + await showTakeoverStatus(flags, client); return true; } if (action === 'release') { if (positionals.length !== 2 || !positionals[1]) { throw new AppError('INVALID_ARGS', 'takeover release requires a hold id.'); } - await releaseTakeover(flags, positionals[1]); + await releaseTakeover(flags, client, positionals[1]); return true; } if (positionals.length > 0) { @@ -42,10 +36,12 @@ async function runForegroundTakeover( flags: CliFlags, agentDeviceClient: AgentDeviceClient, ): Promise { - const device = await resolveTakeoverDevice(agentDeviceClient, flags); const holdId = `takeover-${randomUUID()}`; - const input = buildForegroundHoldInput(device); - const client = await createLocalHumanControlClient(flags); + const input = { + reason: 'Human is interacting with the simulator or device.', + ttlMs: FOREGROUND_HOLD_TTL_MS, + }; + const client = agentDeviceClient.leases.humanControl; const hold = await client.put(holdId, input); writeCommandOutput(flags, { hold, state: 'active' }, () => renderTakeoverStarted(hold)); @@ -92,42 +88,24 @@ async function runForegroundTakeover( } } -async function resolveTakeoverDevice( - client: AgentDeviceClient, - flags: CliFlags, -): Promise { - return await resolvePublicInventoryDevice(client.devices, flags); -} - -async function showTakeoverStatus(flags: CliFlags): Promise { - const holds = await (await createLocalHumanControlClient(flags)).list(); +async function showTakeoverStatus(flags: CliFlags, client: AgentDeviceClient): Promise { + const holds = await client.leases.humanControl.list(); writeCommandOutput(flags, { holds }, () => renderTakeoverStatus(holds)); } -async function releaseTakeover(flags: CliFlags, holdId: string): Promise { - const released = await (await createLocalHumanControlClient(flags)).remove(holdId); +async function releaseTakeover( + flags: CliFlags, + client: AgentDeviceClient, + holdId: string, +): Promise { + const released = await client.leases.humanControl.remove(holdId); writeCommandOutput(flags, { holdId, released }, () => released ? `Released human-control hold ${holdId}.` : `No active hold found for ${holdId}.`, ); } -function buildForegroundHoldInput(device: DeviceInfo): HumanControlHoldInput { - return { - scope: { - deviceKey: device.id, - deviceName: device.name, - platform: publicPlatformString(device), - kind: device.kind, - }, - reason: 'Human is interacting with the simulator or device.', - ttlMs: FOREGROUND_HOLD_TTL_MS, - }; -} - export function renderTakeoverStarted(hold: HumanControlHold): string { - const target = hold.scope.deviceName - ? `${hold.scope.deviceName} (${hold.scope.deviceKey})` - : hold.scope.deviceKey; + const target = hold.scope.deviceKey; return [ `Human control active for ${target}.`, 'Agent interactions are paused. Press Ctrl+C to return control.', @@ -140,9 +118,7 @@ export function renderTakeoverStatus(holds: HumanControlHold[]): string { return [ 'Active human-control holds:', ...holds.map((hold) => { - const target = hold.scope.deviceName - ? `${hold.scope.deviceName} (${hold.scope.deviceKey})` - : hold.scope.deviceKey; + const target = hold.scope.deviceKey; return ` ${hold.id}: ${target}`; }), ].join('\n'); diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 772be788ee..5975ea9211 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -98,6 +98,8 @@ import type { Lease, LeaseAllocateOptions, LeaseScopedOptions, + HumanControlHold, + HumanControlHoldOptions, LogsOptions, LongPressOptions, MaterializationReleaseOptions, @@ -239,6 +241,15 @@ export type AgentDeviceClient = { release: ( options: LeaseScopedOptions, ) => Promise<{ released: boolean; provider?: CloudProviderSessionResult }>; + humanControl: { + list: (options?: AgentDeviceRequestOverrides) => Promise; + put: ( + id: string, + input?: HumanControlHoldOptions, + options?: AgentDeviceRequestOverrides, + ) => Promise; + remove: (id: string, options?: AgentDeviceRequestOverrides) => Promise; + }; }; metro: { prepare: (options: MetroPrepareOptions) => Promise; diff --git a/src/client/lease-client.test.ts b/src/client/lease-client.test.ts new file mode 100644 index 0000000000..cfcf9dfcb0 --- /dev/null +++ b/src/client/lease-client.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { + HUMAN_CONTROL_HOLD, + HUMAN_CONTROL_SCOPE, +} from '../daemon/__tests__/human-control-fixtures.ts'; +import type { DaemonRequest } from '@agent-device/kernel/contracts'; + +test('human-control client carries normal remote authentication and full lease metadata', async () => { + const requests: Array> = []; + const client = createAgentDeviceClient( + { + daemonBaseUrl: 'https://daemon.example.test', + daemonAuthToken: 'tenant-token', + tenant: 'tenant-a', + runId: 'run-a', + leaseId: 'lease-1', + clientId: 'client-a', + leaseBackend: HUMAN_CONTROL_SCOPE.backend, + leaseProvider: HUMAN_CONTROL_SCOPE.leaseProvider, + deviceKey: HUMAN_CONTROL_SCOPE.deviceKey, + }, + { + transport: async (request, context) => { + assert.equal(context?.authToken, 'tenant-token'); + requests.push(request); + return { + ok: true, + data: { hold: HUMAN_CONTROL_HOLD, holds: [HUMAN_CONTROL_HOLD], released: true }, + }; + }, + }, + ); + assert.equal( + (await client.leases.humanControl.put('operator-1', { ttlMs: 15_000 })).id, + 'operator-1', + ); + assert.equal((await client.leases.humanControl.list()).length, 1); + assert.equal(await client.leases.humanControl.remove('operator-1'), true); + for (const request of requests) { + assert.equal(request.command, 'human_control'); + assert.equal(request.meta?.leaseId, 'lease-1'); + assert.equal(request.meta?.leaseProvider, 'proxy'); + assert.equal(request.meta?.deviceKey, HUMAN_CONTROL_SCOPE.deviceKey); + assert.equal(request.meta?.clientId, 'client-a'); + } +}); + +test('lease client rejects invalid control responses and preserves normalized errors', async () => { + const client = createAgentDeviceClient({}, { transport: async () => ({ ok: true, data: {} }) }); + await assert.rejects(client.leases.humanControl.put('operator-1'), { code: 'COMMAND_FAILED' }); + await assert.rejects(client.leases.humanControl.list(), { code: 'COMMAND_FAILED' }); + const denied = createAgentDeviceClient( + {}, + { + transport: async () => ({ + ok: false, + error: { + code: 'UNAUTHORIZED', + message: 'Wrong lease', + details: { reason: 'LEASE_SCOPE_MISMATCH' }, + }, + }), + }, + ); + await assert.rejects( + denied.leases.humanControl.remove('operator-1'), + (error: unknown) => + error instanceof AppError && + error.code === 'UNAUTHORIZED' && + error.details?.reason === 'LEASE_SCOPE_MISMATCH', + ); +}); diff --git a/src/client/lease-client.ts b/src/client/lease-client.ts new file mode 100644 index 0000000000..eb2eac8e1c --- /dev/null +++ b/src/client/lease-client.ts @@ -0,0 +1,93 @@ +import type { + HumanControlHold, + InternalRequestOptions, + Lease, +} from '@agent-device/contracts/client'; +import { AppError } from '@agent-device/kernel/errors'; +import { INTERNAL_COMMANDS } from '../command-catalog.ts'; +import { isRecord } from '@agent-device/kernel/record'; +import type { AgentDeviceClient } from './client-types.ts'; +import { readOptionalString, readRequiredString } from './client-normalizers.ts'; + +export function createLeaseClient( + execute: ( + command: string, + positionals?: string[], + options?: InternalRequestOptions, + ) => Promise>, +): AgentDeviceClient['leases'] { + const control = async (positionals: string[], options?: InternalRequestOptions) => + await execute(INTERNAL_COMMANDS.humanControl, positionals, options); + return { + allocate: async (options) => + normalizeLease( + await execute(INTERNAL_COMMANDS.leaseAllocate, [], { ...options, leaseId: undefined }), + ), + heartbeat: async (options) => + normalizeLease(await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], options)), + release: async (options) => { + const data = await execute(INTERNAL_COMMANDS.leaseRelease, [], options); + return { + released: data.released === true, + provider: isRecord(data.provider) ? data.provider : undefined, + }; + }, + humanControl: { + list: async (options) => { + const data = await control(['list'], options); + if (!Array.isArray(data.holds)) + throw new AppError('COMMAND_FAILED', 'Daemon did not return human-control holds.'); + return data.holds.map(normalizeHumanControlHold); + }, + put: async (id, input = {}, options) => { + const data = await control(['put', id, JSON.stringify(input)], options); + return normalizeHumanControlHold(data.hold); + }, + remove: async (id, options) => (await control(['remove', id], options)).released === true, + }, + }; +} + +function normalizeHumanControlHold(value: unknown): HumanControlHold { + if ( + !isRecord(value) || + !isRecord(value.scope) || + (value.state !== 'active' && value.state !== 'activating') || + typeof value.createdAt !== 'number' || + typeof value.updatedAt !== 'number' + ) { + throw new AppError('COMMAND_FAILED', 'Daemon returned an invalid human-control hold.'); + } + return { + id: readRequiredString(value, 'id'), + scope: { + backend: readRequiredString(value.scope, 'backend') as HumanControlHold['scope']['backend'], + leaseProvider: readOptionalString(value.scope, 'leaseProvider'), + deviceKey: readRequiredString(value.scope, 'deviceKey'), + }, + state: value.state, + reason: readOptionalString(value, 'reason'), + createdAt: value.createdAt, + updatedAt: value.updatedAt, + ...(typeof value.expiresAt === 'number' ? { expiresAt: value.expiresAt } : {}), + }; +} + +function normalizeLease(data: Record): Lease { + const rawLease = data.lease; + if (!isRecord(rawLease)) { + throw new Error('Invalid lease response from daemon'); + } + return { + leaseId: readRequiredString(rawLease, 'leaseId'), + tenantId: readRequiredString(rawLease, 'tenantId'), + runId: readRequiredString(rawLease, 'runId'), + backend: readRequiredString(rawLease, 'backend') as Lease['backend'], + leaseProvider: readOptionalString(rawLease, 'leaseProvider'), + clientId: readOptionalString(rawLease, 'clientId'), + deviceKey: readOptionalString(rawLease, 'deviceKey'), + createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined, + heartbeatAt: typeof rawLease.heartbeatAt === 'number' ? rawLease.heartbeatAt : undefined, + expiresAt: typeof rawLease.expiresAt === 'number' ? rawLease.expiresAt : undefined, + }; +} diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 9c2e5b1e7b..71735fa54b 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -253,6 +253,18 @@ test('recordsSessionAction is explicit on every raw descriptor and drives daemon }); test('recordingEffect resolves request-sensitive observation and mutation subcommands', () => { + assert.equal( + resolveCommandRecordingEffect({ command: 'clipboard', positionals: ['read'], flags: {} }), + 'observes-app', + ); + assert.equal( + resolveCommandRecordingEffect({ + command: 'clipboard', + positionals: ['write', 'text'], + flags: {}, + }), + 'mutates-app', + ); assert.equal( resolveCommandRecordingEffect({ command: 'keyboard', positionals: ['status'], flags: {} }), 'observes-app', diff --git a/src/core/command-descriptor/daemon-command-descriptor.ts b/src/core/command-descriptor/daemon-command-descriptor.ts index 00d69ac42a..2fa89e82c4 100644 --- a/src/core/command-descriptor/daemon-command-descriptor.ts +++ b/src/core/command-descriptor/daemon-command-descriptor.ts @@ -2,7 +2,6 @@ import type { DispatchedCommand } from '@agent-device/contracts/command'; import type { RefFrameEffect } from '@agent-device/contracts/replay'; export type SessionCommandKind = 'inventory' | 'state' | 'observability' | 'publication' | 'replay'; -export type HumanControlEffect = 'read' | 'mutate' | 'control'; /** * Routes a daemon command to its handler family. The handler table in @@ -30,7 +29,6 @@ export type DaemonRefFrameEffect = */ export type DaemonCommandDescriptor = { command: string; - humanControlEffect: HumanControlEffect | ((req: TRequest) => HumanControlEffect); route: DaemonCommandRoute; sessionKind?: SessionCommandKind; refFrameEffect?: DaemonRefFrameEffect; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index c6e9b8bda6..84f342e23b 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -162,10 +162,6 @@ const REQUEST_EXECUTION_EXEMPT = { selectorValidationExempt: true, } as const; -const HUMAN_CONTROL_READ = { humanControlEffect: 'read' } as const; -const HUMAN_CONTROL_MUTATE = { humanControlEffect: 'mutate' } as const; -const HUMAN_CONTROL_CONTROL = { humanControlEffect: 'control' } as const; - const allowAnyDeviceSessionless = (): boolean => true; const isRecordingStartRequest = (req: DispatchedCommand): boolean => @@ -218,20 +214,8 @@ const findRecordingEffect = (req: DispatchedCommand): RecordingEffect => { } }; -const humanControlEffectFromRecording = (effect: RecordingEffect): 'read' | 'mutate' => - effect === 'observes-app' ? 'read' : 'mutate'; - -const clipboardHumanControlEffect = (req: DispatchedCommand): 'read' | 'mutate' => - req.positionals?.[0]?.toLowerCase() === 'read' ? 'read' : 'mutate'; - -const keyboardHumanControlEffect = (req: DispatchedCommand): 'read' | 'mutate' => - humanControlEffectFromRecording(keyboardRecordingEffect(req)); - -const alertHumanControlEffect = (req: DispatchedCommand): 'read' | 'mutate' => - humanControlEffectFromRecording(alertRecordingEffect(req)); - -const findHumanControlEffect = (req: DispatchedCommand): 'read' | 'mutate' => - humanControlEffectFromRecording(findRecordingEffect(req)); +const clipboardRecordingEffect = (req: DispatchedCommand): RecordingEffect => + readOnlySubactionRecordingEffect(req, new Set(['read']), ''); function readOnlySubactionRefFrameEffect( req: DispatchedCommand, @@ -271,7 +255,6 @@ const GENERIC_MUTATING_COMMAND_TRAITS = { route: 'generic', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -300,7 +283,6 @@ const TARGETED_TOUCH_INTERACTION_TRAITS = { route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, - ...HUMAN_CONTROL_MUTATE, }, } as const satisfies Pick< Extract, @@ -428,7 +410,7 @@ const DEPLOY_APP_COMMAND_DESCRIPTOR = { frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, platformExecution: { kind: 'device-runtime', use: deployAppUse }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, @@ -444,8 +426,8 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'humanControl', refFrameEffect: 'preserve', - ...REQUEST_EXECUTION_EXEMPT, - ...HUMAN_CONTROL_CONTROL, + selectorValidationExempt: true, + skipSessionlessProviderDevice: allowAnyDeviceSessionless, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -463,7 +445,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT, - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: LEASE_ALLOCATE_TIMEOUT_POLICY, batchable: false, @@ -479,7 +460,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT, - ...HUMAN_CONTROL_CONTROL, }, timeoutPolicy: LEASE_TIMEOUT_POLICY, batchable: false, @@ -495,7 +475,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT, - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: LEASE_TIMEOUT_POLICY, batchable: false, @@ -512,7 +491,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'lease', refFrameEffect: 'preserve', ...ADMISSION_AND_LOCK_EXEMPT, - ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -533,7 +511,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'preserve', sessionKind: 'inventory', ...REQUEST_EXECUTION_EXEMPT, - ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -551,7 +528,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'preserve', sessionKind: 'publication', - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -570,7 +546,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, ...REQUEST_EXECUTION_EXEMPT, - ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -590,7 +565,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, ...REQUEST_EXECUTION_EXEMPT, - ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -615,7 +589,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ lockPolicySelectorOverride: true, allowSessionlessDefaultDevice: allowAnyDeviceSessionless, ...REQUEST_EXECUTION_EXEMPT, - ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -634,7 +607,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, - ...HUMAN_CONTROL_READ, }, platformExecution: { kind: 'device-runtime', uses: [appsRuntimeUse] as const }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -651,7 +623,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state', - ...HUMAN_CONTROL_MUTATE, }, platformExecution: { kind: 'device-runtime', uses: deviceBootRuntimeUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -668,7 +639,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state', - ...HUMAN_CONTROL_MUTATE, }, platformExecution: { kind: 'device-runtime', use: shutdownTargetUse }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -685,7 +655,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'preserve', sessionKind: 'state', - ...HUMAN_CONTROL_READ, }, platformExecution: { kind: 'device-runtime', uses: appStateRuntimeUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -703,7 +672,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability', - ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -720,7 +688,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability', - ...HUMAN_CONTROL_READ, }, platformExecution: { kind: 'device-runtime', uses: appLogRuntimePlanUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -739,7 +706,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'observability', allowInvalidRecording: true, ...REQUEST_EXECUTION_EXEMPT, - ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -758,7 +724,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability', - ...HUMAN_CONTROL_READ, }, platformExecution: { kind: 'device-runtime', use: networkDumpUse }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -775,7 +740,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability', - ...HUMAN_CONTROL_READ, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -794,7 +758,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'replay', skipSessionlessProviderDevice: isShardedTestRequest, saveScriptFlagOwner: true, - ...HUMAN_CONTROL_MUTATE, }, // Replay durations are script-dependent; --timeout bounds the envelope. timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag' } }, @@ -816,7 +779,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'delegated', sessionKind: 'replay', skipSessionlessProviderDevice: isShardedTestRequest, - ...HUMAN_CONTROL_MUTATE, }, // Test runs stream per-scenario progress and are budgeted downstream; no // client envelope at all. @@ -839,7 +801,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ : {}), catalog: { group: 'internal' }, recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'session', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, platformExecution: { @@ -857,11 +819,10 @@ export const RAW_COMMAND_DESCRIPTORS = [ // whichever action-selected fact (`readClipboard`/`writeClipboard`) the parsed subcommand // names, and the only execution is that one bound operation (ADR 0019 §9). recordsSessionAction: true, - recordingEffect: 'observes-app', + recordingEffect: clipboardRecordingEffect, daemon: { route: 'session', refFrameEffect: 'preserve', - humanControlEffect: clipboardHumanControlEffect, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -882,7 +843,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: keyboardRefFrameEffect, androidBlockingDialogGuard: true, - humanControlEffect: keyboardHumanControlEffect, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -905,7 +865,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'internal', key: 'installSource' }, recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, platformExecution: { kind: 'device-runtime', use: readyMaterializeAndDeployAppUse }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: false, @@ -922,7 +882,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'session', refFrameEffect: 'preserve', ...REQUEST_EXECUTION_EXEMPT, - ...HUMAN_CONTROL_CONTROL, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -936,7 +895,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, platformExecution: { kind: 'device-runtime', use: readySendPushNotificationUse }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -953,7 +912,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // no command, request, or CLI flag), so the owner receives a URL to open. recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: [appEventRuntimeUse] }, @@ -971,7 +930,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', allowSessionlessDefaultDevice: allowAnyDeviceSessionless, saveScriptFlagOwner: true, - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -984,7 +942,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'preserve', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'session', refFrameEffect: 'preserve' }, // Runner warm-up builds are the longest fixed envelope; --timeout overrides. timeoutPolicy: { budget: { source: 'flag' }, @@ -1002,7 +960,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: false, - daemon: { route: 'session', refFrameEffect: 'delegated', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'session', refFrameEffect: 'delegated' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, // Wave 6 residue: every step runs as its own daemon request under its own descriptor, which @@ -1023,7 +981,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ allowInvalidRecording: true, saveScriptFlagOwner: true, sessionlessPlainCloseAdmissionExempt: isPlainCloseRequest, - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -1039,7 +996,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'snapshot', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, // First Apple snapshot on a device can sit behind runner startup; --timeout // widens the envelope, and a timeout must not tear down the daemon. timeoutPolicy: { ...PRESERVE_DAEMON_TIMEOUT_POLICY, budget: { source: 'flag' } }, @@ -1054,7 +1011,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'snapshot', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: snapshotRuntimePlanUses }, @@ -1070,7 +1027,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // #1349: a wait's landmark may legitimately be absent when the step // starts, so identity verification runs inside its polling resolution. targetIdentityVerification: 'post-resolution', - daemon: { route: 'snapshot', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, + daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, // The wait budget travels as a positional, not a flag; parse it the same // way the daemon will so the request envelope extends past it (#1075). timeoutPolicy: { @@ -1108,7 +1065,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'snapshot', refFrameEffect: alertRefFrameEffect, - humanControlEffect: alertHumanControlEffect, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, @@ -1126,7 +1082,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // it keys on the requested setting, which is not a device fact. recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: [settingsRuntimeUse] }, @@ -1144,7 +1100,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // verification capture are daemon policy over an already-migrated snapshot route. recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: [tapPointUse] }, @@ -1162,7 +1118,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'preserve', allowInvalidRecording: true, allowSessionlessDefaultDevice: isRecordingStartRequest, - ...HUMAN_CONTROL_MUTATE, }, platformExecution: { kind: 'device-runtime', uses: screenRecordingRuntimePlanUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -1176,7 +1131,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'recordTrace', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, + daemon: { route: 'recordTrace', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: NO_PLATFORM_EXECUTION, @@ -1192,7 +1147,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'find', refFrameEffect: 'may-invalidate', - humanControlEffect: findHumanControlEffect, }, timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY, batchable: true, @@ -1219,7 +1173,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: postActionObservationTimeoutPolicy('click', PRESERVE_DAEMON_TIMEOUT_POLICY), postActionObservation: postActionObservation('click'), @@ -1267,7 +1220,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'interaction', refFrameEffect: 'may-invalidate', - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: postActionObservationTimeoutPolicy('hover', PRESERVE_DAEMON_TIMEOUT_POLICY), postActionObservation: postActionObservation('hover'), @@ -1297,7 +1249,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, - ...HUMAN_CONTROL_MUTATE, }, timeoutPolicy: postActionObservationTimeoutPolicy('type', PRESERVE_DAEMON_TIMEOUT_POLICY), batchable: true, @@ -1312,7 +1263,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'interaction', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, + daemon: { route: 'interaction', refFrameEffect: 'preserve' }, timeoutPolicy: postActionObservationTimeoutPolicy('get', PRESERVE_DAEMON_TIMEOUT_POLICY), batchable: true, platformExecution: { kind: 'device-runtime', uses: selectorTextCaptureRuntimePlanUses }, @@ -1326,7 +1277,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'interaction', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, + daemon: { route: 'interaction', refFrameEffect: 'preserve' }, timeoutPolicy: postActionObservationTimeoutPolicy('is', PRESERVE_DAEMON_TIMEOUT_POLICY), batchable: true, platformExecution: { kind: 'device-runtime', uses: selectorCaptureRuntimePlanUses }, @@ -1359,7 +1310,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, - ...HUMAN_CONTROL_MUTATE, }, // R52 retires this command's capability bucket: admission is the owner's gesture-tier facts, // which the retired `requireGestureSupported` used to decide inside the daemon. The declared @@ -1423,7 +1373,6 @@ export const RAW_COMMAND_DESCRIPTORS = [ route: 'interaction', refFrameEffect: 'may-invalidate', androidBlockingDialogGuard: true, - ...HUMAN_CONTROL_MUTATE, }, // R54 retires this command's capability bucket. A swipe always normalizes to a coordinate // fling, so it declares only the one-contact plan it can select. @@ -1456,7 +1405,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'core', recordsSessionAction: true, recordingEffect: 'observes-app', - daemon: { route: 'generic', refFrameEffect: 'preserve', ...HUMAN_CONTROL_READ }, + daemon: { route: 'generic', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: screenshotRuntimePlanUses }, @@ -1469,7 +1418,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'generic', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, platformExecution: { kind: 'device-runtime', uses: [viewportRuntimeUse] }, @@ -1491,7 +1440,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // classified. Add the facet (route unchanged) so its device mutation is // covered by the completeness gate; this is the escape hatch the ADR calls // out, not a new specialized route. - daemon: { route: 'generic', refFrameEffect: 'may-invalidate', ...HUMAN_CONTROL_MUTATE }, + daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, platformExecution: { kind: 'device-runtime', uses: [appSwitcherRuntimeUse] }, diff --git a/src/core/device-selection-resolver.ts b/src/core/device-selection-resolver.ts index 2103ae33bb..ed7e878e99 100644 --- a/src/core/device-selection-resolver.ts +++ b/src/core/device-selection-resolver.ts @@ -1,12 +1,9 @@ import type { - AgentDeviceDevice, - AgentDeviceSelectionOptions, DeviceSelectionMetadata, DeviceSelectionReason, DeviceSelectionSource, } from '@agent-device/contracts/client'; import { - deviceFieldsFromPublicPlatform, hasExplicitDeviceIdentitySelector, isIosFamily, isSerialAddressablePlatform, @@ -34,40 +31,6 @@ export type InventoryDeviceSelectionParams = { appleSimulatorAppTarget?: string; }; -export async function resolvePublicInventoryDevice( - source: { - list(options?: AgentDeviceSelectionOptions): Promise; - }, - options: AgentDeviceSelectionOptions, -): Promise { - const devices = await source.list({ - platform: options.platform, - target: options.target, - device: options.device, - udid: options.udid, - serial: options.serial, - iosSimulatorDeviceSet: options.iosSimulatorDeviceSet, - androidDeviceAllowlist: options.androidDeviceAllowlist, - }); - return await resolveDevice( - devices.map((device) => ({ - ...deviceFieldsFromPublicPlatform(device.platform), - id: device.id, - name: device.name, - kind: device.kind, - target: device.target, - booted: device.booted, - })), - { - platform: options.platform, - target: options.target, - deviceName: options.device, - udid: options.udid, - serial: options.serial, - }, - ); -} - export async function resolveInventoryDeviceSelection( params: InventoryDeviceSelectionParams, ): Promise { diff --git a/src/core/lease-scope.ts b/src/core/lease-scope.ts index 5e8c7d299a..530fa97c10 100644 --- a/src/core/lease-scope.ts +++ b/src/core/lease-scope.ts @@ -1,38 +1,9 @@ import type { LeaseBackend } from '@agent-device/kernel/contracts'; import { stripUndefined } from '@agent-device/kernel/record'; -import { - DEVICE_TARGETS, - isPublicPlatform, - publicPlatformString, - type DeviceInfo, -} from '@agent-device/kernel/device'; const PROXY_LEASE_PROVIDER = 'proxy'; export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; -export function proxyLeaseDeviceKey(device: DeviceInfo): string { - return `${publicPlatformString(device)}:${device.target ?? 'mobile'}:${device.id}`; -} - -export function deviceIdentityAliases(deviceKeys: readonly string[]): string[] { - const aliases = new Set(); - for (const rawKey of deviceKeys) { - const deviceKey = rawKey.trim(); - if (!deviceKey) continue; - aliases.add(deviceKey); - const [platform, target, ...identityParts] = deviceKey.split(':'); - if ( - isPublicPlatform(platform) && - (DEVICE_TARGETS as readonly string[]).includes(target ?? '') && - identityParts.length > 0 - ) { - const identity = identityParts.join(':').trim(); - if (identity) aliases.add(identity); - } - } - return [...aliases]; -} - const REQUIRED_PROXY_LEASE_FIELDS = [ 'leaseId', 'tenantId', diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index 6507ed71af..2446ba6eac 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -6,7 +6,7 @@ import { canRunReplayScopedAction, getDaemonCommandRoute, getSessionCommandKind, - humanControlEffectForRequest, + isHumanControlMutation, isLeaseAdmissionExempt, shouldBlockForInvalidRecording, shouldGuardAndroidBlockingDialog, @@ -252,44 +252,52 @@ test('every lease-route command skips sessionless provider-device resolution', ( } }); -test('daemon command registry owns human-control effects and fails closed', () => { +test('takeover passes lease admission and uses the normal execution lock', () => { + assert.equal(isLeaseAdmissionExempt(INTERNAL_COMMANDS.humanControl), false); + assert.equal(shouldLockSessionExecution(INTERNAL_COMMANDS.humanControl), true); +}); + +test('human-control admission derives existing semantics and treats unclassified requests as mutations', () => { for (const command of [ - PUBLIC_COMMANDS.snapshot, - PUBLIC_COMMANDS.screenshot, - PUBLIC_COMMANDS.get, - PUBLIC_COMMANDS.is, - PUBLIC_COMMANDS.logs, - PUBLIC_COMMANDS.devices, - PUBLIC_COMMANDS.trace, + 'snapshot', + 'screenshot', + 'get', + 'is', + 'logs', + 'network', + 'events', + 'audio', + 'trace', + 'devices', + 'apps', + 'appstate', + 'doctor', + 'human_control', + 'lease_heartbeat', ]) { - assert.equal(humanControlEffectForRequest(makeRequest(command)), 'read', `${command} effect`); + assert.equal(isHumanControlMutation(makeRequest(command)), false, command); + } + for (const [command, positionals] of [ + ['clipboard', ['read']], + ['keyboard', ['status']], + ['alert', ['get']], + ['find', ['text', 'Save', 'get', 'text']], + ] as const) { + assert.equal(isHumanControlMutation(makeRequest(command, [...positionals])), false, command); + } + for (const [command, positionals] of [ + ['clipboard', ['write', 'value']], + ['clipboard', []], + ['keyboard', ['dismiss']], + ['alert', ['accept']], + ['find', ['text', 'Save', 'click']], + ['click', []], + ['viewport', []], + ['lease_release', []], + ['future-command', []], + ] as const) { + assert.equal(isHumanControlMutation(makeRequest(command, [...positionals])), true, command); } - - assert.equal( - humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.clipboard, ['read'])), - 'read', - ); - assert.equal( - humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.clipboard, ['write', 'value'])), - 'mutate', - ); - assert.equal( - humanControlEffectForRequest( - makeRequest(PUBLIC_COMMANDS.find, ['text', 'Save', 'get', 'text']), - ), - 'read', - ); - assert.equal( - humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.find, ['text', 'Save', 'click'])), - 'mutate', - ); - assert.equal(humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.click)), 'mutate'); - assert.equal(humanControlEffectForRequest(makeRequest(PUBLIC_COMMANDS.viewport)), 'mutate'); - assert.equal(humanControlEffectForRequest(makeRequest('future-command')), 'mutate'); - assert.equal( - humanControlEffectForRequest(makeRequest(INTERNAL_COMMANDS.leaseHeartbeat)), - 'control', - ); }); function makeRequest(command: string, positionals: string[] = []): DaemonRequest { diff --git a/src/daemon/__tests__/device-mutation-drain.test.ts b/src/daemon/__tests__/device-mutation-drain.test.ts new file mode 100644 index 0000000000..5bdb3c6a57 --- /dev/null +++ b/src/daemon/__tests__/device-mutation-drain.test.ts @@ -0,0 +1,26 @@ +import { createControlLatch } from './human-control-fixtures.ts'; +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { DeviceMutationDrain } from '../device-mutation-drain.ts'; + +test('drain counts concurrent operations, releases on failure, and isolates device keys', async () => { + const drain = new DeviceMutationDrain(); + const first = createControlLatch(); + const second = createControlLatch(); + const one = drain.run('device-a', () => first.promise); + const two = drain.run('device-a', () => second.promise); + let idle = false; + const waited = drain.wait('device-a').then(() => { + idle = true; + }); + await drain.wait('device-b'); + first.resolve(); + await one; + assert.equal(idle, false); + const rejected = assert.rejects(two, /failure/); + second.reject(new Error('failure')); + await rejected; + await waited; + assert.equal(idle, true); + await drain.wait('device-a'); +}); diff --git a/src/daemon/__tests__/human-control-fixtures.ts b/src/daemon/__tests__/human-control-fixtures.ts new file mode 100644 index 0000000000..8430d2537d --- /dev/null +++ b/src/daemon/__tests__/human-control-fixtures.ts @@ -0,0 +1,72 @@ +import type { HumanControlHold, HumanControlHoldScope } from '@agent-device/contracts/client'; +import type { DeviceLease } from '@agent-device/contracts/device'; +import type { AllocateLeaseRequest } from '../lease-registry-scope.ts'; +import type { DaemonRequest } from '../types.ts'; +import { AppError } from '@agent-device/kernel/errors'; + +export const HUMAN_CONTROL_SCOPE: HumanControlHoldScope = { + backend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:mobile:sim-1', +}; + +export const HUMAN_CONTROL_LEASE_REQUEST: AllocateLeaseRequest = { + tenantId: 'tenant-a', + runId: 'run-a', + clientId: 'client-a', + leaseBackend: HUMAN_CONTROL_SCOPE.backend, + leaseProvider: HUMAN_CONTROL_SCOPE.leaseProvider, + deviceKey: HUMAN_CONTROL_SCOPE.deviceKey, +}; + +export const HUMAN_CONTROL_HOLD: HumanControlHold = { + id: 'operator-1', + scope: HUMAN_CONTROL_SCOPE, + state: 'active', + reason: 'Manual inspection', + createdAt: 1_000, + updatedAt: 1_000, + expiresAt: 16_000, +}; + +export function humanControlRequest( + lease: DeviceLease, + command = 'human_control', + positionals = ['list'], +): DaemonRequest { + return { + token: 'test-token', + session: 'takeover-test', + command, + positionals, + flags: {}, + meta: { + sessionIsolation: 'tenant', + tenantId: lease.tenantId, + runId: lease.runId, + leaseId: lease.leaseId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }, + }; +} + +export function isHumanControlError(error: unknown): boolean { + return ( + error instanceof AppError && + error.code === 'DEVICE_IN_USE' && + error.details?.reason === 'human_control_active' + ); +} + +export function createControlLatch() { + let resolve: () => void = () => {}; + let reject: (error: Error) => void = () => {}; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} diff --git a/src/daemon/__tests__/human-control-http.test.ts b/src/daemon/__tests__/human-control-http.test.ts index 98cd526365..7df0755af3 100644 --- a/src/daemon/__tests__/human-control-http.test.ts +++ b/src/daemon/__tests__/human-control-http.test.ts @@ -6,7 +6,10 @@ import { listenOnLoopback, skipWhenLoopbackUnavailable, } from '../../__tests__/test-utils/loopback.ts'; -import { HUMAN_CONTROL_HTTP_PREFIX, HumanControlRegistry } from '../human-control.ts'; +import { HUMAN_CONTROL_HTTP_PREFIX } from '../human-control-contract.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { HUMAN_CONTROL_SCOPE, humanControlRequest } from './human-control-fixtures.ts'; +import { createHumanControlHarness } from './human-control-router-fixture.ts'; import { tryHandleHumanControlHttpRoute } from '../human-control-http.ts'; import { createDaemonHttpServer } from '../server/http-server.ts'; @@ -35,7 +38,7 @@ test('malformed request URLs return a normalized error', async () => { req, res, expectedToken: 'daemon-secret', - registry: new HumanControlRegistry(), + registry: new LeaseRegistry(), }), true, ); @@ -44,134 +47,65 @@ test('malformed request URLs return a normalized error', async () => { assert.equal((JSON.parse(responseBody) as { code?: string }).code, 'INVALID_ARGS'); }); -test('daemon human-control API authenticates and manages persistent holds', async (t) => { +test('host administration and tenant RPC use the same lease registry with distinct authority', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; - - const registry = new HumanControlRegistry(); - let releasedHoldId: string | undefined; - let handlerCalls = 0; + const { registry, lease, handleRequest } = createHumanControlHarness(); const server = await createDaemonHttpServer({ - token: 'daemon-secret', - humanControlRegistry: registry, - onHumanControlHoldReleased: (hold) => { - releasedHoldId = hold.id; - }, - handleRequest: async (request) => { - handlerCalls += 1; - if (request.command === 'click') { - return { - ok: false, - error: { - code: 'DEVICE_IN_USE', - message: - 'A human is interacting with this simulator or device; agent interactions are temporarily disabled.', - details: { reason: 'human_control_active' }, - }, - }; - } - return { ok: true, data: {} }; - }, + token: 'test-token', + leaseRegistry: registry, + handleRequest, }); - try { const port = await listenOnLoopback(server); - const baseUrl = `http://127.0.0.1:${String(port)}${HUMAN_CONTROL_HTTP_PREFIX}`; - const unauthorized = await fetch(baseUrl); - assert.equal(unauthorized.status, 401); - assert.deepEqual(await unauthorized.json(), { - ok: false, - error: 'Invalid token', - code: 'UNAUTHORIZED', - }); - - const malformedHoldId = await fetch(`${baseUrl}/%`, { + const origin = `http://127.0.0.1:${String(port)}`; + const baseUrl = origin + HUMAN_CONTROL_HTTP_PREFIX; + const headers = { authorization: 'Bearer test-token', 'content-type': 'application/json' }; + const denied = await fetch(baseUrl, { headers: { authorization: 'Bearer tenant-credential' } }); + assert.equal(denied.status, 401); + const invalid = await fetch(baseUrl + '/console', { method: 'PUT', - headers: { - authorization: 'Bearer daemon-secret', - 'content-type': 'application/json', - }, + headers, body: JSON.stringify({ scope: { deviceKey: 'sim-1' } }), }); - assert.equal(malformedHoldId.status, 400); - assert.equal(((await malformedHoldId.json()) as { code?: string }).code, 'INVALID_ARGS'); - - const socketOnlyRpc = await fetch(`http://127.0.0.1:${String(port)}/rpc`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 'human-control-rpc', - method: 'agent_device.command', - params: { - token: 'daemon-secret', - command: 'human_control', - positionals: ['list'], - }, - }), - }); - assert.equal(socketOnlyRpc.status, 404); - assert.match(JSON.stringify(await socketOnlyRpc.json()), /socket-only/); - - const created = await fetch(`${baseUrl}/vm-console`, { + assert.equal(invalid.status, 400); + const created = await fetch(baseUrl + '/host', { method: 'PUT', - headers: { - authorization: 'Bearer daemon-secret', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - scope: { - deviceKey: 'sim-1', - deviceName: 'iPhone 17 Pro', - platform: 'ios', - kind: 'simulator', - }, - reason: 'Human is using the VM console.', - }), + headers, + body: JSON.stringify({ scope: HUMAN_CONTROL_SCOPE }), }); assert.equal(created.status, 200); - const createdBody = (await created.json()) as { - hold?: { id?: string; expiresAt?: number }; - state?: string; - }; - assert.equal(createdBody.hold?.id, 'vm-console'); - assert.equal(createdBody.hold?.expiresAt, undefined); - assert.equal(createdBody.state, 'active'); + const body = (await created.json()) as { state: string; hold: { scope: unknown } }; + assert.equal(body.state, 'active'); + assert.deepEqual(body.hold.scope, HUMAN_CONTROL_SCOPE); - const blockedRpc = await fetch(`http://127.0.0.1:${String(port)}/rpc`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 'blocked-click', - method: 'agent_device.command', - params: { - token: 'daemon-secret', - command: 'click', - positionals: ['10', '10'], - }, - }), - }); - assert.equal(blockedRpc.status, 423); - assert.match(JSON.stringify(await blockedRpc.json()), /DEVICE_IN_USE/); + const rpc = async (command: string, positionals: string[]) => { + const request = humanControlRequest(lease, command, positionals); + return await fetch(origin + '/rpc', { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'takeover-rpc', + method: 'agent_device.command', + params: request, + }), + }); + }; + assert.equal((await rpc('human_control', ['put', 'tenant', '{}'])).status, 200); + const blocked = await rpc('click', ['10', '10']); + assert.equal(blocked.status, 423); + assert.match(JSON.stringify(await blocked.json()), /human_control_active/); + assert.equal((await rpc('snapshot', [])).status, 200); + assert.equal((await rpc('human_control', ['remove', 'host'])).status, 401); + assert.equal((await rpc('human_control', ['remove', 'tenant'])).status, 200); + assert.equal((await rpc('click', ['10', '10'])).status, 423); - const listed = await fetch(baseUrl, { - headers: { 'x-agent-device-token': 'daemon-secret' }, - }); + const listed = await fetch(baseUrl, { headers }); assert.equal(listed.status, 200); - const listedBody = (await listed.json()) as { holds?: Array<{ id?: string }> }; - assert.deepEqual( - listedBody.holds?.map((hold) => hold.id), - ['vm-console'], - ); - - const removed = await fetch(`${baseUrl}/vm-console`, { - method: 'DELETE', - headers: { authorization: 'Bearer daemon-secret' }, - }); - assert.equal(removed.status, 200); - assert.equal(((await removed.json()) as { released?: boolean }).released, true); - assert.equal(releasedHoldId, 'vm-console'); - assert.equal(handlerCalls, 1); + assert.equal(((await listed.json()) as { holds: unknown[] }).holds.length, 1); + const released = await fetch(baseUrl + '/host', { method: 'DELETE', headers }); + assert.equal(released.status, 200); + assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []); } finally { await closeLoopbackServer(server); } diff --git a/src/daemon/__tests__/human-control-request.test.ts b/src/daemon/__tests__/human-control-request.test.ts deleted file mode 100644 index c7fd0949f9..0000000000 --- a/src/daemon/__tests__/human-control-request.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; -import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; -import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; -import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; -import { HumanControlRegistry } from '../human-control.ts'; -import { LeaseRegistry } from '../lease-registry.ts'; -import { createRequestExecutionScope } from '../request-execution-scope.ts'; -import { createRequestHandler } from '../request-router.ts'; -import type { DaemonRequest } from '../types.ts'; -import { lifecycleDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; - -test('request execution blocks mutations but permits read-only commands during human control', async () => { - const sessionName = 'human-control-request'; - const sessionStore = makeSessionStore('agent-device-human-control-request-'); - sessionStore.set(sessionName, makeIosSession(sessionName)); - const registry = new HumanControlRegistry(); - await registry.upsert('operator-1', { scope: { deviceKey: 'sim-1' } }); - - let mutationRan = false; - const mutationScope = await createRequestExecutionScope({ - req: makeRequest(sessionName, 'click'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - humanControlRegistry: registry, - }); - await assert.rejects( - mutationScope.runLocked(async () => { - mutationRan = true; - }), - (error: unknown) => (error as { code?: string }).code === 'DEVICE_IN_USE', - ); - assert.equal(mutationRan, false); - - const readScope = await createRequestExecutionScope({ - req: makeRequest(sessionName, 'snapshot'), - sessionStore, - leaseRegistry: new LeaseRegistry(), - humanControlRegistry: registry, - }); - assert.equal(await readScope.runLocked(async () => 'read-completed'), 'read-completed'); -}); - -test('socket management command activates the production request gate and releases it', async () => { - const sessionName = 'human-control-router'; - const sessionStore = makeSessionStore('agent-device-human-control-router-'); - sessionStore.set(sessionName, makeIosSession(sessionName)); - const registry = new HumanControlRegistry(); - let releasedHoldId: string | undefined; - const handleRequest = createRequestHandler({ - logPath: '/tmp/agent-device-human-control-router.log', - token: 'test-token', - sessionStore, - leaseRegistry: new LeaseRegistry(), - deviceInventoryGateways: createTestDeviceInventoryGateways(), - deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, - humanControlRegistry: registry, - onHumanControlHoldReleased: (hold) => { - releasedHoldId = hold.id; - }, - trackDownloadableArtifact: () => 'artifact-1', - }); - - const activated = await handleRequest({ - ...makeRequest(sessionName, INTERNAL_COMMANDS.humanControl), - positionals: [ - 'put', - 'operator-1', - JSON.stringify({ scope: { deviceKey: 'sim-1' }, reason: 'Manual inspection' }), - ], - }); - assert.equal(activated.ok, true); - - const blocked = await handleRequest(makeRequest(sessionName, 'click')); - assert.equal(blocked.ok, false); - if (blocked.ok) throw new Error('Expected click to be blocked'); - assert.equal(blocked.error.code, 'DEVICE_IN_USE'); - assert.equal(blocked.error.details?.reason, 'human_control_active'); - assert.equal(blocked.error.retriable, true); - assert.match(blocked.error.message, /agent interactions are temporarily disabled/i); - - const released = await handleRequest({ - ...makeRequest(sessionName, INTERNAL_COMMANDS.humanControl), - positionals: ['remove', 'operator-1'], - }); - assert.equal(released.ok, true); - assert.equal(releasedHoldId, 'operator-1'); - assert.deepEqual(registry.list(), []); -}); - -function makeRequest(session: string, command: string): DaemonRequest { - return { - token: 'test-token', - session, - command, - positionals: [], - flags: {}, - }; -} diff --git a/src/daemon/__tests__/human-control-router-fixture.ts b/src/daemon/__tests__/human-control-router-fixture.ts new file mode 100644 index 0000000000..8cbc7ee6d4 --- /dev/null +++ b/src/daemon/__tests__/human-control-router-fixture.ts @@ -0,0 +1,32 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { makeIosAppSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { buildSessionLeaseFromRequest } from '../lease-context.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { createRequestHandler } from '../request-router.ts'; +import { tenantScopedSessionName } from '../session-tenant-scope.ts'; +import { lifecycleDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; +import { HUMAN_CONTROL_LEASE_REQUEST, humanControlRequest } from './human-control-fixtures.ts'; + +export function createHumanControlHarness() { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const sessionStore = makeSessionStore('agent-device-human-control-'); + const sessionName = tenantScopedSessionName(lease.tenantId, 'takeover-test'); + sessionStore.set( + sessionName, + makeIosAppSession(sessionName, { + lease: buildSessionLeaseFromRequest(humanControlRequest(lease), lease), + }), + ); + const handleRequest = createRequestHandler({ + logPath: '/tmp/agent-device-human-control.log', + token: 'test-token', + sessionStore, + leaseRegistry: registry, + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + trackDownloadableArtifact: () => 'artifact-1', + }); + return { registry, lease, sessionStore, sessionName, handleRequest }; +} diff --git a/src/daemon/__tests__/human-control.test.ts b/src/daemon/__tests__/human-control.test.ts deleted file mode 100644 index 53b5e7499e..0000000000 --- a/src/daemon/__tests__/human-control.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { test } from 'vitest'; -import { HumanControlRegistry } from '../human-control.ts'; - -test('human-control holds persist and expire by ttl', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-human-control-')); - const statePath = path.join(root, 'human-control.json'); - let now = 1_000; - - try { - const registry = new HumanControlRegistry({ statePath, now: () => now }); - const hold = await registry.upsert('operator-1', { - scope: { deviceKey: 'SIM-1', deviceName: 'iPhone 17 Pro', platform: 'ios' }, - reason: 'Manual inspection', - ttlMs: 5_000, - }); - - assert.equal(hold.createdAt, 1_000); - assert.equal(hold.expiresAt, 6_000); - assert.equal(fs.statSync(statePath).mode & 0o777, 0o600); - - const restored = new HumanControlRegistry({ statePath, now: () => now }); - assert.deepEqual(restored.list(), [hold]); - assert.equal(restored.isDeviceControlled('sim-1'), true); - - now = 6_000; - assert.deepEqual(restored.list(), []); - assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')).holds, []); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test('human-control activation waits for an active mutation and blocks later mutations', async () => { - const registry = new HumanControlRegistry(); - let finishMutation: (() => void) | undefined; - let markMutationStarted: (() => void) | undefined; - const mutationStarted = new Promise((resolve) => { - markMutationStarted = resolve; - }); - const mutationFinished = new Promise((resolve) => { - finishMutation = resolve; - }); - const mutation = registry.runDeviceMutation(['SIM-1', 'iPhone 17 Pro'], async () => { - markMutationStarted?.(); - await mutationFinished; - }); - await mutationStarted; - - let activated = false; - const activation = registry - .upsert('operator-1', { - scope: { deviceKey: 'sim-1' }, - reason: 'Human is interacting with the simulator.', - }) - .then(() => { - activated = true; - }); - await Promise.resolve(); - assert.equal(activated, false); - - finishMutation?.(); - await mutation; - await activation; - assert.equal(activated, true); - - await assert.rejects( - registry.runDeviceMutation(['SIM-1'], async () => undefined), - (error: unknown) => { - assert.equal((error as { code?: string }).code, 'DEVICE_IN_USE'); - assert.equal( - (error as { details?: { reason?: string } }).details?.reason, - 'human_control_active', - ); - assert.match((error as Error).message, /agent interactions are temporarily disabled/i); - assert.equal((error as { details?: { holdId?: string } }).details?.holdId, 'operator-1'); - return true; - }, - ); -}); - -test('human-control ttl starts after active mutations drain', async () => { - let now = 1_000; - const registry = new HumanControlRegistry({ now: () => now }); - let finishMutation: (() => void) | undefined; - let markMutationStarted: (() => void) | undefined; - const mutationStarted = new Promise((resolve) => { - markMutationStarted = resolve; - }); - const mutationFinished = new Promise((resolve) => { - finishMutation = resolve; - }); - const mutation = registry.runDeviceMutation(['ios:mobile:SIM-1'], async () => { - markMutationStarted?.(); - await mutationFinished; - }); - await mutationStarted; - - let activated = false; - const activation = registry - .upsert('operator-1', { - scope: { deviceKey: 'SIM-1' }, - ttlMs: 1_000, - }) - .then((hold) => { - activated = true; - return hold; - }); - await Promise.resolve(); - assert.equal(activated, false); - - now = 5_000; - assert.equal(registry.isDeviceControlled('ios:mobile:SIM-1'), true); - await assert.rejects( - registry.runDeviceMutation(['SIM-1'], async () => undefined), - (error: unknown) => (error as { code?: string }).code === 'DEVICE_IN_USE', - ); - - finishMutation?.(); - await mutation; - const hold = await activation; - assert.equal(hold.expiresAt, 6_000); - assert.equal(registry.isDeviceControlled('SIM-1'), true); - - now = 6_000; - assert.equal(registry.isDeviceControlled('SIM-1'), false); -}); diff --git a/src/daemon/__tests__/lease-registry-scope.test.ts b/src/daemon/__tests__/lease-registry-scope.test.ts new file mode 100644 index 0000000000..5ac5fc0d52 --- /dev/null +++ b/src/daemon/__tests__/lease-registry-scope.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { leaseDeviceBindingKey, normalizeAllocateLeaseRequest } from '../lease-registry-scope.ts'; +import { HUMAN_CONTROL_LEASE_REQUEST, HUMAN_CONTROL_SCOPE } from './human-control-fixtures.ts'; + +test('allocation and human control share the exact contention identity', () => { + const scope = normalizeAllocateLeaseRequest(HUMAN_CONTROL_LEASE_REQUEST); + assert.equal(leaseDeviceBindingKey(scope), leaseDeviceBindingKey(HUMAN_CONTROL_SCOPE)); + assert.notEqual( + leaseDeviceBindingKey(scope), + leaseDeviceBindingKey({ ...scope, leaseProvider: 'other' }), + ); + assert.notEqual( + leaseDeviceBindingKey(scope), + leaseDeviceBindingKey({ ...scope, backend: 'ios-simulator' }), + ); + assert.notEqual( + leaseDeviceBindingKey(scope), + leaseDeviceBindingKey({ ...scope, deviceKey: 'sim-1' }), + ); + assert.equal(leaseDeviceBindingKey({ backend: 'ios-simulator' }), undefined); +}); diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index d42d364df4..4e9df2b661 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -1,7 +1,12 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { HumanControlRegistry } from '../human-control.ts'; import { LeaseRegistry } from '../lease-registry.ts'; +import { + HUMAN_CONTROL_LEASE_REQUEST, + HUMAN_CONTROL_SCOPE, + isHumanControlError, + createControlLatch, +} from './human-control-fixtures.ts'; test('allocateLease creates lease and enforces tenant/run validation', () => { const registry = new LeaseRegistry(); @@ -89,63 +94,6 @@ test('expired leases are cleaned before admission checks', () => { ); }); -test('human-controlled device leases survive expiry and refresh when control is released', () => { - let now = 1_000; - let protectedByHumanControl = true; - const registry = new LeaseRegistry({ - now: () => now, - defaultLeaseTtlMs: 5_000, - isDeviceLeaseProtected: (lease) => protectedByHumanControl && lease.deviceKey === 'device-1', - }); - const lease = registry.allocateLease({ - tenantId: 'tenant-a', - runId: 'run-1', - leaseBackend: 'ios-instance', - leaseProvider: 'proxy', - deviceKey: 'device-1', - }); - - now = 7_000; - assert.deepEqual(registry.consumeExpiredLeases(), []); - assert.equal(registry.listActiveLeases()[0]?.leaseId, lease.leaseId); - - now = 8_000; - const [refreshed] = registry.refreshLeasesForDeviceKey('DEVICE-1'); - assert.equal(refreshed?.expiresAt, 13_000); - protectedByHumanControl = false; - now = 12_000; - assert.equal(registry.listActiveLeases()[0]?.leaseId, lease.leaseId); - now = 14_000; - assert.deepEqual(registry.listActiveLeases(), []); -}); - -test('bare takeover identity protects and refreshes a composite proxy lease key', async () => { - let now = 1_000; - const humanControl = new HumanControlRegistry({ now: () => now }); - const registry = new LeaseRegistry({ - now: () => now, - defaultLeaseTtlMs: 5_000, - isDeviceLeaseProtected: (lease) => humanControl.isDeviceControlled(lease.deviceKey), - }); - const lease = registry.allocateLease({ - tenantId: 'tenant-a', - runId: 'run-1', - leaseBackend: 'ios-instance', - leaseProvider: 'proxy', - deviceKey: 'ios:mobile:SIM-1', - }); - const hold = await humanControl.upsert('operator-1', { - scope: { deviceKey: 'SIM-1' }, - }); - - now = 7_000; - assert.deepEqual(registry.consumeExpiredLeases(), []); - humanControl.remove(hold.id); - const [refreshed] = registry.refreshLeasesForDeviceKey(hold.scope.deviceKey); - assert.equal(refreshed?.leaseId, lease.leaseId); - assert.equal(refreshed?.expiresAt, 12_000); -}); - test('capacity limits reject additional simulator leases', () => { const registry = new LeaseRegistry({ maxActiveSimulatorLeases: 1, @@ -413,3 +361,153 @@ function captureThrown(task: () => unknown): unknown { return error; } } + +test('human holds protect leases and release refreshes the original lease TTL atomically', async () => { + let now = 1_000; + const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 }); + const lease = registry.allocateLease({ ...HUMAN_CONTROL_LEASE_REQUEST, ttlMs: 10_000 }); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + await registry.putHumanControlHold(authority, 'console', {}); + now = 25_000; + assert.deepEqual(registry.consumeExpiredLeases(), []); + assert.equal(registry.consumeExpiredLease(lease.leaseId), undefined); + assert.equal(registry.listActiveLeases()[0]?.leaseId, lease.leaseId); + assert.throws( + () => registry.allocateLease({ ...HUMAN_CONTROL_LEASE_REQUEST, runId: 'run-b' }), + isHumanControlError, + ); + registry.removeHumanControlHold(authority, 'console'); + assert.equal(registry.listActiveLeases()[0]?.expiresAt, 35_000); + now = 35_000; + assert.equal(registry.consumeExpiredLeases()[0]?.leaseId, lease.leaseId); +}); + +test('hold expiry refreshes from the expiry instant, without reviving abandoned leases', async () => { + let now = 0; + const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 }); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + await registry.putHumanControlHold(authority, 'console', { ttlMs: 10_000 }); + now = 9_000; + assert.equal(registry.listActiveLeases().length, 1); + now = 10_000; + assert.deepEqual(registry.listHumanControlHolds(authority), []); + assert.equal(registry.listActiveLeases()[0]?.expiresAt, 15_000); + now = 15_000; + assert.deepEqual(registry.listActiveLeases(), []); +}); + +test('heartbeat and overlapping holds preserve the lease until the final hold ends', async () => { + let now = 0; + const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 }); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + await registry.putHumanControlHold(authority, 'first', { ttlMs: 10_000 }); + await registry.putHumanControlHold(authority, 'second', { ttlMs: 10_000 }); + now = 7_000; + const renewed = await registry.putHumanControlHold(authority, 'second', { ttlMs: 10_000 }); + assert.equal(renewed.createdAt, 0); + assert.equal(renewed.expiresAt, 17_000); + registry.removeHumanControlHold(authority, 'first'); + now = 12_000; + assert.throws(() => registry.assertHumanControlAdmission(lease), isHumanControlError); + assert.equal(registry.consumeExpiredLease(lease.leaseId), undefined); + now = 17_000; + assert.doesNotThrow(() => registry.assertHumanControlAdmission(lease)); + assert.equal(registry.listActiveLeases()[0]?.expiresAt, 22_000); +}); + +test('human control uses the lease backend/provider/device key, never aliases', async () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + await registry.putHumanControlHold({ kind: 'host' }, 'console', { scope: HUMAN_CONTROL_SCOPE }); + assert.throws(() => registry.assertHumanControlAdmission(lease), isHumanControlError); + for (const scope of [ + { ...HUMAN_CONTROL_SCOPE, deviceKey: 'sim-1' }, + { ...HUMAN_CONTROL_SCOPE, deviceKey: 'ios:mobile:SIM-1' }, + { ...HUMAN_CONTROL_SCOPE, leaseProvider: 'another-provider' }, + { ...HUMAN_CONTROL_SCOPE, backend: 'ios-simulator' as const }, + ]) { + assert.doesNotThrow(() => registry.assertHumanControlAdmission(scope)); + } +}); + +test('lease owners cannot select another device or alter host/other-lease holds', async () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const other = registry.allocateLease({ + ...HUMAN_CONTROL_LEASE_REQUEST, + deviceKey: 'other', + runId: 'run-b', + }); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + await registry.putHumanControlHold({ kind: 'host' }, 'host', { scope: HUMAN_CONTROL_SCOPE }); + await registry.putHumanControlHold({ kind: 'lease', leaseId: other.leaseId }, 'other', {}); + await assert.rejects( + registry.putHumanControlHold(authority, 'spoofed', { scope: HUMAN_CONTROL_SCOPE }), + { code: 'INVALID_ARGS' }, + ); + for (const id of ['host', 'other']) { + await assert.rejects(registry.putHumanControlHold(authority, id, {}), { code: 'UNAUTHORIZED' }); + assert.throws(() => registry.removeHumanControlHold(authority, id), { code: 'UNAUTHORIZED' }); + } + assert.deepEqual( + registry.listHumanControlHolds(authority).map((hold) => hold.id), + ['host'], + ); +}); + +test('activation drains admitted mutations, fences new ones, and starts TTL after draining', async () => { + let now = 0; + const registry = new LeaseRegistry({ now: () => now }); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + const finished = createControlLatch(); + const mutation = registry.runDeviceMutation(lease, () => finished.promise); + let active = false; + const activation = registry + .putHumanControlHold(authority, 'console', { ttlMs: 1_000 }) + .then((hold) => { + active = true; + return hold; + }); + await Promise.resolve(); + assert.equal(active, false); + assert.equal(registry.listHumanControlHolds(authority)[0]?.state, 'activating'); + await assert.rejects( + registry.runDeviceMutation(lease, async () => 'late mutation'), + isHumanControlError, + ); + now = 20_000; + finished.resolve(); + await mutation; + const hold = await activation; + assert.equal(active, true); + assert.equal(hold.expiresAt, 21_000); + registry.removeHumanControlHold(authority, 'console'); + assert.equal(await registry.runDeviceMutation(lease, async () => 'resumed'), 'resumed'); +}); + +test('release during activation cannot report a removed hold as active', async () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + const finished = createControlLatch(); + const mutation = registry.runDeviceMutation(lease, () => finished.promise); + const activation = registry.putHumanControlHold(authority, 'console', {}); + registry.removeHumanControlHold(authority, 'console'); + const rejected = assert.rejects(activation, { code: 'COMMAND_FAILED' }); + finished.resolve(); + await mutation; + await rejected; + assert.deepEqual(registry.listHumanControlHolds(authority), []); +}); + +test('holds do not survive registry restart, including host holds created without a lease', async () => { + const registry = new LeaseRegistry(); + await registry.putHumanControlHold({ kind: 'host' }, 'console', { scope: HUMAN_CONTROL_SCOPE }); + assert.throws(() => registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST), isHumanControlError); + const restarted = new LeaseRegistry(); + assert.deepEqual(restarted.listHumanControlHolds({ kind: 'host' }), []); + assert.doesNotThrow(() => restarted.allocateLease(HUMAN_CONTROL_LEASE_REQUEST)); +}); diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index c1fc9e83e2..609f6aefb9 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -10,7 +10,6 @@ import { getDaemonCommandRoute, type DaemonCommandRoute } from '../daemon-comman import { cleanupDownloadableArtifact, trackDownloadableArtifact } from '../artifact-tracking.ts'; import { contextFromFlags } from '../context.ts'; import { handleLeaseCommands } from '../handlers/lease.ts'; -import { HumanControlRegistry } from '../human-control.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { runRequestHandlerChain } from '../request-handler-chain.ts'; import { @@ -477,7 +476,6 @@ async function runCatalogCommandThroughHandlerChain( logPath: '/tmp/agent-device-catalog-route.log', sessionStore, leaseRegistry, - humanControlRegistry: new HumanControlRegistry(), invoke: async () => ({ ok: true, data: {} }), providerScope: { androidAdbExecutor: async () => ({ stdout: '', stderr: '', exitCode: 0 }), diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index c2daf62d83..9c9c0f4b8d 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -2,10 +2,13 @@ import { type DaemonCommandDescriptor, type DaemonCommandRoute, type SessionCommandKind, - type HumanControlEffect, } from '../core/command-descriptor/daemon-command-descriptor.ts'; import { deriveDaemonCommandDescriptors } from '../core/command-descriptor/derive.ts'; -import { commandDescriptors } from '../core/command-descriptor/registry.ts'; +import { + commandDescriptors, + resolveCommandRecordingEffect, + resolveCommandDeviceClaimPolicy, +} from '../core/command-descriptor/registry.ts'; import type { RefFrameEffect } from '@agent-device/contracts/replay'; import type { DaemonRequest } from './types.ts'; @@ -75,9 +78,12 @@ export function shouldGuardAndroidBlockingDialog(command: string): boolean { return getDaemonCommandDescriptor(command)?.androidBlockingDialogGuard === true; } -export function humanControlEffectForRequest(req: DaemonRequest): HumanControlEffect { - const effect = getDaemonCommandDescriptor(req.command)?.humanControlEffect; - return typeof effect === 'function' ? effect(req) : (effect ?? 'mutate'); +export function isHumanControlMutation(req: DaemonRequest): boolean { + if (req.command === 'human_control' || req.command === 'lease_heartbeat') return false; + const recordingEffect = resolveCommandRecordingEffect(req); + if (recordingEffect !== undefined) return recordingEffect !== 'observes-app'; + if (getSessionCommandKind(req.command) === 'observability') return false; + return resolveCommandDeviceClaimPolicy(req.command) !== 'observe'; } export function shouldPreferExplicitDeviceOverExistingSession(req: DaemonRequest): boolean { diff --git a/src/daemon/device-mutation-drain.ts b/src/daemon/device-mutation-drain.ts new file mode 100644 index 0000000000..f7ac96c6f4 --- /dev/null +++ b/src/daemon/device-mutation-drain.ts @@ -0,0 +1,30 @@ +export class DeviceMutationDrain { + private readonly active = new Map(); + private readonly waiters = new Map void>>(); + + async run(key: string, task: () => Promise): Promise { + this.active.set(key, (this.active.get(key) ?? 0) + 1); + try { + return await task(); + } finally { + const remaining = (this.active.get(key) ?? 1) - 1; + if (remaining > 0) { + this.active.set(key, remaining); + } else { + this.active.delete(key); + const waiters = this.waiters.get(key); + this.waiters.delete(key); + for (const resolve of waiters ?? []) resolve(); + } + } + } + + async wait(key: string): Promise { + if (!this.active.has(key)) return; + await new Promise((resolve) => { + const waiters = this.waiters.get(key) ?? new Set<() => void>(); + waiters.add(resolve); + this.waiters.set(key, waiters); + }); + } +} diff --git a/src/daemon/handlers/__tests__/human-control.test.ts b/src/daemon/handlers/__tests__/human-control.test.ts new file mode 100644 index 0000000000..ea816cfd55 --- /dev/null +++ b/src/daemon/handlers/__tests__/human-control.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createRequestExecutionScope } from '../../request-execution-scope.ts'; +import { + HUMAN_CONTROL_LEASE_REQUEST, + HUMAN_CONTROL_SCOPE, + humanControlRequest, + isHumanControlError, + createControlLatch, +} from '../../__tests__/human-control-fixtures.ts'; +import { createHumanControlHarness } from '../../__tests__/human-control-router-fixture.ts'; + +test('lease-owner takeover uses the production admission gate, preserves the session, and resumes after release', async () => { + const { registry, lease, handleRequest, sessionStore, sessionName } = createHumanControlHarness(); + const activate = await handleRequest( + humanControlRequest(lease, 'human_control', ['put', 'console', '{}']), + ); + assert.equal(activate.ok, true); + const blocked = await handleRequest(humanControlRequest(lease, 'click', ['10', '10'])); + assert.equal(blocked.ok, false); + if (blocked.ok) throw new Error('Expected blocked mutation'); + assert.equal(blocked.error.code, 'DEVICE_IN_USE'); + assert.equal(blocked.error.details?.reason, 'human_control_active'); + assert.equal(blocked.error.retriable, true); + assert.equal((await handleRequest(humanControlRequest(lease, 'snapshot', []))).ok, true); + assert.equal((await handleRequest(humanControlRequest(lease, 'lease_heartbeat', []))).ok, true); + assert.ok(sessionStore.get(sessionName)); + const release = await handleRequest( + humanControlRequest(lease, 'human_control', ['remove', 'console']), + ); + assert.equal(release.ok, true); + assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []); + const scope = await createRequestExecutionScope({ + req: humanControlRequest(lease, 'click', []), + sessionStore, + leaseRegistry: registry, + }); + assert.equal(await scope.runLocked(async () => 'resumed'), 'resumed'); +}); + +test('takeover refuses missing, expired, and foreign lease ownership before changing state', async () => { + const { registry, lease, handleRequest } = createHumanControlHarness(); + const valid = humanControlRequest(lease, 'human_control', ['put', 'console', '{}']); + for (const patch of [ + { tenantId: 'tenant-b' }, + { runId: 'run-b' }, + { clientId: 'client-b' }, + { deviceKey: 'other-device' }, + { leaseId: 'aaaaaaaaaaaaaaaa' }, + { leaseProvider: 'other-provider' }, + ]) { + const response = await handleRequest({ ...valid, meta: { ...valid.meta, ...patch } }); + assert.equal(response.ok, false, JSON.stringify(patch)); + assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []); + } + const local = await handleRequest({ ...valid, meta: undefined, session: 'local-no-lease' }); + assert.equal(local.ok, false); + if (!local.ok) assert.equal(local.error.code, 'UNSUPPORTED_OPERATION'); + registry.releaseLease({ ...HUMAN_CONTROL_LEASE_REQUEST, leaseId: lease.leaseId }); + assert.equal((await handleRequest(valid)).ok, false); +}); + +test('admitted tenants cannot retarget takeover or release provider-host holds', async () => { + const { registry, lease, handleRequest } = createHumanControlHarness(); + await registry.putHumanControlHold({ kind: 'host' }, 'host', { scope: HUMAN_CONTROL_SCOPE }); + const injected = await handleRequest( + humanControlRequest(lease, 'human_control', [ + 'put', + 'tenant', + JSON.stringify({ scope: { ...HUMAN_CONTROL_SCOPE, deviceKey: 'other' } }), + ]), + ); + assert.equal(injected.ok, false); + const denied = await handleRequest( + humanControlRequest(lease, 'human_control', ['remove', 'host']), + ); + assert.equal(denied.ok, false); + if (!denied.ok) assert.equal(denied.error.code, 'UNAUTHORIZED'); + assert.equal(registry.listHumanControlHolds({ kind: 'host' }).length, 1); +}); + +test('a fresh-session mutation without an advisory device lock still drains before host activation', async () => { + const { registry, lease, sessionStore } = createHumanControlHarness(); + const req = { ...humanControlRequest(lease, 'click', []), session: 'fresh-session' }; + const scope = await createRequestExecutionScope({ req, sessionStore, leaseRegistry: registry }); + const started = createControlLatch(); + const finish = createControlLatch(); + const mutation = scope.runLocked(async () => { + started.resolve(); + await finish.promise; + }); + await started.promise; + let active = false; + const activation = registry + .putHumanControlHold({ kind: 'host' }, 'host', { scope: HUMAN_CONTROL_SCOPE }) + .then(() => { + active = true; + }); + await Promise.resolve(); + assert.equal(active, false); + const later = await createRequestExecutionScope({ + req: { ...req, session: 'another-fresh-session' }, + sessionStore, + leaseRegistry: registry, + }); + await assert.rejects( + later.runLocked(async () => 'must not run'), + isHumanControlError, + ); + finish.resolve(); + await mutation; + await activation; + assert.equal(active, true); +}); + +test('a nested mutation is stopped when takeover begins during its parent request', async () => { + const { registry, lease, sessionStore } = createHumanControlHarness(); + const req = humanControlRequest(lease, 'replay', []); + const scope = await createRequestExecutionScope({ req, sessionStore, leaseRegistry: registry }); + let activation: Promise | undefined; + await scope.runLocked(async () => { + activation = registry.putHumanControlHold({ kind: 'host' }, 'host', { + scope: HUMAN_CONTROL_SCOPE, + }); + const nested = await createRequestExecutionScope({ + req: humanControlRequest(lease, 'click', []), + sessionStore, + leaseRegistry: registry, + }); + await assert.rejects( + nested.runAdmitted(async () => 'must not run'), + isHumanControlError, + ); + }); + await activation; + assert.equal(registry.listHumanControlHolds({ kind: 'host' })[0]?.state, 'active'); +}); diff --git a/src/daemon/handlers/__tests__/lease-artifacts.test.ts b/src/daemon/handlers/__tests__/lease-artifacts.test.ts index abf0062ee7..9306343ada 100644 --- a/src/daemon/handlers/__tests__/lease-artifacts.test.ts +++ b/src/daemon/handlers/__tests__/lease-artifacts.test.ts @@ -118,16 +118,18 @@ test('artifacts refuses an expired provider session after retention before lazy }); test('artifacts refuses a provider session returned after allocation expiry retention', async () => { - const clockValues = [1_000, 1_000, 1_099, 1_151] as const; - let clockIndex = 0; + let now = 1_000; const world = createWorld({ - now: () => clockValues[Math.min(clockIndex++, clockValues.length - 1)] ?? 1_151, + now: () => now, defaultLeaseTtlMs: 100, minLeaseTtlMs: 1, maxLeaseTtlMs: 100, providerSessionRetentionMs: 50, }); - world.lifecycle.allocate = async () => ({ providerSessionId: 'late-allocation-session' }); + world.lifecycle.allocate = async (lease) => { + now = lease.expiresAt + 51; + return { providerSessionId: 'late-allocation-session' }; + }; await allocateLease(world, 'tenant-a', 'run-a'); await assertProviderSessionNotOwned(world, { diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts new file mode 100644 index 0000000000..08fdb22c7f --- /dev/null +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { handleLeaseCommands } from '../lease.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { clearRequestCanceled, markRequestCanceled } from '@agent-device/host-kit/request'; +import { + HUMAN_CONTROL_LEASE_REQUEST, + HUMAN_CONTROL_SCOPE, + createControlLatch, + humanControlRequest, +} from '../../__tests__/human-control-fixtures.ts'; + +for (const operation of ['allocate', 'release'] as const) { + test(`host activation drains provider lease ${operation} before reporting active`, async () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const started = createControlLatch(); + const finish = createControlLatch(); + const request = humanControlRequest(lease, `lease_${operation}`, []); + const mutation = handleLeaseCommands({ + req: request, + sessionName: request.session, + sessionStore: makeSessionStore('agent-device-held-provider-'), + leaseRegistry: registry, + leaseLifecycleProvider: { + [operation]: async () => { + started.resolve(); + await finish.promise; + return {}; + }, + }, + }); + await started.promise; + let active = false; + const activation = registry + .putHumanControlHold({ kind: 'host' }, 'host', { scope: HUMAN_CONTROL_SCOPE }) + .then(() => { + active = true; + }); + await Promise.resolve(); + assert.equal(active, false); + finish.resolve(); + assert.equal((await mutation)?.ok, true); + await activation; + assert.equal(active, true); + }); +} + +test('activation drains canceled provider allocation and its release cleanup', async () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const started = createControlLatch(); + const finish = createControlLatch(); + const request = humanControlRequest(lease, 'lease_allocate', []); + const requestId = 'held-canceled-allocation'; + request.meta = { ...request.meta, requestId }; + let providerReleased = false; + const mutation = handleLeaseCommands({ + req: request, + sessionName: request.session, + sessionStore: makeSessionStore('agent-device-held-canceled-provider-'), + leaseRegistry: registry, + leaseLifecycleProvider: { + allocate: async () => { + started.resolve(); + await finish.promise; + markRequestCanceled(requestId); + return {}; + }, + release: async () => { + assert.equal(registry.listHumanControlHolds({ kind: 'host' })[0]?.state, 'activating'); + providerReleased = true; + return {}; + }, + }, + }); + await started.promise; + const activation = registry.putHumanControlHold({ kind: 'host' }, 'host', { + scope: HUMAN_CONTROL_SCOPE, + }); + finish.resolve(); + try { + await assert.rejects( + mutation, + (error: unknown) => error instanceof AppError && error.details?.released === true, + ); + await activation; + assert.equal(providerReleased, true); + assert.equal(registry.listActiveLeases().length, 0); + } finally { + clearRequestCanceled(requestId); + } +}); diff --git a/src/daemon/handlers/human-control.ts b/src/daemon/handlers/human-control.ts index 4309aed55c..d099ed1443 100644 --- a/src/daemon/handlers/human-control.ts +++ b/src/daemon/handlers/human-control.ts @@ -1,52 +1,43 @@ import { AppError } from '@agent-device/kernel/errors'; -import { parseHumanControlHoldInput, type HumanControlHold } from '../human-control-contract.ts'; -import { releaseHumanControlHold, type HumanControlRegistry } from '../human-control.ts'; +import { parseHumanControlHoldInput } from '../human-control-contract.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; export async function handleHumanControlCommand(params: { req: DaemonRequest; - registry: HumanControlRegistry | undefined; - onHoldReleased?: (hold: HumanControlHold) => void; + registry: LeaseRegistry; }): Promise { - const { req, registry, onHoldReleased } = params; - if (!registry) { - throw new AppError('COMMAND_FAILED', 'Human-control registry is unavailable.'); + const { req, registry } = params; + const lease = req.internal?.admittedLease; + if (!lease) { + throw new AppError('UNAUTHORIZED', 'Human control requires an admitted remote lease.'); } - const [action, holdId, rawInput] = req.positionals ?? []; - if (action === 'list') return { ok: true, data: { holds: registry.list() } }; - if (action === 'put') return await putHold(registry, holdId, rawInput); - if (action === 'remove') return removeHold(registry, holdId, onHoldReleased); - throw new AppError('INVALID_ARGS', 'human_control requires list, put, or remove.'); -} - -async function putHold( - registry: HumanControlRegistry, - holdId: string | undefined, - rawInput: string | undefined, -): Promise { - if (!holdId || rawInput === undefined) { - throw new AppError('INVALID_ARGS', 'human_control put requires a hold id and payload.'); - } - const hold = await registry.upsert(holdId, parseHumanControlHoldInput(parsePayload(rawInput))); - return { ok: true, data: { hold, state: 'active' } }; -} - -function removeHold( - registry: HumanControlRegistry, - holdId: string | undefined, - onHoldReleased: ((hold: HumanControlHold) => void) | undefined, -): DaemonResponse { - if (!holdId) { - throw new AppError('INVALID_ARGS', 'human_control remove requires a hold id.'); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + const positionals = req.positionals ?? []; + const [action, holdId = '', rawInput = ''] = positionals; + switch (action) { + case 'list': + assertArgumentCount(positionals, 1); + return { ok: true, data: { holds: registry.listHumanControlHolds(authority) } }; + case 'put': { + assertArgumentCount(positionals, 3); + const hold = await registry.putHumanControlHold(authority, holdId, readHoldInput(rawInput)); + return { ok: true, data: { hold, state: 'active' } }; + } + case 'remove': { + assertArgumentCount(positionals, 2); + const hold = registry.removeHumanControlHold(authority, holdId); + return { ok: true, data: { released: Boolean(hold), ...(hold ? { hold } : {}) } }; + } + default: + throw invalidArguments(); } - const hold = releaseHumanControlHold(registry, holdId); - if (hold) onHoldReleased?.(hold); - return { ok: true, data: { released: Boolean(hold), ...(hold ? { hold } : {}) } }; } -function parsePayload(rawInput: string): unknown { +function readHoldInput(raw: string) { + let input: unknown; try { - return JSON.parse(rawInput) as unknown; + input = JSON.parse(raw); } catch (error) { throw new AppError( 'INVALID_ARGS', @@ -55,4 +46,16 @@ function parsePayload(rawInput: string): unknown { error, ); } + return parseHumanControlHoldInput(input); +} + +function assertArgumentCount(positionals: string[], expected: number): void { + if (positionals.length !== expected) throw invalidArguments(); +} + +function invalidArguments(): AppError { + return new AppError( + 'INVALID_ARGS', + 'human_control requires list, put , or remove .', + ); } diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 36117e3109..6c14c8b11b 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -9,7 +9,8 @@ import type { CloudArtifactProvider, } from '@agent-device/contracts/observability'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; -import type { LeaseRegistry, ReleaseLeaseRequest } from '../lease-registry.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; +import type { ReleaseLeaseRequest } from '../lease-registry-scope.ts'; import type { SessionStore } from '../session-store.ts'; import { isProxyLeaseScope, @@ -70,27 +71,33 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise | undefined; - try { - providerData = await leaseLifecycleProvider?.allocate?.(lease, { - ...leaseLifecycleContext(req), - signal: getRequestSignal(req.meta?.requestId), - deadline: Date.now() + LEASE_ALLOCATION_BUDGET_MS, - }); - recordProviderSession(leaseRegistry, lease, providerData); - } catch (error) { - leaseRegistry.releaseLease(leaseReleaseRequestFor(lease)); - throw error; - } - if (isRequestCanceled(req.meta?.requestId)) { - // The requester left while the provider was allocating; the lease it - // produced is real (and billed) and nobody will ever release it. - throw await releaseAllocationForGoneRequester(lease, leaseLifecycleProvider, leaseRegistry); - } - return { - ok: true, - data: { lease, ...(providerData ? { provider: providerData } : {}) }, - }; + return await leaseRegistry.runDeviceMutation(lease, async () => { + let providerData: Record | undefined; + try { + providerData = await leaseLifecycleProvider?.allocate?.(lease, { + ...leaseLifecycleContext(req), + signal: getRequestSignal(req.meta?.requestId), + deadline: Date.now() + LEASE_ALLOCATION_BUDGET_MS, + }); + recordProviderSession(leaseRegistry, lease, providerData); + } catch (error) { + leaseRegistry.releaseLease(leaseReleaseRequestFor(lease)); + throw error; + } + if (isRequestCanceled(req.meta?.requestId)) { + // The requester left while the provider was allocating; the lease it + // produced is real (and billed) and nobody will ever release it. + throw await releaseAllocationForGoneRequester( + lease, + leaseLifecycleProvider, + leaseRegistry, + ); + } + return { + ok: true, + data: { lease, ...(providerData ? { provider: providerData } : {}) }, + }; + }); } case 'lease_heartbeat': { const lease = leaseRegistry.heartbeatLease(leaseScopeToHeartbeatRequest(leaseScope)); @@ -105,12 +112,17 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise + await releaseLease( + leaseRegistry, + leaseLifecycleProvider, + lease, + releaseRequest, + leaseLifecycleContext(req), + ), ); return { ok: true, diff --git a/src/daemon/human-control-contract.ts b/src/daemon/human-control-contract.ts index 1fd181b19a..266260e322 100644 --- a/src/daemon/human-control-contract.ts +++ b/src/daemon/human-control-contract.ts @@ -1,163 +1,103 @@ +import type { + HumanControlHold, + HumanControlHoldOptions, + HumanControlHoldScope, +} from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; +import { + normalizeDeviceKey, + normalizeLeaseBackend, + normalizeLeaseProvider, +} from './lease-registry-scope.ts'; -const MIN_HOLD_TTL_MS = 1_000; -const MAX_HOLD_TTL_MS = 24 * 60 * 60_000; +export const HUMAN_CONTROL_HTTP_PREFIX = '/admin/human-control/holds'; -export type HumanControlHoldScope = { - deviceKey: string; - deviceName?: string; - platform?: string; - kind?: string; +export type HumanControlHoldInput = HumanControlHoldOptions & { + scope?: HumanControlHoldScope; }; -export type HumanControlHold = { - id: string; - scope: HumanControlHoldScope; - reason?: string; - createdAt: number; - updatedAt: number; - expiresAt?: number; -}; - -export type HumanControlHoldInput = { - scope: HumanControlHoldScope; - reason?: string; - ttlMs?: number; -}; +export type HumanControlAuthority = { kind: 'host' } | { kind: 'lease'; leaseId: string }; export function parseHumanControlHoldInput(value: unknown): HumanControlHoldInput { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new AppError('INVALID_ARGS', 'Human-control request body must be an object.'); + const record = readHumanControlRecord(value, 'Human-control request body'); + const reason = record.reason; + if (reason !== undefined && (typeof reason !== 'string' || reason.trim().length > 512)) { + throw new AppError('INVALID_ARGS', 'Human-control reason must be at most 512 characters.'); } - const record = value as Record; - const scope = record.scope; - if (!scope || typeof scope !== 'object' || Array.isArray(scope)) { - throw new AppError('INVALID_ARGS', 'Human-control request requires scope.deviceKey.'); + const ttlMs = record.ttlMs; + if ( + ttlMs !== undefined && + (typeof ttlMs !== 'number' || !Number.isInteger(ttlMs) || ttlMs < 1_000 || ttlMs > 86_400_000) + ) { + throw new AppError('INVALID_ARGS', 'Human-control ttlMs must be between 1000 and 86400000.'); } - const scopeRecord = scope as Record; return { - scope: { - deviceKey: readRequiredString(scopeRecord.deviceKey, 'scope.deviceKey'), - ...readOptionalStringField(scopeRecord, 'deviceName'), - ...readOptionalStringField(scopeRecord, 'platform'), - ...readOptionalStringField(scopeRecord, 'kind'), - }, - ...(record.reason === undefined ? {} : { reason: readRequiredString(record.reason, 'reason') }), - ...(record.ttlMs === undefined ? {} : { ttlMs: readInteger(record.ttlMs, 'ttlMs') }), + ...(record.scope === undefined ? {} : { scope: parseHumanControlScope(record.scope) }), + ...(typeof reason === 'string' && reason.trim() ? { reason: reason.trim() } : {}), + ...(typeof ttlMs === 'number' ? { ttlMs } : {}), }; } -export function normalizeHumanControlHoldId(id: string): string { - const value = typeof id === 'string' ? id.trim() : ''; - if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) { +function parseHumanControlScope(value: unknown): HumanControlHoldScope { + const scope = readHumanControlRecord(value, 'Host human-control scope'); + if ( + typeof scope.backend !== 'string' || + !scope.backend.trim() || + typeof scope.deviceKey !== 'string' + ) { throw new AppError( 'INVALID_ARGS', - 'Invalid human-control hold id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + 'Host human control requires scope.backend and scope.deviceKey.', ); } - return value; -} - -export function normalizeHumanControlHoldScope( - scope: HumanControlHoldScope, -): HumanControlHoldScope { - if (!scope || typeof scope !== 'object') { - throw new AppError('INVALID_ARGS', 'Human-control hold requires a device scope.'); + if (scope.leaseProvider !== undefined && typeof scope.leaseProvider !== 'string') { + throw new AppError('INVALID_ARGS', 'scope.leaseProvider must be a string.'); } - const deviceName = normalizeOptionalLabel(scope.deviceName, 'device name'); - const platform = normalizeOptionalLabel(scope.platform, 'platform'); - const kind = normalizeOptionalLabel(scope.kind, 'device kind'); + const leaseProvider = normalizeLeaseProvider(scope.leaseProvider); return { - deviceKey: normalizeDeviceKey(scope.deviceKey), - ...(deviceName ? { deviceName } : {}), - ...(platform ? { platform } : {}), - ...(kind ? { kind } : {}), + backend: normalizeLeaseBackend(scope.backend), + deviceKey: normalizeDeviceKey(scope.deviceKey)!, + ...(leaseProvider ? { leaseProvider } : {}), }; } -export function normalizeHumanControlReason(reason: string | undefined): string | undefined { - if (reason === undefined) return undefined; - const value = reason.trim(); - if (!value) return undefined; - if (value.length > 512) { - throw new AppError('INVALID_ARGS', 'Human-control reason must be at most 512 characters.'); +function readHumanControlRecord(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', `${label} must be an object.`); } - return value; + return value as Record; } -export function normalizeHumanControlTtlMs(ttlMs: number | undefined): number | undefined { - if (ttlMs === undefined) return undefined; - if (!Number.isInteger(ttlMs) || ttlMs < MIN_HOLD_TTL_MS || ttlMs > MAX_HOLD_TTL_MS) { +export function normalizeHumanControlHoldId(id: string): string { + const value = typeof id === 'string' ? id.trim() : ''; + if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) { throw new AppError( 'INVALID_ARGS', - `Human-control ttlMs must be between ${String(MIN_HOLD_TTL_MS)} and ${String(MAX_HOLD_TTL_MS)}.`, + 'Invalid human-control hold id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', ); } - return ttlMs; -} - -export function normalizeStoredHumanControlHold(raw: HumanControlHold): HumanControlHold { - if (!raw || typeof raw !== 'object') { - throw new AppError('COMMAND_FAILED', 'Persisted human-control hold is invalid.'); - } - const createdAt = normalizeTimestamp(raw.createdAt, 'createdAt'); - const updatedAt = normalizeTimestamp(raw.updatedAt, 'updatedAt'); - const expiresAt = - raw.expiresAt === undefined ? undefined : normalizeTimestamp(raw.expiresAt, 'expiresAt'); - const reason = normalizeHumanControlReason(raw.reason); - return { - id: normalizeHumanControlHoldId(raw.id), - scope: normalizeHumanControlHoldScope(raw.scope), - ...(reason ? { reason } : {}), - createdAt, - updatedAt, - ...(expiresAt === undefined ? {} : { expiresAt }), - }; -} - -function readRequiredString(value: unknown, field: string): string { - if (typeof value !== 'string' || !value.trim()) { - throw new AppError('INVALID_ARGS', `Human-control ${field} must be a non-empty string.`); - } - return value; -} - -function readOptionalStringField( - record: Record, - key: 'deviceName' | 'platform' | 'kind', -): Partial> { - const value = record[key]; - if (value === undefined) return {}; - return { [key]: readRequiredString(value, `scope.${key}`) }; -} - -function readInteger(value: unknown, field: string): number { - if (!Number.isInteger(value)) { - throw new AppError('INVALID_ARGS', `Human-control ${field} must be an integer.`); - } - return Number(value); -} - -function normalizeDeviceKey(deviceKey: string): string { - const value = typeof deviceKey === 'string' ? deviceKey.trim() : ''; - if (!value || value.length > 256 || !/^[\x20-\x7E]+$/.test(value)) { - throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.'); - } return value; } -function normalizeOptionalLabel(value: string | undefined, label: string): string | undefined { - if (value === undefined) return undefined; - const normalized = value.trim(); - if (!normalized || normalized.length > 256) { - throw new AppError('INVALID_ARGS', `Invalid ${label}. Use 1-256 characters.`); - } - return normalized; +export function cloneHumanControlHold(hold: HumanControlHold): HumanControlHold { + return { ...hold, scope: { ...hold.scope } }; } -function normalizeTimestamp(value: number, field: string): number { - if (!Number.isFinite(value) || value < 0) { - throw new AppError('COMMAND_FAILED', `Persisted human-control ${field} is invalid.`); - } - return value; +export function humanControlActiveError(hold: HumanControlHold): AppError { + return new AppError( + 'DEVICE_IN_USE', + 'A human is interacting with this simulator or device; agent interactions are temporarily disabled.', + { + reason: 'human_control_active', + blockedBy: 'human_control', + holdId: hold.id, + deviceKey: hold.scope.deviceKey, + pausedAt: new Date(hold.createdAt).toISOString(), + ...(hold.expiresAt === undefined + ? {} + : { expiresAt: new Date(hold.expiresAt).toISOString() }), + ...(hold.reason ? { humanReason: hold.reason } : {}), + hint: 'Wait until the human-control hold is released before retrying.', + }, + ); } diff --git a/src/daemon/human-control-http.ts b/src/daemon/human-control-http.ts index d694e719f5..517698290b 100644 --- a/src/daemon/human-control-http.ts +++ b/src/daemon/human-control-http.ts @@ -4,15 +4,11 @@ import { readNodeHttpRequestBody } from '../utils/node-http.ts'; import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import { sendRestJsonError } from './http-errors.ts'; import { + HUMAN_CONTROL_HTTP_PREFIX, parseHumanControlHoldInput, - type HumanControlHold, type HumanControlHoldInput, } from './human-control-contract.ts'; -import { - HUMAN_CONTROL_HTTP_PREFIX, - releaseHumanControlHold, - type HumanControlRegistry, -} from './human-control.ts'; +import type { LeaseRegistry } from './lease-registry.ts'; const MAX_HUMAN_CONTROL_BODY_BYTES = 16 * 1024; @@ -27,8 +23,7 @@ type HumanControlHttpParams = { req: http.IncomingMessage; res: http.ServerResponse; expectedToken: string; - registry: HumanControlRegistry; - onHoldReleased?: (hold: HumanControlHold) => void; + registry: LeaseRegistry; }; export function tryHandleHumanControlHttpRoute(params: HumanControlHttpParams): boolean { @@ -57,7 +52,10 @@ async function executeHumanControlRoute( ): Promise { switch (route.kind) { case 'list': - sendJson(params.res, { ok: true, holds: params.registry.list() }); + sendJson(params.res, { + ok: true, + holds: params.registry.listHumanControlHolds({ kind: 'host' }), + }); return; case 'upsert': await upsertHumanControlHold(route.holdId, params); @@ -78,13 +76,12 @@ async function upsertHumanControlHold( params: HumanControlHttpParams, ): Promise { const input = await readHoldInput(params.req); - const hold = await params.registry.upsert(holdId, input); + const hold = await params.registry.putHumanControlHold({ kind: 'host' }, holdId, input); sendJson(params.res, { ok: true, hold, state: 'active' }); } function removeHumanControlHold(holdId: string, params: HumanControlHttpParams): void { - const hold = releaseHumanControlHold(params.registry, holdId); - if (hold) params.onHoldReleased?.(hold); + const hold = params.registry.removeHumanControlHold({ kind: 'host' }, holdId); sendJson(params.res, { ok: true, released: Boolean(hold), ...(hold ? { hold } : {}) }); } diff --git a/src/daemon/human-control-request.ts b/src/daemon/human-control-request.ts deleted file mode 100644 index 8be01e0b34..0000000000 --- a/src/daemon/human-control-request.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { resolveTargetDevice, type ResolveDeviceFlags } from '../core/dispatch-resolve.ts'; -import { uniqueStrings } from '@agent-device/kernel/collections'; -import { humanControlEffectForRequest } from './daemon-command-registry.ts'; -import type { HumanControlRegistry } from './human-control.ts'; -import type { SessionStore } from './session-store.ts'; -import type { DaemonRequest, SessionState } from './types.ts'; - -export async function runRequestWithHumanControl(params: { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - registry: HumanControlRegistry | undefined; - task: () => Promise; -}): Promise { - const { req, sessionName, sessionStore, registry, task } = params; - if (!registry || humanControlEffectForRequest(req) !== 'mutate') return await task(); - - const aliases = await resolveRequestDeviceAliases(req, sessionStore.get(sessionName)); - return await registry.runDeviceMutation(aliases, task); -} - -async function resolveRequestDeviceAliases( - req: DaemonRequest, - session: SessionState | undefined, -): Promise { - const requestAliases = readRequestDeviceAliases(req); - if (session) { - return uniqueDefinedStrings([ - session.device.id, - session.device.name, - session.lease?.deviceKey, - ...requestAliases, - ]); - } - - const directHoldAliases = uniqueDefinedStrings(requestAliases); - try { - const device = await resolveTargetDevice(resolveDeviceFlags(req)); - return uniqueDefinedStrings([device.id, device.name, ...directHoldAliases]); - } catch { - return directHoldAliases; - } -} - -function resolveDeviceFlags(req: DaemonRequest): ResolveDeviceFlags { - return { - ...(req.flags ?? {}), - leaseProvider: req.meta?.leaseProvider, - deviceKey: req.meta?.deviceKey, - clientId: req.meta?.clientId, - }; -} - -function readRequestDeviceAliases(req: DaemonRequest): Array { - return [ - req.meta?.deviceKey, - req.internal?.admittedLease?.deviceKey, - req.flags?.udid, - req.flags?.serial, - req.flags?.device, - ].map((value) => (typeof value === 'string' ? value : undefined)); -} - -function uniqueDefinedStrings(values: Array): string[] { - return uniqueStrings( - values - .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - .map((value) => value.trim()), - ); -} diff --git a/src/daemon/human-control-store.ts b/src/daemon/human-control-store.ts deleted file mode 100644 index 33a6ba30b2..0000000000 --- a/src/daemon/human-control-store.ts +++ /dev/null @@ -1,53 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; -import { - type HumanControlHold, - normalizeStoredHumanControlHold, -} from './human-control-contract.ts'; - -export class HumanControlStore { - private readonly statePath: string | undefined; - - constructor(statePath: string | undefined) { - this.statePath = statePath; - } - - load(): HumanControlHold[] { - if (!this.statePath || !fs.existsSync(this.statePath)) return []; - let parsed: { version: 1; holds: HumanControlHold[] }; - try { - parsed = JSON.parse(fs.readFileSync(this.statePath, 'utf8')) as typeof parsed; - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - 'Failed to read persisted human-control state.', - { path: this.statePath }, - error, - ); - } - if (parsed.version !== 1 || !Array.isArray(parsed.holds)) { - throw new AppError('COMMAND_FAILED', 'Persisted human-control state is invalid.', { - path: this.statePath, - }); - } - return parsed.holds.map((hold) => normalizeStoredHumanControlHold(hold)); - } - - persist(holds: Iterable): void { - if (!this.statePath) return; - fs.mkdirSync(path.dirname(this.statePath), { recursive: true }); - const temporaryPath = `${this.statePath}.${String(process.pid)}.tmp`; - const state = { - version: 1, - holds: Array.from(holds, (hold) => cloneHumanControlHold(hold)), - }; - fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2), { mode: 0o600 }); - fs.renameSync(temporaryPath, this.statePath); - fs.chmodSync(this.statePath, 0o600); - } -} - -export function cloneHumanControlHold(hold: HumanControlHold): HumanControlHold { - return { ...hold, scope: { ...hold.scope } }; -} diff --git a/src/daemon/human-control.ts b/src/daemon/human-control.ts deleted file mode 100644 index 324fb805a1..0000000000 --- a/src/daemon/human-control.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { AppError } from '@agent-device/kernel/errors'; -import { deviceIdentityAliases } from '../core/lease-scope.ts'; -import type { HumanControlHold, HumanControlHoldInput } from './human-control-contract.ts'; -import { - normalizeHumanControlHoldId, - normalizeHumanControlHoldScope, - normalizeHumanControlReason, - normalizeHumanControlTtlMs, -} from './human-control-contract.ts'; -import { cloneHumanControlHold, HumanControlStore } from './human-control-store.ts'; - -export const HUMAN_CONTROL_HTTP_PREFIX = '/admin/human-control/holds'; - -export class HumanControlRegistry { - private readonly holds = new Map(); - private readonly activeMutations = new Map(); - private readonly idleWaiters = new Map void>>(); - private readonly store: HumanControlStore; - private readonly now: () => number; - - constructor(options: { statePath?: string; now?: () => number } = {}) { - this.store = new HumanControlStore(options.statePath); - this.now = options.now ?? (() => Date.now()); - for (const hold of this.store.load()) this.holds.set(hold.id, hold); - this.cleanupExpired(); - } - - list(): HumanControlHold[] { - this.cleanupExpired(); - return Array.from(this.holds.values(), (hold) => cloneHumanControlHold(hold)).sort( - (left, right) => left.id.localeCompare(right.id), - ); - } - - async upsert(id: string, input: HumanControlHoldInput): Promise { - const normalizedId = normalizeHumanControlHoldId(id); - const scope = normalizeHumanControlHoldScope(input.scope); - const reason = normalizeHumanControlReason(input.reason); - const ttlMs = normalizeHumanControlTtlMs(input.ttlMs); - const now = this.now(); - const existing = this.holds.get(normalizedId); - const pendingHold: HumanControlHold = { - id: normalizedId, - scope, - ...(reason ? { reason } : {}), - createdAt: existing?.createdAt ?? now, - updatedAt: now, - }; - this.holds.set(normalizedId, pendingHold); - this.persist(); - await this.waitForDeviceIdle([scope.deviceKey]); - if (this.holds.get(normalizedId) !== pendingHold) { - throw new AppError( - 'COMMAND_FAILED', - 'Human-control hold changed before activation completed.', - { holdId: normalizedId }, - ); - } - const activatedAt = this.now(); - const activeHold: HumanControlHold = { - ...pendingHold, - updatedAt: activatedAt, - ...(ttlMs === undefined ? {} : { expiresAt: activatedAt + ttlMs }), - }; - this.holds.set(normalizedId, activeHold); - this.persist(); - return cloneHumanControlHold(activeHold); - } - - remove(id: string): HumanControlHold | undefined { - const normalizedId = normalizeHumanControlHoldId(id); - const hold = this.holds.get(normalizedId); - if (!hold) return undefined; - this.holds.delete(normalizedId); - this.persist(); - return cloneHumanControlHold(hold); - } - - isDeviceControlled(deviceKey: string | undefined): boolean { - if (!deviceKey) return false; - return this.findMatchingHold([deviceKey]) !== undefined; - } - - findMatchingHold(deviceKeys: readonly string[]): HumanControlHold | undefined { - this.cleanupExpired(); - const keys = normalizeDeviceAliases(deviceKeys); - if (keys.length === 0) return undefined; - for (const hold of this.holds.values()) { - const holdKeys = normalizeDeviceAliases([hold.scope.deviceKey]); - if (holdKeys.some((key) => keys.includes(key))) return cloneHumanControlHold(hold); - } - return undefined; - } - - async runDeviceMutation(deviceKeys: readonly string[], task: () => Promise): Promise { - const keys = normalizeDeviceAliases(deviceKeys); - const hold = this.findMatchingHold(keys); - if (hold) throw humanControlActiveError(hold); - if (keys.length === 0) return await task(); - - for (const key of keys) { - this.activeMutations.set(key, (this.activeMutations.get(key) ?? 0) + 1); - } - try { - return await task(); - } finally { - for (const key of keys) this.finishMutation(key); - } - } - - private async waitForDeviceIdle(deviceKeys: readonly string[]): Promise { - await Promise.all( - normalizeDeviceAliases(deviceKeys).map(async (key) => this.waitForKeyIdle(key)), - ); - } - - private async waitForKeyIdle(key: string): Promise { - if ((this.activeMutations.get(key) ?? 0) === 0) return; - await new Promise((resolve) => { - const waiters = this.idleWaiters.get(key) ?? new Set<() => void>(); - waiters.add(resolve); - this.idleWaiters.set(key, waiters); - }); - } - - private finishMutation(key: string): void { - const remaining = (this.activeMutations.get(key) ?? 1) - 1; - if (remaining > 0) { - this.activeMutations.set(key, remaining); - return; - } - this.activeMutations.delete(key); - const waiters = this.idleWaiters.get(key); - if (!waiters) return; - this.idleWaiters.delete(key); - for (const resolve of waiters) resolve(); - } - - private cleanupExpired(): void { - const now = this.now(); - let changed = false; - for (const [id, hold] of this.holds) { - if (hold.expiresAt === undefined || hold.expiresAt > now) continue; - this.holds.delete(id); - changed = true; - } - if (changed) this.persist(); - } - - private persist(): void { - this.store.persist(this.holds.values()); - } -} - -function humanControlActiveError(hold: HumanControlHold): AppError { - return new AppError( - 'DEVICE_IN_USE', - 'A human is interacting with this simulator or device; agent interactions are temporarily disabled.', - { - reason: 'human_control_active', - blockedBy: 'human_control', - holdId: hold.id, - deviceKey: hold.scope.deviceKey, - pausedAt: new Date(hold.createdAt).toISOString(), - ...(hold.expiresAt === undefined - ? {} - : { expiresAt: new Date(hold.expiresAt).toISOString() }), - ...(hold.reason ? { humanReason: hold.reason } : {}), - hint: 'Wait until the human-control hold is released before retrying.', - }, - ); -} - -export function releaseHumanControlHold( - registry: HumanControlRegistry, - holdId: string, -): HumanControlHold | undefined { - return registry.remove(holdId); -} - -function normalizeDeviceAliases(deviceKeys: readonly string[]): string[] { - return Array.from( - new Set( - deviceIdentityAliases(deviceKeys) - .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - .map((value) => normalizeDeviceAlias(value)), - ), - ); -} - -function normalizeDeviceAlias(value: string): string { - return value.trim().toLocaleLowerCase('en-US'); -} diff --git a/src/daemon/lease-registry-scope.ts b/src/daemon/lease-registry-scope.ts new file mode 100644 index 0000000000..5a029f0776 --- /dev/null +++ b/src/daemon/lease-registry-scope.ts @@ -0,0 +1,319 @@ +import crypto from 'node:crypto'; +import type { DeviceLease } from '@agent-device/contracts/device'; +import type { LeaseBackend } from '@agent-device/kernel/contracts'; +import { AppError } from '@agent-device/kernel/errors'; +import { normalizeTenantId } from './config.ts'; + +export type LeaseRegistryOptions = { + maxActiveSimulatorLeases?: number; + defaultLeaseTtlMs?: number; + minLeaseTtlMs?: number; + maxLeaseTtlMs?: number; + providerSessionRetentionMs?: number; + now?: () => number; + onLeaseExpired?: (lease: DeviceLease) => void; +}; + +export type AllocateLeaseRequest = { + tenantId: string; + runId: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; +}; + +export type HeartbeatLeaseRequest = { + leaseId: string; + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; +}; + +export type ReleaseLeaseRequest = { + leaseId: string; + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +export type AdmissionRequest = { + tenantId?: string; + runId?: string; + leaseId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +type LeaseScopeMatchRequest = { + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +type NormalizedLeaseScopeMatchRequest = { + tenantId?: string; + runId?: string; + leaseBackend?: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; +}; + +export type NormalizedAllocateLeaseRequest = { + tenantId: string; + runId: string; + backend: LeaseBackend; + leaseProvider?: string; + deviceKey?: string; + clientId?: string; + ttlMs?: number; +}; + +export const DEFAULT_LEASE_TTL_MS = 60_000; +export const MIN_LEASE_TTL_MS = 5_000; +export const MAX_LEASE_TTL_MS = 10 * 60_000; +const DEFAULT_LEASE_PROVIDER = 'default'; + +function normalizeRunId(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const value = raw.trim(); + if (!value) return undefined; + if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) return undefined; + return value; +} + +export function normalizeLeaseId(raw: string | undefined): string | undefined { + if (!raw) return undefined; + const value = raw.trim(); + if (!value) return undefined; + if (!/^[a-f0-9]{16,128}$/i.test(value)) return undefined; + return value.toLowerCase(); +} + +export function normalizeRequiredLeaseId(raw: string | undefined): string { + const leaseId = normalizeLeaseId(raw); + if (!leaseId) throw new AppError('INVALID_ARGS', 'Invalid lease id.'); + return leaseId; +} + +export function normalizeLeaseBackend(raw: string | undefined): LeaseBackend { + const value = (raw ?? '').trim().toLowerCase(); + if (!value || value === 'ios-simulator') return 'ios-simulator'; + if (value === 'ios-instance' || value === 'android-instance') return value; + throw new AppError('INVALID_ARGS', `Unsupported lease backend: ${raw ?? ''}`); +} + +export function normalizeDeviceKey(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + const value = raw.trim(); + if (!value || value.length > 256 || !/^[\u0020-\u007E]+$/.test(value)) { + throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.'); + } + return value; +} + +function normalizeClientId(raw: string | undefined): string | undefined { + return normalizeAgentIdentifier(raw, 'client id', 128); +} + +export function normalizeLeaseProvider(raw: string | undefined): string | undefined { + return normalizeAgentIdentifier(raw, 'lease provider', 64); +} + +function normalizeAgentIdentifier( + raw: string | undefined, + label: string, + maxLength: number, +): string | undefined { + if (raw === undefined) return undefined; + const value = raw.trim(); + if (!value || value.length > maxLength || !/^[a-zA-Z0-9._-]+$/.test(value)) { + throw new AppError( + 'INVALID_ARGS', + `Invalid ${label}. Use 1-${String(maxLength)} chars: letters, numbers, dot, underscore, hyphen.`, + ); + } + return value; +} + +function normalizeRequiredTenantId(raw: string): string { + const tenantId = normalizeTenantId(raw); + if (!tenantId) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return tenantId; +} + +function normalizeRequiredRunId(raw: string): string { + const runId = normalizeRunId(raw); + if (!runId) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return runId; +} + +export function normalizeAllocateLeaseRequest( + request: AllocateLeaseRequest, +): NormalizedAllocateLeaseRequest { + return { + backend: normalizeLeaseBackend(request.leaseBackend), + leaseProvider: normalizeLeaseProvider(request.leaseProvider), + deviceKey: normalizeDeviceKey(request.deviceKey), + clientId: normalizeClientId(request.clientId), + tenantId: normalizeRequiredTenantId(request.tenantId), + runId: normalizeRequiredRunId(request.runId), + ttlMs: request.ttlMs, + }; +} + +function leaseRequiresOwnerScope(lease: DeviceLease): boolean { + return Boolean(lease.leaseProvider ?? lease.deviceKey ?? lease.clientId); +} + +function hasRequiredOwnerScope(lease: DeviceLease, request: LeaseScopeMatchRequest): boolean { + if (!request.tenantId || !request.runId) return false; + return [ + [lease.leaseProvider, request.leaseProvider], + [lease.deviceKey, request.deviceKey], + [lease.clientId, request.clientId], + ].every(([leaseValue, requestValue]) => !leaseValue || Boolean(requestValue)); +} + +export function assertLeaseScopeMatch( + lease: DeviceLease, + request: HeartbeatLeaseRequest | AdmissionRequest, +): void { + const normalized = normalizeOptionalLeaseScope(request); + if ( + (normalized.tenantId && lease.tenantId !== normalized.tenantId) || + (normalized.runId && lease.runId !== normalized.runId) || + (normalized.leaseBackend && lease.backend !== normalized.leaseBackend) || + (normalized.leaseProvider && lease.leaseProvider !== normalized.leaseProvider) || + (normalized.deviceKey && lease.deviceKey !== normalized.deviceKey) || + (normalized.clientId && lease.clientId !== normalized.clientId) + ) { + throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { + reason: 'LEASE_SCOPE_MISMATCH', + }); + } +} + +export function normalizeLeaseAdmissionRequest( + request: AdmissionRequest, +): AdmissionRequest & { leaseId: string } { + const leaseBackend = normalizeLeaseBackend(request.leaseBackend); + const tenantId = normalizeTenantId(request.tenantId); + if (!tenantId) throw new AppError('INVALID_ARGS', 'tenant isolation requires tenant id.'); + const runId = normalizeRunId(request.runId); + if (!runId) throw new AppError('INVALID_ARGS', 'tenant isolation requires run id.'); + const leaseId = normalizeLeaseId(request.leaseId); + if (!leaseId) throw new AppError('INVALID_ARGS', 'tenant isolation requires lease id.'); + return { ...request, tenantId, runId, leaseId, leaseBackend }; +} +function normalizeOptionalLeaseScope( + request: LeaseScopeMatchRequest, +): NormalizedLeaseScopeMatchRequest { + const tenantId = normalizeTenantId(request.tenantId); + const runId = normalizeRunId(request.runId); + if (request.tenantId && !tenantId) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + if (request.runId && !runId) { + throw new AppError( + 'INVALID_ARGS', + 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', + ); + } + return { + tenantId, + runId, + leaseBackend: request.leaseBackend ? normalizeLeaseBackend(request.leaseBackend) : undefined, + leaseProvider: normalizeLeaseProvider(request.leaseProvider), + deviceKey: normalizeDeviceKey(request.deviceKey), + clientId: normalizeClientId(request.clientId), + }; +} + +export function assertLeaseOwnerScope(lease: DeviceLease, request: HeartbeatLeaseRequest): void { + if (leaseRequiresOwnerScope(lease) && !hasRequiredOwnerScope(lease, request)) { + throw new AppError('UNAUTHORIZED', 'Lease owner scope is required', { + reason: 'LEASE_SCOPE_REQUIRED', + }); + } +} + +export function leaseDeviceBindingKey( + scope: Pick, +): string | undefined { + if (!scope.deviceKey) return undefined; + return JSON.stringify([ + scope.backend, + scope.leaseProvider ?? DEFAULT_LEASE_PROVIDER, + scope.deviceKey, + ]); +} + +export function leaseRunBindingKey( + scope: Pick, +): string { + return JSON.stringify([ + scope.tenantId, + scope.runId, + scope.backend, + scope.leaseProvider ?? DEFAULT_LEASE_PROVIDER, + scope.deviceKey ?? '*', + ]); +} + +export function createDeviceLease( + request: NormalizedAllocateLeaseRequest, + leaseTtlMs: number, + now: number, +): DeviceLease { + return { + leaseId: crypto.randomBytes(16).toString('hex'), + tenantId: request.tenantId, + runId: request.runId, + backend: request.backend, + ...(request.leaseProvider ? { leaseProvider: request.leaseProvider } : {}), + ...(request.deviceKey ? { deviceKey: request.deviceKey } : {}), + ...(request.clientId ? { clientId: request.clientId } : {}), + createdAt: now, + heartbeatAt: now, + expiresAt: now + leaseTtlMs, + }; +} + +export function deviceLeaseBusyError(activeLease: DeviceLease): AppError { + return new AppError('DEVICE_IN_USE', 'Device is already leased', { + reason: 'DEVICE_LEASE_BUSY', + deviceKey: activeLease.deviceKey, + backend: activeLease.backend, + leaseProvider: activeLease.leaseProvider, + expiresAt: activeLease.expiresAt, + hint: 'Retry after the lease expires or close the owning session.', + }); +} diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index b5be59f0e4..c8182ac9cd 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -1,207 +1,49 @@ import type { DeviceLease } from '@agent-device/contracts/device'; -import crypto from 'node:crypto'; +import type { HumanControlHold, HumanControlHoldScope } from '@agent-device/contracts/client'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; -import { deviceIdentityAliases } from '../core/lease-scope.ts'; -import { normalizeTenantId } from './config.ts'; import { ProviderSessionOwnershipRegistry, type ProviderSessionOwnership, } from './provider-session-ownership.ts'; +import { + type AdmissionRequest, + type AllocateLeaseRequest, + type HeartbeatLeaseRequest, + type ReleaseLeaseRequest, + type LeaseRegistryOptions, + type NormalizedAllocateLeaseRequest, + DEFAULT_LEASE_TTL_MS, + MIN_LEASE_TTL_MS, + MAX_LEASE_TTL_MS, + normalizeAllocateLeaseRequest, + createDeviceLease, + deviceLeaseBusyError, + normalizeLeaseId, + normalizeRequiredLeaseId, + normalizeLeaseAdmissionRequest, + assertLeaseOwnerScope, + assertLeaseScopeMatch, + leaseDeviceBindingKey, + leaseRunBindingKey, +} from './lease-registry-scope.ts'; +import { DeviceMutationDrain } from './device-mutation-drain.ts'; +import { + type HumanControlAuthority, + type HumanControlHoldInput, + normalizeHumanControlHoldId, + parseHumanControlHoldInput, + cloneHumanControlHold, + humanControlActiveError, +} from './human-control-contract.ts'; export type SimulatorLease = DeviceLease; -export type LeaseRegistryOptions = { - maxActiveSimulatorLeases?: number; - defaultLeaseTtlMs?: number; - minLeaseTtlMs?: number; - maxLeaseTtlMs?: number; - providerSessionRetentionMs?: number; - now?: () => number; - onLeaseExpired?: (lease: DeviceLease) => void; - isDeviceLeaseProtected?: (lease: DeviceLease) => boolean; -}; - -export type AllocateLeaseRequest = { - tenantId: string; - runId: string; - leaseBackend?: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; - ttlMs?: number; -}; - -export type HeartbeatLeaseRequest = { - leaseId: string; - tenantId?: string; - runId?: string; - leaseBackend?: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; - ttlMs?: number; -}; - -export type ReleaseLeaseRequest = { - leaseId: string; - tenantId?: string; - runId?: string; - leaseBackend?: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; -}; - -export type AdmissionRequest = { - tenantId?: string; - runId?: string; - leaseId?: string; - leaseBackend?: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; -}; - -type LeaseScopeMatchRequest = { - tenantId?: string; - runId?: string; - leaseBackend?: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; -}; - -type NormalizedLeaseScopeMatchRequest = { - tenantId?: string; - runId?: string; - leaseBackend?: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; -}; - -type NormalizedAllocateLeaseRequest = { - tenantId: string; - runId: string; - backend: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - clientId?: string; - ttlMs?: number; -}; - -const DEFAULT_LEASE_TTL_MS = 60_000; -const MIN_LEASE_TTL_MS = 5_000; -const MAX_LEASE_TTL_MS = 10 * 60_000; -const DEFAULT_LEASE_PROVIDER = 'default'; - -function normalizeRunId(raw: string | undefined): string | undefined { - if (!raw) return undefined; - const value = raw.trim(); - if (!value) return undefined; - if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) return undefined; - return value; -} - -function normalizeLeaseId(raw: string | undefined): string | undefined { - if (!raw) return undefined; - const value = raw.trim(); - if (!value) return undefined; - if (!/^[a-f0-9]{16,128}$/i.test(value)) return undefined; - return value.toLowerCase(); -} - -function normalizeLeaseBackend(raw: string | undefined): LeaseBackend { - const value = (raw ?? '').trim().toLowerCase(); - if (!value || value === 'ios-simulator') return 'ios-simulator'; - if (value === 'ios-instance' || value === 'android-instance') return value; - throw new AppError('INVALID_ARGS', `Unsupported lease backend: ${raw ?? ''}`); -} - -function normalizeDeviceKey(raw: string | undefined): string | undefined { - if (raw === undefined) return undefined; - const value = raw.trim(); - if (!value || value.length > 256 || !/^[\u0020-\u007E]+$/.test(value)) { - throw new AppError('INVALID_ARGS', 'Invalid device key. Use 1-256 printable characters.'); - } - return value; -} - -function normalizeClientId(raw: string | undefined): string | undefined { - return normalizeAgentIdentifier(raw, 'client id', 128); -} - -function normalizeLeaseProvider(raw: string | undefined): string | undefined { - return normalizeAgentIdentifier(raw, 'lease provider', 64); -} - -function normalizeAgentIdentifier( - raw: string | undefined, - label: string, - maxLength: number, -): string | undefined { - if (raw === undefined) return undefined; - const value = raw.trim(); - if (!value || value.length > maxLength || !/^[a-zA-Z0-9._-]+$/.test(value)) { - throw new AppError( - 'INVALID_ARGS', - `Invalid ${label}. Use 1-${String(maxLength)} chars: letters, numbers, dot, underscore, hyphen.`, - ); - } - return value; -} - -function normalizeRequiredTenantId(raw: string): string { - const tenantId = normalizeTenantId(raw); - if (!tenantId) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } - return tenantId; -} - -function normalizeRequiredRunId(raw: string): string { - const runId = normalizeRunId(raw); - if (!runId) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } - return runId; -} - -function normalizeAllocateLeaseRequest( - request: AllocateLeaseRequest, -): NormalizedAllocateLeaseRequest { - return { - backend: normalizeLeaseBackend(request.leaseBackend), - leaseProvider: normalizeLeaseProvider(request.leaseProvider), - deviceKey: normalizeDeviceKey(request.deviceKey), - clientId: normalizeClientId(request.clientId), - tenantId: normalizeRequiredTenantId(request.tenantId), - runId: normalizeRequiredRunId(request.runId), - ttlMs: request.ttlMs, - }; -} - -function leaseRequiresOwnerScope(lease: DeviceLease): boolean { - return Boolean(lease.leaseProvider ?? lease.deviceKey ?? lease.clientId); -} - -function hasRequiredOwnerScope(lease: DeviceLease, request: LeaseScopeMatchRequest): boolean { - if (!request.tenantId || !request.runId) return false; - return [ - [lease.leaseProvider, request.leaseProvider], - [lease.deviceKey, request.deviceKey], - [lease.clientId, request.clientId], - ].every(([leaseValue, requestValue]) => !leaseValue || Boolean(requestValue)); -} +type OwnedHumanControlHold = { hold: HumanControlHold; ownerLeaseId?: string }; export class LeaseRegistry { + private readonly holdsByDevice = new Map>(); + private readonly mutations = new DeviceMutationDrain(); private readonly leases = new Map(); private readonly runBindings = new Map(); private readonly deviceBindings = new Map(); @@ -212,7 +54,6 @@ export class LeaseRegistry { private readonly now: () => number; private readonly onLeaseExpired?: (lease: DeviceLease) => void; private readonly providerSessionOwnership: ProviderSessionOwnershipRegistry; - private readonly isDeviceLeaseProtected: (lease: DeviceLease) => boolean; constructor(options: LeaseRegistryOptions = {}) { this.maxActiveSimulatorLeases = Number.isInteger(options.maxActiveSimulatorLeases) @@ -233,18 +74,18 @@ export class LeaseRegistry { now: this.now, retentionMs: options.providerSessionRetentionMs, }); - this.isDeviceLeaseProtected = options.isDeviceLeaseProtected ?? (() => false); } allocateLease(request: AllocateLeaseRequest): DeviceLease { const normalized = normalizeAllocateLeaseRequest(request); this.cleanupExpiredLeases(); const leaseTtlMs = this.resolveLeaseTtlMs(normalized.ttlMs); + this.assertHumanControlAdmission(normalized); const existingLease = this.refreshExistingRunBinding(normalized, leaseTtlMs); if (existingLease) return existingLease; this.assertDeviceAvailable(normalized); this.enforceCapacity(normalized.backend); - const lease = this.createLease(normalized, leaseTtlMs); + const lease = createDeviceLease(normalized, leaseTtlMs, this.now()); this.leases.set(lease.leaseId, lease); this.bindLease(lease); return { ...lease }; @@ -254,7 +95,7 @@ export class LeaseRegistry { request: NormalizedAllocateLeaseRequest, leaseTtlMs: number, ): DeviceLease | undefined { - const bindingKey = this.bindingKey(request); + const bindingKey = leaseRunBindingKey(request); const existingId = this.runBindings.get(bindingKey); if (!existingId) return undefined; const existingLease = this.leases.get(existingId); @@ -262,38 +103,22 @@ export class LeaseRegistry { this.runBindings.delete(bindingKey); return undefined; } - if (this.canReuseRunBinding(existingLease, request)) { + if (existingLease.clientId === request.clientId) { return this.refreshLease(existingLease, leaseTtlMs); } if (existingLease.deviceKey) { - this.throwDeviceBusy(existingLease); + throw deviceLeaseBusyError(existingLease); } - this.assertOptionalLeaseIdentityMatch(existingLease, request); + assertLeaseScopeMatch(existingLease, request); return this.refreshLease(existingLease, leaseTtlMs); } - private createLease(request: NormalizedAllocateLeaseRequest, leaseTtlMs: number): DeviceLease { - const now = this.now(); - return { - leaseId: crypto.randomBytes(16).toString('hex'), - tenantId: request.tenantId, - runId: request.runId, - backend: request.backend, - ...(request.leaseProvider ? { leaseProvider: request.leaseProvider } : {}), - ...(request.deviceKey ? { deviceKey: request.deviceKey } : {}), - ...(request.clientId ? { clientId: request.clientId } : {}), - createdAt: now, - heartbeatAt: now, - expiresAt: now + leaseTtlMs, - }; - } - heartbeatLease(request: HeartbeatLeaseRequest): DeviceLease { - const leaseId = this.normalizeRequiredLeaseId(request.leaseId); + const leaseId = normalizeRequiredLeaseId(request.leaseId); this.cleanupExpiredLeases(); const lease = this.getActiveLease(leaseId); - this.assertRequiredScopeForDeviceAwareLease(lease, request); - this.assertOptionalScopeMatch(lease, request); + assertLeaseOwnerScope(lease, request); + assertLeaseScopeMatch(lease, request); const leaseTtlMs = this.resolveLeaseTtlMs(request.ttlMs); return this.refreshLease(lease, leaseTtlMs); } @@ -314,39 +139,19 @@ export class LeaseRegistry { * transient provider failure leaves the local lease available for retry. */ getLease(request: ReleaseLeaseRequest): DeviceLease | undefined { - const leaseId = this.normalizeRequiredLeaseId(request.leaseId); + const leaseId = normalizeRequiredLeaseId(request.leaseId); this.cleanupExpiredLeases(); const lease = this.leases.get(leaseId); if (!lease) return undefined; - this.assertRequiredScopeForDeviceAwareLease(lease, request); - this.assertOptionalScopeMatch(lease, request); + assertLeaseOwnerScope(lease, request); + assertLeaseScopeMatch(lease, request); return { ...lease }; } assertLeaseAdmission(request: AdmissionRequest): void { - const backend = normalizeLeaseBackend(request.leaseBackend); - const tenantId = normalizeTenantId(request.tenantId); - if (!tenantId) { - throw new AppError('INVALID_ARGS', 'tenant isolation requires tenant id.'); - } - const runId = normalizeRunId(request.runId); - if (!runId) { - throw new AppError('INVALID_ARGS', 'tenant isolation requires run id.'); - } - const leaseId = normalizeLeaseId(request.leaseId); - if (!leaseId) { - throw new AppError('INVALID_ARGS', 'tenant isolation requires lease id.'); - } + const scope = normalizeLeaseAdmissionRequest(request); this.cleanupExpiredLeases(); - const lease = this.getActiveLease(leaseId); - this.assertOptionalScopeMatch(lease, { - tenantId, - runId, - leaseBackend: backend, - leaseProvider: request.leaseProvider, - deviceKey: request.deviceKey, - clientId: request.clientId, - }); + assertLeaseScopeMatch(this.getActiveLease(scope.leaseId), scope); } listActiveLeases(): DeviceLease[] { @@ -375,25 +180,208 @@ export class LeaseRegistry { return this.providerSessionOwnership.resolve(params); } - refreshLeasesForDeviceKey(deviceKey: string): DeviceLease[] { - const normalizedDeviceKey = normalizeDeviceKey(deviceKey); - if (!normalizedDeviceKey) return []; - const comparisonKeys = normalizedDeviceAliases([normalizedDeviceKey]); - const refreshed: DeviceLease[] = []; - for (const lease of this.leases.values()) { - if (!lease.deviceKey) continue; - const leaseKeys = normalizedDeviceAliases([lease.deviceKey]); - if (!leaseKeys.some((key) => comparisonKeys.includes(key))) continue; - refreshed.push(this.refreshLease(lease, this.defaultLeaseTtlMs)); + listHumanControlHolds(authority: HumanControlAuthority): HumanControlHold[] { + this.cleanupExpiredLeases(); + const key = + authority.kind === 'lease' + ? leaseDeviceBindingKey(this.requireHumanControlLease(authority.leaseId)) + : undefined; + const holds = [...this.holdsByDevice.entries()] + .filter(([deviceKey]) => key === undefined || key === deviceKey) + .flatMap(([, entries]) => + [...entries.values()].map(({ hold }) => cloneHumanControlHold(hold)), + ); + return holds.sort((left, right) => left.id.localeCompare(right.id)); + } + + async putHumanControlHold( + authority: HumanControlAuthority, + rawId: string, + rawInput: HumanControlHoldInput, + ): Promise { + const id = normalizeHumanControlHoldId(rawId); + const input = parseHumanControlHoldInput(rawInput); + this.cleanupExpiredLeases(); + const scope = this.resolveHumanControlScope(authority, input); + const key = leaseDeviceBindingKey(scope)!; + const existing = this.findHumanControlHold(id); + if (existing) { + this.assertHoldAuthority(authority, existing); + if (leaseDeviceBindingKey(existing.hold.scope) !== key) { + throw new AppError('INVALID_ARGS', 'Release a hold before changing its device scope.'); + } + } + const now = this.now(); + const pending: OwnedHumanControlHold = { + ...(authority.kind === 'lease' ? { ownerLeaseId: authority.leaseId } : {}), + hold: { + id, + scope, + state: 'activating', + ...(input.reason ? { reason: input.reason } : {}), + createdAt: existing?.hold.createdAt ?? now, + updatedAt: now, + }, + }; + const holds = this.holdsByDevice.get(key) ?? new Map(); + this.holdsByDevice.set(key, holds); + holds.set(id, pending); + await this.mutations.wait(key); + if (holds.get(id) !== pending) { + throw new AppError( + 'COMMAND_FAILED', + 'Human-control hold changed before activation completed.', + { holdId: id }, + ); + } + const activatedAt = this.now(); + pending.hold = { + ...pending.hold, + state: 'active', + updatedAt: activatedAt, + ...(input.ttlMs === undefined ? {} : { expiresAt: activatedAt + input.ttlMs }), + }; + this.refreshHeldLease(key, activatedAt); + return cloneHumanControlHold(pending.hold); + } + + removeHumanControlHold( + authority: HumanControlAuthority, + rawId: string, + ): HumanControlHold | undefined { + const id = normalizeHumanControlHoldId(rawId); + this.cleanupExpiredLeases(); + const existing = this.findHumanControlHold(id); + if (!existing) return undefined; + this.assertHoldAuthority(authority, existing); + const key = leaseDeviceBindingKey(existing.hold.scope)!; + const holds = this.holdsByDevice.get(key)!; + holds.delete(id); + if (holds.size === 0) { + this.holdsByDevice.delete(key); + this.refreshHeldLease(key, this.now()); + } + return cloneHumanControlHold(existing.hold); + } + + assertHumanControlAdmission( + scope: Pick, + ): void { + this.expireHumanControlHolds(); + const key = leaseDeviceBindingKey(scope); + const entry = + key === undefined ? undefined : this.holdsByDevice.get(key)?.values().next().value; + if (entry) throw humanControlActiveError(entry.hold); + } + + async runDeviceMutation(lease: DeviceLease | undefined, task: () => Promise): Promise { + if (!lease) return await task(); + this.cleanupExpiredLeases(); + const activeLease = this.getActiveLease(lease.leaseId); + this.assertHumanControlAdmission(activeLease); + const key = leaseDeviceBindingKey(activeLease); + return key === undefined ? await task() : await this.mutations.run(key, task); + } + + private requireHumanControlLease(leaseId: string): DeviceLease & { deviceKey: string } { + const lease = this.getActiveLease(leaseId); + if (!lease.deviceKey) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Human control requires a device-scoped remote lease.', + ); + } + return { ...lease, deviceKey: lease.deviceKey }; + } + + private resolveHumanControlScope( + authority: HumanControlAuthority, + input: HumanControlHoldInput, + ): HumanControlHoldScope { + if (authority.kind === 'host') { + if (!input.scope) + throw new AppError('INVALID_ARGS', 'Host human control requires a lease device scope.'); + return input.scope; + } + if (input.scope) { + throw new AppError( + 'INVALID_ARGS', + 'Lease-owner human control uses the admitted lease device; do not supply scope.', + ); + } + const lease = this.requireHumanControlLease(authority.leaseId); + return { + backend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + }; + } + + private assertHoldAuthority( + authority: HumanControlAuthority, + entry: OwnedHumanControlHold, + ): void { + if (authority.kind === 'host') return; + const lease = this.requireHumanControlLease(authority.leaseId); + if ( + entry.ownerLeaseId !== lease.leaseId || + leaseDeviceBindingKey(lease) !== leaseDeviceBindingKey(entry.hold.scope) + ) { + throw new AppError( + 'UNAUTHORIZED', + 'Human-control hold does not belong to the admitted lease.', + { reason: 'LEASE_SCOPE_MISMATCH' }, + ); + } + } + + private findHumanControlHold(id: string): OwnedHumanControlHold | undefined { + for (const holds of this.holdsByDevice.values()) { + const entry = holds.get(id); + if (entry) return entry; + } + return undefined; + } + + private hasHumanControl(lease: DeviceLease): boolean { + const key = leaseDeviceBindingKey(lease); + return key !== undefined && this.holdsByDevice.has(key); + } + + private expireHumanControlHolds(): void { + const now = this.now(); + for (const [key, holds] of this.holdsByDevice) { + let releasedAt = 0; + for (const [id, { hold }] of holds) { + if (hold.expiresAt === undefined || hold.expiresAt > now) continue; + releasedAt = Math.max(releasedAt, hold.expiresAt); + holds.delete(id); + } + if (holds.size === 0) { + this.holdsByDevice.delete(key); + this.refreshHeldLease(key, releasedAt); + } + } + } + + private refreshHeldLease(key: string, at: number): void { + const leaseId = this.deviceBindings.get(key); + const lease = leaseId ? this.leases.get(leaseId) : undefined; + if (lease) { + this.refreshLease( + lease, + lease.expiresAt - lease.heartbeatAt, + Math.max(at, lease.heartbeatAt), + ); } - return refreshed; } consumeExpiredLeases(): DeviceLease[] { + this.expireHumanControlHolds(); const now = this.now(); const expired: DeviceLease[] = []; for (const lease of this.leases.values()) { - if (lease.expiresAt > now || this.isDeviceLeaseProtected(lease)) continue; + if (lease.expiresAt > now || this.hasHumanControl(lease)) continue; this.leases.delete(lease.leaseId); this.unbindLease(lease, lease.expiresAt); const expiredLease = { ...lease }; @@ -404,10 +392,11 @@ export class LeaseRegistry { } consumeExpiredLease(leaseId: string): DeviceLease | undefined { + this.expireHumanControlHolds(); const normalizedLeaseId = normalizeLeaseId(leaseId); if (!normalizedLeaseId) return undefined; const lease = this.leases.get(normalizedLeaseId); - if (!lease || lease.expiresAt > this.now() || this.isDeviceLeaseProtected(lease)) { + if (!lease || lease.expiresAt > this.now() || this.hasHumanControl(lease)) { return undefined; } this.leases.delete(lease.leaseId); @@ -449,14 +438,6 @@ export class LeaseRegistry { return value; } - private normalizeRequiredLeaseId(raw: string | undefined): string { - const leaseId = normalizeLeaseId(raw); - if (!leaseId) { - throw new AppError('INVALID_ARGS', 'Invalid lease id.'); - } - return leaseId; - } - private getActiveLease(leaseId: string): DeviceLease { const lease = this.leases.get(leaseId); if (lease) return lease; @@ -465,8 +446,7 @@ export class LeaseRegistry { }); } - private refreshLease(lease: DeviceLease, ttlMs: number): DeviceLease { - const now = this.now(); + private refreshLease(lease: DeviceLease, ttlMs: number, now = this.now()): DeviceLease { const updated: DeviceLease = { ...lease, heartbeatAt: now, @@ -478,72 +458,28 @@ export class LeaseRegistry { } private bindLease(lease: DeviceLease): void { - this.runBindings.set( - this.bindingKey({ - tenantId: lease.tenantId, - runId: lease.runId, - backend: lease.backend, - leaseProvider: lease.leaseProvider, - deviceKey: lease.deviceKey, - }), - lease.leaseId, - ); - const deviceBindingKey = this.deviceBindingKey(lease); + this.runBindings.set(leaseRunBindingKey(lease), lease.leaseId); + const deviceBindingKey = leaseDeviceBindingKey(lease); if (deviceBindingKey) { this.deviceBindings.set(deviceBindingKey, lease.leaseId); } } private unbindLease(lease: DeviceLease, releasedAt = this.now()): void { - this.runBindings.delete( - this.bindingKey({ - tenantId: lease.tenantId, - runId: lease.runId, - backend: lease.backend, - leaseProvider: lease.leaseProvider, - deviceKey: lease.deviceKey, - }), - ); - const deviceBindingKey = this.deviceBindingKey(lease); + this.runBindings.delete(leaseRunBindingKey(lease)); + const deviceBindingKey = leaseDeviceBindingKey(lease); if (deviceBindingKey) { this.deviceBindings.delete(deviceBindingKey); } this.providerSessionOwnership.markLeaseReleased(lease, releasedAt); } - private bindingKey(params: { - tenantId: string; - runId: string; - backend: LeaseBackend; - leaseProvider?: string; - deviceKey?: string; - }): string { - return JSON.stringify([ - params.tenantId, - params.runId, - params.backend, - params.leaseProvider ?? DEFAULT_LEASE_PROVIDER, - params.deviceKey ?? '*', - ]); - } - - private deviceBindingKey( - lease: Pick, - ): string | undefined { - if (!lease.deviceKey) return undefined; - return JSON.stringify([ - lease.backend, - lease.leaseProvider ?? DEFAULT_LEASE_PROVIDER, - lease.deviceKey, - ]); - } - private assertDeviceAvailable(params: { backend: LeaseBackend; leaseProvider?: string; deviceKey?: string; }): void { - const deviceBindingKey = this.deviceBindingKey({ + const deviceBindingKey = leaseDeviceBindingKey({ backend: params.backend, leaseProvider: params.leaseProvider, deviceKey: params.deviceKey, @@ -556,110 +492,6 @@ export class LeaseRegistry { this.deviceBindings.delete(deviceBindingKey); return; } - this.throwDeviceBusy(activeLease); - } - - private canReuseRunBinding( - lease: DeviceLease, - request: { - clientId?: string; - }, - ): boolean { - return lease.clientId === request.clientId; - } - - private throwDeviceBusy(activeLease: DeviceLease): never { - throw new AppError('DEVICE_IN_USE', 'Device is already leased', { - reason: 'DEVICE_LEASE_BUSY', - deviceKey: activeLease.deviceKey, - backend: activeLease.backend, - leaseProvider: activeLease.leaseProvider, - expiresAt: activeLease.expiresAt, - hint: 'Retry after the lease expires or close the owning session.', - }); + throw deviceLeaseBusyError(activeLease); } - - private assertRequiredScopeForDeviceAwareLease( - lease: DeviceLease, - request: LeaseScopeMatchRequest, - ): void { - if (!leaseRequiresOwnerScope(lease)) return; - if (!hasRequiredOwnerScope(lease, request)) { - this.throwScopeRequired(); - } - } - - private assertOptionalScopeMatch(lease: DeviceLease, request: LeaseScopeMatchRequest): void { - const normalized = this.normalizeOptionalScopeMatchRequest(request); - if ( - (normalized.tenantId && lease.tenantId !== normalized.tenantId) || - (normalized.runId && lease.runId !== normalized.runId) || - (normalized.leaseBackend && lease.backend !== normalized.leaseBackend) - ) { - this.throwScopeMismatch(); - } - this.assertOptionalLeaseIdentityMatch(lease, normalized); - } - - private normalizeOptionalScopeMatchRequest( - request: LeaseScopeMatchRequest, - ): NormalizedLeaseScopeMatchRequest { - const tenantId = normalizeTenantId(request.tenantId); - const runId = normalizeRunId(request.runId); - if (request.tenantId && !tenantId) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid tenant id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } - if (request.runId && !runId) { - throw new AppError( - 'INVALID_ARGS', - 'Invalid run id. Use 1-128 chars: letters, numbers, dot, underscore, hyphen.', - ); - } - return { - tenantId, - runId, - leaseBackend: request.leaseBackend ? normalizeLeaseBackend(request.leaseBackend) : undefined, - leaseProvider: normalizeLeaseProvider(request.leaseProvider), - deviceKey: normalizeDeviceKey(request.deviceKey), - clientId: normalizeClientId(request.clientId), - }; - } - - private assertOptionalLeaseIdentityMatch( - lease: DeviceLease, - request: { - leaseProvider?: string; - deviceKey?: string; - clientId?: string; - }, - ): void { - if (request.leaseProvider && lease.leaseProvider !== request.leaseProvider) { - this.throwScopeMismatch(); - } - if (request.deviceKey && lease.deviceKey !== request.deviceKey) { - this.throwScopeMismatch(); - } - if (request.clientId && lease.clientId !== request.clientId) { - this.throwScopeMismatch(); - } - } - - private throwScopeMismatch(): never { - throw new AppError('UNAUTHORIZED', 'Lease does not match tenant/run scope', { - reason: 'LEASE_SCOPE_MISMATCH', - }); - } - - private throwScopeRequired(): never { - throw new AppError('UNAUTHORIZED', 'Lease owner scope is required', { - reason: 'LEASE_SCOPE_REQUIRED', - }); - } -} - -function normalizedDeviceAliases(deviceKeys: readonly string[]): string[] { - return deviceIdentityAliases(deviceKeys).map((key) => key.toLocaleLowerCase('en-US')); } diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 655350751c..2f5628cd10 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -3,6 +3,7 @@ import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts'; import { isTenantOwnedSessionName, tenantScopedSessionName } from './session-tenant-scope.ts'; import { isLeaseAdmissionExempt, + isHumanControlMutation, isSessionlessPlainCloseAdmissionExempt, } from './daemon-command-registry.ts'; import { @@ -91,7 +92,13 @@ export function assertRequestLeaseAdmission( ) { return undefined; } - if (!sessionLease && req.meta?.sessionIsolation !== 'tenant') { + if (req.command === 'human_control' && !sessionLease && !requestLeaseScope.leaseId) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Takeover requires an active remote device lease. Local takeover is not supported.', + ); + } + if (req.command !== 'human_control' && !sessionLease && req.meta?.sessionIsolation !== 'tenant') { if (!requestLeaseScope.leaseId) return undefined; if (!requestLeaseScope.tenantId && !requestLeaseScope.runId) return undefined; } @@ -104,7 +111,9 @@ export function assertRequestLeaseAdmission( (isProxyLeaseScope(leaseScope) ? DEFAULT_PROXY_LEASE_TTL_MS : undefined), }; leaseRegistry.assertLeaseAdmission(leaseScopeToHeartbeatRequest(leaseScope)); - return leaseRegistry.heartbeatLease(leaseScopeToHeartbeatRequest(heartbeatLeaseScope)); + const lease = leaseRegistry.heartbeatLease(leaseScopeToHeartbeatRequest(heartbeatLeaseScope)); + if (isHumanControlMutation(req)) leaseRegistry.assertHumanControlAdmission(lease); + return lease; } export function assertRequestLeaseAdmissionPreflight(req: DaemonRequest): void { diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index d082b19633..da9fd447ad 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -25,6 +25,7 @@ import { throwIfRequestCanceled } from '@agent-device/host-kit/request'; import { finalizeDaemonResponse } from './request-finalization.ts'; import { refreshRecordingHealth } from './request-recording-health.ts'; import { + isHumanControlMutation, shouldBlockForInvalidRecording, shouldLockSessionExecution, shouldValidateSessionSelector, @@ -58,8 +59,6 @@ import { createDeviceClaimAdmission, type DeviceClaimAdmission } from './device- import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; import { resolveCommandDeviceClaimPolicy } from '../core/command-descriptor/registry.ts'; import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; -import { runRequestWithHumanControl } from './human-control-request.ts'; -import type { HumanControlRegistry } from './human-control.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -118,7 +117,6 @@ export async function createRequestExecutionScope(params: { deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; - humanControlRegistry?: HumanControlRegistry; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -218,37 +216,31 @@ export async function createRequestExecutionScope(params: { }), throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runAdmitted: async (task) => { - return await runRequestWithHumanControl({ - req: scopedReq, + throwIfRequestCanceled(scopedReq.meta?.requestId); + await cleanupExpiredLeasedSession({ sessionName, sessionStore, - registry: params.humanControlRegistry, - task: async () => { - throwIfRequestCanceled(scopedReq.meta?.requestId); - await cleanupExpiredLeasedSession({ - sessionName, - sessionStore, - leaseRegistry, - teardownSession: async (session, expiredSessionName) => - await teardownExpiredSession({ - session, - sessionName: expiredSessionName, - sessionStore, - inspectFacts: scope.inspectFacts, - bindDevice: scope.bindDevice, - platformCleanup: requirePlatformCleanup(params.platformResourceCleanup), - }), - }); - scopedReq = admitRequestLeaseForLockedScope({ - req: scopedReq, - sessionName, + leaseRegistry, + teardownSession: async (session, expiredSessionName) => + await teardownExpiredSession({ + session, + sessionName: expiredSessionName, sessionStore, - leaseRegistry, - }); - scope.req = scopedReq; - return await task(); - }, + inspectFacts: scope.inspectFacts, + bindDevice: scope.bindDevice, + platformCleanup: requirePlatformCleanup(params.platformResourceCleanup), + }), + }); + scopedReq = admitRequestLeaseForLockedScope({ + req: scopedReq, + sessionName, + sessionStore, + leaseRegistry, }); + scope.req = scopedReq; + return isHumanControlMutation(scopedReq) + ? await leaseRegistry.runDeviceMutation(scopedReq.internal?.admittedLease, task) + : await task(); }, runLocked: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 0c55e2022d..4ef68b81d3 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -24,8 +24,6 @@ import type { PlatformRequestScope } from '@agent-device/contracts/platform-runt import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; -import type { HumanControlHold } from './human-control-contract.ts'; -import type { HumanControlRegistry } from './human-control.ts'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -37,8 +35,6 @@ type RequestHandlerChainParams = { providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; - humanControlRegistry?: HumanControlRegistry; - onHumanControlHoldReleased?: (hold: HumanControlHold) => void; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; /** @@ -135,8 +131,7 @@ async function runHumanControlHandler( ): Promise { return await handleHumanControlCommand({ req: params.req, - registry: params.humanControlRegistry, - onHoldReleased: params.onHumanControlHoldReleased, + registry: params.leaseRegistry, }); } diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index daf0908120..4228a4b80e 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -68,8 +68,6 @@ import { import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; -import type { HumanControlHold } from './human-control-contract.ts'; -import type { HumanControlRegistry } from './human-control.ts'; // --------------------------------------------------------------------------- // Request handler API @@ -94,8 +92,6 @@ export type RequestRouterDeps = { cloudArtifactProvider?: CloudArtifactProvider; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; - humanControlRegistry?: HumanControlRegistry; - onHumanControlHoldReleased?: (hold: HumanControlHold) => void; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; trackDownloadableArtifact: (opts: { artifactPath: string; @@ -156,8 +152,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { cloudArtifactProvider, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, - humanControlRegistry, - onHumanControlHoldReleased, providerDeviceRuntimeScope, trackDownloadableArtifact, } = deps; @@ -222,7 +216,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope, platformResourceCleanup, - humanControlRegistry, }); return await executeRequestScope(scope); }), @@ -292,8 +285,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeIds, providerRuntimeRequiredIds, cloudArtifactProvider, - humanControlRegistry, - onHumanControlHoldReleased, invoke: handleRequest, invokeReplayAction: allowReplayActions ? createReplayScopedActionInvoker(lockedScope, providerScope) @@ -350,7 +341,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, - humanControlRegistry, }); // The outer replay keeps its stable session lock plus the device lock // from the first device binding through response projection and ref diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index a800f62bdf..1215c34066 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -1,5 +1,4 @@ import crypto from 'node:crypto'; -import path from 'node:path'; import { asAppError, AppError } from '@agent-device/kernel/errors'; import { resolveSessionRequestLogPath, SessionStore } from '../session-store.ts'; import { resolveDaemonPaths, resolveDaemonServerMode } from '../config.ts'; @@ -77,8 +76,6 @@ import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts' import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; import { createAudioProbeAdmissionLedger } from '../audio-probe-admission-ledger.ts'; import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; -import type { HumanControlHold } from '../human-control-contract.ts'; -import { HumanControlRegistry } from '../human-control.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; export const SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS = 11_000; @@ -258,9 +255,6 @@ export async function startDaemonRuntime( await configureAppleRunnerLeaseOwnerStateDir(baseDir); const sessionStore = new SessionStore(sessionsDir); - const humanControlRegistry = new HumanControlRegistry({ - statePath: path.join(baseDir, 'human-control.json'), - }); const ownedProcessRecords = createOwnedProcessRecordStore({ stateDir: baseDir, sessionsDir, @@ -325,23 +319,7 @@ export async function startDaemonRuntime( onLeaseExpired: (lease) => { void expiredProviderLeaseReleaser.release(lease); }, - isDeviceLeaseProtected: (lease) => humanControlRegistry.isDeviceControlled(lease.deviceKey), }); - const refreshReleasedHumanControlLeases = (hold: HumanControlHold): void => { - const refreshedLeases = leaseRegistry.refreshLeasesForDeviceKey(hold.scope.deviceKey); - const expiresAtByLeaseId = new Map( - refreshedLeases.map((lease) => [lease.leaseId, lease.expiresAt]), - ); - for (const session of sessionStore.values()) { - const leaseId = session.lease?.leaseId; - const expiresAt = leaseId ? expiresAtByLeaseId.get(leaseId) : undefined; - if (!session.lease || expiresAt === undefined) continue; - sessionStore.set(session.name, { - ...session, - lease: { ...session.lease, expiresAt }, - }); - } - }; const cloudArtifactProvider = providerRuntimeProviders.cloudArtifactProvider; const deviceInventoryGateways = createPlatformDeviceInventoryGateways( providerRuntimeProviders.deviceInventorySource, @@ -364,8 +342,6 @@ export async function startDaemonRuntime( requestPlatformProviders, androidObservation, platformResourceCleanup, - humanControlRegistry, - onHumanControlHoldReleased: refreshReleasedHumanControlLeases, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, @@ -468,11 +444,10 @@ export async function startDaemonRuntime( if (startHttpServer) { const httpServer = await createDaemonHttpServer({ handleRequest, + leaseRegistry, token, retainArtifacts, env, - humanControlRegistry, - onHumanControlHoldReleased: refreshReleasedHumanControlLeases, // #1801: the same record `DaemonError.logPath` names, addressed by its // locator so a remote caller can fetch what it cannot read by path. resolveRequestDiagnosticsPath: (ref) => diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index b63ac633c3..f023417c6e 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -44,9 +44,7 @@ import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact import { tryHandleRequestDiagnosticsHttpRoute } from '../request-diagnostics-http.ts'; import { resolveTrustedTenant, tenantTrustRejectionError } from './tenant-trust.ts'; import { tryHandleHumanControlHttpRoute } from '../human-control-http.ts'; -import type { HumanControlHold } from '../human-control-contract.ts'; -import type { HumanControlRegistry } from '../human-control.ts'; -import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; +import type { LeaseRegistry } from '../lease-registry.ts'; type JsonRpcRequest = JsonRpcRequestEnvelope; @@ -556,11 +554,10 @@ async function loadHttpAuthHook( export async function createDaemonHttpServer(options: { handleRequest: DaemonInvokeFn; + leaseRegistry?: LeaseRegistry; token?: string; retainArtifacts?: boolean; env?: NodeJS.ProcessEnv; - humanControlRegistry?: HumanControlRegistry; - onHumanControlHoldReleased?: (hold: HumanControlHold) => void; /** * Resolves a request diagnostics record path for the `/sessions/.../requests/...` * route (#1801). Omitted by embedded servers with no session store; the route @@ -582,13 +579,12 @@ export async function createDaemonHttpServer(options: { if ( token && - options.humanControlRegistry && + options.leaseRegistry && tryHandleHumanControlHttpRoute({ req, res, expectedToken: token, - registry: options.humanControlRegistry, - onHoldReleased: options.onHumanControlHoldReleased, + registry: options.leaseRegistry, }) ) { return; @@ -765,14 +761,6 @@ export async function createDaemonHttpServer(options: { authHook !== null, req.headers[DAEMON_HTTP_NETWORK_ACCESS_HEADER], ); - if (daemonRequest.command === INTERNAL_COMMANDS.humanControl) { - sendJson( - res, - createRpcError(rpcRequest.id ?? null, -32601, 'Human-control RPC is socket-only'), - 404, - ); - return; - } let canceledInFlight = false; // Request-scoped cancellation: mark this request canceled whenever its client diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 83c7260321..5634f10985 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -110,25 +110,31 @@ agent-device metro reload ## Human Takeover -Use `takeover` on the machine or VM that owns the simulator/device when a person needs to interact -with it without racing the agent: +Use `takeover` with an active remote connection when a person needs to interact with its leased +device without racing the agent: ```bash -agent-device takeover --platform ios -agent-device takeover --platform android --serial emulator-5554 +agent-device takeover --session remote-session +agent-device takeover status --session remote-session +agent-device takeover release --session remote-session ``` -The command resolves the local target, installs a short-lived device-scoped hold, keeps it alive in -the foreground, and releases it on Ctrl+C. While held, state-changing commands fail with +The command uses the device from the admitted remote lease, installs a short-lived hold, keeps it +alive in the foreground, and releases it on Ctrl+C. Activation waits for admitted mutations to finish +before reporting active. While held, state-changing commands fail with `DEVICE_IN_USE` and `details.reason: "human_control_active"`, explaining that agent interactions are temporarily disabled. Snapshots, screenshots, selector reads, logs, and other read-only diagnostics remain available. The hold also protects an existing remote device lease from inactivity expiry so the human does not accidentally hand the simulator to a different agent. -Use `agent-device takeover status` to list holds. A foreground hold expires automatically if its -process disappears; `agent-device takeover release ` is available for explicit recovery. -`takeover` always controls the local daemon, even when the CLI has an active remote connection. +A foreground hold expires automatically if its process disappears. Releasing or expiring the final +hold refreshes the existing lease's inactivity window. Tenant commands can modify only holds owned +by their admitted lease, not provider-host administrative holds. + +Holds do not survive daemon restart; reconnect and re-establish them before continuing human +interaction. Local takeover without a device-scoped remote lease is not supported in this version. +See [remote takeover and host administration](./remote-proxy.md#human-takeover) for the VM-side API. ## Web Automation diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 2b14a90022..b5d3a25f8a 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -53,24 +53,31 @@ Do not put proxy endpoint, token, tenant, or provider fields in `./agent-device. configuration is intentionally limited to project-safe automation defaults. Use `connect proxy`, user config, an explicit `--config` file, or protected CI environment variables for the endpoint and token. -## Human Takeover on the Host +## Human Takeover -If a person needs the simulator or device, run this on the host—not on the remote agent client: +With a remote device already leased by `open`, pause mutations through the same connection: ```bash -agent-device takeover --platform ios +agent-device takeover --session remote-session ``` -The local daemon pauses state-changing agent commands for the selected device until Ctrl+C. Read-only -diagnostics remain available, and an existing remote lease is preserved during the hold. +The foreground command renews its hold until Ctrl+C. Read-only diagnostics remain available, the +agent session stays open, and its lease is protected from inactivity expiry. Activation waits for +already-admitted mutations to finish. Status and recovery use `takeover status` and +`takeover release ` with the same session. -VM-side automation can use the same feature without a foreground CLI process. Read the local -daemon's `httpPort` and `token` from `daemon.json` in the effective state directory, then call the -loopback-only API with either `Authorization: Bearer ` or -`X-Agent-Device-Token: `: +Lease-owner operations use ordinary `agent_device.command` RPCs at `POST /rpc`, with command +`human_control` and positionals `["list"]`, `["put", "", "{\"ttlMs\":15000}"]`, or +`["remove", ""]`. Supply the same tenant, run, client, lease, backend, provider, and device +metadata as other requests. The PUT payload contains only `reason` and `ttlMs`; the server derives +the target from the admitted lease. It rejects caller-supplied `scope`. -The API is available when the daemon runs with an HTTP listener, including remote-mode daemons. A -default socket-only local daemon should use the `takeover` CLI command instead. +### Host administration + +VM-side automation can manage holds independently of a tenant. Read the daemon's `httpPort` and +`token` from `daemon.json` in its effective state directory, then use the loopback listener with +`Authorization: Bearer ` or `X-Agent-Device-Token: `. An HTTP listener +is required. A tenant credential does not grant this capability. ```text PUT /admin/human-control/holds/ @@ -78,22 +85,29 @@ GET /admin/human-control/holds DELETE /admin/human-control/holds/ ``` -A PUT body has the shape below. Omitting `ttlMs` creates a persistent hold that must be deleted; -including it creates an expiring hold. +The host PUT body names the exact lease contention identity, including its backend and provider. +Use the lease's `deviceKey`, not a bare device ID or a display name: ```json { "scope": { - "deviceKey": "", - "deviceName": "iPhone 17 Pro", - "platform": "ios", - "kind": "simulator" + "backend": "ios-instance", + "leaseProvider": "proxy", + "deviceKey": "ios:mobile:" }, "reason": "Human is using the VM console.", "ttlMs": 15000 } ``` +Repeated PUT renews the hold. Omitting `ttlMs` keeps it until explicit release or daemon shutdown. +Tenant RPCs cannot modify host holds. Multiple holds can coexist; mutations resume only when all +holds on the device end. + +Holds do not survive daemon restart, matching lease state. Reconnect and re-establish the hold +before continuing human interaction. Local takeover without a device-scoped remote lease is +deferred; this does not provide a host-global fence across local daemons. + ## What Is Exposed The proxy allows only the daemon HTTP contract: `/health`, `/rpc`, `/upload` plus resumable `/upload/*` routes, and `/artifacts/*`, with the same routes also available under `/agent-device/*`. Health checks are unauthenticated; command, upload, and artifact routes require the bearer token. diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index dbcc8d21f4..a74abc7ba2 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -24,9 +24,11 @@ CLI commands run through a per-user background daemon: For remote or cloud deployments, the daemon supports a custom auth hook for remotely consumable HTTP routes: `AGENT_DEVICE_HTTP_AUTH_HOOK` names a module path that is dynamically imported (with `AGENT_DEVICE_HTTP_AUTH_EXPORT` selecting the export). The host-local `/admin/human-control/*` route uses the daemon token instead. The hook runs with the daemon's full privileges, so treat it as part of your trusted computing base: point it only at a read-only path you control, never at a location writable by less-trusted users or processes. Whoever controls the daemon's environment controls the hook. -Human-control administration is host-local. The daemon accepts it only on its loopback listener with -the local daemon token, and `agent-device proxy` does not forward `/admin/*`. Persistent holds are -stored as `human-control.json` in the daemon state directory with `0600` permissions. +Lease-owner human-control RPCs pass normal authentication and tenant/lease admission. They can +target only the admitted lease's device and cannot alter a host administrator's hold. Host +administration is a separate capability: the daemon accepts `/admin/human-control/*` only on its +loopback listener with the local daemon token, and `agent-device proxy` does not forward `/admin/*`. +Holds and leases are in-memory; neither survives daemon restart. If a hook is configured and its result does not attest a `tenantId`, the daemon refuses the request (401) outright — it never falls back to a tenant the client declares itself (RPC body `meta.tenantId` or `flags.tenant`, or the `x-agent-device-tenant` header on the upload/artifact-download/diagnostics routes), and it never admits the request unscoped either: a shared token must not let one caller claim another tenant's identity, nor read a tenant-owned session or artifact by simply declaring none. A hook must attest `tenantId` on every request it wants admitted; a deployment with no hook configured is unaffected. From 6d43be22d9e3489b114d63af0158cf8cbceaa93a Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Fri, 28 Aug 2026 23:46:27 +0200 Subject: [PATCH 10/10] fix: cancel pending human takeover on disconnect --- docs/adr/0007-remote-device-leases.md | 5 ++ .../__tests__/device-mutation-drain.test.ts | 28 ++++++ .../__tests__/human-control-http.test.ts | 80 ++++++++++++++++- .../__tests__/lease-registry-scope.test.ts | 23 ++++- src/daemon/__tests__/lease-registry.test.ts | 53 +++++++++++ src/daemon/device-mutation-drain.ts | 24 +++-- src/daemon/handlers/human-control.ts | 8 +- src/daemon/human-control-http.ts | 26 +++++- src/daemon/lease-registry-scope.ts | 26 +++++- src/daemon/lease-registry.ts | 87 +++++++++---------- website/docs/docs/remote-proxy.md | 4 + 11 files changed, 302 insertions(+), 62 deletions(-) diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index c3117841ea..4f592267ff 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -68,6 +68,11 @@ their ownership checks. Unknown effects are treated as mutations. A pending acti 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. diff --git a/src/daemon/__tests__/device-mutation-drain.test.ts b/src/daemon/__tests__/device-mutation-drain.test.ts index 5bdb3c6a57..6c5a76cde6 100644 --- a/src/daemon/__tests__/device-mutation-drain.test.ts +++ b/src/daemon/__tests__/device-mutation-drain.test.ts @@ -2,6 +2,7 @@ import { createControlLatch } from './human-control-fixtures.ts'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { DeviceMutationDrain } from '../device-mutation-drain.ts'; +import { getEventListeners } from 'node:events'; test('drain counts concurrent operations, releases on failure, and isolates device keys', async () => { const drain = new DeviceMutationDrain(); @@ -24,3 +25,30 @@ test('drain counts concurrent operations, releases on failure, and isolates devi assert.equal(idle, true); await drain.wait('device-a'); }); + +test('canceling a drain waiter detaches its signal without canceling mutations or other waiters', async () => { + const drain = new DeviceMutationDrain(); + const finish = createControlLatch(); + const mutation = drain.run('device-a', () => finish.promise); + const controller = new AbortController(); + const reason = new Error('canceled waiter'); + const rejected = assert.rejects( + drain.wait('device-a', controller.signal), + (error) => error === reason, + ); + let drained = false; + const survivor = drain.wait('device-a').then(() => { + drained = true; + }); + controller.abort(reason); + await rejected; + assert.equal(drained, false); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + const successController = new AbortController(); + const successful = drain.wait('device-a', successController.signal); + finish.resolve(); + await Promise.all([mutation, survivor, successful]); + assert.equal(drained, true); + assert.equal(getEventListeners(successController.signal, 'abort').length, 0); + await assert.rejects(drain.wait('device-a', controller.signal), (error) => error === reason); +}); diff --git a/src/daemon/__tests__/human-control-http.test.ts b/src/daemon/__tests__/human-control-http.test.ts index 7df0755af3..326f2061f7 100644 --- a/src/daemon/__tests__/human-control-http.test.ts +++ b/src/daemon/__tests__/human-control-http.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; -import type http from 'node:http'; -import { test } from 'vitest'; +import http from 'node:http'; +import { test, vi } from 'vitest'; import { closeLoopbackServer, listenOnLoopback, @@ -8,7 +8,11 @@ import { } from '../../__tests__/test-utils/loopback.ts'; import { HUMAN_CONTROL_HTTP_PREFIX } from '../human-control-contract.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { HUMAN_CONTROL_SCOPE, humanControlRequest } from './human-control-fixtures.ts'; +import { + HUMAN_CONTROL_SCOPE, + humanControlRequest, + createControlLatch, +} from './human-control-fixtures.ts'; import { createHumanControlHarness } from './human-control-router-fixture.ts'; import { tryHandleHumanControlHttpRoute } from '../human-control-http.ts'; import { createDaemonHttpServer } from '../server/http-server.ts'; @@ -47,6 +51,76 @@ test('malformed request URLs return a normalized error', async () => { assert.equal((JSON.parse(responseBody) as { code?: string }).code, 'INVALID_ARGS'); }); +for (const transport of ['tenant RPC', 'host PUT'] as const) { + test(`${transport} disconnect during drain removes its pending hold before the mutation ends`, async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const { registry, lease, handleRequest } = createHumanControlHarness(); + const finish = createControlLatch(); + const disconnected = createControlLatch(); + let mutationFinished = false; + const mutation = registry.runDeviceMutation(lease, async () => { + await finish.promise; + mutationFinished = true; + }); + const server = await createDaemonHttpServer({ + token: 'test-token', + leaseRegistry: registry, + handleRequest, + }); + server.on('request', (_req, res) => { + res.once('close', () => { + if (!res.writableFinished) disconnected.resolve(); + }); + }); + let request: http.ClientRequest | undefined; + try { + const port = await listenOnLoopback(server); + const isRpc = transport === 'tenant RPC'; + const body = JSON.stringify( + isRpc + ? { + jsonrpc: '2.0', + id: 'disconnected-takeover', + method: 'agent_device.command', + params: humanControlRequest(lease, 'human_control', ['put', 'disconnected', '{}']), + } + : { scope: HUMAN_CONTROL_SCOPE }, + ); + request = http.request({ + host: '127.0.0.1', + port, + path: isRpc ? '/rpc' : `${HUMAN_CONTROL_HTTP_PREFIX}/disconnected`, + method: isRpc ? 'POST' : 'PUT', + headers: { + authorization: 'Bearer test-token', + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }, + }); + request.on('error', () => undefined); + request.end(body); + await vi.waitFor(() => { + assert.equal(registry.listHumanControlHolds({ kind: 'host' })[0]?.state, 'activating'); + }); + request.destroy(); + await disconnected.promise; + await vi.waitFor(() => { + assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []); + }); + assert.equal(mutationFinished, false); + finish.resolve(); + await mutation; + assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []); + assert.equal(await registry.runDeviceMutation(lease, async () => 'resumed'), 'resumed'); + } finally { + request?.destroy(); + finish.resolve(); + await mutation; + await closeLoopbackServer(server); + } + }); +} + test('host administration and tenant RPC use the same lease registry with distinct authority', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; const { registry, lease, handleRequest } = createHumanControlHarness(); diff --git a/src/daemon/__tests__/lease-registry-scope.test.ts b/src/daemon/__tests__/lease-registry-scope.test.ts index 5ac5fc0d52..1bb76af552 100644 --- a/src/daemon/__tests__/lease-registry-scope.test.ts +++ b/src/daemon/__tests__/lease-registry-scope.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { leaseDeviceBindingKey, normalizeAllocateLeaseRequest } from '../lease-registry-scope.ts'; +import { + createLeaseTtlResolver, + leaseDeviceBindingKey, + normalizeAllocateLeaseRequest, +} from '../lease-registry-scope.ts'; import { HUMAN_CONTROL_LEASE_REQUEST, HUMAN_CONTROL_SCOPE } from './human-control-fixtures.ts'; test('allocation and human control share the exact contention identity', () => { @@ -20,3 +24,20 @@ test('allocation and human control share the exact contention identity', () => { ); assert.equal(leaseDeviceBindingKey({ backend: 'ios-simulator' }), undefined); }); + +test('lease TTL normalization retains defaults, limits, and invalid configuration handling', () => { + const defaults = createLeaseTtlResolver({}); + assert.equal(defaults(undefined), 60_000); + assert.equal(defaults(1.5), 60_000); + assert.equal(defaults(5_000), 5_000); + assert.equal(defaults(600_000), 600_000); + for (const ttl of [4_999, 600_001]) assert.throws(() => defaults(ttl), { code: 'INVALID_ARGS' }); + const configured = createLeaseTtlResolver({ + defaultLeaseTtlMs: 0, + minLeaseTtlMs: 0, + maxLeaseTtlMs: -1, + }); + assert.equal(configured(undefined), 1); + assert.equal(configured(1), 1); + assert.throws(() => configured(2), { code: 'INVALID_ARGS' }); +}); diff --git a/src/daemon/__tests__/lease-registry.test.ts b/src/daemon/__tests__/lease-registry.test.ts index 4e9df2b661..61dbffbe65 100644 --- a/src/daemon/__tests__/lease-registry.test.ts +++ b/src/daemon/__tests__/lease-registry.test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import { createRequestCanceledError } from '@agent-device/kernel/errors'; import { LeaseRegistry } from '../lease-registry.ts'; import { HUMAN_CONTROL_LEASE_REQUEST, @@ -511,3 +512,55 @@ test('holds do not survive registry restart, including host holds created withou assert.deepEqual(restarted.listHumanControlHolds({ kind: 'host' }), []); assert.doesNotThrow(() => restarted.allocateLease(HUMAN_CONTROL_LEASE_REQUEST)); }); + +test('canceled activation refreshes the protected lease without waiting for the mutation', async () => { + let now = 0; + const registry = new LeaseRegistry({ now: () => now, defaultLeaseTtlMs: 5_000 }); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + const finish = createControlLatch(); + const mutation = registry.runDeviceMutation(lease, () => finish.promise); + const controller = new AbortController(); + const activation = registry.putHumanControlHold(authority, 'console', {}, controller.signal); + const canceled = createRequestCanceledError(); + const rejected = assert.rejects(activation, (error) => error === canceled); + now = 20_000; + controller.abort(canceled); + await rejected; + assert.deepEqual(registry.listHumanControlHolds(authority), []); + assert.equal(registry.listActiveLeases()[0]?.expiresAt, 25_000); + finish.resolve(); + await mutation; + assert.deepEqual(registry.listHumanControlHolds(authority), []); +}); + +test('canceling a superseded activation cannot remove its successor or another hold', async () => { + const registry = new LeaseRegistry(); + const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); + const authority = { kind: 'lease', leaseId: lease.leaseId } as const; + const finish = createControlLatch(); + const mutation = registry.runDeviceMutation(lease, () => finish.promise); + const controller = new AbortController(); + const canceled = createRequestCanceledError(); + const rejected = assert.rejects( + registry.putHumanControlHold(authority, 'console', {}, controller.signal), + (error) => error === canceled, + ); + const successor = registry.putHumanControlHold(authority, 'console', { reason: 'successor' }); + const other = registry.putHumanControlHold(authority, 'other', {}); + controller.abort(canceled); + await rejected; + assert.deepEqual( + registry.listHumanControlHolds(authority).map((hold) => hold.id), + ['console', 'other'], + ); + finish.resolve(); + await mutation; + assert.equal((await successor).reason, 'successor'); + assert.equal((await other).state, 'active'); + await assert.rejects( + registry.putHumanControlHold(authority, 'console', {}, controller.signal), + (error) => error === canceled, + ); + assert.equal(registry.listHumanControlHolds(authority)[0]?.reason, 'successor'); +}); diff --git a/src/daemon/device-mutation-drain.ts b/src/daemon/device-mutation-drain.ts index f7ac96c6f4..a37befe5f2 100644 --- a/src/daemon/device-mutation-drain.ts +++ b/src/daemon/device-mutation-drain.ts @@ -19,12 +19,24 @@ export class DeviceMutationDrain { } } - async wait(key: string): Promise { + async wait(key: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); if (!this.active.has(key)) return; - await new Promise((resolve) => { - const waiters = this.waiters.get(key) ?? new Set<() => void>(); - waiters.add(resolve); - this.waiters.set(key, waiters); - }); + const waiters = this.waiters.get(key) ?? new Set<() => void>(); + let drained: () => void = () => {}; + let aborted: () => void = () => {}; + try { + await new Promise((resolve, reject) => { + drained = resolve; + aborted = () => reject(signal?.reason); + waiters.add(drained); + this.waiters.set(key, waiters); + signal?.addEventListener('abort', aborted, { once: true }); + }); + } finally { + signal?.removeEventListener('abort', aborted); + waiters.delete(drained); + if (waiters.size === 0 && this.waiters.get(key) === waiters) this.waiters.delete(key); + } } } diff --git a/src/daemon/handlers/human-control.ts b/src/daemon/handlers/human-control.ts index d099ed1443..6ab31ba5f0 100644 --- a/src/daemon/handlers/human-control.ts +++ b/src/daemon/handlers/human-control.ts @@ -1,4 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; +import { getRequestSignal } from '@agent-device/host-kit/request'; import { parseHumanControlHoldInput } from '../human-control-contract.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; @@ -21,7 +22,12 @@ export async function handleHumanControlCommand(params: { return { ok: true, data: { holds: registry.listHumanControlHolds(authority) } }; case 'put': { assertArgumentCount(positionals, 3); - const hold = await registry.putHumanControlHold(authority, holdId, readHoldInput(rawInput)); + const hold = await registry.putHumanControlHold( + authority, + holdId, + readHoldInput(rawInput), + getRequestSignal(req.meta?.requestId), + ); return { ok: true, data: { hold, state: 'active' } }; } case 'remove': { diff --git a/src/daemon/human-control-http.ts b/src/daemon/human-control-http.ts index 517698290b..0f88d43ba6 100644 --- a/src/daemon/human-control-http.ts +++ b/src/daemon/human-control-http.ts @@ -1,5 +1,5 @@ import type http from 'node:http'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError, createRequestCanceledError, normalizeError } from '@agent-device/kernel/errors'; import { readNodeHttpRequestBody } from '../utils/node-http.ts'; import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import { sendRestJsonError } from './http-errors.ts'; @@ -75,9 +75,27 @@ async function upsertHumanControlHold( holdId: string, params: HumanControlHttpParams, ): Promise { - const input = await readHoldInput(params.req); - const hold = await params.registry.putHumanControlHold({ kind: 'host' }, holdId, input); - sendJson(params.res, { ok: true, hold, state: 'active' }); + const { req, res } = params; + const controller = new AbortController(); + const cancelIfDisconnected = () => { + if (!res.writableFinished) controller.abort(createRequestCanceledError()); + }; + req.once('aborted', cancelIfDisconnected); + res.once('close', cancelIfDisconnected); + if (req.aborted || res.destroyed) cancelIfDisconnected(); + try { + const input = await readHoldInput(req); + const hold = await params.registry.putHumanControlHold( + { kind: 'host' }, + holdId, + input, + controller.signal, + ); + sendJson(res, { ok: true, hold, state: 'active' }); + } finally { + req.off('aborted', cancelIfDisconnected); + res.off('close', cancelIfDisconnected); + } } function removeHumanControlHold(holdId: string, params: HumanControlHttpParams): void { diff --git a/src/daemon/lease-registry-scope.ts b/src/daemon/lease-registry-scope.ts index 5a029f0776..614afd2449 100644 --- a/src/daemon/lease-registry-scope.ts +++ b/src/daemon/lease-registry-scope.ts @@ -83,11 +83,31 @@ export type NormalizedAllocateLeaseRequest = { ttlMs?: number; }; -export const DEFAULT_LEASE_TTL_MS = 60_000; -export const MIN_LEASE_TTL_MS = 5_000; -export const MAX_LEASE_TTL_MS = 10 * 60_000; +const DEFAULT_LEASE_TTL_MS = 60_000; +const MIN_LEASE_TTL_MS = 5_000; +const MAX_LEASE_TTL_MS = 10 * 60_000; const DEFAULT_LEASE_PROVIDER = 'default'; +export function createLeaseTtlResolver(options: LeaseRegistryOptions) { + const defaultTtl = Number.isInteger(options.defaultLeaseTtlMs) + ? Math.max(1, Number(options.defaultLeaseTtlMs)) + : DEFAULT_LEASE_TTL_MS; + const minTtl = Number.isInteger(options.minLeaseTtlMs) + ? Math.max(1, Number(options.minLeaseTtlMs)) + : MIN_LEASE_TTL_MS; + const maxTtl = Number.isInteger(options.maxLeaseTtlMs) + ? Math.max(minTtl, Number(options.maxLeaseTtlMs)) + : MAX_LEASE_TTL_MS; + return (raw: number | undefined): number => { + if (!Number.isInteger(raw)) return defaultTtl; + const value = Number(raw); + if (value < minTtl || value > maxTtl) { + throw new AppError('INVALID_ARGS', `Lease ttlMs must be between ${minTtl} and ${maxTtl}.`); + } + return value; + }; +} + function normalizeRunId(raw: string | undefined): string | undefined { if (!raw) return undefined; const value = raw.trim(); diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index c8182ac9cd..35e9e89696 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -13,9 +13,7 @@ import { type ReleaseLeaseRequest, type LeaseRegistryOptions, type NormalizedAllocateLeaseRequest, - DEFAULT_LEASE_TTL_MS, - MIN_LEASE_TTL_MS, - MAX_LEASE_TTL_MS, + createLeaseTtlResolver, normalizeAllocateLeaseRequest, createDeviceLease, deviceLeaseBusyError, @@ -48,9 +46,7 @@ export class LeaseRegistry { private readonly runBindings = new Map(); private readonly deviceBindings = new Map(); private readonly maxActiveSimulatorLeases: number; - private readonly defaultLeaseTtlMs: number; - private readonly minLeaseTtlMs: number; - private readonly maxLeaseTtlMs: number; + private readonly resolveLeaseTtlMs: ReturnType; private readonly now: () => number; private readonly onLeaseExpired?: (lease: DeviceLease) => void; private readonly providerSessionOwnership: ProviderSessionOwnershipRegistry; @@ -59,15 +55,7 @@ export class LeaseRegistry { this.maxActiveSimulatorLeases = Number.isInteger(options.maxActiveSimulatorLeases) ? Math.max(0, Number(options.maxActiveSimulatorLeases)) : 0; - this.defaultLeaseTtlMs = Number.isInteger(options.defaultLeaseTtlMs) - ? Math.max(1, Number(options.defaultLeaseTtlMs)) - : DEFAULT_LEASE_TTL_MS; - this.minLeaseTtlMs = Number.isInteger(options.minLeaseTtlMs) - ? Math.max(1, Number(options.minLeaseTtlMs)) - : MIN_LEASE_TTL_MS; - this.maxLeaseTtlMs = Number.isInteger(options.maxLeaseTtlMs) - ? Math.max(this.minLeaseTtlMs, Number(options.maxLeaseTtlMs)) - : MAX_LEASE_TTL_MS; + this.resolveLeaseTtlMs = createLeaseTtlResolver(options); this.now = options.now ?? (() => Date.now()); this.onLeaseExpired = options.onLeaseExpired; this.providerSessionOwnership = new ProviderSessionOwnershipRegistry({ @@ -198,7 +186,9 @@ export class LeaseRegistry { authority: HumanControlAuthority, rawId: string, rawInput: HumanControlHoldInput, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const id = normalizeHumanControlHoldId(rawId); const input = parseHumanControlHoldInput(rawInput); this.cleanupExpiredLeases(); @@ -226,23 +216,40 @@ export class LeaseRegistry { const holds = this.holdsByDevice.get(key) ?? new Map(); this.holdsByDevice.set(key, holds); holds.set(id, pending); - await this.mutations.wait(key); - if (holds.get(id) !== pending) { - throw new AppError( - 'COMMAND_FAILED', - 'Human-control hold changed before activation completed.', - { holdId: id }, - ); + return await this.activateHumanControlHold(key, pending, input.ttlMs, signal); + } + + private async activateHumanControlHold( + key: string, + pending: OwnedHumanControlHold, + ttlMs: number | undefined, + signal?: AbortSignal, + ): Promise { + const holds = this.holdsByDevice.get(key)!; + const { id } = pending.hold; + try { + await this.mutations.wait(key, signal); + signal?.throwIfAborted(); + if (holds.get(id) !== pending) { + throw new AppError( + 'COMMAND_FAILED', + 'Human-control hold changed before activation completed.', + { holdId: id }, + ); + } + const activatedAt = this.now(); + pending.hold = { + ...pending.hold, + state: 'active', + updatedAt: activatedAt, + ...(ttlMs === undefined ? {} : { expiresAt: activatedAt + ttlMs }), + }; + this.refreshHeldLease(key, activatedAt); + return cloneHumanControlHold(pending.hold); + } catch (error) { + if (holds.get(id) === pending) this.deleteHumanControlHold(key, id); + throw error; } - const activatedAt = this.now(); - pending.hold = { - ...pending.hold, - state: 'active', - updatedAt: activatedAt, - ...(input.ttlMs === undefined ? {} : { expiresAt: activatedAt + input.ttlMs }), - }; - this.refreshHeldLease(key, activatedAt); - return cloneHumanControlHold(pending.hold); } removeHumanControlHold( @@ -255,13 +262,17 @@ export class LeaseRegistry { if (!existing) return undefined; this.assertHoldAuthority(authority, existing); const key = leaseDeviceBindingKey(existing.hold.scope)!; + this.deleteHumanControlHold(key, id); + return cloneHumanControlHold(existing.hold); + } + + private deleteHumanControlHold(key: string, id: string): void { const holds = this.holdsByDevice.get(key)!; holds.delete(id); if (holds.size === 0) { this.holdsByDevice.delete(key); this.refreshHeldLease(key, this.now()); } - return cloneHumanControlHold(existing.hold); } assertHumanControlAdmission( @@ -426,18 +437,6 @@ export class LeaseRegistry { }); } - private resolveLeaseTtlMs(raw: number | undefined): number { - if (!Number.isInteger(raw)) return this.defaultLeaseTtlMs; - const value = Number(raw); - if (value < this.minLeaseTtlMs || value > this.maxLeaseTtlMs) { - throw new AppError( - 'INVALID_ARGS', - `Lease ttlMs must be between ${this.minLeaseTtlMs} and ${this.maxLeaseTtlMs}.`, - ); - } - return value; - } - private getActiveLease(leaseId: string): DeviceLease { const lease = this.leases.get(leaseId); if (lease) return lease; diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index b5d3a25f8a..593a2a8ae0 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -66,6 +66,10 @@ agent session stays open, and its lease is protected from inactivity expiry. Act already-admitted mutations to finish. Status and recovery use `takeover status` and `takeover release ` with the same session. +If the requesting connection disconnects while activation is waiting for mutations to finish, its +pending hold is removed and cannot activate later. This applies to both tenant RPCs and host PUTs. +Once active, holds follow their configured TTL or explicit release lifecycle. + Lease-owner operations use ordinary `agent_device.command` RPCs at `POST /rpc`, with command `human_control` and positionals `["list"]`, `["put", "", "{\"ttlMs\":15000}"]`, or `["remove", ""]`. Supply the same tenant, run, client, lease, backend, provider, and device