diff --git a/.fallowrc.json b/.fallowrc.json index a39923f0a0..685eb04c7b 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -220,7 +220,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,plugins,proxy,recording,replay,screenshot,takeover}.ts", + "file": "src/cli/commands/{auth,connection,daemon,device,host,plugins,proxy,recording,replay,screenshot,takeover}.ts", "exports": [ "authCommand", "pluginsCommand", @@ -229,6 +229,7 @@ "connectionCommand", "daemonCommand", "deviceCommand", + "hostCommand", "proxyCommand", "recordingCommand", "replayCommand", diff --git a/packages/command-registry/src/__tests__/device-claim-policy.test.ts b/packages/command-registry/src/__tests__/device-claim-policy.test.ts index 82b8b503af..4df3670e2e 100644 --- a/packages/command-registry/src/__tests__/device-claim-policy.test.ts +++ b/packages/command-registry/src/__tests__/device-claim-policy.test.ts @@ -61,6 +61,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set 'daemon', 'debug', 'disconnect', + 'host', 'human_control', 'install-from-source', 'lease_allocate', diff --git a/packages/command-registry/src/flag-definitions-connection.ts b/packages/command-registry/src/flag-definitions-connection.ts index 2b6fc8fbda..6a872b96b2 100644 --- a/packages/command-registry/src/flag-definitions-connection.ts +++ b/packages/command-registry/src/flag-definitions-connection.ts @@ -76,7 +76,7 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--host'], type: 'string', usageLabel: '--host ', - usageDescription: 'Proxy: host interface to bind (default: 127.0.0.1)', + usageDescription: 'Proxy and host: interface to bind (default: 127.0.0.1)', projectConfig: false, recorded: false, }, @@ -87,7 +87,25 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ min: 1, max: 65535, usageLabel: '--port ', - usageDescription: 'Proxy: TCP port to bind (default: 0, choose a free port)', + usageDescription: 'Proxy and host: TCP port to bind (default: 0, choose a free port)', + projectConfig: false, + recorded: false, + }, + { + key: 'hostTlsCert', + names: ['--tls-cert'], + type: 'string', + usageLabel: '--tls-cert ', + usageDescription: 'Host: PEM certificate to serve HTTPS (requires --tls-key)', + projectConfig: false, + recorded: false, + }, + { + key: 'hostTlsKey', + names: ['--tls-key'], + type: 'string', + usageLabel: '--tls-key ', + usageDescription: 'Host: PEM private key to serve HTTPS (requires --tls-cert)', projectConfig: false, recorded: false, }, diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 8b0ce02337..61352092ba 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1760,6 +1760,17 @@ export const RAW_COMMAND_DESCRIPTORS = [ mcpExposed: false, platformExecution: NO_PLATFORM_EXECUTION, }, + { + name: 'host', + deviceClaimPolicy: 'none', + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/host.ts'] as const } : {}), + catalog: { group: 'local-cli' }, + recordsSessionAction: false, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + mcpExposed: false, + platformExecution: NO_PLATFORM_EXECUTION, + }, { name: 'proxy', deviceClaimPolicy: 'none', diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 8cd4412922..70c643581f 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -43,6 +43,8 @@ export type CliFlags = CloudProviderProfileFields & daemonServerMode?: DaemonServerMode; proxyHost?: string; proxyPort?: number; + hostTlsCert?: string; + hostTlsKey?: string; tenant?: string; sessionIsolation?: SessionIsolationMode; runId?: string; diff --git a/packages/contracts/src/daemon-http.ts b/packages/contracts/src/daemon-http.ts index 868338e76b..b47d95d4eb 100644 --- a/packages/contracts/src/daemon-http.ts +++ b/packages/contracts/src/daemon-http.ts @@ -7,6 +7,11 @@ export const DAEMON_HTTP_BASE_PATH = '/agent-device'; export const DAEMON_HTTP_TENANT_HEADER = 'x-agent-device-tenant'; export const DAEMON_HTTP_NETWORK_ACCESS_HEADER = 'x-agent-device-network-access'; export const DAEMON_HTTP_PUBLIC_NETWORK_ACCESS = 'public-only'; +/** + * The principal the Host front-end authenticated (ADR 0021 §6). The daemon trusts it only on a + * request that already carries the daemon token, and the proxy never forwards it from a client. + */ +export const DAEMON_HTTP_PRINCIPAL_HEADER = 'x-agent-device-principal'; export function buildDaemonHttpBaseUrl(baseUrl: string): string { return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH); @@ -52,16 +57,27 @@ export function buildDaemonInstanceMismatchRpcResponse( export type DaemonHealthPayload = { ok: true; - service: 'agent-device-daemon' | 'agent-device-proxy'; + service: 'agent-device-daemon' | 'agent-device-proxy' | typeof DAEMON_HOST_SERVICE; version: string; rpcProtocolVersion: number; instanceId?: string; hostArch?: string; /** The lease backends this daemon admits; a host checks it before relying on one. */ leaseBackends?: readonly string[]; + /** Optional capabilities a client checks before sending a request that relies on one. */ + features?: readonly DaemonHealthFeature[]; upstream?: unknown; }; +/** + * Host allocates a fresh device per lease from a shape (`--device "iPhone 16"`) instead of a + * local inventory identity (ADR 0021 §5). A client sends a shape only to a peer advertising it. + */ +export const DAEMON_HOST_DEVICE_SHAPE_FEATURE = 'device-shape'; +/** The `service` a Host front-end reports, which a client reads to treat `--device` as a type. */ +export const DAEMON_HOST_SERVICE = 'agent-device-host'; +export type DaemonHealthFeature = typeof DAEMON_HOST_DEVICE_SHAPE_FEATURE; + export function buildDaemonHealthPayload( service: DaemonHealthPayload['service'], version: string, @@ -70,6 +86,7 @@ export function buildDaemonHealthPayload( instanceId?: string; hostArch?: string; leaseBackends?: readonly string[]; + features?: readonly DaemonHealthFeature[]; } = {}, ): DaemonHealthPayload { return { @@ -80,6 +97,7 @@ export function buildDaemonHealthPayload( ...(options.instanceId !== undefined ? { instanceId: options.instanceId } : {}), ...(options.hostArch !== undefined ? { hostArch: options.hostArch } : {}), ...(options.leaseBackends !== undefined ? { leaseBackends: options.leaseBackends } : {}), + ...(options.features !== undefined ? { features: options.features } : {}), ...(options.upstream !== undefined ? { upstream: options.upstream } : {}), }; } diff --git a/packages/proxy/src/daemon-proxy.test.ts b/packages/proxy/src/daemon-proxy.test.ts index 72178e8484..05c86963fb 100644 --- a/packages/proxy/src/daemon-proxy.test.ts +++ b/packages/proxy/src/daemon-proxy.test.ts @@ -62,6 +62,53 @@ test('rpc reaches the daemon through the supplied upstream transport with the da expect(payload.echo.params.token).toBe('daemon-secret'); }); +test('a client-sent principal header never reaches the daemon', async () => { + const upstream = recordingUpstream(() => Response.json({ jsonrpc: '2.0', id: 1, result: {} })); + const proxy = proxyWith(upstream.fetch); + + await proxy.handle( + rpcRequest( + { jsonrpc: '2.0', id: 1, method: 'agent-device.command', params: {} }, + { + headers: { + authorization: 'Bearer client-secret', + 'content-type': 'application/json', + 'x-agent-device-principal': 'host-svc-attacker', + }, + }, + ), + ); + + expect(upstream.requests[0]?.headers.has('x-agent-device-principal')).toBe(false); +}); + +test('an embedder admits authorized rpc params before they are forwarded', async () => { + const upstream = recordingUpstream(async (request) => Response.json(await request.json())); + const proxy = createDaemonProxy({ + upstreamBaseUrl: 'http://daemon.internal:4310', + upstreamToken: 'daemon-secret', + clientToken: 'client-secret', + upstreamFetch: upstream.fetch, + admitRpc: ({ params }) => + params.command === 'refuse' + ? new Response(null, { status: 403 }) + : { ...params, rewritten: true }, + }); + const rpc = (command: string) => + rpcRequest({ jsonrpc: '2.0', id: 1, method: 'agent-device.command', params: { command } }); + + expect((await proxy.handle(rpc('refuse'))).status).toBe(403); + expect(upstream.requests).toHaveLength(0); + const forwarded = (await (await proxy.handle(rpc('devices'))).json()) as { + params: Record; + }; + expect(forwarded.params).toMatchObject({ + command: 'devices', + rewritten: true, + token: 'daemon-secret', + }); +}); + test('a request without the client token never reaches the upstream transport', async () => { const upstream = recordingUpstream(() => Response.json({})); const proxy = proxyWith(upstream.fetch); diff --git a/packages/proxy/src/daemon-proxy.ts b/packages/proxy/src/daemon-proxy.ts index 5314c08e75..533136022b 100644 --- a/packages/proxy/src/daemon-proxy.ts +++ b/packages/proxy/src/daemon-proxy.ts @@ -27,6 +27,15 @@ import { */ export type DaemonProxyUpstreamFetch = (request: Request) => Promise; +/** + * Admits one authorized JSON-RPC request before it is forwarded: return the params to forward, + * possibly rewritten, or a response to answer with instead. It runs after the proxy checked the + * client token, so an embedder applies its own policy without repeating authentication. + */ +export type DaemonProxyRpcAdmission = ( + rpc: Readonly<{ id: unknown; method: string; params: Record }>, +) => Record | Response; + export type DaemonProxyOptions = { /** Base URL of the upstream agent-device daemon HTTP server. */ upstreamBaseUrl: string; @@ -37,6 +46,7 @@ export type DaemonProxyOptions = { maxRpcBodyBytes?: number; upstreamTimeoutMs?: number; upstreamFetch?: DaemonProxyUpstreamFetch; + admitRpc?: DaemonProxyRpcAdmission; }; export type DaemonProxy = { @@ -51,7 +61,8 @@ export type DaemonProxy = { handle(request: Request): Promise; }; -type NormalizedProxyOptions = Required; +type NormalizedProxyOptions = Required> & + Pick; const DEFAULT_MAX_RPC_BODY_BYTES = 1024 * 1024; const DEFAULT_UPSTREAM_TIMEOUT_MS = 5 * 60 * 1000; @@ -137,6 +148,10 @@ async function handleProxyRequest( ); if (staleInstance) return staleInstance; + const admitted = admitRpcBody(rpcBody, options.admitRpc); + if (admitted instanceof Response) return admitted; + rpcBody = admitted; + if (carriesUnbackedHostPathInstallSource(rpcBody)) { return hostPathInstallSourceRefusedResponse(readJsonRpcId(rpcBody)); } @@ -144,6 +159,26 @@ async function handleProxyRequest( return await forwardProxyRequest({ request, route, options, rpcBody }); } +/** A body that is not a JSON-RPC request is forwarded as it came, for the daemon to refuse. */ +function admitRpcBody( + rpcBody: string | undefined, + admitRpc: DaemonProxyRpcAdmission | undefined, +): string | undefined | Response { + if (rpcBody === undefined || !admitRpc) return rpcBody; + let envelope: Record; + try { + envelope = JSON.parse(rpcBody) as Record; + } catch { + return rpcBody; + } + const { method, params } = envelope ?? {}; + if (typeof method !== 'string' || !params || typeof params !== 'object') return rpcBody; + const admitted = admitRpc({ id: envelope.id, method, params: params as Record }); + return admitted instanceof Response + ? admitted + : JSON.stringify({ ...envelope, params: admitted }); +} + async function proxyHealthResponse( request: Request, options: NormalizedProxyOptions, @@ -316,6 +351,7 @@ function normalizeProxyOptions(options: DaemonProxyOptions): NormalizedProxyOpti maxRpcBodyBytes: options.maxRpcBodyBytes ?? DEFAULT_MAX_RPC_BODY_BYTES, upstreamTimeoutMs: options.upstreamTimeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS, upstreamFetch: options.upstreamFetch ?? ((request) => fetch(request)), + admitRpc: options.admitRpc, }; } diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts index dfccde8ea1..a0f6e49ade 100644 --- a/packages/proxy/src/index.ts +++ b/packages/proxy/src/index.ts @@ -4,5 +4,6 @@ export { createDaemonProxyServer, type DaemonProxy, type DaemonProxyOptions, + type DaemonProxyRpcAdmission, type DaemonProxyUpstreamFetch, } from './daemon-proxy.ts'; diff --git a/scripts/__tests__/help-conformance-topic-coverage.test.ts b/scripts/__tests__/help-conformance-topic-coverage.test.ts index 80a55664fb..830ff6c25d 100644 --- a/scripts/__tests__/help-conformance-topic-coverage.test.ts +++ b/scripts/__tests__/help-conformance-topic-coverage.test.ts @@ -12,6 +12,7 @@ const WAIVED_TOPICS: Record = { cdp: 'JS-heap forensics niche; add cases when heap-guidance regressions show up in practice.', commands: 'Derived command/configuration reference, not a planning loop; catalog completeness is structurally tested.', + host: 'Operator setup for the Host front-end, not a planning loop; no worker-planning case is defined yet.', macos: 'macOS surface guidance is thin and stable; no observed planning regressions yet.', maestro: 'Compatibility reference, not a planning loop; conformance is oracle-tested instead.', 'physical-device': 'Needs device-specific setup guidance; no portable planning task defined yet.', diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 3a1be8a0b8..a5ae1cf814 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -332,6 +332,11 @@ function summarizeProviderScenarioFlagExclusions() { 'stale', ], }, + { + name: 'Host front-end TLS options', + owner: 'Host server tests (src/cli/host/host-server.test.ts)', + keys: ['hostTlsCert', 'hostTlsKey'], + }, { name: 'daemon lifecycle control', owner: 'daemon CLI lifecycle tests', diff --git a/src/cli.ts b/src/cli.ts index 0bdbb3de5c..a84f8b0d08 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -103,6 +103,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([ 'plugins', 'device', 'disconnect', + 'host', 'metro', 'proxy', 'session', @@ -726,6 +727,7 @@ function resolveActiveConnectionDefaults(options: { options.command === 'connection' || options.command === 'daemon' || options.command === 'plugins' || + options.command === 'host' || options.command === 'proxy' ) { return null; @@ -755,6 +757,7 @@ function shouldResolveRemoteAuth(command: string): boolean { command !== 'daemon' && command !== 'plugins' && command !== 'device' && + command !== 'host' && command !== 'proxy' ); } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index a959b7a173..5312ae598f 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -7,22 +7,15 @@ 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, - resolveDevice, - type DeviceInfo, -} from '@agent-device/kernel/device'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import { isInactiveLeaseError } from '@agent-device/contracts/lease-scope'; import { narrowConnectionPlatform, - buildConnectionDeviceKey, buildRemoteConnectionDaemonState, buildRemoteConnectionRequestMetadata, hashRemoteConfigFile, mergeRemoteConnectionRequestMetadata, readRemoteConnectionState, - resolveConnectionDeviceScope, writeRemoteConnectionState, type RemoteConnectionState, type RemoteConnectionRequestMetadata, @@ -39,11 +32,12 @@ import { import type { CliFlags } from '@agent-device/contracts/command'; import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; import { connectionProviderCapabilities } from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; +import type { ResolvedLeaseState } from './proxy-lease-device.ts'; const leaseDeferredCommands = new Set([ 'artifacts', @@ -55,12 +49,6 @@ const leaseDeferredCommands = new Set([ 'session', ]); const runtimeDeferredCommands = new Set(['open']); -const proxyLeaseAllocatingCommands: ReadonlySet = new Set([ - PUBLIC_COMMANDS.open, - PUBLIC_COMMANDS.install, - PUBLIC_COMMANDS.reinstall, - INTERNAL_COMMANDS.installSource, -]); export const PROXY_REMOTE_LEASE_TTL_MS = 5 * 60 * 1000; export const CLOUD_WEBDRIVER_REMOTE_LEASE_TTL_MS = 10 * 60 * 1000; @@ -336,6 +324,7 @@ async function materializeLeaseForCommand(options: { }); nextState = resolvedLeaseState.state; if (resolvedLeaseState.device) { + const { applyResolvedDeviceSelector } = await import('./proxy-lease-device.ts'); applyResolvedDeviceSelector(nextFlags, resolvedLeaseState.device); } const leaseBackend = @@ -374,6 +363,7 @@ async function materializeLeaseForCommand(options: { options.initialApp, ); const lease = materializedLease.lease; + resolvedLeaseState.pinLeasedDevice?.(nextFlags, lease); nextFlags.leaseId = lease.leaseId; nextFlags.leaseBackend = leaseBackend; nextFlags.target = nextState.target ?? nextFlags.target; @@ -430,7 +420,7 @@ type ConnectionLeasePolicy = { state: RemoteConnectionState; flags: CliFlags; leaseBackend?: LeaseBackend; - }): Promise<{ state: RemoteConnectionState; device?: DeviceInfo }>; + }): Promise; }; function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { @@ -464,7 +454,11 @@ const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { const PROXY_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => command !== 'devices' && !leaseDeferredCommands.has(command), ttlMs: () => PROXY_REMOTE_LEASE_TTL_MS, - resolveLeaseState: resolveProxyLeaseState, + // Loaded on demand, like every proxy-only path, to stay out of the CLI's eager import closure. + resolveLeaseState: async (options) => { + const { resolveProxyLeaseState } = await import('./proxy-lease-device.ts'); + return await resolveProxyLeaseState(options); + }, }; /** @@ -927,75 +921,6 @@ async function allocateOrReuseLease( return { lease, acquired: true }; } -async function resolveProxyLeaseState(options: { - command: string; - client: AgentDeviceClient; - state: RemoteConnectionState; - flags: CliFlags; - leaseBackend?: LeaseBackend; -}): Promise<{ state: RemoteConnectionState; device?: DeviceInfo }> { - if (!proxyLeaseAllocatingCommands.has(options.command)) { - if (options.state.leaseId && options.state.deviceKey) return { state: options.state }; - throw new AppError( - 'INVALID_ARGS', - 'No active proxy device lease for this session; run open first.', - ); - } - const device = await resolveSelectedDevice(options.client, options.flags); - const scope = resolveConnectionDeviceScope(device); - return { - state: { - ...options.state, - deviceKey: buildConnectionDeviceKey(scope), - leaseBackend: options.state.leaseBackend ?? options.leaseBackend ?? scope.leaseBackend, - platform: scope.platform, - target: options.state.target ?? scope.target, - updatedAt: new Date().toISOString(), - }, - device, - }; -} - -function applyResolvedDeviceSelector(flags: CliFlags, device: DeviceInfo): void { - const scope = resolveConnectionDeviceScope(device); - flags.platform = scope.platform; - flags.target = scope.target ?? flags.target; - if (scope.identityFlag === 'udid') flags.udid = scope.id; - if (scope.identityFlag === 'serial') flags.serial = scope.id; -} - -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, - }, - ); -} - /** * The refusal raised when the platform axis cannot be decided: two of the backend, the record, and * the request name devices that are not the same device. `detail` says which of the three disagreed, diff --git a/src/cli/commands/host-device-shape-connection.test.ts b/src/cli/commands/host-device-shape-connection.test.ts new file mode 100644 index 0000000000..78190206d4 --- /dev/null +++ b/src/cli/commands/host-device-shape-connection.test.ts @@ -0,0 +1,178 @@ +import { expect, test, type TestContext } from 'vitest'; +import fs from 'node:fs'; +import http from 'node:http'; +import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http'; +import type { LeaseAllocateOptions } from '@agent-device/contracts/client'; +import { + connectionWorkspace, + createTestClient, +} from '../../__tests__/remote-connection.fixtures.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { LeaseRegistry } from '../../daemon/lease-registry.ts'; +import { materializeRemoteConnectionForCommand } from './connection-runtime.ts'; + +async function serveHealth(t: TestContext, health: Record): Promise { + const server = http.createServer((_req, res) => { + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ ok: true, rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION, ...health }), + ); + }); + const port = await listenOnLoopback(server); + t.onTestFinished(() => closeLoopbackServer(server)); + return `http://127.0.0.1:${port}/agent-device`; +} + +function workerFlags(daemonBaseUrl: string) { + const { stateDir, remoteConfigPath } = connectionWorkspace('agent-device-host-shape-'); + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl, + daemonAuthToken: 'host-service-token', + tenant: 'proxy', + runId: 'verify-812', + leaseProvider: 'proxy', + clientId: 'ab12cd34', + }), + ); + return { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + session: 'verify', + platform: 'ios' as const, + device: 'iPhone 16', + }; +} + +function recordingClient() { + const registry = new LeaseRegistry(); + const allocations: LeaseAllocateOptions[] = []; + let inventoryReads = 0; + const client = createTestClient({ + listDevices: async () => { + inventoryReads += 1; + return []; + }, + allocate: async (options) => { + allocations.push(options); + return registry.allocateLease({ + tenantId: options.tenant, + runId: options.runId, + clientId: options.clientId, + leaseBackend: options.leaseBackend, + leaseProvider: options.leaseProvider, + deviceKey: 'ios:mobile:SIM-UDID-1', + }); + }, + }); + return { client, allocations, inventoryReads: () => inventoryReads }; +} + +test('on a Host, --device sends the device type without resolving inventory', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const daemonBaseUrl = await serveHealth(t, { + service: 'agent-device-host', + instanceId: 'host-1', + features: ['device-shape'], + upstream: { + service: 'agent-device-daemon', + instanceId: 'daemon-1', + features: ['device-shape'], + }, + }); + const worker = recordingClient(); + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'open', + positionals: ['com.example.app'], + flags: workerFlags(daemonBaseUrl), + client: worker.client, + }); + + expect(worker.inventoryReads()).toBe(0); + expect(worker.allocations).toHaveLength(1); + expect(worker.allocations[0]).toMatchObject({ platform: 'ios', device: 'iPhone 16' }); + expect(worker.allocations[0]?.udid).toBeUndefined(); + expect(materialized.connection).toMatchObject({ deviceKey: 'ios:mobile:SIM-UDID-1' }); + expect(materialized.flags).toMatchObject({ udid: 'SIM-UDID-1' }); + expect(materialized.flags.device).toBeUndefined(); +}); + +const SHAPE_HOST = { + service: 'agent-device-host', + instanceId: 'host-2', + features: ['device-shape'], + upstream: { service: 'agent-device-daemon', instanceId: 'daemon-2' }, +}; + +test('a Host refuses what it cannot allocate before any lease request', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const shapeHost = await serveHealth(t, SHAPE_HOST); + const cases: Array<[string, string, Record]> = [ + ['host-shape-unsupported', await serveHealth(t, { ...SHAPE_HOST, features: [] }), {}], + ['host-unauthenticated', await serveHealth(t, { service: 'agent-device-host' }), {}], + ['host-shape-invalid', shapeHost, { udid: 'SIM-X' }], + ['host-shape-invalid', shapeHost, { device: undefined }], + ['host-shape-platform-required', shapeHost, { platform: undefined }], + ]; + for (const [reason, daemonBaseUrl, override] of cases) { + const worker = recordingClient(); + await expect( + materializeRemoteConnectionForCommand({ + command: 'open', + positionals: ['com.example.app'], + flags: { ...workerFlags(daemonBaseUrl), ...override }, + client: worker.client, + }), + ).rejects.toMatchObject({ details: { reason } }); + expect(worker.allocations, reason).toHaveLength(0); + expect(worker.inventoryReads(), reason).toBe(0); + } +}); + +test('a session holding one Host device type refuses another', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const flags = workerFlags(await serveHealth(t, SHAPE_HOST)); + const worker = recordingClient(); + const open = (device: string) => + materializeRemoteConnectionForCommand({ + command: 'open', + positionals: ['com.example.app'], + flags: { ...flags, device }, + client: worker.client, + }); + + await open('iPhone 16'); + await expect(open('iPad Pro')).rejects.toMatchObject({ + details: { reason: 'host-shape-mismatch' }, + }); +}); + +test('plain proxy keeps resolving --device against remote inventory', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const daemonBaseUrl = await serveHealth(t, { + service: 'agent-device-proxy', + instanceId: 'proxy-1', + upstream: { service: 'agent-device-daemon', instanceId: 'daemon-3' }, + }); + const worker = recordingClient(); + + await expect( + materializeRemoteConnectionForCommand({ + command: 'open', + positionals: ['com.example.app'], + flags: workerFlags(daemonBaseUrl), + client: worker.client, + }), + ).rejects.toThrow(); + expect(worker.inventoryReads()).toBe(1); + expect(worker.allocations).toHaveLength(0); +}); diff --git a/src/cli/commands/host-device-shape-connection.ts b/src/cli/commands/host-device-shape-connection.ts new file mode 100644 index 0000000000..0d61b492fd --- /dev/null +++ b/src/cli/commands/host-device-shape-connection.ts @@ -0,0 +1,76 @@ +import type { Lease } from '@agent-device/contracts/client'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { + DAEMON_HOST_DEVICE_SHAPE_FEATURE, + DAEMON_HOST_SERVICE, +} from '@agent-device/contracts/daemon-http'; +import { AppError } from '@agent-device/kernel/errors'; +import { readRemoteDaemonHealthForFlags } from '../../daemon-client/daemon-client-lifecycle.ts'; +import type { RemoteConnectionState } from '../../remote/remote-connection-state.ts'; + +const HINT = 'Example: open com.example.app --platform ios --device "iPhone 16"'; + +/** + * On Host every lease is a fresh device allocated by type (ADR 0021 §5), so `--device` is a type, + * never a name resolved against inventory, and nothing else selects the device. Returns the lease + * state to allocate with, or undefined for any endpoint that is not a Host. Every refusal happens + * here, before a lease is requested (§8). + */ +export async function resolveHostShapeLeaseState( + state: RemoteConnectionState, + flags: CliFlags, +): Promise { + const health = await readRemoteDaemonHealthForFlags(flags); + if (health?.service !== DAEMON_HOST_SERVICE) return undefined; + // Only authenticated Host health carries an instance id; the anonymous one is minimal. + if (!health.instanceId) { + throw hostShapeError('UNAUTHORIZED', 'The Host did not accept the daemon auth token.', { + reason: 'host-unauthenticated', + hint: 'Pass the Host service token with --daemon-auth-token or AGENT_DEVICE_DAEMON_AUTH_TOKEN.', + }); + } + if (!health.features?.includes(DAEMON_HOST_DEVICE_SHAPE_FEATURE)) { + throw hostShapeError('UNSUPPORTED_OPERATION', 'This Host cannot allocate devices by type.', { + reason: 'host-shape-unsupported', + hint: 'The Host daemon advertises no device-shape allocation; it needs its lease coordinator.', + }); + } + const platform = flags.platform ?? state.platform; + if (platform !== 'ios' && platform !== 'android') { + throw hostShapeError('INVALID_ARGS', 'A Host device type needs --platform ios or android.', { + reason: 'host-shape-platform-required', + hint: HINT, + }); + } + const deviceType = flags.device?.trim(); + if (!deviceType || flags.udid || flags.serial) { + throw hostShapeError('INVALID_ARGS', 'A Host lease is requested by --device "" only.', { + reason: 'host-shape-invalid', + hint: HINT, + }); + } + if (state.leaseId && state.hostDeviceType && state.hostDeviceType !== deviceType) { + throw hostShapeError('INVALID_ARGS', `This session already holds a ${state.hostDeviceType}.`, { + reason: 'host-shape-mismatch', + hint: 'Close the session, or use another --session, to get a different device type.', + }); + } + return { ...state, platform, hostDeviceType: deviceType, updatedAt: new Date().toISOString() }; +} + +/** After allocation the command addresses the leased device, not a device that shares its name. */ +export function pinLeasedHostDevice(flags: CliFlags, lease: Lease): void { + const id = lease.deviceKey?.split(':').slice(2).join(':'); + if (!id) return; + delete flags.device; + if (flags.platform === 'android') flags.serial = id; + else flags.udid = id; +} + +function hostShapeError( + code: 'UNAUTHORIZED' | 'UNSUPPORTED_OPERATION' | 'INVALID_ARGS', + message: string, + details: Record, +): AppError { + return new AppError(code, message, details); +} diff --git a/src/cli/commands/host.test.ts b/src/cli/commands/host.test.ts new file mode 100644 index 0000000000..ff5d0fed6d --- /dev/null +++ b/src/cli/commands/host.test.ts @@ -0,0 +1,142 @@ +import { test, vi, type TestContext } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import type net from 'node:net'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { createTestClient } from '../../__tests__/remote-connection.fixtures.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { ensureDaemon } from '../../daemon-client/daemon-client-lifecycle.ts'; +import { hostCommand } from './host.ts'; + +const servedHosts = vi.hoisted(() => [] as net.Server[]); + +vi.mock('../../daemon-client/daemon-client-lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), + ensureDaemon: vi.fn(), +})); + +vi.mock('../host/local-daemon.ts', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + listenOnTcp: async (server: net.Server, bind: { host: string; port: number }) => { + servedHosts.push(server); + return await original.listenOnTcp(server, bind); + }, + waitForever: async () => {}, + }; +}); + +const DAEMON_TOKEN = 'daemon-token-never-leaves-host'; + +function startHost(stateDir: string, extraFlags: Record = {}) { + return hostCommand({ + positionals: [], + flags: { json: true, help: false, version: false, stateDir, ...extraFlags }, + client: createTestClient(), + }); +} + +async function refusalBeforeDaemon(run: Promise): Promise { + vi.mocked(ensureDaemon).mockClear(); + try { + await run; + } catch (error) { + assert.ok(error instanceof AppError, `expected AppError, got ${String(error)}`); + assert.equal(vi.mocked(ensureDaemon).mock.calls.length, 0, 'no daemon starts'); + return error.details?.reason; + } + assert.fail('expected host to refuse to start'); +} + +function tlsFiles(stateDir: string) { + const hostTlsCert = path.join(stateDir, 'cert.pem'); + const hostTlsKey = path.join(stateDir, 'key.pem'); + fs.writeFileSync(hostTlsCert, 'not a certificate\n'); + fs.writeFileSync(hostTlsKey, 'not a key\n', { mode: 0o600 }); + return { hostTlsCert, hostTlsKey }; +} + +test('a malformed or insecure credential stops host before any daemon starts', async () => { + for (const mode of [0o600, 0o644]) { + const stateDir = mkdtempForTestSync('agent-device-host-start-'); + const hostDir = path.join(stateDir, 'host'); + fs.mkdirSync(hostDir, { mode: 0o700 }); + const file = path.join(hostDir, 'service-credential.json'); + fs.writeFileSync(file, '{}\n', { mode }); + fs.chmodSync(file, mode); + + const expected = mode === 0o600 ? 'host-credential-invalid' : 'host-credential-insecure'; + assert.equal(await refusalBeforeDaemon(startHost(stateDir)), expected); + } +}); + +test('TLS problems are typed refusals before any daemon starts', async () => { + const cases: Array<[string, (stateDir: string) => Record]> = [ + ['host-tls-incomplete', (stateDir) => ({ hostTlsCert: path.join(stateDir, 'cert.pem') })], + [ + 'host-tls-unreadable', + (stateDir) => ({ + hostTlsCert: path.join(stateDir, 'missing-cert.pem'), + hostTlsKey: path.join(stateDir, 'missing-key.pem'), + }), + ], + ['host-tls-invalid', tlsFiles], + ['host-tls-required', () => ({ proxyHost: '0.0.0.0' })], + ]; + for (const [reason, flags] of cases) { + const stateDir = mkdtempForTestSync('agent-device-host-start-'); + assert.equal(await refusalBeforeDaemon(startHost(stateDir, flags(stateDir))), reason, reason); + } +}); + +async function startServingHost(t: TestContext, stateDir: string) { + const output = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await startHost(stateDir); + return JSON.parse(String(output.mock.calls.at(-1)?.[0])).data; + } finally { + output.mockRestore(); + for (const server of servedHosts.splice(0)) t.onTestFinished(() => closeLoopbackServer(server)); + } +} + +function rpc(baseUrl: string, token: string): Promise { + return fetch(`${baseUrl}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent_device.command', params: {} }), + }); +} + +test('a started host serves health and forwards with the daemon token, also after a restart', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const daemonAuthorizations: Array = []; + const daemon = http.createServer((req, res) => { + if (req.url?.endsWith('/rpc')) daemonAuthorizations.push(req.headers.authorization); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true, data: {} } })); + }); + const httpPort = await listenOnLoopback(daemon); + t.onTestFinished(() => closeLoopbackServer(daemon)); + vi.mocked(ensureDaemon).mockResolvedValue({ info: { httpPort, token: DAEMON_TOKEN } } as never); + const stateDir = mkdtempForTestSync('agent-device-host-start-'); + + const first = await startServingHost(t, stateDir); + const restarted = await startServingHost(t, stateDir); + const health = await fetch(`${restarted.agentDeviceBaseUrl}/health`); + + assert.equal(health.status, 200); + assert.equal((await health.json()).ok, true); + assert.equal(restarted.token, undefined, 'the token shows only on the start that creates it'); + assert.equal((await rpc(restarted.agentDeviceBaseUrl, 'not-the-service-token')).status, 401); + assert.equal((await rpc(restarted.agentDeviceBaseUrl, first.token)).status, 200); + assert.deepEqual(daemonAuthorizations, [`Bearer ${DAEMON_TOKEN}`]); +}); diff --git a/src/cli/commands/host.ts b/src/cli/commands/host.ts new file mode 100644 index 0000000000..54a49ae482 --- /dev/null +++ b/src/cli/commands/host.ts @@ -0,0 +1,176 @@ +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import tls from 'node:tls'; +import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { resolveUserPath } from '@agent-device/host-kit/file'; +import { AppError } from '@agent-device/kernel/errors'; +import { supportsColor } from '../../commands/output/color.ts'; +import { createHostServer, type HostTlsMaterial } from '../host/host-server.ts'; +import { prepareHostServiceCredential } from '../host/service-credential.ts'; +import { + ensureLocalHttpDaemon, + formatHostForUrl, + formatOutputValue, + listenOnTcp, + resolveBindAddress, + resolveLocalHttpDaemonSettings, + waitForever, +} from '../host/local-daemon.ts'; +import { writeCommandOutput } from './shared.ts'; +import type { ClientCommandHandler } from './router-types.ts'; + +type HostStartup = { + hostBaseUrl: string; + agentDeviceBaseUrl: string; + listenAddress: string; + principal: string; + credentialFile: string; + /** Present only on the start that created the credential, so restart logs never repeat it. */ + token?: string; + upstreamBaseUrl: string; + stateDir: string; +}; + +const WILDCARD_ADDRESSES = new Set(['0.0.0.0', '::']); + +export const hostCommand: ClientCommandHandler = async ({ positionals, flags }) => { + if (positionals.length > 0) { + throw new AppError('INVALID_ARGS', 'host does not accept positional arguments.'); + } + const startup = await startHost(flags); + await writeCommandOutput(flags, startup, () => renderHostStartup(startup)); + await waitForever(); + return true; +}; + +async function startHost(flags: CliFlags): Promise { + // Every Host-side refusal happens before a daemon is started or reused. + const settings = resolveLocalHttpDaemonSettings({ command: 'host', stateDir: flags.stateDir }); + const bind = resolveBindAddress(flags); + const tlsMaterial = readHostTlsMaterial(flags); + if (!tlsMaterial && !isLoopbackHost(bind.host)) { + throw new AppError('INVALID_ARGS', `host needs TLS to listen on ${bind.host}.`, { + reason: 'host-tls-required', + hint: 'Pass --tls-cert and --tls-key, or keep the default 127.0.0.1 bind behind a TLS tunnel.', + }); + } + const prepared = prepareHostServiceCredential(path.join(settings.paths.baseDir, 'host')); + const { upstreamBaseUrl, upstreamToken, stateDir } = await ensureLocalHttpDaemon( + 'host', + settings, + ); + const server = createHostServer({ + upstreamBaseUrl, + upstreamToken, + credential: prepared.credential, + tls: tlsMaterial, + }); + const address = await listenOnTcp(server, bind); + try { + prepared.publish(); + } catch (error) { + server.close(); + throw error; + } + const scheme = tlsMaterial ? 'https' : 'http'; + const advertised = formatHostForUrl(advertisedHost(bind.host, address.address)); + const hostBaseUrl = `${scheme}://${advertised}:${address.port}`; + return { + hostBaseUrl, + agentDeviceBaseUrl: buildDaemonHttpBaseUrl(hostBaseUrl), + listenAddress: `${formatHostForUrl(address.address)}:${address.port}`, + principal: prepared.credential.principal, + credentialFile: prepared.credentialFile, + ...(prepared.created ? { token: prepared.credential.token } : {}), + upstreamBaseUrl, + stateDir, + }; +} + +function isLoopbackHost(host: string): boolean { + const bare = host.replace(/^\[(.*)\]$/, '$1').toLowerCase(); + return bare === 'localhost' || bare === '::1' || /^127\./.test(bare); +} + +/** + * The address workers dial: the name the operator bound to, the machine's name for a wildcard + * bind (which nobody can dial), or the bound literal address. + */ +function advertisedHost(requestedHost: string, boundAddress: string): string { + if (WILDCARD_ADDRESSES.has(boundAddress)) return os.hostname(); + return net.isIP(requestedHost.replace(/^\[(.*)\]$/, '$1')) === 0 ? requestedHost : boundAddress; +} + +function readHostTlsMaterial(flags: CliFlags): HostTlsMaterial | undefined { + const certPath = flags.hostTlsCert?.trim(); + const keyPath = flags.hostTlsKey?.trim(); + if (!certPath && !keyPath) return undefined; + if (!certPath || !keyPath) { + throw new AppError('INVALID_ARGS', 'host needs both --tls-cert and --tls-key to serve HTTPS.', { + reason: 'host-tls-incomplete', + }); + } + const material = { + cert: readTlsFile(certPath, '--tls-cert'), + key: readTlsFile(keyPath, '--tls-key'), + }; + try { + tls.createSecureContext(material); + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + 'host cannot use the TLS certificate and key.', + { + reason: 'host-tls-invalid', + hint: 'Pass a PEM certificate with --tls-cert and its matching PEM private key with --tls-key.', + }, + error, + ); + } + return material; +} + +function readTlsFile(rawPath: string, flag: string): Buffer { + const resolved = resolveUserPath(rawPath); + try { + return fs.readFileSync(resolved); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `host cannot read the ${flag} file.`, + { + reason: 'host-tls-unreadable', + path: resolved, + hint: `Check that ${resolved} exists and the Host user can read it.`, + }, + error, + ); + } +} + +function renderHostStartup(startup: HostStartup): string { + const useColor = supportsColor(); + const boundSuffix = startup.hostBaseUrl.endsWith(`//${startup.listenAddress}`) + ? '' + : ` (bound to ${startup.listenAddress})`; + const hostUrl = formatOutputValue(startup.hostBaseUrl, 'cyan', useColor); + const credentialLines = startup.token + ? [ + `Service credential created: ${startup.credentialFile}`, + `Token: ${formatOutputValue(startup.token, 'yellow', useColor)} (shown once; read it from the credential file later)`, + ] + : [`Service credential: ${startup.credentialFile}`]; + const workerToken = startup.token ?? ''; + return [ + `${formatOutputValue('✓', 'green', useColor)} Host listening at ${hostUrl}${boundSuffix}`, + '', + ...credentialLines, + `Principal: ${startup.principal}`, + '', + 'Workers connect with:', + ` agent-device connect proxy --daemon-base-url ${startup.agentDeviceBaseUrl} --daemon-auth-token ${workerToken}`, + ].join('\n'); +} diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts new file mode 100644 index 0000000000..ef87e685eb --- /dev/null +++ b/src/cli/commands/proxy-lease-device.ts @@ -0,0 +1,101 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import type { LeaseBackend } from '@agent-device/kernel/contracts'; +import { + deviceFieldsFromPublicPlatform, + resolveDevice, + type DeviceInfo, +} from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; +import { + buildConnectionDeviceKey, + resolveConnectionDeviceScope, + type RemoteConnectionState, +} from '../../remote/remote-connection-state.ts'; + +export type ResolvedLeaseState = { + state: RemoteConnectionState; + device?: DeviceInfo; + /** Points the command at the device a lease bound when allocation chose it. */ + pinLeasedDevice?: (flags: CliFlags, lease: Lease) => void; +}; + +const proxyLeaseAllocatingCommands: ReadonlySet = new Set([ + PUBLIC_COMMANDS.open, + PUBLIC_COMMANDS.install, + PUBLIC_COMMANDS.reinstall, + INTERNAL_COMMANDS.installSource, +]); + +export async function resolveProxyLeaseState(options: { + command: string; + client: AgentDeviceClient; + state: RemoteConnectionState; + flags: CliFlags; + leaseBackend?: LeaseBackend; +}): Promise { + if (!proxyLeaseAllocatingCommands.has(options.command)) { + if (options.state.leaseId && options.state.deviceKey) return { state: options.state }; + throw new AppError( + 'INVALID_ARGS', + 'No active proxy device lease for this session; run open first.', + ); + } + const host = await import('./host-device-shape-connection.ts'); + const hostShapeState = await host.resolveHostShapeLeaseState(options.state, options.flags); + if (hostShapeState) return { state: hostShapeState, pinLeasedDevice: host.pinLeasedHostDevice }; + const device = await resolveSelectedDevice(options.client, options.flags); + const scope = resolveConnectionDeviceScope(device); + return { + state: { + ...options.state, + deviceKey: buildConnectionDeviceKey(scope), + leaseBackend: options.state.leaseBackend ?? options.leaseBackend ?? scope.leaseBackend, + platform: scope.platform, + target: options.state.target ?? scope.target, + updatedAt: new Date().toISOString(), + }, + device, + }; +} + +export function applyResolvedDeviceSelector(flags: CliFlags, device: DeviceInfo): void { + const scope = resolveConnectionDeviceScope(device); + flags.platform = scope.platform; + flags.target = scope.target ?? flags.target; + if (scope.identityFlag === 'udid') flags.udid = scope.id; + if (scope.identityFlag === 'serial') flags.serial = scope.id; +} + +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, + }, + ); +} diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 5d618aaf29..f11366aa3e 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -16,6 +16,7 @@ const dedicatedCliCommandHandlerLoaders = { plugins: async () => (await import('./plugins.ts')).pluginsCommand, daemon: async () => (await import('./daemon.ts')).daemonCommand, device: async () => (await import('./device.ts')).deviceCommand, + host: async () => (await import('./host.ts')).hostCommand, proxy: async () => (await import('./proxy.ts')).proxyCommand, takeover: async () => (await import('./takeover.ts')).takeoverCommand, replay: async () => (await import('./replay.ts')).replayCommand, diff --git a/src/cli/host/host-front-end.test.ts b/src/cli/host/host-front-end.test.ts new file mode 100644 index 0000000000..8f11aec269 --- /dev/null +++ b/src/cli/host/host-front-end.test.ts @@ -0,0 +1,258 @@ +import { test, type TestContext } from 'vitest'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import type { DaemonRequest } from '../../daemon/daemon-request.ts'; +import { createDaemonHttpServer } from '../../daemon/server/http-server.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { createHostServer } from './host-server.ts'; +import { prepareHostServiceCredential } from './service-credential.ts'; + +const DAEMON_TOKEN = 'daemon-token-never-leaves-host'; + +/** Host in front of a real daemon HTTP server whose handler records what the daemon admitted. */ +async function startHostOverDaemon(t: TestContext) { + const admitted: DaemonRequest[] = []; + const env = { ...process.env }; + delete env.AGENT_DEVICE_HTTP_AUTH_HOOK; + delete env.AGENT_DEVICE_HTTP_AUTH_EXPORT; + const daemon = await createDaemonHttpServer({ + token: DAEMON_TOKEN, + env, + handleRequest: async (request) => { + admitted.push(request); + return { ok: true, data: {} }; + }, + }); + const daemonPort = await listenOnLoopback(daemon); + t.onTestFinished(() => closeLoopbackServer(daemon)); + const prepared = prepareHostServiceCredential( + path.join(mkdtempForTestSync('agent-device-host-policy-'), 'host'), + ); + prepared.publish(); + const { credential } = prepared; + const host = createHostServer({ + upstreamBaseUrl: `http://127.0.0.1:${daemonPort}`, + upstreamToken: DAEMON_TOKEN, + credential, + }); + const hostPort = await listenOnLoopback(host); + t.onTestFinished(() => closeLoopbackServer(host)); + const baseUrl = `http://127.0.0.1:${hostPort}/agent-device`; + const rpc = async ( + method: string, + params: Record, + headers: Record = {}, + ) => { + const response = await fetch(`${baseUrl}/rpc`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${credential.token}`, + ...headers, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 'host-policy', method, params }), + }); + return { status: response.status, body: (await response.json()) as Record }; + }; + const command = (params: Record, headers?: Record) => + rpc('agent_device.command', { positionals: [], flags: {}, ...params }, headers); + return { admitted, credential, baseUrl, rpc, command }; +} + +function assertRefused( + response: { status: number; body: Record }, + reason: string, + admitted: readonly unknown[], +) { + assert.equal(response.status, 403, JSON.stringify(response.body)); + assert.equal(response.body.error?.data?.code, 'UNSUPPORTED_OPERATION'); + assert.equal(response.body.error?.data?.details?.reason, reason); + assert.equal(admitted.length, 0, 'a refused request must never reach the daemon'); +} + +test('a client-sent principal header is replaced by the server principal', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + const response = await host.command( + { command: 'devices' }, + { 'x-agent-device-principal': 'host-svc-attacker' }, + ); + + assert.equal(response.status, 200, JSON.stringify(response.body)); + assert.equal(host.admitted[0]?.internal?.hostPrincipal, host.credential.principal); + assert.equal(host.admitted[0]?.meta?.tenantId, host.credential.principal); +}); + +test('a client tenant header is dropped and the daemon isolates the server principal', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + await host.command({ command: 'devices' }, { 'x-agent-device-tenant': 'someone-else' }); + + assert.equal(host.admitted[0]?.meta?.tenantId, host.credential.principal); + assert.equal(host.admitted[0]?.meta?.sessionIsolation, 'tenant'); +}); + +test('tenant claims in the command body are dropped', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + await host.command({ + command: 'devices', + meta: { tenantId: 'someone-else', sessionIsolation: 'none' }, + flags: { tenant: 'someone-else' }, + }); + + const admitted = host.admitted[0]; + assert.equal(admitted?.meta?.tenantId, host.credential.principal); + assert.equal(admitted?.meta?.sessionIsolation, 'tenant'); + assert.equal(admitted?.flags?.tenant, undefined); +}); + +test('a tenant claim on a lease method is dropped', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + await host.rpc('agent_device.lease.allocate', { + tenant: 'someone-else', + tenantId: 'someone-else', + runId: 'verify-812', + }); + + assert.equal(host.admitted[0]?.meta?.tenantId, host.credential.principal); + assert.equal(host.admitted[0]?.meta?.runId, 'verify-812'); +}); + +test('administration routes are not served, with or without the token', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + for (const route of ['/admin/leases', '/admin/human-control/holds']) { + const variants: Record[] = [ + {}, + { authorization: `Bearer ${host.credential.token}` }, + ]; + for (const headers of variants) { + assert.equal((await fetch(`${host.baseUrl}${route}`, { headers })).status, 404, route); + } + } +}); + +test('allocating a host-administered macos-app lease is refused', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + for (const backend of ['macos-app', ' MacOS-App ']) { + const response = await host.rpc('agent_device.lease.allocate', { + runId: 'verify-812', + backend, + }); + assertRefused(response, 'host-admin-refused', host.admitted); + } +}); + +test('an install source naming a Host path is refused', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + const response = await host.command({ + command: 'install_source', + meta: { installSource: { kind: 'path', path: '/Users/operator/app.ipa' } }, + }); + + assertRefused(response, 'host-path-refused', host.admitted); +}); + +test('a flag naming a Host path is refused, and a daemon temp artifact location is not', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + const refused = await host.command({ + command: 'screenshot', + flags: { out: '/Users/operator/.ssh/id_ed25519' }, + }); + assertRefused(refused, 'host-path-refused', host.admitted); + + const accepted = await host.command({ + command: 'screenshot', + flags: { out: '/tmp/agent-device-screenshot-1767225600000-k3x9qa.png' }, + }); + assert.equal(accepted.status, 200, JSON.stringify(accepted.body)); +}); + +test('Host paths hidden in URLs, uploads, batches and other commands are refused', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + const planted = '/tmp/agent-device-evil-1-a.xctestrun'; + const cases: Array<[string, Record]> = [ + [ + 'host-path-refused', + { command: 'screenshot', positionals: ['https://x/../../Users/op/.zshrc'] }, + ], + [ + 'host-path-refused', + { + command: 'screenshot', + positionals: ['/Users/op/.zshrc'], + meta: { uploadedArtifactId: 'x' }, + }, + ], + [ + 'host-path-refused', + { command: 'batch', flags: { batchSteps: [{ command: 'screenshot', positionals: ['/x'] }] } }, + ], + [ + 'host-path-refused', + { command: 'trace', positionals: ['stop', '/Users/op/.ssh/authorized_keys'] }, + ], + [ + 'host-path-refused', + { command: 'push', positionals: ['com.example', '/Users/op/config.json'] }, + ], + ['host-path-refused', { command: 'open', flags: { launchConsole: '/Users/op/.zprofile' } }], + ['host-path-refused', { command: 'open', flags: { iosXctestrunFile: planted } }], + ['host-script-refused', { command: 'replay', positionals: ['flow.ad'] }], + ]; + for (const [reason, params] of cases) { + assertRefused(await host.command(params), reason, host.admitted); + } +}); + +test('a request that asks the allocator to download components is refused', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + const response = await host.rpc('agent_device.lease.allocate', { + runId: 'verify-812', + allowDownload: true, + }); + + assertRefused(response, 'host-component-download-refused', host.admitted); +}); + +test('anonymous health is minimal and authenticated health names the Host', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + const anonymous = await (await fetch(`${host.baseUrl}/health`)).json(); + assert.deepEqual(Object.keys(anonymous as object).sort(), [ + 'ok', + 'rpcProtocolVersion', + 'service', + ]); + assert.equal((anonymous as Record).service, 'agent-device-host'); + + const authenticated = (await ( + await fetch(`${host.baseUrl}/health`, { + headers: { authorization: `Bearer ${host.credential.token}` }, + }) + ).json()) as Record; + assert.equal(authenticated.service, 'agent-device-host'); + assert.equal(authenticated.upstream?.service, 'agent-device-daemon'); +}); diff --git a/src/cli/host/host-front-end.ts b/src/cli/host/host-front-end.ts new file mode 100644 index 0000000000..7ee0d30ff9 --- /dev/null +++ b/src/cli/host/host-front-end.ts @@ -0,0 +1,113 @@ +import { + DAEMON_HOST_SERVICE as HOST_SERVICE, + DAEMON_HTTP_BASE_PATH, + DAEMON_HTTP_PRINCIPAL_HEADER, + DAEMON_HTTP_TENANT_HEADER, + DAEMON_RPC_PROTOCOL_VERSION, +} from '@agent-device/contracts/daemon-http'; +import { timingSafeStringEqual } from '@agent-device/host-kit/transport'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import type { DaemonProxy, DaemonProxyRpcAdmission } from '@agent-device/proxy'; +import { + findHostRpcRefusal, + normalizeRpcMethod, + stripClientIdentity, + type HostRefusal, +} from './request-policy.ts'; +import type { HostServiceCredential } from './service-credential.ts'; + +const HEALTH_PATHS: ReadonlySet = new Set(['/health', `${DAEMON_HTTP_BASE_PATH}/health`]); + +/** + * The Host public route policy (ADR 0021 §6), applied by the proxy after it authenticated the + * request: refuse what a public caller may not do, and drop the identity it claims. + */ +export const admitHostRpc: DaemonProxyRpcAdmission = ({ id, method, params }) => { + const refusal = findHostRpcRefusal(normalizeRpcMethod(method), params); + return refusal ? rpcRefusal(id, refusal) : stripClientIdentity(params); +}; + +/** + * The Host front-end around the daemon proxy. Only `/health` differs from the proxy: anonymous + * callers see minimal status, and authenticated ones see the Host and the daemon's features. + */ +export function createHostFrontEnd( + proxy: DaemonProxy, + credential: Pick, +): DaemonProxy { + return { + instanceId: proxy.instanceId, + handle: async (request) => { + try { + return await handleHostRequest(request, proxy, credential.token); + } catch (error) { + const normalized = normalizeError(error); + return Response.json( + { ok: false, error: normalized.message, code: normalized.code }, + { + status: normalized.code === 'INVALID_ARGS' ? 400 : 500, + }, + ); + } + }, + }; +} + +/** The outbound loopback request: the server-controlled principal replaces any claimed tenant. */ +export function withHostPrincipal(request: Request, principal: string): Request { + const headers = new Headers(request.headers); + headers.delete(DAEMON_HTTP_TENANT_HEADER); + headers.set(DAEMON_HTTP_PRINCIPAL_HEADER, principal); + return new Request(request, { + headers, + ...(request.body ? { duplex: 'half' } : {}), + } as RequestInit); +} + +async function handleHostRequest( + request: Request, + proxy: DaemonProxy, + token: string, +): Promise { + const pathname = URL.parse(request.url)?.pathname ?? ''; + if (request.method !== 'GET' || !HEALTH_PATHS.has(pathname)) return await proxy.handle(request); + if (!hasHeaderToken(request.headers, token)) { + return Response.json({ + ok: true, + service: HOST_SERVICE, + rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION, + }); + } + const response = await proxy.handle(request); + if (!response.ok) return response; + const payload = (await response.json()) as Record; + // The daemon owns the allocator, so its health is what says whether Host can allocate by shape. + const upstream = payload.upstream as Record | undefined; + const features = Array.isArray(upstream?.features) ? { features: upstream.features } : {}; + return Response.json( + { ...payload, service: HOST_SERVICE, ...features }, + { status: response.status }, + ); +} + +function hasHeaderToken(headers: Headers, token: string): boolean { + const authorization = headers.get('authorization') ?? ''; + const presented = authorization.toLowerCase().startsWith('bearer ') + ? authorization.slice('bearer '.length) + : headers.get('x-agent-device-token'); + return presented !== null && timingSafeStringEqual(presented, token); +} + +function rpcRefusal(id: unknown, refusal: HostRefusal): Response { + const data = normalizeError( + new AppError('UNSUPPORTED_OPERATION', refusal.message, { + reason: refusal.reason, + ...(refusal.field ? { field: refusal.field } : {}), + hint: 'Host serves remote verification only; see agent-device help host.', + }), + ); + return Response.json( + { jsonrpc: '2.0', id: id ?? null, error: { code: -32000, message: data.message, data } }, + { status: 403 }, + ); +} diff --git a/src/cli/host/host-server.test.ts b/src/cli/host/host-server.test.ts new file mode 100644 index 0000000000..bdc6f0621e --- /dev/null +++ b/src/cli/host/host-server.test.ts @@ -0,0 +1,123 @@ +import { test, type TestContext } from 'vitest'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { createHostServer, type HostTlsMaterial } from './host-server.ts'; +import { prepareHostServiceCredential } from './service-credential.ts'; + +const UPSTREAM_TOKEN = 'daemon-token-never-leaves-host'; + +type UpstreamCall = { url: string; authorization: string | undefined; body: string }; + +async function startUpstreamDaemon(t: TestContext) { + const calls: UpstreamCall[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + calls.push({ url: req.url ?? '', authorization: req.headers.authorization, body }); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true, data: {} } })); + }); + }); + const port = await listenOnLoopback(server); + t.onTestFinished(() => closeLoopbackServer(server)); + return { calls, upstreamBaseUrl: `http://127.0.0.1:${port}` }; +} + +async function startHost( + t: TestContext, + options: { upstreamBaseUrl: string; hostDir: string; tls?: HostTlsMaterial }, +) { + const prepared = prepareHostServiceCredential(options.hostDir); + prepared.publish(); + const { credential } = prepared; + const server = createHostServer({ + upstreamBaseUrl: options.upstreamBaseUrl, + upstreamToken: UPSTREAM_TOKEN, + credential, + tls: options.tls, + }); + const port = await listenOnLoopback(server); + t.onTestFinished(() => closeLoopbackServer(server)); + return { token: credential.token, server, port, baseUrl: `http://127.0.0.1:${port}` }; +} + +test('host answers unserved routes with 404', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const upstream = await startUpstreamDaemon(t); + const host = await startHost(t, { + upstreamBaseUrl: upstream.upstreamBaseUrl, + hostDir: path.join(mkdtempForTestSync('agent-device-host-'), 'host'), + }); + const authorization = `Bearer ${host.token}`; + + for (const route of ['/agent-device/nope', '/agent-device/sessions', '/']) { + const response = await fetch(`${host.baseUrl}${route}`, { headers: { authorization } }); + assert.equal(response.status, 404, route); + } + assert.equal(upstream.calls.length, 0); +}); + +function generateSelfSignedCertificate(dir: string): HostTlsMaterial | undefined { + const certPath = path.join(dir, 'cert.pem'); + const keyPath = path.join(dir, 'key.pem'); + try { + const subject = ['-subj', '/CN=127.0.0.1', '-keyout', keyPath, '-out', certPath]; + execFileSync( + 'openssl', + ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', ...subject], + { stdio: 'ignore' }, + ); + } catch { + return undefined; + } + return { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) }; +} + +test('host serves HTTPS when given a certificate and key', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const dir = mkdtempForTestSync('agent-device-host-tls-'); + const tls = generateSelfSignedCertificate(dir); + if (!tls) { + t.skip('openssl is not available to generate a test certificate'); + return; + } + const upstream = await startUpstreamDaemon(t); + const host = await startHost(t, { + upstreamBaseUrl: upstream.upstreamBaseUrl, + hostDir: path.join(dir, 'host'), + tls, + }); + + const status = await new Promise((resolve, reject) => { + const req = https.request( + { + host: '127.0.0.1', + port: host.port, + path: '/agent-device/rpc', + method: 'POST', + rejectUnauthorized: false, + headers: { authorization: `Bearer ${host.token}`, 'content-type': 'application/json' }, + }, + (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }, + ); + req.on('error', reject); + req.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent_device.command', params: {} })); + }); + + assert.equal(status, 200); + assert.equal(upstream.calls.length, 1); +}); diff --git a/src/cli/host/host-server.ts b/src/cli/host/host-server.ts new file mode 100644 index 0000000000..1ad3f22f9c --- /dev/null +++ b/src/cli/host/host-server.ts @@ -0,0 +1,31 @@ +import http from 'node:http'; +import https from 'node:https'; +import { createDaemonProxy, createDaemonProxyRequestListener } from '@agent-device/proxy'; +import { admitHostRpc, createHostFrontEnd, withHostPrincipal } from './host-front-end.ts'; +import type { HostServiceCredential } from './service-credential.ts'; + +export type HostTlsMaterial = Readonly<{ cert: Buffer; key: Buffer }>; + +/** + * The Host front-end (ADR 0021 §3): the daemon proxy's transport, uploads, artifacts and + * upstream token rewrite, authenticated by the persistent service credential, behind the Host + * route policy, with the credential's principal on every loopback request. + */ +export function createHostServer(options: { + upstreamBaseUrl: string; + upstreamToken: string; + credential: HostServiceCredential; + tls?: HostTlsMaterial; +}): http.Server | https.Server { + const proxy = createDaemonProxy({ + upstreamBaseUrl: options.upstreamBaseUrl, + upstreamToken: options.upstreamToken, + clientToken: options.credential.token, + upstreamFetch: (request) => fetch(withHostPrincipal(request, options.credential.principal)), + admitRpc: admitHostRpc, + }); + const listener = createDaemonProxyRequestListener(createHostFrontEnd(proxy, options.credential)); + return options.tls + ? https.createServer({ cert: options.tls.cert, key: options.tls.key }, listener) + : http.createServer(listener); +} diff --git a/src/cli/host/local-daemon.ts b/src/cli/host/local-daemon.ts new file mode 100644 index 0000000000..c5cef68389 --- /dev/null +++ b/src/cli/host/local-daemon.ts @@ -0,0 +1,100 @@ +import type net from 'node:net'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { AppError } from '@agent-device/kernel/errors'; +import { colorize } from '../../commands/output/color.ts'; +import { + ensureDaemon, + resolveClientSettings, + type DaemonClientSettings, +} from '../../daemon-client/daemon-client-lifecycle.ts'; + +export type LocalDaemonUpstream = Readonly<{ + upstreamBaseUrl: string; + upstreamToken: string; + stateDir: string; +}>; + +/** + * The local HTTP daemon Host forwards to over loopback. An empty `daemonBaseUrl` masks + * `AGENT_DEVICE_DAEMON_BASE_URL`, so Host never chains to another remote daemon. Resolving + * starts nothing, so Host can refuse its own configuration before a daemon exists. + */ +export function resolveLocalHttpDaemonSettings(params: { + command: string; + stateDir: string | undefined; +}): DaemonClientSettings { + return resolveClientSettings({ + session: 'default', + command: params.command, + positionals: [], + flags: { + stateDir: params.stateDir, + daemonBaseUrl: '', + daemonTransport: 'http', + daemonServerMode: 'http', + }, + }); +} + +export async function ensureLocalHttpDaemon( + command: string, + settings: DaemonClientSettings, +): Promise { + const daemon = await ensureDaemon(settings); + return { + upstreamBaseUrl: resolveLocalDaemonBaseUrl(command, daemon.info.httpPort), + upstreamToken: daemon.info.token, + stateDir: settings.paths.baseDir, + }; +} + +function resolveLocalDaemonBaseUrl(command: string, httpPort: number | undefined): string { + if (!httpPort) { + throw new AppError('COMMAND_FAILED', 'Local daemon HTTP endpoint is unavailable.', { + hint: `Retry after cleaning daemon state, or run ${command} with a fresh --state-dir.`, + }); + } + return `http://127.0.0.1:${httpPort}`; +} + +/** The bind address `--host`/`--port` name; loopback and a free port by default. */ +export function resolveBindAddress(flags: Pick): { + host: string; + port: number; +} { + return { host: flags.proxyHost?.trim() || '127.0.0.1', port: flags.proxyPort ?? 0 }; +} + +export async function listenOnTcp( + server: net.Server, + bind: { host: string; port: number }, +): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(bind.port, bind.host, () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new AppError('COMMAND_FAILED', 'Host did not bind to a TCP address.'); + } + return address; +} + +export function formatHostForUrl(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} + +export function formatOutputValue( + value: string, + format: Parameters[1], + useColor: boolean, +): string { + return useColor ? colorize(value, format, { validateStream: false }) : value; +} + +export function waitForever(): Promise { + return new Promise(() => {}); +} diff --git a/src/cli/host/request-policy.test.ts b/src/cli/host/request-policy.test.ts new file mode 100644 index 0000000000..19f61c2608 --- /dev/null +++ b/src/cli/host/request-policy.test.ts @@ -0,0 +1,51 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { findHostRpcRefusal, stripClientIdentity } from './request-policy.ts'; + +const COMMAND = 'agent_device.command'; + +test('positionals naming a Host file are refused; client-rewritten locations are not', () => { + const refused = [ + { command: 'install', positionals: ['com.example.app', '/Users/operator/app.apk'] }, + { command: 'record', positionals: ['start', '/Users/operator/out.mp4'] }, + { command: 'screenshot', positionals: ['/etc/agent-device.png'] }, + { command: 'install', positionals: ['com.example.app', 'https://x/../../Users/op/app.apk'] }, + { command: 'install', positionals: ['app.apk'] }, + ]; + for (const params of refused) { + assert.equal(findHostRpcRefusal(COMMAND, params)?.reason, 'host-path-refused', params.command); + } + + const accepted = [ + { command: 'record', positionals: ['start', '/tmp/agent-device-recording-1-k3x9qa.mp4'] }, + { command: 'screenshot', positionals: ['/tmp/agent-device-screenshot-1-k3x9qa.png'] }, + { command: 'record', positionals: ['stop'] }, + ]; + for (const params of accepted) { + assert.equal(findHostRpcRefusal(COMMAND, params), undefined, params.command); + } +}); + +test('an uploaded install keeps its client-local path and is accepted', () => { + assert.equal( + findHostRpcRefusal(COMMAND, { + command: 'install', + positionals: ['com.example.app', './build/app.apk'], + meta: { uploadedArtifactId: 'upload-1', installSource: { kind: 'path', path: '/x' } }, + }), + undefined, + ); +}); + +test('identity claims are removed and attribution is kept', () => { + assert.deepEqual( + stripClientIdentity({ + tenant: 'a', + tenantId: 'b', + runId: 'verify-812', + meta: { tenantId: 'c', clientId: 'ab12cd34' }, + flags: { tenant: 'd', platform: 'ios' }, + }), + { runId: 'verify-812', meta: { clientId: 'ab12cd34' }, flags: { platform: 'ios' } }, + ); +}); diff --git a/src/cli/host/request-policy.ts b/src/cli/host/request-policy.ts new file mode 100644 index 0000000000..751bdaaabe --- /dev/null +++ b/src/cli/host/request-policy.ts @@ -0,0 +1,228 @@ +import path from 'node:path'; +import { normalizeBatchCommandName } from '@agent-device/command-registry/batch-policy'; +import { getCommandSchema } from '../../commands/schema/command-schema.ts'; +import { HOST_PATH_INPUT_KEYS } from '../../daemon/host-path-inputs.ts'; +import { normalizeLeaseBackend } from '../../daemon/lease-registry-scope.ts'; +import { isRemoteTempArtifactPath } from '../../remote/daemon-artifacts.ts'; + +/** Typed refusals of the Host public route policy (ADR 0021 §5, §6, §10 item 6). */ +const HOST_REFUSAL_REASONS = { + admin: 'host-admin-refused', + hostPath: 'host-path-refused', + componentDownload: 'host-component-download-refused', + script: 'host-script-refused', +} as const; + +export type HostRefusal = Readonly<{ + reason: (typeof HOST_REFUSAL_REASONS)[keyof typeof HOST_REFUSAL_REASONS]; + message: string; + field?: string; +}>; + +type Params = Record; + +/** Allocator policy a public caller may not override; Simlock reads it as "download components". */ +const ALLOCATOR_POLICY_KEYS = ['allowDownload'] as const; + +/** + * Commands that run nested actions from a script the Host cannot inspect before the daemon + * dispatches them, so each action would escape this policy. + */ +const SCRIPT_COMMANDS: ReadonlySet = new Set(['replay', 'test']); + +/** The daemon reads an upload id only on these; anywhere else it would just switch the check off. */ +const UPLOAD_COMMANDS: ReadonlySet = new Set(['install', 'reinstall', 'install_source']); + +export function normalizeRpcMethod(method: string): string { + return method.replace(/^agent-device\./, 'agent_device.'); +} + +export function findHostRpcRefusal(method: string, params: Params): HostRefusal | undefined { + return ( + findAllocatorPolicyOverride(params) ?? + findAdminAllocation(method, params) ?? + findHostPathInSource(params) ?? + findRequestRefusal(params) + ); +} + +/** + * Drops every identity a client can claim in the body, batch steps included. The daemon pins + * the tenant to the Host principal anyway; removing the claims keeps them out of the request. + */ +export function stripClientIdentity(params: Params): Params { + const { tenant: _tenant, tenantId: _tenantId, ...rest } = params; + const meta = asRecord(rest.meta); + const flags = asRecord(rest.flags); + return { + ...rest, + ...(meta ? { meta: withoutKey(meta, 'tenantId') } : {}), + ...(flags ? { flags: stripStepIdentity(flags) } : {}), + }; +} + +function stripStepIdentity(flags: Params): Params { + const stripped = withoutKey(flags, 'tenant'); + if (!Array.isArray(stripped.batchSteps)) return stripped; + return { + ...stripped, + batchSteps: stripped.batchSteps.map((step) => { + const record = asRecord(step); + const stepFlags = asRecord(record?.flags); + return record && stepFlags ? { ...record, flags: stripStepIdentity(stepFlags) } : step; + }), + }; +} + +function findAllocatorPolicyOverride(params: Params): HostRefusal | undefined { + const scopes = [params, asRecord(params.flags), asRecord(params.input), asRecord(params.shape)]; + for (const key of ALLOCATOR_POLICY_KEYS) { + if (scopes.some((scope) => scope?.[key] !== undefined)) { + return { + reason: HOST_REFUSAL_REASONS.componentDownload, + message: `Host does not accept ${key}; components are installed by the operator.`, + field: key, + }; + } + } + return undefined; +} + +function findAdminAllocation(method: string, params: Params): HostRefusal | undefined { + if (method !== 'agent_device.lease.allocate' || !isMacOsAppBackend(params.backend)) { + return undefined; + } + return { + reason: HOST_REFUSAL_REASONS.admin, + message: 'Host does not allocate macos-app leases; they are host-administered.', + field: 'backend', + }; +} + +/** Reads the backend the way the daemon does; a value it would refuse is not macos-app. */ +function isMacOsAppBackend(raw: unknown): boolean { + if (typeof raw !== 'string') return false; + try { + return normalizeLeaseBackend(raw) === 'macos-app'; + } catch { + return false; + } +} + +function findHostPathInSource(params: Params): HostRefusal | undefined { + const meta = asRecord(params.meta); + if (isPathSource(params.source)) return hostPathRefusal('source'); + if (isPathSource(meta?.installSource) && !hasUpload(meta)) + return hostPathRefusal('installSource'); + return undefined; +} + +/** Checks one command request and, for a batch, every step the daemon will admit after it. */ +function findRequestRefusal(request: Params): HostRefusal | undefined { + const command = normalizeBatchCommandName(request.command); + if (SCRIPT_COMMANDS.has(command)) { + return { + reason: HOST_REFUSAL_REASONS.script, + message: `Host does not run ${command}; send the commands one by one or as a batch.`, + field: 'command', + }; + } + const flags = asRecord(request.flags); + const refusal = + findHostPathInput(command, { ...asRecord(request.input), ...flags }) ?? + findHostPathPositional(command, request); + if (refusal) return refusal; + for (const step of Array.isArray(flags?.batchSteps) ? flags.batchSteps : []) { + const stepRefusal = findRequestRefusal(asRecord(step) ?? {}); + if (stepRefusal) return stepRefusal; + } + return undefined; +} + +function findHostPathInput(command: string, fields: Params): HostRefusal | undefined { + const field = HOST_PATH_INPUT_KEYS.find( + (key) => + key !== 'installSource' && + fields[key] !== undefined && + fields[key] !== false && + !isClientArtifactLocation(command, key, fields[key]), + ); + return field ? hostPathRefusal(field) : undefined; +} + +/** Positionals the command schema names as paths, such as `path?` or `appOrPath`. */ +function findHostPathPositional(command: string, request: Params): HostRefusal | undefined { + const positionals = Array.isArray(request.positionals) ? request.positionals.map(String) : []; + const names = getCommandSchema(command)?.positionalArgs ?? []; + const uploaded = UPLOAD_COMMANDS.has(command) && hasUpload(asRecord(request.meta)); + const refused = names.some( + (name, index) => !uploaded && namesHostPathPositional(command, name, positionals, index), + ); + return refused ? hostPathRefusal('positionals') : undefined; +} + +function namesHostPathPositional( + command: string, + name: string, + positionals: readonly string[], + index: number, +): boolean { + const value = positionals[index]; + if (value === undefined) return false; + if (!namesPathPositional(name, value, index === positionals.length - 1)) return false; + return !isClientArtifactLocation(command, `positional:${index}`, value); +} + +/** + * `path` always names a path. An `appOrPath`-style positional names one when it is the last + * positional given, which is how `install ` and `install ` read it, and + * `payloadOrJson` names one unless the value is inline JSON. + */ +function namesPathPositional(name: string, value: string, isLast: boolean): boolean { + const bare = name.replace(/\?$/, ''); + if (/^payload/i.test(bare)) return !/^\s*[[{]/.test(value); + if (bare === 'path' || /^pathOr/.test(bare)) return true; + return /Path$/.test(bare) && isLast; +} + +/** + * The daemon temp locations the remote client itself writes outputs to and downloads afterwards + * (`src/remote/daemon-artifacts.ts`). Any other value names the Host machine's disk. + */ +function isClientArtifactLocation(command: string, field: string, value: unknown): boolean { + if (typeof value !== 'string') return false; + if (command === 'screenshot' && (field === 'out' || field === 'positional:0')) { + return isRemoteTempArtifactPath(value, 'screenshot', '.png'); + } + if (command === 'record' && field === 'positional:1') { + return isRemoteTempArtifactPath(value, 'recording', path.posix.extname(value)); + } + return false; +} + +function hasUpload(meta: Params | undefined): boolean { + return typeof meta?.uploadedArtifactId === 'string' && meta.uploadedArtifactId.trim() !== ''; +} + +function isPathSource(source: unknown): boolean { + return asRecord(source)?.kind === 'path'; +} + +function hostPathRefusal(field: string): HostRefusal { + return { + reason: HOST_REFUSAL_REASONS.hostPath, + message: `Host does not accept ${field}, which names or returns a path on the Host machine.`, + field, + }; +} + +function asRecord(value: unknown): Params | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Params) + : undefined; +} + +function withoutKey(record: Params, key: string): Params { + const { [key]: _removed, ...rest } = record; + return rest; +} diff --git a/src/cli/host/service-credential.test.ts b/src/cli/host/service-credential.test.ts new file mode 100644 index 0000000000..7f27dfbbf8 --- /dev/null +++ b/src/cli/host/service-credential.test.ts @@ -0,0 +1,110 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { prepareHostServiceCredential } from './service-credential.ts'; + +function hostDir(): string { + return path.join(mkdtempForTestSync('agent-device-host-credential-'), 'host'); +} + +function publishedCredential(dir: string) { + const prepared = prepareHostServiceCredential(dir); + prepared.publish(); + return prepared; +} + +function refusalReason(run: () => unknown): unknown { + try { + run(); + } catch (error) { + assert.ok(error instanceof AppError, `expected AppError, got ${String(error)}`); + return error.details?.reason; + } + assert.fail('expected the credential to be refused'); +} + +test('a new credential reaches disk only when Host publishes it', () => { + const dir = hostDir(); + const prepared = prepareHostServiceCredential(dir); + + assert.equal(prepared.created, true); + assert.equal(fs.existsSync(prepared.credentialFile), false); + prepared.publish(); + + assert.equal(prepared.credentialFile, path.join(dir, 'service-credential.json')); + assert.match(prepared.credential.token, /^[0-9a-f]{64}$/); + assert.equal(prepared.credential.principal, `host-svc-${prepared.credential.credentialId}`); + assert.equal(fs.statSync(prepared.credentialFile).mode & 0o777, 0o600); + assert.equal(fs.statSync(dir).mode & 0o777, 0o700); +}); + +test('a restart reuses the same credential instead of issuing a new token', () => { + const dir = hostDir(); + const first = publishedCredential(dir); + const afterRestart = prepareHostServiceCredential(dir); + + assert.equal(afterRestart.created, false); + assert.deepEqual(afterRestart.credential, first.credential); +}); + +test('a credential file readable by group or others refuses to start', () => { + const dir = hostDir(); + const { credentialFile } = publishedCredential(dir); + fs.chmodSync(credentialFile, 0o644); + + assert.equal( + refusalReason(() => prepareHostServiceCredential(dir)), + 'host-credential-insecure', + ); +}); + +test('a credential directory open to group or others refuses to start', () => { + const dir = hostDir(); + publishedCredential(dir); + fs.chmodSync(dir, 0o755); + + assert.equal( + refusalReason(() => prepareHostServiceCredential(dir)), + 'host-credential-insecure', + ); +}); + +test('a credential that is a link is refused with a typed reason', () => { + const dir = hostDir(); + const { credentialFile } = publishedCredential(dir); + const target = `${credentialFile}.real`; + fs.renameSync(credentialFile, target); + fs.symlinkSync(target, credentialFile); + + assert.equal( + refusalReason(() => prepareHostServiceCredential(dir)), + 'host-credential-insecure', + ); +}); + +test('a malformed credential file is refused and never regenerated', () => { + const dir = hostDir(); + const { credentialFile } = publishedCredential(dir); + fs.writeFileSync(credentialFile, '{"version":1,"token":"short"}\n', { mode: 0o600 }); + + assert.equal( + refusalReason(() => prepareHostServiceCredential(dir)), + 'host-credential-invalid', + ); + assert.equal(fs.readFileSync(credentialFile, 'utf8'), '{"version":1,"token":"short"}\n'); +}); + +test('a credential another start published first is a typed refusal, never an overwrite', () => { + const dir = hostDir(); + const late = prepareHostServiceCredential(dir); + const winner = publishedCredential(dir); + + assert.equal( + refusalReason(() => late.publish()), + 'host-credential-raced', + ); + assert.deepEqual(prepareHostServiceCredential(dir).credential, winner.credential); +}); diff --git a/src/cli/host/service-credential.ts b/src/cli/host/service-credential.ts new file mode 100644 index 0000000000..dc1cb943bd --- /dev/null +++ b/src/cli/host/service-credential.ts @@ -0,0 +1,192 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { openVerifiedFileForRead, publishDurableFileSync } from '@agent-device/host-kit/file'; +import { AppError } from '@agent-device/kernel/errors'; + +/** The one service credential Host v1 accepts (ADR 0021 §6), mapped to a server-controlled principal. */ +export type HostServiceCredential = Readonly<{ + credentialId: string; + token: string; + principal: string; + createdAt: string; +}>; + +export type HostServiceCredentialLoad = Readonly<{ + credential: HostServiceCredential; + credentialFile: string; + /** True when this start generated the credential; it reaches disk only through `publish`. */ + created: boolean; + /** + * Writes a generated credential. Host calls it once it is serving, so a start that fails + * earlier leaves no credential behind and the next start shows the token it creates. + */ + publish(): void; +}>; + +const CREDENTIAL_FILE_NAME = 'service-credential.json'; +const CREDENTIAL_FILE_VERSION = 1; +const CREDENTIAL_ID_PATTERN = /^[0-9a-f]{16}$/; +const TOKEN_PATTERN = /^[0-9a-f]{64}$/; +const NON_EMPTY_PATTERN = /\S/; +const GROUP_OR_OTHER_ACCESS = 0o077; + +function hostPrincipalForCredential(credentialId: string): string { + return `host-svc-${credentialId}`; +} + +/** + * Returns the persisted credential, or a new one to publish once Host is serving. An existing + * file is never replaced: regenerating it would silently lock out every worker holding the token. + */ +export function prepareHostServiceCredential(hostDir: string): HostServiceCredentialLoad { + const credentialFile = path.join(hostDir, CREDENTIAL_FILE_NAME); + ensurePrivateDirectory(hostDir); + const existing = readCredential(credentialFile); + if (existing) { + return { credential: existing, credentialFile, created: false, publish: () => {} }; + } + const credential = generateCredential(); + return { + credential, + credentialFile, + created: true, + publish: () => publishCredential(credentialFile, credential), + }; +} + +function publishCredential(credentialFile: string, credential: HostServiceCredential): void { + try { + publishDurableFileSync({ + destination: credentialFile, + contents: `${JSON.stringify({ version: CREDENTIAL_FILE_VERSION, ...credential }, null, 2)}\n`, + mode: 0o600, + publish: 'link-exclusive', + }); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== 'EEXIST') throw error; + throw new AppError( + 'COMMAND_FAILED', + 'Another Host start created the service credential first.', + { + reason: 'host-credential-raced', + path: credentialFile, + hint: 'Run one Host per state dir, then restart this Host to use the stored credential.', + }, + ); + } +} + +function generateCredential(): HostServiceCredential { + const credentialId = crypto.randomBytes(8).toString('hex'); + return { + credentialId, + token: crypto.randomBytes(32).toString('hex'), + principal: hostPrincipalForCredential(credentialId), + createdAt: new Date().toISOString(), + }; +} + +function ensurePrivateDirectory(hostDir: string): void { + fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(hostDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw insecureCredentialError(hostDir, `${hostDir} must be a real directory, not a link.`); + } + if (!isPrivateToCurrentUser(stat)) throw insecureCredentialError(hostDir, privateHint(hostDir)); +} + +function readCredential(credentialFile: string): HostServiceCredential | undefined { + const descriptor = openCredentialFile(credentialFile); + if (descriptor === undefined) return undefined; + try { + if (!isPrivateToCurrentUser(fs.fstatSync(descriptor))) { + throw insecureCredentialError(credentialFile, privateHint(credentialFile)); + } + return parseCredential(fs.readFileSync(descriptor, 'utf8'), credentialFile); + } finally { + fs.closeSync(descriptor); + } +} + +function openCredentialFile(credentialFile: string): number | undefined { + try { + return openVerifiedFileForRead(credentialFile); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'EACCES' || code === 'EPERM') { + throw new AppError( + 'COMMAND_FAILED', + 'Host service credential file is not readable.', + { + reason: 'host-credential-unreadable', + path: credentialFile, + hint: `Make ${credentialFile} readable by the Host user (chmod 600).`, + }, + error, + ); + } + throw insecureCredentialError( + credentialFile, + `${credentialFile} must be a regular file, not a link or directory.`, + ); + } +} + +function parseCredential(contents: string, credentialFile: string): HostServiceCredential { + const credential = readCredentialFields(parseJsonRecord(contents)); + if (!credential) throw invalidCredentialError(credentialFile); + return credential; +} + +function parseJsonRecord(contents: string): Record | undefined { + try { + const parsed: unknown = JSON.parse(contents); + return parsed && typeof parsed === 'object' ? (parsed as Record) : undefined; + } catch { + return undefined; + } +} + +function readCredentialFields( + record: Record | undefined, +): HostServiceCredential | undefined { + if (record?.version !== CREDENTIAL_FILE_VERSION) return undefined; + const credentialId = matchingString(record.credentialId, CREDENTIAL_ID_PATTERN); + const token = matchingString(record.token, TOKEN_PATTERN); + const createdAt = matchingString(record.createdAt, NON_EMPTY_PATTERN); + if (!credentialId || !token || !createdAt) return undefined; + const principal = hostPrincipalForCredential(credentialId); + return record.principal === principal ? { credentialId, token, principal, createdAt } : undefined; +} + +function matchingString(value: unknown, pattern: RegExp): string | undefined { + return typeof value === 'string' && pattern.test(value) ? value : undefined; +} + +/** Platforms without POSIX ownership (no getuid) report synthetic mode bits, so only POSIX checks them. */ +function isPrivateToCurrentUser(stat: fs.Stats): boolean { + const uid = process.getuid?.(); + if (uid === undefined) return true; + return (stat.mode & GROUP_OR_OTHER_ACCESS) === 0 && stat.uid === uid; +} + +function privateHint(target: string): string { + return `Make ${target} owned by the Host user and inaccessible to group and others (chmod 700 for the directory, 600 for the file).`; +} + +function insecureCredentialError(target: string, hint: string): AppError { + return new AppError('COMMAND_FAILED', 'Host service credential is not private to this user.', { + reason: 'host-credential-insecure', + path: target, + hint, + }); +} + +function invalidCredentialError(credentialFile: string): AppError { + return new AppError('COMMAND_FAILED', 'Host service credential file is malformed.', { + reason: 'host-credential-invalid', + path: credentialFile, + hint: `Delete ${credentialFile} to create a new credential. Workers then need the new token.`, + }); +} diff --git a/src/commands/schema/cli-help-command-usage.test.ts b/src/commands/schema/cli-help-command-usage.test.ts index 1a1fd35766..7c4a247998 100644 --- a/src/commands/schema/cli-help-command-usage.test.ts +++ b/src/commands/schema/cli-help-command-usage.test.ts @@ -181,8 +181,8 @@ test('proxy command help describes tunnel usage', async () => { if (help === null) throw new Error('Expected command help text'); assert.match(help, /Usage:\s+agent-device proxy/); assert.match(help, /cloudflared tunnel --url http:\/\/127\.0\.0\.1:4310/); - assert.match(help, /--host \s+Proxy: host interface to bind/); - assert.match(help, /--port \s+Proxy: TCP port to bind/); + assert.match(help, /--host \s+Proxy and host: interface to bind/); + assert.match(help, /--port \s+Proxy and host: TCP port to bind/); assert.match(help, /--daemon-auth-token \s+Remote HTTP daemon or proxy auth token/); assert.match(help, /--state-dir \s+Daemon state directory/); assert.match(help, /\/agent-device\/\*/); diff --git a/src/commands/schema/cli-help-host.ts b/src/commands/schema/cli-help-host.ts new file mode 100644 index 0000000000..b67c078a99 --- /dev/null +++ b/src/commands/schema/cli-help-host.ts @@ -0,0 +1,50 @@ +export const hostHelpTopics = { + host: { + summary: 'Host front-end for remote verification workers', + body: `agent-device help host + +The host command runs the Host front-end on the Mac that owns the devices. It is a separate process +from the daemon: it starts or reuses the local HTTP daemon and forwards remote requests to it over +loopback with the local daemon token. + +Service credential: + Created at /host/service-credential.json (mode 0600, directory 0700) once Host is + serving. The token is printed on that start only; read it from the file afterwards. + Every later start reuses the same credential, so workers survive Host restarts. + Host refuses to start when the file is malformed, a link, or open to group or others. + Rotate by deleting the file and restarting Host; workers then need the new token. + +Serving: + --host --port Bind address (default 127.0.0.1, free port) + --tls-cert --tls-key Serve HTTPS; both are required together + Any bind other than loopback needs TLS. The key must match the certificate. + A wildcard bind such as 0.0.0.0 advertises the machine's hostname. + These checks all run before a daemon is started. + Routes match proxy: /health, /rpc, uploads, /artifacts, request diagnostics, also under /agent-device/*. + GET /health is public. Every other route needs the service token (401 without it); + unserved routes get 404. + +Public route policy: + Host drops any identity a client claims (tenant headers and body fields) and forwards the + credential's principal to the daemon, which isolates sessions under it. + Refused with 403 and a typed details.reason: + host-admin-refused macos-app lease allocation (/admin/* is not served) + host-path-refused inputs naming a path on the Host machine, batch steps included + host-component-download-refused allowDownload + host-script-refused replay and test, whose nested actions Host cannot check + Anonymous /health shows only ok, service and rpcProtocolVersion. + +Requesting a device: + agent-device open com.example.app --platform ios --device "iPhone 16" + agent-device open com.example.app --platform android --device "Pixel 7" --os-version 15 + On Host, --device names a device type. Host allocates a fresh device for every lease instead + of resolving the name against inventory; never pass a UDID or serial. + A Host whose daemon has no device allocator refuses this with host-shape-unsupported before + any lease is requested. + +Worker: + agent-device connect proxy --daemon-base-url https://host.example:8443/agent-device --daemon-auth-token + +See also: help remote (plain proxy and remote profiles).`, + }, +}; diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts index 2364272b00..085b0378dd 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -447,6 +447,18 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /install-from-source --github-actions-artifact org\/repo:artifact/); }); +test('usageForCommand resolves host help topic', async () => { + const help = await usageForCommand('host'); + if (help === null) throw new Error('Expected host help text'); + assert.match(help, /^agent-device \S+ — host/); + assert.match(help, /host\/service-credential\.json \(mode 0600, directory 0700\)/); + assert.match(help, /--tls-cert --tls-key /); + assert.match( + help, + /GET \/health is public\. Every other route needs the service token \(401 without it\);\s+unserved routes get 404\./, + ); +}); + test('usageForCommand resolves physical-device help topic', async () => { const help = await usageForCommand('physical-device'); if (help === null) throw new Error('Expected physical-device help text'); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 0dc5d845df..ee18ef11d8 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -24,6 +24,7 @@ import { qaReportHelpTopics, WAIT_FAILURE_CONTRACT, } from './cli-help-workflows.ts'; +import { hostHelpTopics } from './cli-help-host.ts'; import { renderCliHelpOverview } from './cli-help-overview.ts'; import { foldableHelpTopic } from '../system/index.ts'; @@ -694,6 +695,7 @@ Rules: For remote Android and iOS bridge React DevTools, run agent-device react-devtools normally. The CLI opens the needed local service tunnel for the DevTools daemon and keeps it alive until agent-device react-devtools stop or disconnect. Use --debug when remote connection or transport errors need diagnostic ids and remote log hints.`, }, + ...hostHelpTopics, macos: { summary: 'macOS desktop, frontmost-app, and menu bar surfaces', body: `agent-device help macos diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 25e68ffbd1..603d754d66 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -144,6 +144,15 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'Start the official stdio MCP server. It exposes structured command tools backed by the agent-device client.', }, }, + host: { + text: { + summary: 'Serve the local daemon to remote verification workers', + description: + 'Run the Host front-end: start or reuse the local HTTP daemon and serve it to remote workers, authenticated by one persistent service credential stored under the state dir. See help host.', + }, + listUsageOverride: 'host', + allowedFlags: ['proxyHost', 'proxyPort', 'hostTlsCert', 'hostTlsKey', 'stateDir'], + }, proxy: { text: { summary: 'Expose a local daemon through an HTTP tunnel', diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index acef7bbf85..74315c02c1 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -37,6 +37,7 @@ import { type DaemonTakeoverDecision, } from './daemon-launch-spec.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import type { CliFlags } from '@agent-device/contracts/command'; import { getDaemonMetadataState, @@ -49,6 +50,7 @@ import { canConnect, cachedRemoteDaemonHealth, isDaemonTransportUnavailableError, + type RemoteDaemonHealth, } from './daemon-client-transport.ts'; export type DaemonClientSettings = { @@ -171,13 +173,7 @@ async function ensureLocalDaemon(settings: DaemonClientSettings): Promise { - const remoteInfo: DaemonInfo = { - transport: 'http', - // Remote mode reuses the auth token as the daemon token so the existing JSON-RPC contract still works. - token: settings.remoteAuthToken ?? '', - pid: 0, - baseUrl: settings.remoteBaseUrl, - }; + const remoteInfo = remoteDaemonInfo(settings.remoteBaseUrl, settings.remoteAuthToken); const health = await cachedRemoteDaemonHealth(remoteInfo); if (health.reachable) { remoteInfo.remoteInstanceId = health.instanceId; @@ -848,6 +844,29 @@ function readRecentLogTail(logPath: string): string | undefined { } } +function remoteDaemonInfo(baseUrl: string | undefined, authToken: string | undefined): DaemonInfo { + return { + transport: 'http', + // Remote mode reuses the auth token as the daemon token so the existing JSON-RPC contract still works. + token: authToken ?? '', + pid: 0, + baseUrl, + }; +} + +/** + * The health of the remote endpoint these flags name, read through the same cache the RPC + * transport probes before every command, so asking first costs no extra request. + */ +export async function readRemoteDaemonHealthForFlags( + flags: Pick, +): Promise { + const baseUrl = resolveRemoteDaemonBaseUrl(flags.daemonBaseUrl); + if (!baseUrl) return undefined; + const authToken = flags.daemonAuthToken ?? process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; + return await cachedRemoteDaemonHealth(remoteDaemonInfo(baseUrl, authToken)); +} + function resolveRemoteDaemonBaseUrl(raw: string | undefined): string | undefined { if (!raw) return undefined; let parsed: URL; diff --git a/src/daemon-client/daemon-client-transport.ts b/src/daemon-client/daemon-client-transport.ts index 0bf96b8473..b111116c0d 100644 --- a/src/daemon-client/daemon-client-transport.ts +++ b/src/daemon-client/daemon-client-transport.ts @@ -152,6 +152,8 @@ export type RemoteDaemonHealth = { rpcProtocolVersion?: number; instanceId?: string; hostArch?: string; + /** Capabilities the peer advertises, such as Host's device-shape allocation. */ + features?: readonly string[]; /** The daemon behind a proxy, as the proxy's health reported it. */ upstream?: RemoteDaemonHealthLink; /** The probe ran out of its time budget before an answer, rather than failing outright. */ @@ -160,7 +162,7 @@ export type RemoteDaemonHealth = { type RemoteDaemonHealthLink = Pick< RemoteDaemonHealth, - 'service' | 'version' | 'rpcProtocolVersion' | 'instanceId' | 'hostArch' + 'service' | 'version' | 'rpcProtocolVersion' | 'instanceId' | 'hostArch' | 'features' >; export async function canConnect( @@ -355,6 +357,9 @@ function readHealthLink(parsed: Record): RemoteDaemonHealthLink typeof parsed.rpcProtocolVersion === 'number' ? parsed.rpcProtocolVersion : undefined, ...(typeof parsed.instanceId === 'string' ? { instanceId: parsed.instanceId } : {}), ...(typeof parsed.hostArch === 'string' ? { hostArch: parsed.hostArch } : {}), + ...(Array.isArray(parsed.features) + ? { features: parsed.features.filter((feature) => typeof feature === 'string') } + : {}), }; } diff --git a/src/daemon/daemon-request.ts b/src/daemon/daemon-request.ts index bd9063528f..6eeb8e0dba 100644 --- a/src/daemon/daemon-request.ts +++ b/src/daemon/daemon-request.ts @@ -33,6 +33,11 @@ type DaemonRequestInternal = ReplayDispatchOptions & { */ openDeviceWait?: { waitedMs: number }; publicNetworkOnly?: true; + /** + * The principal the Host front-end authenticated and sent over the daemon-token loopback channel + * (ADR 0021 §6). Read from a header only after the daemon token matched, never from the body. + */ + hostPrincipal?: string; /** * The steps a batch still has ahead of this one. The open seam derives platform readiness * policy (runner demand) from it; the transport strips `internal`, so it never arrives from a diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 15f4a96325..eb5deed69a 100644 --- a/src/daemon/handlers/__tests__/lease.test.ts +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -21,6 +21,11 @@ import { createControlLatch, humanControlRequest, } from '../../__tests__/human-control-fixtures.ts'; +import type { HostShapeAllocator } from '../../host-shape-allocation.ts'; +import { + createScriptedManagedDeviceAllocator, + type ScriptedManagedDeviceAllocator, +} from '../../../__tests__/test-utils/managed-device-allocator.fixtures.ts'; for (const operation of ['allocate', 'release'] as const) { test(`host activation drains provider lease ${operation} before reporting active`, async () => { @@ -554,3 +559,198 @@ test('a tenant cannot allocate a macos-app lease', async () => { ); assert.deepEqual(registry.listActiveLeases(), []); }); + +const HOST_PRINCIPAL = 'host-svc-3f9c2a1b'; + +/** The Host seam over the scripted allocator port, iOS first; the lease side owns the real one. */ +function hostAllocatorOverScriptedPort(port: ScriptedManagedDeviceAllocator) { + const released: string[] = []; + const allocator: HostShapeAllocator = { + allocate: async ({ principal, runId, shape, deadline, signal }) => { + const status = await port.requestLease({ + requesterId: `${principal}/${runId}`, + requestGeneration: 1, + attemptKey: `${principal}/${runId}/1`, + shape, + deadlineAtMs: deadline, + admission: 'fail-fast', + activation: 'direct', + signal, + }); + assert.equal(status.state, 'granted'); + return { deviceKey: `${shape.platform}:mobile:${status.lease?.device.address}` }; + }, + release: async ({ deviceKey }) => { + released.push(deviceKey); + }, + }; + return { allocator, released }; +} + +function grantedSimulator(address: string) { + return { + requesterId: `${HOST_PRINCIPAL}/verify-812`, + requestGeneration: 1, + attemptKey: `${HOST_PRINCIPAL}/verify-812/1`, + state: 'granted', + lease: { + id: 'simlock-lease-1', + ttlDeadline: Date.now() + 60_000, + device: { address }, + environment: { SIMLOCK_IOS_DEVICE_SET: '/var/simlock/devices' }, + }, + }; +} + +function hostAllocateRequest(flags: DaemonRequest['flags']): DaemonRequest { + return { + token: 'daemon-token', + session: 'default', + command: 'lease_allocate', + positionals: [], + flags, + meta: { tenantId: HOST_PRINCIPAL, runId: 'verify-812', clientId: 'ab12cd34' }, + internal: { hostPrincipal: HOST_PRINCIPAL }, + }; +} + +test('a Host lease is allocated by shape through the allocator and bound to its device', async () => { + const port = createScriptedManagedDeviceAllocator({ + script: { requestLease: [grantedSimulator('SIM-UDID-1')] }, + }); + const { allocator } = hostAllocatorOverScriptedPort(port); + const registry = new LeaseRegistry(); + + const response = await handleLeaseCommands({ + req: hostAllocateRequest({ platform: 'ios', device: 'iPhone 16' }), + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-host-shape-'), + leaseRegistry: registry, + hostShapeAllocator: allocator, + }); + + assert.equal(response?.ok, true); + const input = port.calls[0]?.input as { requesterId: string; shape: unknown }; + assert.deepEqual(input.shape, { platform: 'ios', deviceType: 'iPhone 16' }); + assert.equal(input.requesterId, `${HOST_PRINCIPAL}/verify-812`); + const lease = (response?.ok ? response.data : undefined)?.lease as DeviceLease; + assert.equal(lease.deviceKey, 'ios:mobile:SIM-UDID-1'); + assert.equal(lease.tenantId, HOST_PRINCIPAL); +}); + +test('a Host lease without an allocator is refused before any lease is published', async () => { + const registry = new LeaseRegistry(); + await assert.rejects( + handleLeaseCommands({ + req: hostAllocateRequest({ platform: 'ios', device: 'iPhone 16' }), + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-host-shape-'), + leaseRegistry: registry, + }), + (error) => + error instanceof AppError && error.details?.reason === 'host-shape-allocation-unavailable', + ); + assert.equal(registry.listActiveLeases().length, 0); +}); + +test('a Host lease is requested by type, never by an inventory identity', async () => { + const port = createScriptedManagedDeviceAllocator(); + const { allocator } = hostAllocatorOverScriptedPort(port); + await assert.rejects( + handleLeaseCommands({ + req: hostAllocateRequest({ platform: 'ios', device: 'iPhone 16', udid: 'SIM-UDID-9' }), + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-host-shape-'), + leaseRegistry: new LeaseRegistry(), + hostShapeAllocator: allocator, + }), + (error) => error instanceof AppError && error.details?.reason === 'host-shape-invalid', + ); + assert.equal(port.calls.length, 0); +}); + +test('an allocation whose Host lease cannot be published is given back', async () => { + const port = createScriptedManagedDeviceAllocator({ + script: { requestLease: [grantedSimulator('SIM-UDID-1')] }, + }); + const { allocator, released } = hostAllocatorOverScriptedPort(port); + const registry = new LeaseRegistry(); + registry.allocateLease({ + tenantId: 'someone-else', + runId: 'other-run', + leaseBackend: 'ios-simulator', + deviceKey: 'ios:mobile:SIM-UDID-1', + }); + + await assert.rejects( + handleLeaseCommands({ + req: hostAllocateRequest({ platform: 'ios', device: 'iPhone 16' }), + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-host-shape-'), + leaseRegistry: registry, + hostShapeAllocator: allocator, + }), + ); + assert.deepEqual(released, ['ios:mobile:SIM-UDID-1']); +}); + +test('a Host allocation whose requester left is given back, not published', async () => { + const port = createScriptedManagedDeviceAllocator({ + script: { requestLease: [grantedSimulator('SIM-UDID-1')] }, + }); + const { allocator, released } = hostAllocatorOverScriptedPort(port); + const registry = new LeaseRegistry(); + const req = hostAllocateRequest({ platform: 'ios', device: 'iPhone 16' }); + req.meta = { ...req.meta, requestId: 'host-gone' }; + registerRequestAbort('host-gone'); + markRequestCanceled('host-gone'); + try { + await assert.rejects( + handleLeaseCommands({ + req, + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-host-shape-'), + leaseRegistry: registry, + hostShapeAllocator: allocator, + }), + ); + } finally { + clearRequestCanceled('host-gone'); + } + assert.deepEqual(released, ['ios:mobile:SIM-UDID-1']); + assert.equal(registry.listActiveLeases().length, 0); +}); + +test('a failed give-back keeps the original refusal and says the device may be held', async () => { + const port = createScriptedManagedDeviceAllocator({ + script: { requestLease: [grantedSimulator('SIM-UDID-1')] }, + }); + const { allocator } = hostAllocatorOverScriptedPort(port); + const failingRelease: HostShapeAllocator = { + allocate: allocator.allocate, + release: async () => { + throw new Error('allocator unreachable'); + }, + }; + const registry = new LeaseRegistry(); + registry.allocateLease({ + tenantId: 'someone-else', + runId: 'other-run', + leaseBackend: 'ios-simulator', + deviceKey: 'ios:mobile:SIM-UDID-1', + }); + + await assert.rejects( + handleLeaseCommands({ + req: hostAllocateRequest({ platform: 'ios', device: 'iPhone 16' }), + sessionName: 'default', + sessionStore: makeSessionStore('agent-device-host-shape-'), + leaseRegistry: registry, + hostShapeAllocator: failingRelease, + }), + (error) => + error instanceof AppError && + error.code === 'DEVICE_IN_USE' && + error.details?.hostAllocationReleaseFailed === 'allocator unreachable', + ); +}); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 9801ce9835..622d29597a 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -12,6 +12,7 @@ import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { leaseReleaseRequestFor, + normalizeAllocateLeaseRequest, normalizeLeaseBackend, type ReleaseLeaseRequest, } from '../lease-registry-scope.ts'; @@ -25,14 +26,26 @@ import { leaseScopeToAllocateRequest, leaseScopeToHeartbeatRequest, leaseScopeToReleaseRequest, + type LeaseScope, } from '@agent-device/contracts/lease-scope'; -import { AppError, createRequestCanceledError, errorMessage } from '@agent-device/kernel/errors'; +import { + AppError, + createRequestCanceledError, + errorMessage, + normalizeError, + toAppErrorCode, +} from '@agent-device/kernel/errors'; import { LEASE_ALLOCATION_BUDGET_MS } from '@agent-device/command-registry/timeout-policy'; import { getRequestSignal, isRequestCanceled } from '@agent-device/host-kit/request'; import { listDownloadableArtifacts } from '../artifact-tracking.ts'; import { providerSessionIdFromData } from '../provider-session-ownership.ts'; import type { DaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; +import { + hostShapeAllocationUnavailable, + readHostShapeRequest, + type HostShapeAllocator, +} from '../host-shape-allocation.ts'; type LeaseHandlerArgs = { req: DaemonRequest; @@ -44,6 +57,7 @@ type LeaseHandlerArgs = { providerCredentials?: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; + hostShapeAllocator?: HostShapeAllocator; }; export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { @@ -73,6 +87,10 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { + const shape = readHostShapeRequest(args.req.flags); + const allocator = args.hostShapeAllocator; + if (!allocator) throw hostShapeAllocationUnavailable(); + const { runId } = normalizeAllocateLeaseRequest(leaseScopeToAllocateRequest(leaseScope)); + const requestId = args.req.meta?.requestId; + const { deviceKey } = await allocator.allocate({ + principal, + runId, + ...(leaseScope.clientId ? { clientId: leaseScope.clientId } : {}), + shape, + ...(leaseScope.leaseTtlMs !== undefined ? { ttlMs: leaseScope.leaseTtlMs } : {}), + signal: getRequestSignal(requestId) ?? new AbortController().signal, + deadline: Date.now() + LEASE_ALLOCATION_BUDGET_MS, + }); + const giveBack = async (cause: unknown): Promise => { + const released = await allocator.release({ principal, runId, deviceKey }).then( + () => undefined, + (releaseError: unknown) => releaseError, + ); + if (released === undefined) throw cause; + const normalized = normalizeError(cause); + throw new AppError( + toAppErrorCode(normalized.code), + normalized.message, + { + ...normalized.details, + hostAllocationReleaseFailed: errorMessage(released), + hint: 'The provisioned device may stay held until its allocator lease expires.', + }, + cause, + ); + }; + if (isRequestCanceled(requestId)) return await giveBack(createRequestCanceledError()); + try { + const lease = args.leaseRegistry.allocateLease( + leaseScopeToAllocateRequest({ ...leaseScope, deviceKey }), + ); + return { ok: true, data: { lease } }; + } catch (error) { + return await giveBack(error); + } +} + type LeaseReleaseOutcome = { /** The daemon's own lease record was released (bookkeeping, not the billed resource). */ registryReleased: boolean; @@ -327,7 +398,7 @@ function assertProviderCredentialsUnchanged( async function listArtifactsForRequest( req: DaemonRequest, - leaseScope: ReturnType, + leaseScope: LeaseScope, leaseRegistry: LeaseRegistry, cloudArtifactProvider: CloudArtifactProvider | undefined, ): Promise { @@ -345,7 +416,7 @@ async function listArtifactsForRequest( } function shouldListDaemonArtifacts( - leaseScope: ReturnType, + leaseScope: LeaseScope, providerSessionId: string | undefined, ): boolean { return isProxyLeaseScope(leaseScope) || (!leaseScope.leaseProvider && !providerSessionId); @@ -362,7 +433,7 @@ async function listDaemonArtifacts(tenantId: string | undefined): Promise, + leaseScope: LeaseScope, providerSessionId: string | undefined, leaseRegistry: LeaseRegistry, cloudArtifactProvider: CloudArtifactProvider | undefined, @@ -396,7 +467,7 @@ async function listCloudArtifactsForRequest( function resolveProviderSession( leaseRegistry: LeaseRegistry, - leaseScope: ReturnType, + leaseScope: LeaseScope, providerSessionId: string | undefined, ): ReturnType { if (!providerSessionId) return undefined; diff --git a/src/daemon/host-path-inputs.ts b/src/daemon/host-path-inputs.ts new file mode 100644 index 0000000000..b2adf5195c --- /dev/null +++ b/src/daemon/host-path-inputs.ts @@ -0,0 +1,23 @@ +/** + * Inputs that name a path on the daemon host. A client of a remote daemon cannot see the host's + * disk; the only such values it sends are the daemon temp artifact locations the client's own + * remote rewrite produces, which the daemon then serves back as artifacts. + */ +export const HOST_PATH_INPUT_KEYS = [ + 'out', + 'saveScript', + 'sessionSaveScript', + 'baseline', + 'launchConsole', + 'artifactsDir', + 'stepsFile', + 'searchPath', + 'retainPaths', + 'installSource', + 'metroProjectRoot', + 'metroRuntimeFile', + 'iosXctestrunFile', + 'iosXctestDerivedDataPath', + 'iosXctestEnvDir', + 'iosSimulatorDeviceSet', +] as const; diff --git a/src/daemon/host-shape-allocation.ts b/src/daemon/host-shape-allocation.ts new file mode 100644 index 0000000000..d1c9a70c86 --- /dev/null +++ b/src/daemon/host-shape-allocation.ts @@ -0,0 +1,65 @@ +import type { ManagedShapeRequest } from '@agent-device/contracts/managed-device-allocation'; +import { AppError } from '@agent-device/kernel/errors'; +import type { DaemonRequest } from './daemon-request.ts'; + +/** + * One fresh managed device for one Host lease (ADR 0021 §4, §5). The lease side implements it + * over Simlock; the daemon calls it before publishing the Host lease that binds the device. + */ +export type HostShapeAllocationRequest = Readonly<{ + /** The principal the Host front-end authenticated; never a value the client chose. */ + principal: string; + runId: string; + clientId?: string; + shape: ManagedShapeRequest; + ttlMs?: number; + signal: AbortSignal; + /** Epoch ms by which `allocate` must settle, from the `lease_allocate` envelope budget. */ + deadline: number; +}>; + +export type HostShapeAllocation = Readonly<{ deviceKey: string }>; + +export type HostShapeAllocator = Readonly<{ + allocate(request: HostShapeAllocationRequest): Promise; + /** Gives back an allocation whose Host lease could not be published. */ + release( + request: Readonly<{ principal: string; runId: string; deviceKey: string }>, + ): Promise; +}>; + +/** + * The shape travels in the device-selection fields `lease_allocate` already carries: `platform`, + * `device` as the device type, and `providerOsVersion` (`--os-version`). A Host lease is never + * addressed by a raw inventory identity, so a UDID or serial is refused rather than ignored. + */ +export function readHostShapeRequest(flags: DaemonRequest['flags']): ManagedShapeRequest { + const platform = flags?.platform; + const deviceType = typeof flags?.device === 'string' ? flags.device.trim() : ''; + if ((platform !== 'ios' && platform !== 'android') || !deviceType) { + throw hostShapeInvalid('A Host lease needs --platform ios|android and --device "".'); + } + if (flags?.udid !== undefined || flags?.serial !== undefined) { + throw hostShapeInvalid('A Host lease is requested by device type, not by UDID or serial.'); + } + const rawOsVersion = flags?.providerOsVersion; + if (rawOsVersion !== undefined && typeof rawOsVersion !== 'string') { + throw hostShapeInvalid('--os-version must be a version string.'); + } + const osVersion = rawOsVersion?.trim(); + return { platform, deviceType, ...(osVersion ? { osVersion } : {}) }; +} + +export function hostShapeAllocationUnavailable(): AppError { + return new AppError('UNSUPPORTED_OPERATION', 'This daemon has no Host device allocator.', { + reason: 'host-shape-allocation-unavailable', + hint: 'Start the daemon with the Host lease coordinator, or connect to a Host that advertises device-shape.', + }); +} + +function hostShapeInvalid(message: string): AppError { + return new AppError('INVALID_ARGS', message, { + reason: 'host-shape-invalid', + hint: 'Example: open com.example.app --platform ios --device "iPhone 16" --os-version 18', + }); +} diff --git a/src/daemon/macos-app-lease.ts b/src/daemon/macos-app-lease.ts index 8d54eab595..9922652919 100644 --- a/src/daemon/macos-app-lease.ts +++ b/src/daemon/macos-app-lease.ts @@ -7,6 +7,7 @@ import { isProcessAlive, readHostEnvironmentVariable } from '@agent-device/host- import { isMacOs } from '@agent-device/kernel/device'; import { AppError, type DaemonError } from '@agent-device/kernel/errors'; import { isAppLeaseAllowed } from './daemon-command-registry.ts'; +import { HOST_PATH_INPUT_KEYS } from './host-path-inputs.ts'; import { isRemoteTempArtifactPath } from '../remote/daemon-artifacts.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData } from './daemon-request.ts'; import type { LeaseRegistry } from './lease-registry.ts'; @@ -63,26 +64,7 @@ type MacOsAppLeaseRule = * Inputs that name a path on the daemon host or launch something beside the app. A client of a * remote daemon cannot see the host's disk, so none of these has a use under the lease. */ -const HOST_INPUT_KEYS = [ - 'out', - 'saveScript', - 'sessionSaveScript', - 'baseline', - 'launchConsole', - 'launchArgs', - 'launchUrl', - 'bundleUrl', - 'artifactsDir', - 'stepsFile', - 'searchPath', - 'retainPaths', - 'installSource', - 'metroProjectRoot', - 'metroRuntimeFile', - 'iosXctestrunFile', - 'iosXctestDerivedDataPath', - 'iosXctestEnvDir', -] as const; +const HOST_INPUT_KEYS = [...HOST_PATH_INPUT_KEYS, 'launchArgs', 'launchUrl', 'bundleUrl'] as const; /** Flags that pick a device other than the leased app's own; a lease never takes a device selector. */ const DEVICE_SELECTOR_KEYS = [ diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 9b976611dc..bb95112da2 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -25,6 +25,7 @@ import type { RequestPlatformProviderScope } from '@agent-device/contracts/platf import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import type { DaemonProviderCredentials } from '../provider-credential-fingerprint.ts'; +import type { HostShapeAllocator } from './host-shape-allocation.ts'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -37,6 +38,7 @@ type RequestHandlerChainParams = { providerCredentials?: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; + hostShapeAllocator?: HostShapeAllocator; providerAppCatalog?: ProviderAppCatalog; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; @@ -155,6 +157,7 @@ async function runLeaseHandler( providerCredentials: params.providerCredentials, leaseLifecycleProvider: params.leaseLifecycleProvider, cloudArtifactProvider: params.cloudArtifactProvider, + hostShapeAllocator: params.hostShapeAllocator, }), ); } diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index fece5d1bb7..66a76814d7 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -86,6 +86,7 @@ import { recordNestedRequests } from './request-dispatch-ledger.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import type { DaemonProviderCredentials } from '../provider-credential-fingerprint.ts'; +import type { HostShapeAllocator } from './host-shape-allocation.ts'; import { restrictDeviceInventoryToDaemonPolicy } from './daemon-policy.ts'; import type { DaemonPolicy } from '../daemon-policy-file.ts'; @@ -111,6 +112,8 @@ export type RequestRouterDeps = { providerCredentials: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; + /** The Host lease side's allocator (ADR 0021 §4); absent on every daemon that is not a Host. */ + hostShapeAllocator?: HostShapeAllocator; providerAppCatalog?: ProviderAppCatalog; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; @@ -169,6 +172,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerCredentials, leaseLifecycleProvider, cloudArtifactProvider, + hostShapeAllocator, providerAppCatalog, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, @@ -347,6 +351,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeRequiredIds, providerCredentials, cloudArtifactProvider, + hostShapeAllocator, providerAppCatalog, invoke: recordNestedRequests(handleRequest, dispatchLedger), invokeReplayAction: allowReplayActions diff --git a/src/daemon/server/host-principal.test.ts b/src/daemon/server/host-principal.test.ts new file mode 100644 index 0000000000..5c28f8fb89 --- /dev/null +++ b/src/daemon/server/host-principal.test.ts @@ -0,0 +1,13 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { readHostPrincipal } from './host-principal.ts'; + +test('a Host principal header is read only in the tenant format, and a malformed one is refused', () => { + assert.equal(readHostPrincipal({}), undefined); + assert.equal( + readHostPrincipal({ 'x-agent-device-principal': 'host-svc-3f9c2a1b' }), + 'host-svc-3f9c2a1b', + ); + assert.equal(readHostPrincipal({ 'x-agent-device-principal': 'host svc/../x' }), null); + assert.equal(readHostPrincipal({ 'x-agent-device-principal': ['a', 'b'] }), null); +}); diff --git a/src/daemon/server/host-principal.ts b/src/daemon/server/host-principal.ts new file mode 100644 index 0000000000..c7a3e287dc --- /dev/null +++ b/src/daemon/server/host-principal.ts @@ -0,0 +1,21 @@ +import type { IncomingHttpHeaders } from 'node:http'; +import { DAEMON_HTTP_PRINCIPAL_HEADER } from '@agent-device/contracts/daemon-http'; +import { AppError } from '@agent-device/kernel/errors'; +import { normalizeTenantId } from '../config.ts'; + +/** + * The principal the Host front-end sent on the daemon-token loopback request (ADR 0021 §6). + * `null` when the header is present but malformed, so the caller refuses it instead of ignoring it. + */ +export function readHostPrincipal(headers: IncomingHttpHeaders): string | undefined | null { + const raw = headers[DAEMON_HTTP_PRINCIPAL_HEADER]; + if (raw === undefined) return undefined; + return (typeof raw === 'string' ? normalizeTenantId(raw) : undefined) ?? null; +} + +export function hostPrincipalInvalidError(): AppError { + return new AppError('INVALID_ARGS', 'Invalid params: the Host principal header is malformed', { + reason: 'host-principal-invalid', + hint: 'A Host principal uses the tenant format: 1-128 letters, digits, dot, underscore or dash.', + }); +} diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index c48a7800eb..db5cbf8161 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -38,6 +38,7 @@ import { buildDaemonHealthPayload, DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, + DAEMON_HOST_DEVICE_SHAPE_FEATURE, DAEMON_HTTP_TENANT_HEADER, } from '@agent-device/contracts/daemon-http'; import { readVersion } from '@agent-device/host-kit/version'; @@ -48,10 +49,12 @@ 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 { hostPrincipalInvalidError, readHostPrincipal } from './host-principal.ts'; import { refuseStaleDaemonInstance } from './http-instance-precondition.ts'; import type { TenantSessionNamespace } from '../session-tenant-scope.ts'; import { tryHandleHostAdminHttpRoute } from '../host-lease-http.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; +import type { HostShapeAllocator } from '../host-shape-allocation.ts'; import { assertMacOsAppLeaseTenantMayReadDiagnostics } from '../macos-app-lease.ts'; type JsonRpcRequest = JsonRpcRequestEnvelope; @@ -588,6 +591,8 @@ export async function createDaemonHttpServer(options: { * rather than handed a daemon-host path. */ resolveRequestDiagnosticsPath?: (ref: DiagnosticsRecordRef) => string; + /** The Host lease side's allocator; `/health` advertises device-shape exactly when it is set. */ + hostShapeAllocator?: HostShapeAllocator; }): Promise { const instanceId = randomUUID(); const hostArch = await readHostCpuArch(); @@ -607,6 +612,7 @@ export async function createDaemonHttpServer(options: { instanceId, hostArch, leaseBackends, + ...(options.hostShapeAllocator ? { features: [DAEMON_HOST_DEVICE_SHAPE_FEATURE] } : {}), }), ), ); @@ -755,6 +761,10 @@ export async function createDaemonHttpServer(options: { }; requestAbortRegistration = registerRequestAbort(requestIdForCleanup); const clientDeclaredTenant = daemonRequest.meta?.tenantId ?? daemonRequest.flags?.tenant; + // The principal counts only on a request that already holds the daemon token. + const hostPrincipal = enforceDaemonToken(daemonRequest.token, token) + ? undefined + : readHostPrincipal(req.headers); const authResult = await runHttpAuthHook(authHook, { headers: req.headers, @@ -769,9 +779,10 @@ export async function createDaemonHttpServer(options: { hookConfigured: authHook !== null, hookAttestedTenant: authResult.tenantId, clientDeclaredTenant, + hostPrincipal: hostPrincipal ?? undefined, }); if (!tenantTrust.trusted) { - const normalized = tenantTrustRejectionError(); + const normalized = tenantTrustRejectionError(tenantTrust); sendJson( res, createRpcError(rpcRequest.id ?? null, -32001, normalized.message, normalized), @@ -788,6 +799,15 @@ export async function createDaemonHttpServer(options: { ); return; } + if (hostPrincipal === null) { + const normalized = normalizeError(hostPrincipalInvalidError()); + sendJson( + res, + createRpcError(rpcRequest.id ?? null, -32602, normalized.message, normalized), + 400, + ); + return; + } if (refuseStaleDaemonInstance(req, res, rpcRequest.id ?? null, instanceId)) return; daemonRequest.meta = { ...daemonRequest.meta, @@ -811,6 +831,9 @@ export async function createDaemonHttpServer(options: { if (tenantTrust.attested && daemonRequest.flags?.sessionIsolation !== undefined) { daemonRequest.flags = { ...daemonRequest.flags, sessionIsolation: 'tenant' }; } + if (hostPrincipal) { + daemonRequest.internal = { ...daemonRequest.internal, hostPrincipal }; + } daemonRequest = restrictRemoteHttpRequest( daemonRequest, authHook !== null, @@ -941,6 +964,11 @@ async function authorizeAuxiliaryHttpRequest(params: { sendRestJsonError(res, tokenError); return null; } + const hostPrincipal = readHostPrincipal(req.headers); + if (hostPrincipal === null) { + sendRestJsonError(res, normalizeError(hostPrincipalInvalidError())); + return null; + } const syntheticRpc: JsonRpcRequest = { jsonrpc: '2.0', @@ -967,9 +995,10 @@ async function authorizeAuxiliaryHttpRequest(params: { hookConfigured: authHook !== null, hookAttestedTenant: authResult.tenantId, clientDeclaredTenant: tenantId, + hostPrincipal, }); if (!tenantTrust.trusted) { - sendRestJsonError(res, tenantTrustRejectionError()); + sendRestJsonError(res, tenantTrustRejectionError(tenantTrust)); return null; } diff --git a/src/daemon/server/tenant-trust.test.ts b/src/daemon/server/tenant-trust.test.ts new file mode 100644 index 0000000000..c0f31751e6 --- /dev/null +++ b/src/daemon/server/tenant-trust.test.ts @@ -0,0 +1,30 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { resolveTrustedTenant, tenantTrustRejectionError } from './tenant-trust.ts'; + +test('a Host principal is an attested tenant that outranks the tenant a client declares', () => { + assert.deepEqual( + resolveTrustedTenant({ + hookConfigured: false, + hookAttestedTenant: undefined, + clientDeclaredTenant: 'someone-else', + hostPrincipal: 'host-svc-3f9c2a1b', + }), + { trusted: true, tenantId: 'host-svc-3f9c2a1b', attested: true }, + ); +}); + +test('a Host principal is refused with a typed reason while an auth hook attests tenants', () => { + const decision = resolveTrustedTenant({ + hookConfigured: true, + hookAttestedTenant: 'hook-tenant', + clientDeclaredTenant: undefined, + hostPrincipal: 'host-svc-3f9c2a1b', + }); + + assert.equal(decision.trusted, false); + if (decision.trusted) return; + const error = tenantTrustRejectionError(decision); + assert.equal(error.code, 'UNAUTHORIZED'); + assert.equal(error.details?.reason, 'host-principal-with-auth-hook'); +}); diff --git a/src/daemon/server/tenant-trust.ts b/src/daemon/server/tenant-trust.ts index 11d2c863a3..577b284933 100644 --- a/src/daemon/server/tenant-trust.ts +++ b/src/daemon/server/tenant-trust.ts @@ -10,14 +10,25 @@ import { AppError, normalizeError } from '@agent-device/kernel/errors'; */ export type TenantTrustDecision = | { trusted: true; tenantId: string | undefined; attested: boolean } - | { trusted: false }; + | { trusted: false; reason?: 'host-principal-with-auth-hook' }; +/** + * A Host principal is attested by the front-end that holds the daemon token, so it partitions the + * session namespace exactly like a hook-attested tenant. With an auth hook configured the hook is + * the only attestor, and a principal header is refused rather than silently ranked against it. + */ export function resolveTrustedTenant(params: { hookConfigured: boolean; hookAttestedTenant: string | undefined; clientDeclaredTenant: string | undefined; + hostPrincipal?: string; }): TenantTrustDecision { - const { hookConfigured, hookAttestedTenant, clientDeclaredTenant } = params; + const { hookConfigured, hookAttestedTenant, clientDeclaredTenant, hostPrincipal } = params; + if (hostPrincipal) { + return hookConfigured + ? { trusted: false, reason: 'host-principal-with-auth-hook' } + : { trusted: true, tenantId: hostPrincipal, attested: true }; + } if (hookAttestedTenant) return { trusted: true, tenantId: hookAttestedTenant, attested: true }; if (!hookConfigured) { return { trusted: true, tenantId: clientDeclaredTenant, attested: false }; @@ -25,7 +36,21 @@ export function resolveTrustedTenant(params: { return { trusted: false }; } -export function tenantTrustRejectionError(): ReturnType { +export function tenantTrustRejectionError( + decision: Extract, +): ReturnType { + if (decision.reason === 'host-principal-with-auth-hook') { + return normalizeError( + new AppError( + 'UNAUTHORIZED', + 'A Host principal is not accepted while an auth hook attests tenants', + { + reason: decision.reason, + hint: 'Run the Host front-end against a daemon without AGENT_DEVICE_HTTP_AUTH_HOOK.', + }, + ), + ); + } return normalizeError( new AppError('UNAUTHORIZED', 'Request tenant is not attested by the auth hook'), ); diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index 5e5342fe5c..7d57f7b13d 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -44,6 +44,8 @@ export type RemoteConnectionState = { leaseProvider?: string; deviceKey?: string; clientId?: string; + /** The device type a Host lease was allocated for (ADR 0021 §5); absent off Host. */ + hostDeviceType?: string; platform?: CliFlags['platform']; target?: CliFlags['target']; runtime?: SessionRuntimeHints; @@ -475,6 +477,7 @@ function isRemoteConnectionState(value: unknown): value is RemoteConnectionState 'leaseProvider', 'deviceKey', 'clientId', + 'hostDeviceType', ]) && isOptionalRemoteConnectionDaemonState(record.daemon) ); diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index cae40d9ab3..b4c2417ed2 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -3,8 +3,10 @@ "declarations": { "packages/contracts/src/daemon-http.ts#DAEMON_HTTP_BASE_PATH": "sha256:a1ada25c6f90d9c69c8c836538e8882547bf239559f7c1accacd0654355802dd", "packages/contracts/src/daemon-http.ts#DAEMON_HTTP_TENANT_HEADER": "sha256:ed49119d232500885f014ac73f6123d298d404c6c176abdae59cf324825927e5", - "packages/contracts/src/daemon-http.ts#DaemonHealthPayload": "sha256:ece08b91cc46c1397309a05a68f1e1be3999cd4b069c4c3b6bc5b75a5700f108", - "packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload": "sha256:49a46bd62207a69a359e7c8be6f97c07748af21344452be1d32507b95ce4650b", + "packages/contracts/src/daemon-http.ts#DaemonHealthPayload": "sha256:498a650432aa022aa286711b0946bd755b1031636d2742c087d6749bd37c0420", + "packages/contracts/src/daemon-http.ts#DaemonHealthFeature": "sha256:b317d8e225c0c594568fb4a3e60c02d832994c6944c60740a9f51c8ebb3308c6", + "packages/contracts/src/daemon-http.ts#DAEMON_HOST_DEVICE_SHAPE_FEATURE": "sha256:40e203382a121019ac99a0fe3540fe481433af380c4b728216ec5348af41cdf4", + "packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload": "sha256:2650d712c6bb58b85901a04933e4bcf9f76e72cd3fa995f0cca1731369e34835", "packages/contracts/src/daemon-http.ts#buildDaemonHttpAuthHeaders": "sha256:5548a44d6248ed858d19a0b2985ec4ae8a7181bae8294b85edf3c1b3b58e80d4", "packages/contracts/src/daemon-http.ts#buildDaemonHttpBaseUrl": "sha256:f92697208d9f42ec0dcfb006b6f2dce761bd5307db27ce2e52927fd7742e3a9a", "packages/contracts/src/daemon-http.ts#buildDaemonHttpTenantHeaders": "sha256:e38a5f0b5ab07ee3a5fe3db229d0de750888660c55bf31692e100002be96de1d", @@ -65,10 +67,10 @@ "src/daemon-client/daemon-client-rpc.ts#rejectDaemonHttpRpcError": "sha256:83b0312fe88bc3b3497de799e23d8d14455f665b7cf880f4617cd62b181351cd", "src/daemon-client/daemon-client-rpc.ts#resolveDaemonHttpResult": "sha256:296f9d376ce67c8cb20209bdf1c59c2423ffcfa35b5565a99e70271263149048", "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", - "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", - "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", + "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:0307546ee0069ee8df5b06e04214f2aa7659d9129bc8a7404e858fa57496be66", + "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:4158516935d64ccade66976e3be9eabe565762d3211cc565aee43670a12eb274", "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth": "sha256:6ce1f9be1e6a977130d0875003ac22da2de5e90b514a3b22b30b9347e63d81c7", - "src/daemon-client/daemon-client-transport.ts#readHealthLink": "sha256:f56404b94d73da57de7248719c9136b760d2be8b4a53cde5315a2311b088425b", + "src/daemon-client/daemon-client-transport.ts#readHealthLink": "sha256:7b3a7d74acb739f6bf2ed6cbe2f578a6b6c3d209a2e59057da1e8984d6a166c3", "src/daemon-client/daemon-client-transport.ts#readHealthPayload": "sha256:4e85ffc3e35e02379c393e9312344757e003cf1f0ad9eb8d1f77d90c81c861f1", "src/daemon-client/daemon-client-transport.ts#readRemoteDaemonHealth": "sha256:df174d1ccf73851ca58bd75533fb4660424003b98bd9e41fe942cf3ab3698e5f", "src/daemon/downloadable-artifact-http.ts#DownloadableArtifactHttpAuthorizer": "sha256:1b2702a929ca9170db2ca97c08e3ab67e17edb3ee75325a576c4c1b9cdbebb44", @@ -109,7 +111,7 @@ "src/daemon/server/http-server.ts#MAX_HTTP_RPC_BODY_BYTES": "sha256:3cb06e12b3110fa27e390d5c6473578b3f13c93b9f2b571e0e8e42d7ce29d48a", "src/daemon/server/http-server.ts#RELEASE_MATERIALIZED_PATHS_RPC_METHODS": "sha256:75a18d3496894309dd6042b16c4dcc241c16ec6336783e6310eddd6db7437ccb", "src/daemon/server/http-server.ts#SUPPORTED_RPC_METHODS": "sha256:4d5ed730dcb669b0dacca3bb4977f35686bab9b7fb464c0590fd50f838e6c243", - "src/daemon/server/http-server.ts#authorizeAuxiliaryHttpRequest": "sha256:1c323f97c3cefec8f95633b6abd127342bf75494bfb109d1cc6eadf3ceacaeae", + "src/daemon/server/http-server.ts#authorizeAuxiliaryHttpRequest": "sha256:28acfc0b437378a401f52823b098498758f5aa45d71793a37ddc4123005557ea", "src/daemon/server/http-server.ts#createRpcError": "sha256:1921762129636afc7772937d48e8afb8f09047b343e3a27d18b49c1c4385bbe8", "src/daemon/server/http-server.ts#enforceDaemonToken": "sha256:60036cffc34388b33fc0dad39c905d9cb3fc18c9ed0cf24255bb7105f5d444c7", "src/daemon/server/http-server.ts#isCommandRpcMethod": "sha256:9922102ba9c72c3032f205717d772ab7c3506c6f3012f55b8e7e5636b672ed7e", @@ -191,7 +193,8 @@ "packages/proxy/src/daemon-proxy.ts#buildUpstreamInstancePreconditionHeaders": "sha256:9f5d76a1f761d65fabd7244fc67295fed5934a544e86a6455451ef8520cf1615", "src/daemon-client/daemon-client-transport.ts#buildRemoteInstancePreconditionHeaders": "sha256:70241561c4070a8ebbdbbdb4ebcdbdfcb9543501cbb649a6d71cf041aee55a08", "src/daemon-client/daemon-client-transport.ts#isRemoteInstanceMismatchResponse": "sha256:7670f48fe0fc7344f8a000cd303e49788ec7ea9d1c729bcdc2af73ff02dae04d", - "src/daemon-client/daemon-client-transport.ts#isRemoteInstanceMismatch": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee" + "src/daemon-client/daemon-client-transport.ts#isRemoteInstanceMismatch": "sha256:6d4cfdd36bd44686d2a48990491faf63ba9117fb760f982fd72922c99be8daee", + "packages/contracts/src/daemon-http.ts#DAEMON_HOST_SERVICE": "sha256:b62925a79171eb9f47881a1017087779a97ab42173751847f4414ea8fee5d42a" }, "compatibleChanges": [ { @@ -281,8 +284,8 @@ }, { "declaration": "src/daemon/server/http-server.ts#authorizeAuxiliaryHttpRequest", - "digest": "sha256:1c323f97c3cefec8f95633b6abd127342bf75494bfb109d1cc6eadf3ceacaeae", - "rationale": "#2198 context: adds one optional field to the value this DAEMON-INTERNAL gate hands its own route handlers — sessionNamespace, the caller's tenant plus whether the daemon partitioned that caller's session names. Nothing about it is serialized: no request header is read that was not read before, no response field is added, and a refusal keeps the same 401 {ok:false,error,code} REST body sendRestJsonError already sent. What it enables is a refusal DROPPED, never one added — an unattested (client-declared) tenant no longer has the : prefix rule applied to it, because scopeRequestSession never applied that prefix to its sessions either. A released peer that now gets 200 where it got 401 is getting the ndjson record it asked for and already parses." + "digest": "sha256:28acfc0b437378a401f52823b098498758f5aa45d71793a37ddc4123005557ea", + "rationale": "#2198 context: adds one optional field to the value this DAEMON-INTERNAL gate hands its own route handlers — sessionNamespace, the caller's tenant plus whether the daemon partitioned that caller's session names. Nothing about it is serialized: no request header is read that was not read before, no response field is added, and a refusal keeps the same 401 {ok:false,error,code} REST body sendRestJsonError already sent. What it enables is a refusal DROPPED, never one added — an unattested (client-declared) tenant no longer has the : prefix rule applied to it, because scopeRequestSession never applied that prefix to its sessions either. A released peer that now gets 200 where it got 401 is getting the ndjson record it asked for and already parses. #3266 reads the Host front-end's x-agent-device-principal header only after the daemon token matched and treats it as an attested tenant; no released client sends that header and the proxy never forwards it, so every released request is authorized exactly as before." }, { "declaration": "src/daemon/request-diagnostics-http.ts#RequestDiagnosticsHttpAuthorizer", @@ -351,28 +354,28 @@ }, { "declaration": "packages/contracts/src/daemon-http.ts#DaemonHealthPayload", - "digest": "sha256:ece08b91cc46c1397309a05a68f1e1be3999cd4b069c4c3b6bc5b75a5700f108", - "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged." + "digest": "sha256:498a650432aa022aa286711b0946bd755b1031636d2742c087d6749bd37c0420", + "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged. #3266 widens `service` with 'agent-device-host' for the Host front-end; clients read `service` as an opaque diagnostic string, and the daemon and proxy keep sending exactly the values they sent before. #3267 adds an optional `features` list (`device-shape`) that a client reads before requesting a Host device by type; peers that send no features parse exactly as before, and released clients ignore the field." }, { "declaration": "packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload", - "digest": "sha256:49a46bd62207a69a359e7c8be6f97c07748af21344452be1d32507b95ce4650b", - "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged." + "digest": "sha256:2650d712c6bb58b85901a04933e4bcf9f76e72cd3fa995f0cca1731369e34835", + "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged. #3267 adds an optional `features` list (`device-shape`) that a client reads before requesting a Host device by type; peers that send no features parse exactly as before, and released clients ignore the field." }, { "declaration": "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth", - "digest": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", - "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged. A restart retry must tell a probe that ran out of time from one that failed, so the client can report the RPC deadline instead of 'Remote daemon is unavailable'. `timedOut` is client-local: the prober sets it on its own timeout and abort paths and never reads it from a /health payload. The health request and the accepted payload fields are unchanged, so a released daemon or proxy is probed and parsed exactly as before." + "digest": "sha256:0307546ee0069ee8df5b06e04214f2aa7659d9129bc8a7404e858fa57496be66", + "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged. A restart retry must tell a probe that ran out of time from one that failed, so the client can report the RPC deadline instead of 'Remote daemon is unavailable'. `timedOut` is client-local: the prober sets it on its own timeout and abort paths and never reads it from a /health payload. The health request and the accepted payload fields are unchanged, so a released daemon or proxy is probed and parsed exactly as before. #3267 adds an optional `features` list (`device-shape`) that a client reads before requesting a Host device by type; peers that send no features parse exactly as before, and released clients ignore the field." }, { "declaration": "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink", - "digest": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", - "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged." + "digest": "sha256:4158516935d64ccade66976e3be9eabe565762d3211cc565aee43670a12eb274", + "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged. #3267 adds an optional `features` list (`device-shape`) that a client reads before requesting a Host device by type; peers that send no features parse exactly as before, and released clients ignore the field." }, { "declaration": "src/daemon-client/daemon-client-transport.ts#readHealthLink", - "digest": "sha256:f56404b94d73da57de7248719c9136b760d2be8b4a53cde5315a2311b088425b", - "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged." + "digest": "sha256:7b3a7d74acb739f6bf2ed6cbe2f578a6b6c3d209a2e59057da1e8984d6a166c3", + "rationale": "#2650 adds an optional daemon instance ID to health metadata or reads it when present, #3047 adds an optional hostArch the same way, and #3229 adds an optional leaseBackends list the same way. Older peers ignore the added fields, and new clients keep probing legacy peers that send neither; existing fields and RPC envelopes are unchanged. #3267 adds an optional `features` list (`device-shape`) that a client reads before requesting a Host device by type; peers that send no features parse exactly as before, and released clients ignore the field." }, { "declaration": "packages/contracts/src/daemon-http.ts#DAEMON_HTTP_INSTANCE_HEADER", diff --git a/test/wire-compat/surface.ts b/test/wire-compat/surface.ts index c076e3b7c6..d4b59297ea 100644 --- a/test/wire-compat/surface.ts +++ b/test/wire-compat/surface.ts @@ -82,6 +82,9 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [ ...from( DAEMON_HTTP, 'DaemonHealthPayload', + 'DaemonHealthFeature', + 'DAEMON_HOST_DEVICE_SHAPE_FEATURE', + 'DAEMON_HOST_SERVICE', 'buildDaemonHealthPayload', 'buildDaemonInstanceMismatchRpcResponse', ), diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 9deea25dae..f14b87bf31 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -230,6 +230,21 @@ The proxy validates the client token and rewrites authorized upstream requests t 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. +## Host + +`agent-device host` is the long-running front-end for remote verification workers ([ADR 0021](https://github.com/callstack/agent-device/blob/main/docs/adr/0021-host-simlock-managed-device-allocation.md)). It serves the same routes as `proxy` and forwards them to the local daemon the same way. Its token is one persistent service credential instead of a token generated on every start. + +```sh +agent-device host --host 0.0.0.0 --port 8443 --tls-cert ./cert.pem --tls-key ./key.pem +``` + +- On first start, once it is serving, Host creates `/host/service-credential.json` with mode 0600 and prints the token that one time. Later starts reuse the credential, so workers keep working when a process manager restarts Host. +- The `/host` directory must be mode 0700 and the credential file mode 0600, both owned by the Host user. Host refuses to start when either is open to group or others, or when the file is malformed. To rotate the token, delete the file and restart Host. +- Pass `--tls-cert` and `--tls-key` together to serve HTTPS. The key must match the certificate. Without TLS, Host serves plain HTTP and only on a loopback address (`127.0.0.1` by default), for use behind a TLS tunnel. A wildcard bind such as `0.0.0.0` advertises the machine's hostname. Host checks all of this before it starts a daemon. +- Host drops any identity a client claims and forwards the credential's principal to the daemon, which isolates sessions under it. Host does not serve `/admin/*`. It refuses macos-app allocation, inputs naming a path on the Host machine (batch steps included), component downloads, and `replay`/`test` with HTTP 403 and a typed `details.reason` (`host-admin-refused`, `host-path-refused`, `host-component-download-refused`, `host-script-refused`). Anonymous `/health` shows only `ok`, `service` and `rpcProtocolVersion`. +- On Host, `--device` names a device type: `open com.example.app --platform ios --device "iPhone 16"`. Host allocates a fresh device for every lease instead of resolving the name against inventory. A Host whose daemon has no device allocator refuses this with `host-shape-unsupported` before any lease is requested. +- Workers connect exactly as they do to a proxy: `agent-device connect proxy --daemon-base-url /agent-device --daemon-auth-token `. + ## Embedding the Proxy in Your Own Gateway `agent-device proxy` is also available as a library, `@agent-device/proxy`, for gateways that front