From f173b22a4a76ff4695ac31f6563075567584be76 Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 02:12:17 +0200 Subject: [PATCH 1/9] feat(host): add the agent-device host front-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `agent-device host`, the Host front-end from ADR 0021 §3. It runs as its own process, starts or reuses the local HTTP daemon, and serves it to remote verification workers through the daemon proxy. Workers authenticate with one persistent service credential. Host creates it on first start at /host/service-credential.json (directory 0700, file 0600) and reuses it after every restart. A malformed or group/other-readable file stops startup with a typed reason, and so does a TLS file Host cannot read. Both checks run before any daemon starts. --tls-cert and --tls-key serve HTTPS. A wildcard bind advertises the machine's hostname, since workers cannot dial 0.0.0.0. The proxy command behaves as before. Its daemon startup and listen helpers move into a module both commands use. Closes #3265 --- .../src/flag-definitions-connection.ts | 18 ++ packages/command-registry/src/registry.ts | 11 ++ packages/contracts/src/cli-flags.ts | 2 + src/cli.ts | 3 + src/cli/commands/host.test.ts | 50 ++++++ src/cli/commands/host.ts | 138 ++++++++++++++ src/cli/commands/local-daemon-front-end.ts | 83 +++++++++ src/cli/commands/proxy.ts | 65 ++----- src/cli/commands/router.ts | 1 + src/cli/host/host-server.test.ts | 168 ++++++++++++++++++ src/cli/host/host-server.ts | 28 +++ src/cli/host/service-credential.test.ts | 76 ++++++++ src/cli/host/service-credential.ts | 142 +++++++++++++++ src/commands/schema/cli-help-topics.test.ts | 9 + src/commands/schema/cli-help.ts | 27 +++ src/commands/schema/command-overrides.ts | 9 + website/docs/docs/remote-proxy.md | 13 ++ 17 files changed, 792 insertions(+), 51 deletions(-) create mode 100644 src/cli/commands/host.test.ts create mode 100644 src/cli/commands/host.ts create mode 100644 src/cli/commands/local-daemon-front-end.ts create mode 100644 src/cli/host/host-server.test.ts create mode 100644 src/cli/host/host-server.ts create mode 100644 src/cli/host/service-credential.test.ts create mode 100644 src/cli/host/service-credential.ts diff --git a/packages/command-registry/src/flag-definitions-connection.ts b/packages/command-registry/src/flag-definitions-connection.ts index 2b6fc8fbda..8a53a80463 100644 --- a/packages/command-registry/src/flag-definitions-connection.ts +++ b/packages/command-registry/src/flag-definitions-connection.ts @@ -91,6 +91,24 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ 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, + }, { key: 'tenant', names: ['--tenant'], 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/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/host.test.ts b/src/cli/commands/host.test.ts new file mode 100644 index 0000000000..e2715c5858 --- /dev/null +++ b/src/cli/commands/host.test.ts @@ -0,0 +1,50 @@ +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 { createTestClient } from '../../__tests__/remote-connection.fixtures.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { hostCommand } from './host.ts'; + +function startHost(stateDir: string, extraFlags: Record = {}) { + return hostCommand({ + positionals: [], + flags: { json: true, help: false, version: false, stateDir, ...extraFlags }, + client: createTestClient(), + }); +} + +async function refusalReason(run: Promise): Promise { + try { + await run; + } catch (error) { + assert.ok(error instanceof AppError, `expected AppError, got ${String(error)}`); + return error.details?.reason; + } + assert.fail('expected host to refuse to start'); +} + +test('a malformed credential stops host before any daemon starts', async () => { + const stateDir = mkdtempForTestSync('agent-device-host-start-'); + const hostDir = path.join(stateDir, 'host'); + fs.mkdirSync(hostDir, { mode: 0o700 }); + fs.writeFileSync(path.join(hostDir, 'service-credential.json'), '{}\n', { mode: 0o600 }); + + assert.equal(await refusalReason(startHost(stateDir)), 'host-credential-invalid'); + assert.equal(fs.existsSync(path.join(stateDir, 'daemon.json')), false); +}); + +test('an unreadable TLS file is a typed refusal before any daemon starts', async () => { + const stateDir = mkdtempForTestSync('agent-device-host-start-'); + + const reason = await refusalReason( + startHost(stateDir, { + hostTlsCert: path.join(stateDir, 'missing-cert.pem'), + hostTlsKey: path.join(stateDir, 'missing-key.pem'), + }), + ); + + assert.equal(reason, 'host-tls-unreadable'); + assert.equal(fs.existsSync(path.join(stateDir, 'daemon.json')), false); +}); diff --git a/src/cli/commands/host.ts b/src/cli/commands/host.ts new file mode 100644 index 0000000000..77d193efe1 --- /dev/null +++ b/src/cli/commands/host.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +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 { colorize, supportsColor } from '../../commands/output/color.ts'; +import { createHostServer, type HostTlsMaterial } from '../host/host-server.ts'; +import { loadOrCreateHostServiceCredential } from '../host/service-credential.ts'; +import { + ensureLocalHttpDaemon, + formatHostForUrl, + listenOnTcp, + resolveLocalHttpDaemonSettings, + waitForever, +} from './local-daemon-front-end.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; + credentialCreated: boolean; + /** Present only when this start created the credential, so restart logs never repeat it. */ + token?: string; + tls: boolean; + upstreamBaseUrl: string; + stateDir: string; +}; + +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 tls = readHostTlsMaterial(flags); + const { credential, credentialFile, created } = loadOrCreateHostServiceCredential( + path.join(settings.paths.baseDir, 'host'), + ); + const { upstreamBaseUrl, upstreamToken, stateDir } = await ensureLocalHttpDaemon( + 'host', + settings, + ); + const server = createHostServer({ upstreamBaseUrl, upstreamToken, credential, tls }); + const address = await listenOnTcp( + server, + flags.proxyHost?.trim() || '127.0.0.1', + flags.proxyPort ?? 0, + ); + const scheme = tls ? 'https' : 'http'; + const hostBaseUrl = `${scheme}://${formatHostForUrl(advertisedHost(address.address))}:${address.port}`; + return { + hostBaseUrl, + agentDeviceBaseUrl: buildDaemonHttpBaseUrl(hostBaseUrl), + listenAddress: `${formatHostForUrl(address.address)}:${address.port}`, + principal: credential.principal, + credentialFile, + credentialCreated: created, + ...(created ? { token: credential.token } : {}), + tls: tls !== undefined, + upstreamBaseUrl, + stateDir, + }; +} + +/** A wildcard bind is not an address a worker can dial, so Host names the machine instead. */ +function advertisedHost(boundAddress: string): string { + return boundAddress === '0.0.0.0' || boundAddress === '::' ? os.hostname() : 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', + }); + } + return { cert: readTlsFile(certPath, '--tls-cert'), key: readTlsFile(keyPath, '--tls-key') }; +} + +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 format = (value: string, style: Parameters[1]) => + useColor ? colorize(value, style, { validateStream: false }) : value; + const credentialLine = startup.credentialCreated + ? `Service credential created: ${startup.credentialFile}` + : `Service credential: ${startup.credentialFile}`; + const boundSuffix = startup.hostBaseUrl.endsWith(`//${startup.listenAddress}`) + ? '' + : ` (bound to ${startup.listenAddress})`; + const tokenLines = startup.token + ? [ + `Token: ${format(startup.token, 'yellow')} (shown once; read it from the credential file later)`, + ] + : []; + return [ + `${format('✓', 'green')} Host listening at ${format(startup.hostBaseUrl, 'cyan')}${boundSuffix}`, + '', + credentialLine, + `Principal: ${startup.principal}`, + ...tokenLines, + '', + 'Workers connect with:', + ` agent-device connect proxy --daemon-base-url /agent-device --daemon-auth-token `, + ].join('\n'); +} diff --git a/src/cli/commands/local-daemon-front-end.ts b/src/cli/commands/local-daemon-front-end.ts new file mode 100644 index 0000000000..f69417ec6d --- /dev/null +++ b/src/cli/commands/local-daemon-front-end.ts @@ -0,0 +1,83 @@ +import type net from 'node:net'; +import { AppError } from '@agent-device/kernel/errors'; +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 a front-end forwards to over loopback. An empty `daemonBaseUrl` masks + * `AGENT_DEVICE_DAEMON_BASE_URL`, so a front-end never chains to another remote daemon. Resolving + * starts nothing, so a front-end 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}`; +} + +export async function listenOnTcp( + server: net.Server, + host: string, + port: number, +): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new AppError('COMMAND_FAILED', 'Server did not bind to a TCP address.'); + } + return address; +} + +export function formatHostForUrl(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} + +export function waitForever(): Promise { + return new Promise(() => {}); +} diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 0d3036ab71..98a5fcacd6 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,14 +1,17 @@ import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '@agent-device/proxy'; import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; -import { - ensureDaemon, - resolveClientSettings, -} from '../../daemon-client/daemon-client-lifecycle.ts'; import { AppError } from '@agent-device/kernel/errors'; import { colorize, supportsColor } from '../../commands/output/color.ts'; import type { CliFlags } from '@agent-device/contracts/command'; import { writeCommandOutput } from './shared.ts'; +import { + ensureLocalHttpDaemon, + formatHostForUrl, + listenOnTcp, + resolveLocalHttpDaemonSettings, + waitForever, +} from './local-daemon-front-end.ts'; import type { ClientCommandHandler } from './router-types.ts'; type ProxyStartup = { @@ -30,69 +33,33 @@ export const proxyCommand: ClientCommandHandler = async ({ positionals, flags }) }; async function startProxy(flags: CliFlags): Promise { - const settings = resolveClientSettings({ - session: 'default', - command: 'proxy', - positionals: [], - flags: { - stateDir: flags.stateDir, - daemonBaseUrl: '', - daemonTransport: 'http', - daemonServerMode: 'http', - }, - }); - const daemon = await ensureDaemon(settings); - const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.info.httpPort); + const { upstreamBaseUrl, upstreamToken, stateDir } = await ensureLocalHttpDaemon( + 'proxy', + resolveLocalHttpDaemonSettings({ command: 'proxy', stateDir: flags.stateDir }), + ); const token = resolveProxyClientToken(flags); const server = createDaemonProxyServer({ upstreamBaseUrl, - upstreamToken: daemon.info.token, + upstreamToken, clientToken: token, }); const host = flags.proxyHost?.trim() || '127.0.0.1'; const port = flags.proxyPort ?? 0; - await listen(server, host, port); - const address = server.address(); - if (!address || typeof address === 'string') { - throw new AppError('COMMAND_FAILED', 'Proxy did not bind to a TCP address.'); - } + const address = await listenOnTcp(server, host, port); const proxyBaseUrl = `http://${formatHostForUrl(address.address)}:${address.port}`; return { proxyBaseUrl, agentDeviceBaseUrl: buildDaemonHttpBaseUrl(proxyBaseUrl), token, upstreamBaseUrl, - stateDir: settings.paths.baseDir, + stateDir, }; } -function resolveLocalDaemonBaseUrl(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 proxy with a fresh --state-dir.', - }); - } - return `http://127.0.0.1:${httpPort}`; -} - function resolveProxyClientToken(flags: CliFlags): string { return flags.daemonAuthToken?.trim() || randomBytes(32).toString('hex'); } -function listen(server: ReturnType, host: string, port: number) { - return new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(port, host, () => { - server.off('error', reject); - resolve(); - }); - }); -} - -function formatHostForUrl(host: string): string { - return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; -} - export function renderProxyStartup( startup: ProxyStartup, options: { useColor?: boolean } = {}, @@ -119,7 +86,3 @@ function formatProxyOutputValue( ): string { return useColor ? colorize(value, format, { validateStream: false }) : value; } - -function waitForever(): Promise { - return new Promise(() => {}); -} 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-server.test.ts b/src/cli/host/host-server.test.ts new file mode 100644 index 0000000000..3bddd5c4dc --- /dev/null +++ b/src/cli/host/host-server.test.ts @@ -0,0 +1,168 @@ +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 { loadOrCreateHostServiceCredential } 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 { credential } = loadOrCreateHostServiceCredential(options.hostDir); + 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}` }; +} + +function rpc(baseUrl: string, token?: string): Promise { + return fetch(`${baseUrl}/agent-device/rpc`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'agent_device.command', + params: { command: 'devices', positionals: [] }, + }), + }); +} + +test('host refuses requests without the service token and never reaches the daemon', 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'), + }); + + assert.equal((await rpc(host.baseUrl)).status, 401); + assert.equal((await rpc(host.baseUrl, 'not-the-service-token')).status, 401); + assert.equal(upstream.calls.length, 0); +}); + +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', '/admin/leases', '/']) { + const response = await fetch(`${host.baseUrl}${route}`, { headers: { authorization } }); + assert.equal(response.status, 404, route); + } + assert.equal(upstream.calls.length, 0); +}); + +test('a restarted host accepts the same service token and forwards with the daemon token', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const upstream = await startUpstreamDaemon(t); + const hostDir = path.join(mkdtempForTestSync('agent-device-host-'), 'host'); + const first = await startHost(t, { upstreamBaseUrl: upstream.upstreamBaseUrl, hostDir }); + await closeLoopbackServer(first.server); + + const restarted = await startHost(t, { upstreamBaseUrl: upstream.upstreamBaseUrl, hostDir }); + const response = await rpc(restarted.baseUrl, first.token); + + assert.equal(restarted.token, first.token); + assert.equal(response.status, 200); + assert.equal(upstream.calls.length, 1); + assert.equal(upstream.calls[0]?.url, '/rpc'); + assert.equal(upstream.calls[0]?.authorization, `Bearer ${UPSTREAM_TOKEN}`); + assert.equal(JSON.parse(upstream.calls[0]?.body ?? '{}').params.token, UPSTREAM_TOKEN); +}); + +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..555b635cd0 --- /dev/null +++ b/src/cli/host/host-server.ts @@ -0,0 +1,28 @@ +import http from 'node:http'; +import https from 'node:https'; +import { createDaemonProxy } from '@agent-device/proxy'; +import { createDaemonProxyRequestListener } from '@agent-device/proxy/node'; +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. + */ +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, + }); + const listener = createDaemonProxyRequestListener(proxy); + return options.tls + ? https.createServer({ cert: options.tls.cert, key: options.tls.key }, listener) + : http.createServer(listener); +} diff --git a/src/cli/host/service-credential.test.ts b/src/cli/host/service-credential.test.ts new file mode 100644 index 0000000000..bdea335e63 --- /dev/null +++ b/src/cli/host/service-credential.test.ts @@ -0,0 +1,76 @@ +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 { loadOrCreateHostServiceCredential } from './service-credential.ts'; + +function hostDir(): string { + return path.join(mkdtempForTestSync('agent-device-host-credential-'), 'host'); +} + +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 load to be refused'); +} + +test('first start creates a private credential mapped to a stable principal', () => { + const dir = hostDir(); + const { credential, credentialFile, created } = loadOrCreateHostServiceCredential(dir); + + assert.equal(created, true); + assert.equal(credentialFile, path.join(dir, 'service-credential.json')); + assert.match(credential.token, /^[0-9a-f]{64}$/); + assert.equal(credential.principal, `host-svc-${credential.credentialId}`); + assert.equal(fs.statSync(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 = loadOrCreateHostServiceCredential(dir); + const afterRestart = loadOrCreateHostServiceCredential(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 } = loadOrCreateHostServiceCredential(dir); + fs.chmodSync(credentialFile, 0o644); + + assert.equal( + refusalReason(() => loadOrCreateHostServiceCredential(dir)), + 'host-credential-insecure', + ); +}); + +test('a credential directory open to group or others refuses to start', () => { + const dir = hostDir(); + loadOrCreateHostServiceCredential(dir); + fs.chmodSync(dir, 0o755); + + assert.equal( + refusalReason(() => loadOrCreateHostServiceCredential(dir)), + 'host-credential-insecure', + ); +}); + +test('a malformed credential file is refused and never regenerated', () => { + const dir = hostDir(); + const { credentialFile } = loadOrCreateHostServiceCredential(dir); + fs.writeFileSync(credentialFile, '{"version":1,"token":"short"}\n', { mode: 0o600 }); + + assert.equal( + refusalReason(() => loadOrCreateHostServiceCredential(dir)), + 'host-credential-invalid', + ); + assert.equal(fs.readFileSync(credentialFile, 'utf8'), '{"version":1,"token":"short"}\n'); +}); diff --git a/src/cli/host/service-credential.ts b/src/cli/host/service-credential.ts new file mode 100644 index 0000000000..aee5c7260e --- /dev/null +++ b/src/cli/host/service-credential.ts @@ -0,0 +1,142 @@ +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; + created: boolean; +}>; + +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, creating it on first start. An existing file is never + * replaced: regenerating it would silently lock out every worker holding the old token. + */ +export function loadOrCreateHostServiceCredential(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 }; + const credential = generateCredential(); + try { + publishDurableFileSync({ + destination: credentialFile, + contents: `${JSON.stringify({ version: CREDENTIAL_FILE_VERSION, ...credential }, null, 2)}\n`, + mode: 0o600, + publish: 'link-exclusive', + }); + } catch (error) { + const concurrent = isAlreadyExistsError(error) ? readCredential(credentialFile) : undefined; + if (!concurrent) throw error; + return { credential: concurrent, credentialFile, created: false }; + } + return { credential, credentialFile, created: true }; +} + +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.isDirectory() || !isPrivateToCurrentUser(stat)) { + throw insecureCredentialError(hostDir); + } +} + +function readCredential(credentialFile: string): HostServiceCredential | undefined { + const descriptor = openVerifiedFileForRead(credentialFile); + if (descriptor === undefined) return undefined; + try { + if (!isPrivateToCurrentUser(fs.fstatSync(descriptor))) { + throw insecureCredentialError(credentialFile); + } + return parseCredential(fs.readFileSync(descriptor, 'utf8'), credentialFile); + } finally { + fs.closeSync(descriptor); + } +} + +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; +} + +function isPrivateToCurrentUser(stat: fs.Stats): boolean { + const uid = process.getuid?.(); + return (stat.mode & GROUP_OR_OTHER_ACCESS) === 0 && (uid === undefined || stat.uid === uid); +} + +function insecureCredentialError(target: string): AppError { + return new AppError('COMMAND_FAILED', 'Host service credential is not private to this user.', { + reason: 'host-credential-insecure', + path: target, + hint: `Make ${target} owned by the Host user and inaccessible to group and others (chmod 700 for the directory, 600 for the file).`, + }); +} + +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.`, + }); +} + +function isAlreadyExistsError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'EEXIST'; +} diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts index 2364272b00..156fe474fe 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -447,6 +447,15 @@ 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/); +}); + 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..1e79adfd06 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -693,6 +693,33 @@ Rules: For connected phone/tablet setup and iOS signing prerequisites, read agent-device help physical-device. 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.`, + }, + 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 on first start at /host/service-credential.json (mode 0600, directory 0700). + The token is printed once, when the credential is created; 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 or readable by 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 + 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. + +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).`, }, macos: { summary: 'macOS desktop, frontmost-app, and menu bar surfaces', 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/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 9deea25dae..0c5406e80e 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -230,6 +230,19 @@ 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, Host creates `/host/service-credential.json` with mode 0600 and prints the token once. 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. Without them, Host serves plain HTTP, bound to `127.0.0.1` by default. +- 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 From 49cfee5b4e41b38876223bde84667375e15e8197 Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 04:44:41 +0200 Subject: [PATCH 2/9] fix(host): validate TLS and the credential before serving - Host checks that the TLS certificate and key load together, and that the key is mode 0600, before any daemon starts. A bind other than loopback without TLS is refused (host-tls-required), so the service token never travels in cleartext. - A new credential reaches disk only once Host is serving, so a start that fails earlier never hides the token from the next one. A credential another start wrote first is refused (host-credential-raced). - A credential that is a link or unreadable gets a typed reason, and platforms without POSIX ownership skip the mode check. - The advertised URL keeps the host name the operator bound to, and the worker command in the startup output names the real URL and token. - The proxy command keeps its original code. Host's daemon and listen helpers live in src/cli/host/local-daemon.ts. - The host help topic moves into its own module. --- .../src/flag-definitions-connection.ts | 4 +- src/cli/commands/host.test.ts | 128 +++++++++++++++--- src/cli/commands/host.ts | 116 ++++++++++------ src/cli/commands/proxy.ts | 65 +++++++-- src/cli/host/host-server.test.ts | 53 +------- src/cli/host/host-server.ts | 3 +- .../local-daemon.ts} | 31 ++++- src/cli/host/service-credential.test.ts | 68 +++++++--- src/cli/host/service-credential.ts | 88 +++++++++--- .../schema/cli-help-command-usage.test.ts | 4 +- src/commands/schema/cli-help-host.ts | 32 +++++ src/commands/schema/cli-help-topics.test.ts | 5 +- src/commands/schema/cli-help.ts | 29 +--- website/docs/docs/remote-proxy.md | 4 +- 14 files changed, 431 insertions(+), 199 deletions(-) rename src/cli/{commands/local-daemon-front-end.ts => host/local-daemon.ts} (65%) create mode 100644 src/commands/schema/cli-help-host.ts diff --git a/packages/command-registry/src/flag-definitions-connection.ts b/packages/command-registry/src/flag-definitions-connection.ts index 8a53a80463..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,7 @@ 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, }, diff --git a/src/cli/commands/host.test.ts b/src/cli/commands/host.test.ts index e2715c5858..ff5d0fed6d 100644 --- a/src/cli/commands/host.test.ts +++ b/src/cli/commands/host.test.ts @@ -1,12 +1,41 @@ -import { test } from 'vitest'; +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: [], @@ -15,36 +44,99 @@ function startHost(stateDir: string, extraFlags: Record = {}) { }); } -async function refusalReason(run: Promise): Promise { +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'); } -test('a malformed credential stops host before any daemon starts', async () => { - const stateDir = mkdtempForTestSync('agent-device-host-start-'); - const hostDir = path.join(stateDir, 'host'); - fs.mkdirSync(hostDir, { mode: 0o700 }); - fs.writeFileSync(path.join(hostDir, 'service-credential.json'), '{}\n', { mode: 0o600 }); +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); + } +}); - assert.equal(await refusalReason(startHost(stateDir)), 'host-credential-invalid'); - assert.equal(fs.existsSync(path.join(stateDir, 'daemon.json')), false); +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); + } }); -test('an unreadable TLS file is a typed refusal before any daemon starts', async () => { +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 reason = await refusalReason( - startHost(stateDir, { - hostTlsCert: path.join(stateDir, 'missing-cert.pem'), - hostTlsKey: path.join(stateDir, 'missing-key.pem'), - }), - ); + const first = await startServingHost(t, stateDir); + const restarted = await startServingHost(t, stateDir); + const health = await fetch(`${restarted.agentDeviceBaseUrl}/health`); - assert.equal(reason, 'host-tls-unreadable'); - assert.equal(fs.existsSync(path.join(stateDir, 'daemon.json')), false); + 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 index 77d193efe1..54a49ae482 100644 --- a/src/cli/commands/host.ts +++ b/src/cli/commands/host.ts @@ -1,20 +1,24 @@ 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 { colorize, supportsColor } from '../../commands/output/color.ts'; +import { supportsColor } from '../../commands/output/color.ts'; import { createHostServer, type HostTlsMaterial } from '../host/host-server.ts'; -import { loadOrCreateHostServiceCredential } from '../host/service-credential.ts'; +import { prepareHostServiceCredential } from '../host/service-credential.ts'; import { ensureLocalHttpDaemon, formatHostForUrl, + formatOutputValue, listenOnTcp, + resolveBindAddress, resolveLocalHttpDaemonSettings, waitForever, -} from './local-daemon-front-end.ts'; +} from '../host/local-daemon.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; @@ -24,14 +28,14 @@ type HostStartup = { listenAddress: string; principal: string; credentialFile: string; - credentialCreated: boolean; - /** Present only when this start created the credential, so restart logs never repeat it. */ + /** Present only on the start that created the credential, so restart logs never repeat it. */ token?: string; - tls: boolean; 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.'); @@ -45,39 +49,59 @@ export const hostCommand: ClientCommandHandler = async ({ positionals, flags }) 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 tls = readHostTlsMaterial(flags); - const { credential, credentialFile, created } = loadOrCreateHostServiceCredential( - path.join(settings.paths.baseDir, 'host'), - ); + 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, tls }); - const address = await listenOnTcp( - server, - flags.proxyHost?.trim() || '127.0.0.1', - flags.proxyPort ?? 0, - ); - const scheme = tls ? 'https' : 'http'; - const hostBaseUrl = `${scheme}://${formatHostForUrl(advertisedHost(address.address))}:${address.port}`; + 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: credential.principal, - credentialFile, - credentialCreated: created, - ...(created ? { token: credential.token } : {}), - tls: tls !== undefined, + principal: prepared.credential.principal, + credentialFile: prepared.credentialFile, + ...(prepared.created ? { token: prepared.credential.token } : {}), upstreamBaseUrl, stateDir, }; } -/** A wildcard bind is not an address a worker can dial, so Host names the machine instead. */ -function advertisedHost(boundAddress: string): string { - return boundAddress === '0.0.0.0' || boundAddress === '::' ? os.hostname() : boundAddress; +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 { @@ -89,7 +113,24 @@ function readHostTlsMaterial(flags: CliFlags): HostTlsMaterial | undefined { reason: 'host-tls-incomplete', }); } - return { cert: readTlsFile(certPath, '--tls-cert'), key: readTlsFile(keyPath, '--tls-key') }; + 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 { @@ -112,27 +153,24 @@ function readTlsFile(rawPath: string, flag: string): Buffer { function renderHostStartup(startup: HostStartup): string { const useColor = supportsColor(); - const format = (value: string, style: Parameters[1]) => - useColor ? colorize(value, style, { validateStream: false }) : value; - const credentialLine = startup.credentialCreated - ? `Service credential created: ${startup.credentialFile}` - : `Service credential: ${startup.credentialFile}`; const boundSuffix = startup.hostBaseUrl.endsWith(`//${startup.listenAddress}`) ? '' : ` (bound to ${startup.listenAddress})`; - const tokenLines = startup.token + const hostUrl = formatOutputValue(startup.hostBaseUrl, 'cyan', useColor); + const credentialLines = startup.token ? [ - `Token: ${format(startup.token, 'yellow')} (shown once; read it from the credential file later)`, + `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 [ - `${format('✓', 'green')} Host listening at ${format(startup.hostBaseUrl, 'cyan')}${boundSuffix}`, + `${formatOutputValue('✓', 'green', useColor)} Host listening at ${hostUrl}${boundSuffix}`, '', - credentialLine, + ...credentialLines, `Principal: ${startup.principal}`, - ...tokenLines, '', 'Workers connect with:', - ` agent-device connect proxy --daemon-base-url /agent-device --daemon-auth-token `, + ` agent-device connect proxy --daemon-base-url ${startup.agentDeviceBaseUrl} --daemon-auth-token ${workerToken}`, ].join('\n'); } diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 98a5fcacd6..0d3036ab71 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,17 +1,14 @@ import { randomBytes } from 'node:crypto'; import { createDaemonProxyServer } from '@agent-device/proxy'; import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; +import { + ensureDaemon, + resolveClientSettings, +} from '../../daemon-client/daemon-client-lifecycle.ts'; import { AppError } from '@agent-device/kernel/errors'; import { colorize, supportsColor } from '../../commands/output/color.ts'; import type { CliFlags } from '@agent-device/contracts/command'; import { writeCommandOutput } from './shared.ts'; -import { - ensureLocalHttpDaemon, - formatHostForUrl, - listenOnTcp, - resolveLocalHttpDaemonSettings, - waitForever, -} from './local-daemon-front-end.ts'; import type { ClientCommandHandler } from './router-types.ts'; type ProxyStartup = { @@ -33,33 +30,69 @@ export const proxyCommand: ClientCommandHandler = async ({ positionals, flags }) }; async function startProxy(flags: CliFlags): Promise { - const { upstreamBaseUrl, upstreamToken, stateDir } = await ensureLocalHttpDaemon( - 'proxy', - resolveLocalHttpDaemonSettings({ command: 'proxy', stateDir: flags.stateDir }), - ); + const settings = resolveClientSettings({ + session: 'default', + command: 'proxy', + positionals: [], + flags: { + stateDir: flags.stateDir, + daemonBaseUrl: '', + daemonTransport: 'http', + daemonServerMode: 'http', + }, + }); + const daemon = await ensureDaemon(settings); + const upstreamBaseUrl = resolveLocalDaemonBaseUrl(daemon.info.httpPort); const token = resolveProxyClientToken(flags); const server = createDaemonProxyServer({ upstreamBaseUrl, - upstreamToken, + upstreamToken: daemon.info.token, clientToken: token, }); const host = flags.proxyHost?.trim() || '127.0.0.1'; const port = flags.proxyPort ?? 0; - const address = await listenOnTcp(server, host, port); + await listen(server, host, port); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new AppError('COMMAND_FAILED', 'Proxy did not bind to a TCP address.'); + } const proxyBaseUrl = `http://${formatHostForUrl(address.address)}:${address.port}`; return { proxyBaseUrl, agentDeviceBaseUrl: buildDaemonHttpBaseUrl(proxyBaseUrl), token, upstreamBaseUrl, - stateDir, + stateDir: settings.paths.baseDir, }; } +function resolveLocalDaemonBaseUrl(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 proxy with a fresh --state-dir.', + }); + } + return `http://127.0.0.1:${httpPort}`; +} + function resolveProxyClientToken(flags: CliFlags): string { return flags.daemonAuthToken?.trim() || randomBytes(32).toString('hex'); } +function listen(server: ReturnType, host: string, port: number) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + server.off('error', reject); + resolve(); + }); + }); +} + +function formatHostForUrl(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} + export function renderProxyStartup( startup: ProxyStartup, options: { useColor?: boolean } = {}, @@ -86,3 +119,7 @@ function formatProxyOutputValue( ): string { return useColor ? colorize(value, format, { validateStream: false }) : value; } + +function waitForever(): Promise { + return new Promise(() => {}); +} diff --git a/src/cli/host/host-server.test.ts b/src/cli/host/host-server.test.ts index 3bddd5c4dc..ac08f9fef2 100644 --- a/src/cli/host/host-server.test.ts +++ b/src/cli/host/host-server.test.ts @@ -12,7 +12,7 @@ import { } from '../../__tests__/test-utils/loopback.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { createHostServer, type HostTlsMaterial } from './host-server.ts'; -import { loadOrCreateHostServiceCredential } from './service-credential.ts'; +import { prepareHostServiceCredential } from './service-credential.ts'; const UPSTREAM_TOKEN = 'daemon-token-never-leaves-host'; @@ -38,7 +38,9 @@ async function startHost( t: TestContext, options: { upstreamBaseUrl: string; hostDir: string; tls?: HostTlsMaterial }, ) { - const { credential } = loadOrCreateHostServiceCredential(options.hostDir); + const prepared = prepareHostServiceCredential(options.hostDir); + prepared.publish(); + const { credential } = prepared; const server = createHostServer({ upstreamBaseUrl: options.upstreamBaseUrl, upstreamToken: UPSTREAM_TOKEN, @@ -50,35 +52,6 @@ async function startHost( return { token: credential.token, server, port, baseUrl: `http://127.0.0.1:${port}` }; } -function rpc(baseUrl: string, token?: string): Promise { - return fetch(`${baseUrl}/agent-device/rpc`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'agent_device.command', - params: { command: 'devices', positionals: [] }, - }), - }); -} - -test('host refuses requests without the service token and never reaches the daemon', 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'), - }); - - assert.equal((await rpc(host.baseUrl)).status, 401); - assert.equal((await rpc(host.baseUrl, 'not-the-service-token')).status, 401); - assert.equal(upstream.calls.length, 0); -}); - test('host answers unserved routes with 404', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; const upstream = await startUpstreamDaemon(t); @@ -95,24 +68,6 @@ test('host answers unserved routes with 404', async (t) => { assert.equal(upstream.calls.length, 0); }); -test('a restarted host accepts the same service token and forwards with the daemon token', async (t) => { - if (await skipWhenLoopbackUnavailable(t)) return; - const upstream = await startUpstreamDaemon(t); - const hostDir = path.join(mkdtempForTestSync('agent-device-host-'), 'host'); - const first = await startHost(t, { upstreamBaseUrl: upstream.upstreamBaseUrl, hostDir }); - await closeLoopbackServer(first.server); - - const restarted = await startHost(t, { upstreamBaseUrl: upstream.upstreamBaseUrl, hostDir }); - const response = await rpc(restarted.baseUrl, first.token); - - assert.equal(restarted.token, first.token); - assert.equal(response.status, 200); - assert.equal(upstream.calls.length, 1); - assert.equal(upstream.calls[0]?.url, '/rpc'); - assert.equal(upstream.calls[0]?.authorization, `Bearer ${UPSTREAM_TOKEN}`); - assert.equal(JSON.parse(upstream.calls[0]?.body ?? '{}').params.token, UPSTREAM_TOKEN); -}); - function generateSelfSignedCertificate(dir: string): HostTlsMaterial | undefined { const certPath = path.join(dir, 'cert.pem'); const keyPath = path.join(dir, 'key.pem'); diff --git a/src/cli/host/host-server.ts b/src/cli/host/host-server.ts index 555b635cd0..71adf97925 100644 --- a/src/cli/host/host-server.ts +++ b/src/cli/host/host-server.ts @@ -1,7 +1,6 @@ import http from 'node:http'; import https from 'node:https'; -import { createDaemonProxy } from '@agent-device/proxy'; -import { createDaemonProxyRequestListener } from '@agent-device/proxy/node'; +import { createDaemonProxy, createDaemonProxyRequestListener } from '@agent-device/proxy'; import type { HostServiceCredential } from './service-credential.ts'; export type HostTlsMaterial = Readonly<{ cert: Buffer; key: Buffer }>; diff --git a/src/cli/commands/local-daemon-front-end.ts b/src/cli/host/local-daemon.ts similarity index 65% rename from src/cli/commands/local-daemon-front-end.ts rename to src/cli/host/local-daemon.ts index f69417ec6d..c5cef68389 100644 --- a/src/cli/commands/local-daemon-front-end.ts +++ b/src/cli/host/local-daemon.ts @@ -1,5 +1,7 @@ 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, @@ -13,9 +15,9 @@ export type LocalDaemonUpstream = Readonly<{ }>; /** - * The local HTTP daemon a front-end forwards to over loopback. An empty `daemonBaseUrl` masks - * `AGENT_DEVICE_DAEMON_BASE_URL`, so a front-end never chains to another remote daemon. Resolving - * starts nothing, so a front-end can refuse its own configuration before a daemon exists. + * 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; @@ -55,21 +57,28 @@ function resolveLocalDaemonBaseUrl(command: string, httpPort: number | undefined 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, - host: string, - port: number, + bind: { host: string; port: number }, ): Promise { await new Promise((resolve, reject) => { server.once('error', reject); - server.listen(port, host, () => { + 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', 'Server did not bind to a TCP address.'); + throw new AppError('COMMAND_FAILED', 'Host did not bind to a TCP address.'); } return address; } @@ -78,6 +87,14 @@ 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/service-credential.test.ts b/src/cli/host/service-credential.test.ts index bdea335e63..7f27dfbbf8 100644 --- a/src/cli/host/service-credential.test.ts +++ b/src/cli/host/service-credential.test.ts @@ -4,12 +4,18 @@ 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 { loadOrCreateHostServiceCredential } from './service-credential.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(); @@ -17,25 +23,28 @@ function refusalReason(run: () => unknown): unknown { assert.ok(error instanceof AppError, `expected AppError, got ${String(error)}`); return error.details?.reason; } - assert.fail('expected the credential load to be refused'); + assert.fail('expected the credential to be refused'); } -test('first start creates a private credential mapped to a stable principal', () => { +test('a new credential reaches disk only when Host publishes it', () => { const dir = hostDir(); - const { credential, credentialFile, created } = loadOrCreateHostServiceCredential(dir); + const prepared = prepareHostServiceCredential(dir); - assert.equal(created, true); - assert.equal(credentialFile, path.join(dir, 'service-credential.json')); - assert.match(credential.token, /^[0-9a-f]{64}$/); - assert.equal(credential.principal, `host-svc-${credential.credentialId}`); - assert.equal(fs.statSync(credentialFile).mode & 0o777, 0o600); + 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 = loadOrCreateHostServiceCredential(dir); - const afterRestart = loadOrCreateHostServiceCredential(dir); + const first = publishedCredential(dir); + const afterRestart = prepareHostServiceCredential(dir); assert.equal(afterRestart.created, false); assert.deepEqual(afterRestart.credential, first.credential); @@ -43,34 +52,59 @@ test('a restart reuses the same credential instead of issuing a new token', () = test('a credential file readable by group or others refuses to start', () => { const dir = hostDir(); - const { credentialFile } = loadOrCreateHostServiceCredential(dir); + const { credentialFile } = publishedCredential(dir); fs.chmodSync(credentialFile, 0o644); assert.equal( - refusalReason(() => loadOrCreateHostServiceCredential(dir)), + refusalReason(() => prepareHostServiceCredential(dir)), 'host-credential-insecure', ); }); test('a credential directory open to group or others refuses to start', () => { const dir = hostDir(); - loadOrCreateHostServiceCredential(dir); + publishedCredential(dir); fs.chmodSync(dir, 0o755); assert.equal( - refusalReason(() => loadOrCreateHostServiceCredential(dir)), + 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 } = loadOrCreateHostServiceCredential(dir); + const { credentialFile } = publishedCredential(dir); fs.writeFileSync(credentialFile, '{"version":1,"token":"short"}\n', { mode: 0o600 }); assert.equal( - refusalReason(() => loadOrCreateHostServiceCredential(dir)), + 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 index aee5c7260e..dc1cb943bd 100644 --- a/src/cli/host/service-credential.ts +++ b/src/cli/host/service-credential.ts @@ -15,7 +15,13 @@ export type HostServiceCredential = Readonly<{ 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'; @@ -30,15 +36,26 @@ function hostPrincipalForCredential(credentialId: string): string { } /** - * Returns the persisted credential, creating it on first start. An existing file is never - * replaced: regenerating it would silently lock out every worker holding the old token. + * 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 loadOrCreateHostServiceCredential(hostDir: string): HostServiceCredentialLoad { +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 }; + 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, @@ -47,11 +64,17 @@ export function loadOrCreateHostServiceCredential(hostDir: string): HostServiceC publish: 'link-exclusive', }); } catch (error) { - const concurrent = isAlreadyExistsError(error) ? readCredential(credentialFile) : undefined; - if (!concurrent) throw error; - return { credential: concurrent, credentialFile, created: false }; + 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.', + }, + ); } - return { credential, credentialFile, created: true }; } function generateCredential(): HostServiceCredential { @@ -67,17 +90,18 @@ function generateCredential(): HostServiceCredential { function ensurePrivateDirectory(hostDir: string): void { fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 }); const stat = fs.lstatSync(hostDir); - if (!stat.isDirectory() || !isPrivateToCurrentUser(stat)) { - throw insecureCredentialError(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 = openVerifiedFileForRead(credentialFile); + const descriptor = openCredentialFile(credentialFile); if (descriptor === undefined) return undefined; try { if (!isPrivateToCurrentUser(fs.fstatSync(descriptor))) { - throw insecureCredentialError(credentialFile); + throw insecureCredentialError(credentialFile, privateHint(credentialFile)); } return parseCredential(fs.readFileSync(descriptor, 'utf8'), credentialFile); } finally { @@ -85,6 +109,30 @@ function readCredential(credentialFile: string): HostServiceCredential | undefin } } +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); @@ -116,16 +164,22 @@ 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?.(); - return (stat.mode & GROUP_OR_OTHER_ACCESS) === 0 && (uid === undefined || stat.uid === uid); + if (uid === undefined) return true; + return (stat.mode & GROUP_OR_OTHER_ACCESS) === 0 && stat.uid === uid; } -function insecureCredentialError(target: string): AppError { +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: `Make ${target} owned by the Host user and inaccessible to group and others (chmod 700 for the directory, 600 for the file).`, + hint, }); } @@ -136,7 +190,3 @@ function invalidCredentialError(credentialFile: string): AppError { hint: `Delete ${credentialFile} to create a new credential. Workers then need the new token.`, }); } - -function isAlreadyExistsError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | undefined)?.code === 'EEXIST'; -} 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..727e7f7df5 --- /dev/null +++ b/src/commands/schema/cli-help-host.ts @@ -0,0 +1,32 @@ +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. + +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 156fe474fe..085b0378dd 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -453,7 +453,10 @@ test('usageForCommand resolves host help topic', async () => { 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/); + 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 () => { diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 1e79adfd06..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,33 +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.`, }, - 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 on first start at /host/service-credential.json (mode 0600, directory 0700). - The token is printed once, when the credential is created; 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 or readable by 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 - 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. - -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).`, - }, + ...hostHelpTopics, macos: { summary: 'macOS desktop, frontmost-app, and menu bar surfaces', body: `agent-device help macos diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 0c5406e80e..e5492b5b17 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -238,9 +238,9 @@ the device-host VM must use the daemon's loopback port and local daemon token. agent-device host --host 0.0.0.0 --port 8443 --tls-cert ./cert.pem --tls-key ./key.pem ``` -- On first start, Host creates `/host/service-credential.json` with mode 0600 and prints the token once. Later starts reuse the credential, so workers keep working when a process manager restarts Host. +- 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. Without them, Host serves plain HTTP, bound to `127.0.0.1` by default. +- 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. - 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 From 025fb41b9d9a9f636c90b98f29e0d814e2d5149c Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 02:12:18 +0200 Subject: [PATCH 3/9] chore(gates): register the host command and its TLS flags Add `host` to the reviewed device-claim policy set, give the --tls-cert/--tls-key flags their own Host bucket in the integration progress model, list `hostCommand` with the dynamically loaded CLI handlers in the fallow production exemptions, and waive the operator-facing `host` help topic from the help benchmark. --- .fallowrc.json | 3 ++- .../src/__tests__/device-claim-policy.test.ts | 1 + scripts/__tests__/help-conformance-topic-coverage.test.ts | 1 + scripts/integration-progress-model.ts | 5 +++++ 4 files changed, 9 insertions(+), 1 deletion(-) 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/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', From 42741ecbe9080583bcf28b94c241509d7c819a13 Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 02:34:50 +0200 Subject: [PATCH 4/9] feat(host): strip client identity and enforce the public route policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host now owns identity and the public route policy (ADR 0021 §6): - The front-end drops every identity a client claims (tenant headers and body fields) and sends the credential's principal to the daemon as x-agent-device-principal on the daemon-token loopback request. - The daemon reads that header only after the daemon token matched and treats it as an attested tenant, so sessions are isolated under the principal and req.internal.hostPrincipal carries it to admission. With an auth hook configured the header is refused with a typed reason. - Administration routes, macos-app allocation, inputs naming a path on the Host machine and allowDownload are refused with 403 and a typed details.reason. The host-path inputs move out of the macos-app lease into one declaration both policies read. - Anonymous /health shows only ok, service and rpcProtocolVersion. The principal handoff is the contract proposed to the lease side on #3264. Closes #3266 --- packages/contracts/src/daemon-http.ts | 7 +- packages/proxy/src/daemon-proxy.test.ts | 20 +++ src/cli/host/host-front-end.test.ts | 218 +++++++++++++++++++++++ src/cli/host/host-front-end.ts | 193 ++++++++++++++++++++ src/cli/host/host-server.test.ts | 2 +- src/cli/host/host-server.ts | 7 +- src/cli/host/request-policy.test.ts | 50 ++++++ src/cli/host/request-policy.ts | 136 ++++++++++++++ src/commands/schema/cli-help-host.ts | 9 + src/daemon/daemon-request.ts | 5 + src/daemon/host-path-inputs.ts | 22 +++ src/daemon/macos-app-lease.ts | 16 +- src/daemon/server/host-principal.test.ts | 13 ++ src/daemon/server/host-principal.ts | 21 +++ src/daemon/server/http-server.ts | 25 ++- src/daemon/server/tenant-trust.test.ts | 30 ++++ src/daemon/server/tenant-trust.ts | 31 +++- src/remote/daemon-artifacts.ts | 8 + website/docs/docs/remote-proxy.md | 1 + 19 files changed, 791 insertions(+), 23 deletions(-) create mode 100644 src/cli/host/host-front-end.test.ts create mode 100644 src/cli/host/host-front-end.ts create mode 100644 src/cli/host/request-policy.test.ts create mode 100644 src/cli/host/request-policy.ts create mode 100644 src/daemon/host-path-inputs.ts create mode 100644 src/daemon/server/host-principal.test.ts create mode 100644 src/daemon/server/host-principal.ts create mode 100644 src/daemon/server/tenant-trust.test.ts diff --git a/packages/contracts/src/daemon-http.ts b/packages/contracts/src/daemon-http.ts index 868338e76b..edc2b63963 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,7 +57,7 @@ export function buildDaemonInstanceMismatchRpcResponse( export type DaemonHealthPayload = { ok: true; - service: 'agent-device-daemon' | 'agent-device-proxy'; + service: 'agent-device-daemon' | 'agent-device-proxy' | 'agent-device-host'; version: string; rpcProtocolVersion: number; instanceId?: string; diff --git a/packages/proxy/src/daemon-proxy.test.ts b/packages/proxy/src/daemon-proxy.test.ts index 72178e8484..71c62b56fd 100644 --- a/packages/proxy/src/daemon-proxy.test.ts +++ b/packages/proxy/src/daemon-proxy.test.ts @@ -62,6 +62,26 @@ 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('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/src/cli/host/host-front-end.test.ts b/src/cli/host/host-front-end.test.ts new file mode 100644 index 0000000000..089601b6d1 --- /dev/null +++ b/src/cli/host/host-front-end.test.ts @@ -0,0 +1,218 @@ +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 refused with a typed reason', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const host = await startHostOverDaemon(t); + + for (const route of ['/admin/leases', '/admin/human-control/holds']) { + const response = await fetch(`${host.baseUrl}${route}`, { + headers: { authorization: `Bearer ${host.credential.token}` }, + }); + const body = (await response.json()) as Record; + assert.equal(response.status, 403, route); + assert.equal(body.details?.reason, 'host-admin-refused', route); + } +}); + +test('allocating a host-administered macos-app lease 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', + backend: 'macos-app', + }); + + 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('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..9f33e3bb2e --- /dev/null +++ b/src/cli/host/host-front-end.ts @@ -0,0 +1,193 @@ +import { + 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 } from '@agent-device/proxy'; +import { + findHostRpcRefusal, + HOST_REFUSAL_REASONS, + normalizeRpcMethod, + stripClientIdentity, + type HostRefusal, +} from './request-policy.ts'; +import type { HostServiceCredential } from './service-credential.ts'; + +const HOST_SERVICE = 'agent-device-host'; +const MAX_RPC_BODY_BYTES = 1024 * 1024; + +/** + * The Host public route policy in front of the daemon proxy (ADR 0021 §6). The proxy keeps + * transport, auth and the daemon token rewrite; this layer refuses what a public caller may not + * do and removes the identity a caller claims before the proxy forwards the request. + */ +export function createHostFrontEnd( + proxy: DaemonProxy, + credential: Pick, +): DaemonProxy { + return { + instanceId: proxy.instanceId, + handle: (request) => handleHostRequest(request, proxy, credential.token), + }; +} + +/** 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 route = resolveRoute(request.url); + if (route === '/admin' || route.startsWith('/admin/')) { + return restRefusal({ + reason: HOST_REFUSAL_REASONS.admin, + message: 'Host does not serve administration routes.', + }); + } + if (route === '/health' && request.method === 'GET') { + return await hostHealth(request, proxy, token); + } + if (route === '/rpc' && request.method === 'POST') return await hostRpc(request, proxy, token); + return await proxy.handle(request); +} + +async function hostHealth(request: Request, proxy: DaemonProxy, token: string): Promise { + 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; + return Response.json({ ...payload, service: HOST_SERVICE }, { status: response.status }); +} + +async function hostRpc(request: Request, proxy: DaemonProxy, token: string): Promise { + const body = await readBoundedText(request, MAX_RPC_BODY_BYTES); + const rpc = parseRpc(body); + // The proxy answers malformed, oversized and unauthorized requests exactly as it always does. + if (!rpc || !isAuthorized(request.headers, rpc.params, token)) { + return await proxy.handle(withBody(request, body)); + } + const refusal = findHostRpcRefusal(normalizeRpcMethod(rpc.method), rpc.params); + if (refusal) return rpcRefusal(rpc.id, refusal); + const forwarded = { ...rpc.envelope, params: stripClientIdentity(rpc.params) }; + return await proxy.handle(withBody(request, JSON.stringify(forwarded))); +} + +type ParsedRpc = { + envelope: Record; + id: unknown; + method: string; + params: Record; +}; + +function parseRpc(body: string): ParsedRpc | undefined { + try { + const envelope = JSON.parse(body) as Record; + const params = envelope?.params; + if (typeof envelope?.method !== 'string' || !params || typeof params !== 'object') { + return undefined; + } + return { + envelope, + id: envelope.id, + method: envelope.method, + params: params as Record, + }; + } catch { + return undefined; + } +} + +function isAuthorized(headers: Headers, params: Record, token: string): boolean { + const presented = readHeaderToken(headers) ?? params.token; + return typeof presented === 'string' && timingSafeStringEqual(presented, token); +} + +function hasHeaderToken(headers: Headers, token: string): boolean { + const presented = readHeaderToken(headers); + return presented !== undefined && timingSafeStringEqual(presented, token); +} + +function readHeaderToken(headers: Headers): string | undefined { + const authorization = headers.get('authorization') ?? ''; + if (authorization.toLowerCase().startsWith('bearer ')) { + return authorization.slice('bearer '.length); + } + return headers.get('x-agent-device-token') ?? undefined; +} + +async function readBoundedText(request: Request, limit: number): Promise { + if (!request.body) return ''; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (size <= limit) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + size += value.byteLength; + } + if (size > limit) await reader.cancel(); + return Buffer.concat(chunks).toString('utf8'); +} + +function withBody(request: Request, body: string): Request { + return new Request(request.url, { + method: request.method, + headers: request.headers, + body, + signal: request.signal, + }); +} + +function resolveRoute(url: string): string { + const pathname = URL.parse(url)?.pathname ?? ''; + if (pathname === DAEMON_HTTP_BASE_PATH) return '/'; + return pathname.startsWith(`${DAEMON_HTTP_BASE_PATH}/`) + ? pathname.slice(DAEMON_HTTP_BASE_PATH.length) + : pathname; +} + +function refusalError(refusal: HostRefusal) { + return 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.', + }), + ); +} + +function rpcRefusal(id: unknown, refusal: HostRefusal): Response { + const data = refusalError(refusal); + return Response.json( + { jsonrpc: '2.0', id: id ?? null, error: { code: -32000, message: data.message, data } }, + { status: 403 }, + ); +} + +function restRefusal(refusal: HostRefusal): Response { + const data = refusalError(refusal); + return Response.json( + { ok: false, error: data.message, code: data.code, details: data.details }, + { status: 403 }, + ); +} diff --git a/src/cli/host/host-server.test.ts b/src/cli/host/host-server.test.ts index ac08f9fef2..bdc6f0621e 100644 --- a/src/cli/host/host-server.test.ts +++ b/src/cli/host/host-server.test.ts @@ -61,7 +61,7 @@ test('host answers unserved routes with 404', async (t) => { }); const authorization = `Bearer ${host.token}`; - for (const route of ['/agent-device/nope', '/admin/leases', '/']) { + 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); } diff --git a/src/cli/host/host-server.ts b/src/cli/host/host-server.ts index 71adf97925..5ad0a5ac14 100644 --- a/src/cli/host/host-server.ts +++ b/src/cli/host/host-server.ts @@ -1,13 +1,15 @@ import http from 'node:http'; import https from 'node:https'; import { createDaemonProxy, createDaemonProxyRequestListener } from '@agent-device/proxy'; +import { 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. + * 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; @@ -19,8 +21,9 @@ export function createHostServer(options: { upstreamBaseUrl: options.upstreamBaseUrl, upstreamToken: options.upstreamToken, clientToken: options.credential.token, + upstreamFetch: (request) => fetch(withHostPrincipal(request, options.credential.principal)), }); - const listener = createDaemonProxyRequestListener(proxy); + 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/request-policy.test.ts b/src/cli/host/request-policy.test.ts new file mode 100644 index 0000000000..8091d4949d --- /dev/null +++ b/src/cli/host/request-policy.test.ts @@ -0,0 +1,50 @@ +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'] }, + ]; + for (const params of refused) { + assert.equal(findHostRpcRefusal(COMMAND, params)?.reason, 'host-path-refused', params.command); + } + + const accepted = [ + { command: 'install', positionals: ['com.example.app', 'https://ci.example.test/app.apk'] }, + { 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..7069b12694 --- /dev/null +++ b/src/cli/host/request-policy.ts @@ -0,0 +1,136 @@ +import { HOST_PATH_INPUT_KEYS } from '../../daemon/host-path-inputs.ts'; +import { isRemoteTempArtifactLocation } from '../../remote/daemon-artifacts.ts'; + +/** Typed refusals of the Host public route policy (ADR 0021 §5, §6, §10 item 6). */ +export const HOST_REFUSAL_REASONS = { + admin: 'host-admin-refused', + hostPath: 'host-path-refused', + componentDownload: 'host-component-download-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; + +/** + * Positional arguments that name a daemon-host file. The remote client rewrites screenshot and + * recording outputs to daemon temp locations and uploads install packages, so anything else here + * names the Host machine's disk. + */ +const HOST_PATH_POSITIONALS: Readonly number>> = + { + screenshot: () => 0, + record: (positionals) => (positionals[0]?.toLowerCase() === 'start' ? 1 : -1), + install: (positionals) => (positionals.length === 1 ? 0 : 1), + reinstall: (positionals) => (positionals.length === 1 ? 0 : 1), + }; + +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) ?? + findHostPathInput(params) + ); +} + +/** + * Drops every identity a client can claim in the body. The daemon pins the tenant to the Host + * principal anyway; removing the claims keeps them out of logs and the auth hook context. + */ +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: withoutKey(flags, 'tenant') } : {}), + }; +} + +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' || params.backend !== 'macos-app') return undefined; + return { + reason: HOST_REFUSAL_REASONS.admin, + message: 'Host does not allocate macos-app leases; they are host-administered.', + field: 'backend', + }; +} + +function findHostPathInput(params: Params): HostRefusal | undefined { + const meta = asRecord(params.meta); + const uploaded = typeof meta?.uploadedArtifactId === 'string' && meta.uploadedArtifactId !== ''; + if (!uploaded && (isPathSource(params.source) || isPathSource(meta?.installSource))) { + return hostPathRefusal('installSource'); + } + const fields = { ...asRecord(params.input), ...asRecord(params.flags) }; + const field = HOST_PATH_INPUT_KEYS.find( + (key) => key !== 'installSource' && namesHostPath(fields[key]), + ); + if (field) return hostPathRefusal(field); + return uploaded ? undefined : findHostPathPositional(params); +} + +function findHostPathPositional(params: Params): HostRefusal | undefined { + const command = typeof params.command === 'string' ? params.command : ''; + const positionals = Array.isArray(params.positionals) ? params.positionals.map(String) : []; + const index = HOST_PATH_POSITIONALS[command]?.(positionals) ?? -1; + const value = positionals[index]; + if (value === undefined || /^https?:\/\//i.test(value) || isRemoteTempArtifactLocation(value)) { + return undefined; + } + return hostPathRefusal('positionals'); +} + +function namesHostPath(value: unknown): boolean { + if (value === undefined || value === false) return false; + return !(typeof value === 'string' && isRemoteTempArtifactLocation(value)); +} + +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} naming 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/commands/schema/cli-help-host.ts b/src/commands/schema/cli-help-host.ts index 727e7f7df5..386ab20af5 100644 --- a/src/commands/schema/cli-help-host.ts +++ b/src/commands/schema/cli-help-host.ts @@ -24,6 +24,15 @@ Serving: 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 /admin/* routes and macos-app lease allocation + host-path-refused inputs naming a path on the Host machine + host-component-download-refused allowDownload + Anonymous /health shows only ok, service and rpcProtocolVersion. + Worker: agent-device connect proxy --daemon-base-url https://host.example:8443/agent-device --daemon-auth-token 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/host-path-inputs.ts b/src/daemon/host-path-inputs.ts new file mode 100644 index 0000000000..822f39af23 --- /dev/null +++ b/src/daemon/host-path-inputs.ts @@ -0,0 +1,22 @@ +/** + * 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', + 'artifactsDir', + 'stepsFile', + 'searchPath', + 'retainPaths', + 'installSource', + 'metroProjectRoot', + 'metroRuntimeFile', + 'iosXctestrunFile', + 'iosXctestDerivedDataPath', + 'iosXctestEnvDir', + 'iosSimulatorDeviceSet', +] as const; diff --git a/src/daemon/macos-app-lease.ts b/src/daemon/macos-app-lease.ts index 8d54eab595..9b00a1f7b2 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'; @@ -64,24 +65,11 @@ type MacOsAppLeaseRule = * 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', + ...HOST_PATH_INPUT_KEYS, 'launchConsole', 'launchArgs', 'launchUrl', 'bundleUrl', - 'artifactsDir', - 'stepsFile', - 'searchPath', - 'retainPaths', - 'installSource', - 'metroProjectRoot', - 'metroRuntimeFile', - 'iosXctestrunFile', - 'iosXctestDerivedDataPath', - 'iosXctestEnvDir', ] as const; /** Flags that pick a device other than the leased app's own; a lease never takes a device selector. */ 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..6a4c132bf9 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -48,6 +48,7 @@ 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'; @@ -755,6 +756,7 @@ export async function createDaemonHttpServer(options: { }; requestAbortRegistration = registerRequestAbort(requestIdForCleanup); const clientDeclaredTenant = daemonRequest.meta?.tenantId ?? daemonRequest.flags?.tenant; + const hostPrincipal = readHostPrincipal(req.headers); const authResult = await runHttpAuthHook(authHook, { headers: req.headers, @@ -769,9 +771,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 +791,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 +823,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 +956,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 +987,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/daemon-artifacts.ts b/src/remote/daemon-artifacts.ts index d7940a45ef..db4d5e23b7 100644 --- a/src/remote/daemon-artifacts.ts +++ b/src/remote/daemon-artifacts.ts @@ -368,6 +368,14 @@ export function isRemoteTempArtifactPath( ); } +/** Any location `buildRemoteTempArtifactPath` or `buildRemoteTempArtifactDirPath` can produce. */ +export function isRemoteTempArtifactLocation(value: string): boolean { + return REMOTE_TEMP_ARTIFACT_LOCATION.test(value); +} + +const REMOTE_TEMP_ARTIFACT_LOCATION = + /^\/tmp\/agent-device-[a-z][a-z-]*-\d+-[a-z0-9]+(?:\.[A-Za-z0-9]+)?$/; + function remoteTempArtifactStem(prefix: string): string { return `agent-device-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index e5492b5b17..afcafc2ba8 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -241,6 +241,7 @@ agent-device host --host 0.0.0.0 --port 8443 --tls-cert ./cert.pem --tls-key ./k - 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. Administration routes, inputs naming a path on the Host machine and component downloads are refused with HTTP 403 and a typed `details.reason` (`host-admin-refused`, `host-path-refused`, `host-component-download-refused`). Anonymous `/health` shows only `ok`, `service` and `rpcProtocolVersion`. - 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 From 99016de4581dbd752c1fec125a793f531c5ff264 Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 04:53:40 +0200 Subject: [PATCH 5/9] fix(host): apply the route policy after the proxy authenticates - The proxy gains an optional admitRpc hook that runs on an authorized /rpc request with its params already parsed. Host uses it instead of re-reading the body and repeating the token check, so oversized and unauthorized requests get the proxy's own answers. Plain proxy sets no hook and behaves as before. - Path positionals come from each command's schema (`path`, `appOrPath`, `payloadOrJson`) instead of a hand-kept table, so trace, push and session save-script are covered. URLs no longer exempt a positional, an upload id exempts only install and reinstall, and only the exact temp locations the remote client writes are accepted. - Batch steps are checked one by one, and replay and test are refused (host-script-refused) because Host cannot check their nested actions. - launchConsole counts as a Host path, macos-app is matched the way the daemon normalizes it, and /admin/* is simply not served. - The daemon reads the principal header only on a request that holds the daemon token, so an unauthenticated caller learns nothing about an auth hook. - Host answers with an error response instead of rejecting. --- packages/proxy/src/daemon-proxy.test.ts | 27 ++++ packages/proxy/src/daemon-proxy.ts | 38 +++++- packages/proxy/src/index.ts | 1 + src/cli/host/host-front-end.test.ts | 66 ++++++++-- src/cli/host/host-front-end.ts | 154 +++++----------------- src/cli/host/host-server.ts | 3 +- src/cli/host/request-policy.test.ts | 3 +- src/cli/host/request-policy.ts | 162 +++++++++++++++++++----- src/commands/schema/cli-help-host.ts | 5 +- src/daemon/host-path-inputs.ts | 1 + src/daemon/macos-app-lease.ts | 8 +- src/daemon/server/http-server.ts | 5 +- src/remote/daemon-artifacts.ts | 8 -- website/docs/docs/remote-proxy.md | 2 +- 14 files changed, 293 insertions(+), 190 deletions(-) diff --git a/packages/proxy/src/daemon-proxy.test.ts b/packages/proxy/src/daemon-proxy.test.ts index 71c62b56fd..05c86963fb 100644 --- a/packages/proxy/src/daemon-proxy.test.ts +++ b/packages/proxy/src/daemon-proxy.test.ts @@ -82,6 +82,33 @@ test('a client-sent principal header never reaches the daemon', async () => { 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/src/cli/host/host-front-end.test.ts b/src/cli/host/host-front-end.test.ts index 089601b6d1..8f11aec269 100644 --- a/src/cli/host/host-front-end.test.ts +++ b/src/cli/host/host-front-end.test.ts @@ -129,17 +129,18 @@ test('a tenant claim on a lease method is dropped', async (t) => { assert.equal(host.admitted[0]?.meta?.runId, 'verify-812'); }); -test('administration routes are refused with a typed reason', async (t) => { +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 response = await fetch(`${host.baseUrl}${route}`, { - headers: { authorization: `Bearer ${host.credential.token}` }, - }); - const body = (await response.json()) as Record; - assert.equal(response.status, 403, route); - assert.equal(body.details?.reason, 'host-admin-refused', route); + const variants: Record[] = [ + {}, + { authorization: `Bearer ${host.credential.token}` }, + ]; + for (const headers of variants) { + assert.equal((await fetch(`${host.baseUrl}${route}`, { headers })).status, 404, route); + } } }); @@ -147,12 +148,13 @@ test('allocating a host-administered macos-app lease 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', - backend: 'macos-app', - }); - - assertRefused(response, 'host-admin-refused', host.admitted); + 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) => { @@ -184,6 +186,44 @@ test('a flag naming a Host path is refused, and a daemon temp artifact location 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); diff --git a/src/cli/host/host-front-end.ts b/src/cli/host/host-front-end.ts index 9f33e3bb2e..8e1c65db40 100644 --- a/src/cli/host/host-front-end.ts +++ b/src/cli/host/host-front-end.ts @@ -6,10 +6,9 @@ import { } 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 } from '@agent-device/proxy'; +import type { DaemonProxy, DaemonProxyRpcAdmission } from '@agent-device/proxy'; import { findHostRpcRefusal, - HOST_REFUSAL_REASONS, normalizeRpcMethod, stripClientIdentity, type HostRefusal, @@ -17,12 +16,20 @@ import { import type { HostServiceCredential } from './service-credential.ts'; const HOST_SERVICE = 'agent-device-host'; -const MAX_RPC_BODY_BYTES = 1024 * 1024; +const HEALTH_PATHS: ReadonlySet = new Set(['/health', `${DAEMON_HTTP_BASE_PATH}/health`]); /** - * The Host public route policy in front of the daemon proxy (ADR 0021 §6). The proxy keeps - * transport, auth and the daemon token rewrite; this layer refuses what a public caller may not - * do and removes the identity a caller claims before the proxy forwards the request. + * 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, @@ -30,7 +37,19 @@ export function createHostFrontEnd( ): DaemonProxy { return { instanceId: proxy.instanceId, - handle: (request) => handleHostRequest(request, proxy, credential.token), + 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, + }, + ); + } + }, }; } @@ -50,21 +69,8 @@ async function handleHostRequest( proxy: DaemonProxy, token: string, ): Promise { - const route = resolveRoute(request.url); - if (route === '/admin' || route.startsWith('/admin/')) { - return restRefusal({ - reason: HOST_REFUSAL_REASONS.admin, - message: 'Host does not serve administration routes.', - }); - } - if (route === '/health' && request.method === 'GET') { - return await hostHealth(request, proxy, token); - } - if (route === '/rpc' && request.method === 'POST') return await hostRpc(request, proxy, token); - return await proxy.handle(request); -} - -async function hostHealth(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, @@ -78,116 +84,24 @@ async function hostHealth(request: Request, proxy: DaemonProxy, token: string): return Response.json({ ...payload, service: HOST_SERVICE }, { status: response.status }); } -async function hostRpc(request: Request, proxy: DaemonProxy, token: string): Promise { - const body = await readBoundedText(request, MAX_RPC_BODY_BYTES); - const rpc = parseRpc(body); - // The proxy answers malformed, oversized and unauthorized requests exactly as it always does. - if (!rpc || !isAuthorized(request.headers, rpc.params, token)) { - return await proxy.handle(withBody(request, body)); - } - const refusal = findHostRpcRefusal(normalizeRpcMethod(rpc.method), rpc.params); - if (refusal) return rpcRefusal(rpc.id, refusal); - const forwarded = { ...rpc.envelope, params: stripClientIdentity(rpc.params) }; - return await proxy.handle(withBody(request, JSON.stringify(forwarded))); -} - -type ParsedRpc = { - envelope: Record; - id: unknown; - method: string; - params: Record; -}; - -function parseRpc(body: string): ParsedRpc | undefined { - try { - const envelope = JSON.parse(body) as Record; - const params = envelope?.params; - if (typeof envelope?.method !== 'string' || !params || typeof params !== 'object') { - return undefined; - } - return { - envelope, - id: envelope.id, - method: envelope.method, - params: params as Record, - }; - } catch { - return undefined; - } -} - -function isAuthorized(headers: Headers, params: Record, token: string): boolean { - const presented = readHeaderToken(headers) ?? params.token; - return typeof presented === 'string' && timingSafeStringEqual(presented, token); -} - function hasHeaderToken(headers: Headers, token: string): boolean { - const presented = readHeaderToken(headers); - return presented !== undefined && timingSafeStringEqual(presented, token); -} - -function readHeaderToken(headers: Headers): string | undefined { const authorization = headers.get('authorization') ?? ''; - if (authorization.toLowerCase().startsWith('bearer ')) { - return authorization.slice('bearer '.length); - } - return headers.get('x-agent-device-token') ?? undefined; -} - -async function readBoundedText(request: Request, limit: number): Promise { - if (!request.body) return ''; - const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - while (size <= limit) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - size += value.byteLength; - } - if (size > limit) await reader.cancel(); - return Buffer.concat(chunks).toString('utf8'); -} - -function withBody(request: Request, body: string): Request { - return new Request(request.url, { - method: request.method, - headers: request.headers, - body, - signal: request.signal, - }); -} - -function resolveRoute(url: string): string { - const pathname = URL.parse(url)?.pathname ?? ''; - if (pathname === DAEMON_HTTP_BASE_PATH) return '/'; - return pathname.startsWith(`${DAEMON_HTTP_BASE_PATH}/`) - ? pathname.slice(DAEMON_HTTP_BASE_PATH.length) - : pathname; + const presented = authorization.toLowerCase().startsWith('bearer ') + ? authorization.slice('bearer '.length) + : headers.get('x-agent-device-token'); + return presented !== null && timingSafeStringEqual(presented, token); } -function refusalError(refusal: HostRefusal) { - return normalizeError( +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.', }), ); -} - -function rpcRefusal(id: unknown, refusal: HostRefusal): Response { - const data = refusalError(refusal); return Response.json( { jsonrpc: '2.0', id: id ?? null, error: { code: -32000, message: data.message, data } }, { status: 403 }, ); } - -function restRefusal(refusal: HostRefusal): Response { - const data = refusalError(refusal); - return Response.json( - { ok: false, error: data.message, code: data.code, details: data.details }, - { status: 403 }, - ); -} diff --git a/src/cli/host/host-server.ts b/src/cli/host/host-server.ts index 5ad0a5ac14..1ad3f22f9c 100644 --- a/src/cli/host/host-server.ts +++ b/src/cli/host/host-server.ts @@ -1,7 +1,7 @@ import http from 'node:http'; import https from 'node:https'; import { createDaemonProxy, createDaemonProxyRequestListener } from '@agent-device/proxy'; -import { createHostFrontEnd, withHostPrincipal } from './host-front-end.ts'; +import { admitHostRpc, createHostFrontEnd, withHostPrincipal } from './host-front-end.ts'; import type { HostServiceCredential } from './service-credential.ts'; export type HostTlsMaterial = Readonly<{ cert: Buffer; key: Buffer }>; @@ -22,6 +22,7 @@ export function createHostServer(options: { 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 diff --git a/src/cli/host/request-policy.test.ts b/src/cli/host/request-policy.test.ts index 8091d4949d..19f61c2608 100644 --- a/src/cli/host/request-policy.test.ts +++ b/src/cli/host/request-policy.test.ts @@ -9,13 +9,14 @@ test('positionals naming a Host file are refused; client-rewritten locations are { 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: 'install', positionals: ['com.example.app', 'https://ci.example.test/app.apk'] }, { 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'] }, diff --git a/src/cli/host/request-policy.ts b/src/cli/host/request-policy.ts index 7069b12694..751bdaaabe 100644 --- a/src/cli/host/request-policy.ts +++ b/src/cli/host/request-policy.ts @@ -1,11 +1,16 @@ +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 { isRemoteTempArtifactLocation } from '../../remote/daemon-artifacts.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). */ -export const HOST_REFUSAL_REASONS = { +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<{ @@ -20,17 +25,13 @@ type Params = Record; const ALLOCATOR_POLICY_KEYS = ['allowDownload'] as const; /** - * Positional arguments that name a daemon-host file. The remote client rewrites screenshot and - * recording outputs to daemon temp locations and uploads install packages, so anything else here - * names the Host machine's disk. + * 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 HOST_PATH_POSITIONALS: Readonly number>> = - { - screenshot: () => 0, - record: (positionals) => (positionals[0]?.toLowerCase() === 'start' ? 1 : -1), - install: (positionals) => (positionals.length === 1 ? 0 : 1), - reinstall: (positionals) => (positionals.length === 1 ? 0 : 1), - }; +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.'); @@ -40,13 +41,14 @@ export function findHostRpcRefusal(method: string, params: Params): HostRefusal return ( findAllocatorPolicyOverride(params) ?? findAdminAllocation(method, params) ?? - findHostPathInput(params) + findHostPathInSource(params) ?? + findRequestRefusal(params) ); } /** - * Drops every identity a client can claim in the body. The daemon pins the tenant to the Host - * principal anyway; removing the claims keeps them out of logs and the auth hook context. + * 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; @@ -55,7 +57,20 @@ export function stripClientIdentity(params: Params): Params { return { ...rest, ...(meta ? { meta: withoutKey(meta, 'tenantId') } : {}), - ...(flags ? { flags: withoutKey(flags, 'tenant') } : {}), + ...(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; + }), }; } @@ -74,7 +89,9 @@ function findAllocatorPolicyOverride(params: Params): HostRefusal | undefined { } function findAdminAllocation(method: string, params: Params): HostRefusal | undefined { - if (method !== 'agent_device.lease.allocate' || params.backend !== 'macos-app') return 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.', @@ -82,34 +99,109 @@ function findAdminAllocation(method: string, params: Params): HostRefusal | unde }; } -function findHostPathInput(params: Params): HostRefusal | undefined { +/** 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); - const uploaded = typeof meta?.uploadedArtifactId === 'string' && meta.uploadedArtifactId !== ''; - if (!uploaded && (isPathSource(params.source) || isPathSource(meta?.installSource))) { + 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 fields = { ...asRecord(params.input), ...asRecord(params.flags) }; + 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' && namesHostPath(fields[key]), + (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), ); - if (field) return hostPathRefusal(field); - return uploaded ? undefined : findHostPathPositional(params); + return refused ? hostPathRefusal('positionals') : undefined; } -function findHostPathPositional(params: Params): HostRefusal | undefined { - const command = typeof params.command === 'string' ? params.command : ''; - const positionals = Array.isArray(params.positionals) ? params.positionals.map(String) : []; - const index = HOST_PATH_POSITIONALS[command]?.(positionals) ?? -1; +function namesHostPathPositional( + command: string, + name: string, + positionals: readonly string[], + index: number, +): boolean { const value = positionals[index]; - if (value === undefined || /^https?:\/\//i.test(value) || isRemoteTempArtifactLocation(value)) { - return undefined; + 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 hostPathRefusal('positionals'); + return false; } -function namesHostPath(value: unknown): boolean { - if (value === undefined || value === false) return false; - return !(typeof value === 'string' && isRemoteTempArtifactLocation(value)); +function hasUpload(meta: Params | undefined): boolean { + return typeof meta?.uploadedArtifactId === 'string' && meta.uploadedArtifactId.trim() !== ''; } function isPathSource(source: unknown): boolean { @@ -119,7 +211,7 @@ function isPathSource(source: unknown): boolean { function hostPathRefusal(field: string): HostRefusal { return { reason: HOST_REFUSAL_REASONS.hostPath, - message: `Host does not accept ${field} naming a path on the Host machine.`, + message: `Host does not accept ${field}, which names or returns a path on the Host machine.`, field, }; } diff --git a/src/commands/schema/cli-help-host.ts b/src/commands/schema/cli-help-host.ts index 386ab20af5..ad263911c6 100644 --- a/src/commands/schema/cli-help-host.ts +++ b/src/commands/schema/cli-help-host.ts @@ -28,9 +28,10 @@ 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 /admin/* routes and macos-app lease allocation - host-path-refused inputs naming a path on the Host machine + 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. Worker: diff --git a/src/daemon/host-path-inputs.ts b/src/daemon/host-path-inputs.ts index 822f39af23..b2adf5195c 100644 --- a/src/daemon/host-path-inputs.ts +++ b/src/daemon/host-path-inputs.ts @@ -8,6 +8,7 @@ export const HOST_PATH_INPUT_KEYS = [ 'saveScript', 'sessionSaveScript', 'baseline', + 'launchConsole', 'artifactsDir', 'stepsFile', 'searchPath', diff --git a/src/daemon/macos-app-lease.ts b/src/daemon/macos-app-lease.ts index 9b00a1f7b2..9922652919 100644 --- a/src/daemon/macos-app-lease.ts +++ b/src/daemon/macos-app-lease.ts @@ -64,13 +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 = [ - ...HOST_PATH_INPUT_KEYS, - 'launchConsole', - 'launchArgs', - 'launchUrl', - 'bundleUrl', -] 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/server/http-server.ts b/src/daemon/server/http-server.ts index 6a4c132bf9..e17beca08b 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -756,7 +756,10 @@ export async function createDaemonHttpServer(options: { }; requestAbortRegistration = registerRequestAbort(requestIdForCleanup); const clientDeclaredTenant = daemonRequest.meta?.tenantId ?? daemonRequest.flags?.tenant; - const hostPrincipal = readHostPrincipal(req.headers); + // 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, diff --git a/src/remote/daemon-artifacts.ts b/src/remote/daemon-artifacts.ts index db4d5e23b7..d7940a45ef 100644 --- a/src/remote/daemon-artifacts.ts +++ b/src/remote/daemon-artifacts.ts @@ -368,14 +368,6 @@ export function isRemoteTempArtifactPath( ); } -/** Any location `buildRemoteTempArtifactPath` or `buildRemoteTempArtifactDirPath` can produce. */ -export function isRemoteTempArtifactLocation(value: string): boolean { - return REMOTE_TEMP_ARTIFACT_LOCATION.test(value); -} - -const REMOTE_TEMP_ARTIFACT_LOCATION = - /^\/tmp\/agent-device-[a-z][a-z-]*-\d+-[a-z0-9]+(?:\.[A-Za-z0-9]+)?$/; - function remoteTempArtifactStem(prefix: string): string { return `agent-device-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index afcafc2ba8..d1d85d8ef1 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -241,7 +241,7 @@ agent-device host --host 0.0.0.0 --port 8443 --tls-cert ./cert.pem --tls-key ./k - 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. Administration routes, inputs naming a path on the Host machine and component downloads are refused with HTTP 403 and a typed `details.reason` (`host-admin-refused`, `host-path-refused`, `host-component-download-refused`). Anonymous `/health` shows only `ok`, `service` and `rpcProtocolVersion`. +- 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`. - 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 From 784bd9393546080d934233bd853ee967c60ae52d Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 02:34:50 +0200 Subject: [PATCH 6/9] chore(gates): acknowledge the additive Host health and principal wire changes DaemonHealthPayload widens `service` with 'agent-device-host', and the auxiliary HTTP authorizer reads x-agent-device-principal after the daemon token check. Both are additive under ADR 0006: released peers send and parse the same bytes as before. --- test/wire-compat/ledger.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index cae40d9ab3..09ba2b3038 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -3,7 +3,7 @@ "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#DaemonHealthPayload": "sha256:5ba198a6820269e1b60f4098334dc1f5588d5850d201c2211088df184431a4ae", "packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload": "sha256:49a46bd62207a69a359e7c8be6f97c07748af21344452be1d32507b95ce4650b", "packages/contracts/src/daemon-http.ts#buildDaemonHttpAuthHeaders": "sha256:5548a44d6248ed858d19a0b2985ec4ae8a7181bae8294b85edf3c1b3b58e80d4", "packages/contracts/src/daemon-http.ts#buildDaemonHttpBaseUrl": "sha256:f92697208d9f42ec0dcfb006b6f2dce761bd5307db27ce2e52927fd7742e3a9a", @@ -109,7 +109,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", @@ -281,8 +281,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,8 +351,8 @@ }, { "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:5ba198a6820269e1b60f4098334dc1f5588d5850d201c2211088df184431a4ae", + "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." }, { "declaration": "packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload", From 475fe18b61b1b867cdd2a7b9185f89fdb3735434 Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 03:36:46 +0200 Subject: [PATCH 7/9] feat(host): request devices by shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Host, `--device ""` names a device type Host allocates a fresh device for (ADR 0021 §5), instead of a name resolved against inventory. - The client asks the endpoint's health first, through the cache the RPC transport already fills. On `service: agent-device-host` it skips the inventory lookup and allocates with the type, platform and --os-version in the device-selection fields lease.allocate already carries. - A Host that does not advertise the `device-shape` feature fails with host-shape-unsupported before any lease request (§8). Plain proxy is unchanged. - The daemon builds a strict { platform, deviceType, osVersion? } shape for a lease_allocate that carries a Host principal, calls the new HostShapeAllocator seam, and only then publishes the Host lease bound to the returned device; an allocation it cannot publish is given back. A UDID or serial, or a daemon with no allocator, is refused with a typed reason. The seam is the lease side's to implement (#3269). Nothing configures it in production yet; the tests drive it through the scripted allocator fake. Closes #3267 --- packages/contracts/src/daemon-http.ts | 11 ++ src/cli/commands/connection-runtime.ts | 4 + .../host-device-shape-connection.test.ts | 147 ++++++++++++++++++ .../commands/host-device-shape-connection.ts | 40 +++++ src/cli/host/host-front-end.ts | 8 +- src/commands/schema/cli-help-host.ts | 8 + src/daemon-client/daemon-client-lifecycle.ts | 33 +++- src/daemon-client/daemon-client-transport.ts | 7 +- src/daemon/handlers/__tests__/lease.test.ts | 139 +++++++++++++++++ src/daemon/handlers/lease.ts | 53 ++++++- src/daemon/host-shape-allocation.ts | 61 ++++++++ src/daemon/request-handler-chain.ts | 3 + src/daemon/request-router.ts | 5 + src/daemon/server/http-server.ts | 4 + website/docs/docs/remote-proxy.md | 1 + 15 files changed, 511 insertions(+), 13 deletions(-) create mode 100644 src/cli/commands/host-device-shape-connection.test.ts create mode 100644 src/cli/commands/host-device-shape-connection.ts create mode 100644 src/daemon/host-shape-allocation.ts diff --git a/packages/contracts/src/daemon-http.ts b/packages/contracts/src/daemon-http.ts index edc2b63963..f4811fb2bf 100644 --- a/packages/contracts/src/daemon-http.ts +++ b/packages/contracts/src/daemon-http.ts @@ -64,9 +64,18 @@ export type DaemonHealthPayload = { 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'; +export type DaemonHealthFeature = typeof DAEMON_HOST_DEVICE_SHAPE_FEATURE; + export function buildDaemonHealthPayload( service: DaemonHealthPayload['service'], version: string, @@ -75,6 +84,7 @@ export function buildDaemonHealthPayload( instanceId?: string; hostArch?: string; leaseBackends?: readonly string[]; + features?: readonly DaemonHealthFeature[]; } = {}, ): DaemonHealthPayload { return { @@ -85,6 +95,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/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index a959b7a173..fd561e2583 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -941,6 +941,10 @@ async function resolveProxyLeaseState(options: { 'No active proxy device lease for this session; run open first.', ); } + // Loaded on demand so the daemon client it probes with stays out of the CLI's eager closure. + const { resolveHostShapeLeaseState } = await import('./host-device-shape-connection.ts'); + const hostShapeState = await resolveHostShapeLeaseState(options.state, options.flags); + if (hostShapeState) return { state: hostShapeState }; const device = await resolveSelectedDevice(options.client, options.flags); const scope = resolveConnectionDeviceScope(device); return { 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..2ec999732f --- /dev/null +++ b/src/cli/commands/host-device-shape-connection.test.ts @@ -0,0 +1,147 @@ +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' }); +}); + +test('a Host that cannot allocate by shape is refused before any lease request', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const daemonBaseUrl = await serveHealth(t, { + service: 'agent-device-host', + instanceId: 'host-2', + upstream: { service: 'agent-device-daemon', instanceId: 'daemon-2' }, + }); + const worker = recordingClient(); + + await expect( + materializeRemoteConnectionForCommand({ + command: 'open', + positionals: ['com.example.app'], + flags: workerFlags(daemonBaseUrl), + client: worker.client, + }), + ).rejects.toMatchObject({ details: { reason: 'host-shape-unsupported' } }); + expect(worker.allocations).toHaveLength(0); + expect(worker.inventoryReads()).toBe(0); +}); + +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..41426761b5 --- /dev/null +++ b/src/cli/commands/host-device-shape-connection.ts @@ -0,0 +1,40 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import { DAEMON_HOST_DEVICE_SHAPE_FEATURE } 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 HOST_SERVICE = 'agent-device-host'; + +/** + * On Host, `--device ""` is a shape Host allocates a fresh device for, not a name resolved + * against inventory (ADR 0021 §5). Returns the lease state to allocate with, or undefined for any + * endpoint that is not a Host. A Host that cannot allocate by shape is refused here, before any + * mutation (§8); plain proxy keeps resolving the device as before. + */ +export async function resolveHostShapeLeaseState( + state: RemoteConnectionState, + flags: CliFlags, +): Promise { + if (!requestsDeviceByType(flags)) return undefined; + const health = await readRemoteDaemonHealthForFlags(flags); + if (health?.service !== HOST_SERVICE) return undefined; + if (!health.features?.includes(DAEMON_HOST_DEVICE_SHAPE_FEATURE)) { + throw new AppError('UNSUPPORTED_OPERATION', 'This Host cannot allocate devices by type.', { + reason: 'host-shape-unsupported', + daemonBaseUrl: flags.daemonBaseUrl, + hint: 'The Host daemon advertises no device-shape allocation; upgrade the Host or start it with its lease coordinator.', + }); + } + if (flags.platform !== 'ios' && flags.platform !== 'android') { + throw new AppError('INVALID_ARGS', 'A Host device type needs --platform ios or android.', { + reason: 'host-shape-platform-required', + hint: 'Example: open com.example.app --platform ios --device "iPhone 16"', + }); + } + return { ...state, platform: flags.platform, updatedAt: new Date().toISOString() }; +} + +function requestsDeviceByType(flags: CliFlags): boolean { + return Boolean(flags.device?.trim()) && !flags.udid && !flags.serial; +} diff --git a/src/cli/host/host-front-end.ts b/src/cli/host/host-front-end.ts index 8e1c65db40..edc2be5b3b 100644 --- a/src/cli/host/host-front-end.ts +++ b/src/cli/host/host-front-end.ts @@ -81,7 +81,13 @@ async function handleHostRequest( const response = await proxy.handle(request); if (!response.ok) return response; const payload = (await response.json()) as Record; - return Response.json({ ...payload, service: HOST_SERVICE }, { status: response.status }); + // 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 { diff --git a/src/commands/schema/cli-help-host.ts b/src/commands/schema/cli-help-host.ts index ad263911c6..b67c078a99 100644 --- a/src/commands/schema/cli-help-host.ts +++ b/src/commands/schema/cli-help-host.ts @@ -34,6 +34,14 @@ Public route policy: 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 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/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 15f4a96325..f208abfbc6 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,137 @@ 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']); +}); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 9801ce9835..506333bee7 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -25,6 +25,7 @@ import { leaseScopeToAllocateRequest, leaseScopeToHeartbeatRequest, leaseScopeToReleaseRequest, + type LeaseScope, } from '@agent-device/contracts/lease-scope'; import { AppError, createRequestCanceledError, errorMessage } from '@agent-device/kernel/errors'; import { LEASE_ALLOCATION_BUDGET_MS } from '@agent-device/command-registry/timeout-policy'; @@ -33,6 +34,11 @@ 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 +50,7 @@ type LeaseHandlerArgs = { providerCredentials?: DaemonProviderCredentials; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; + hostShapeAllocator?: HostShapeAllocator; }; export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { @@ -73,6 +80,10 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { + const shape = readHostShapeRequest(args.req.flags); + const allocator = args.hostShapeAllocator; + if (!allocator) throw hostShapeAllocationUnavailable(); + const runId = leaseScope.runId ?? ''; + 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, + }); + try { + const lease = args.leaseRegistry.allocateLease( + leaseScopeToAllocateRequest({ ...leaseScope, deviceKey }), + ); + return { ok: true, data: { lease } }; + } catch (error) { + await allocator.release({ principal, runId, deviceKey }); + throw error; + } +} + type LeaseReleaseOutcome = { /** The daemon's own lease record was released (bookkeeping, not the billed resource). */ registryReleased: boolean; @@ -327,7 +372,7 @@ function assertProviderCredentialsUnchanged( async function listArtifactsForRequest( req: DaemonRequest, - leaseScope: ReturnType, + leaseScope: LeaseScope, leaseRegistry: LeaseRegistry, cloudArtifactProvider: CloudArtifactProvider | undefined, ): Promise { @@ -345,7 +390,7 @@ async function listArtifactsForRequest( } function shouldListDaemonArtifacts( - leaseScope: ReturnType, + leaseScope: LeaseScope, providerSessionId: string | undefined, ): boolean { return isProxyLeaseScope(leaseScope) || (!leaseScope.leaseProvider && !providerSessionId); @@ -362,7 +407,7 @@ async function listDaemonArtifacts(tenantId: string | undefined): Promise, + leaseScope: LeaseScope, providerSessionId: string | undefined, leaseRegistry: LeaseRegistry, cloudArtifactProvider: CloudArtifactProvider | undefined, @@ -396,7 +441,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-shape-allocation.ts b/src/daemon/host-shape-allocation.ts new file mode 100644 index 0000000000..e38538588a --- /dev/null +++ b/src/daemon/host-shape-allocation.ts @@ -0,0 +1,61 @@ +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 osVersion = flags?.providerOsVersion?.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/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/http-server.ts b/src/daemon/server/http-server.ts index e17beca08b..99bc991693 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, + type DaemonHealthFeature, DAEMON_HTTP_TENANT_HEADER, } from '@agent-device/contracts/daemon-http'; import { readVersion } from '@agent-device/host-kit/version'; @@ -589,6 +590,8 @@ export async function createDaemonHttpServer(options: { * rather than handed a daemon-host path. */ resolveRequestDiagnosticsPath?: (ref: DiagnosticsRecordRef) => string; + /** Capabilities `/health` advertises; the Host lease side adds device-shape with its allocator. */ + features?: readonly DaemonHealthFeature[]; }): Promise { const instanceId = randomUUID(); const hostArch = await readHostCpuArch(); @@ -608,6 +611,7 @@ export async function createDaemonHttpServer(options: { instanceId, hostArch, leaseBackends, + ...(options.features?.length ? { features: options.features } : {}), }), ), ); diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index d1d85d8ef1..f14b87bf31 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -242,6 +242,7 @@ agent-device host --host 0.0.0.0 --port 8443 --tls-cert ./cert.pem --tls-key ./k - 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 From 3df7efcae6d1067a3830c1b0f0ec8a1350a33abe Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 05:00:39 +0200 Subject: [PATCH 8/9] fix(host): pin the leased Host device and refuse early what Host cannot allocate - After a by-type allocation, the command addresses the device the lease bound (its UDID or serial) instead of a name another simulator may share. - On a Host, every lease-allocating command is checked before any lease request. A missing --device, a UDID or serial, a missing token (host-unauthenticated) and a different type on a session that already holds one (host-shape-mismatch) are typed refusals. The platform comes from the connection when --platform is absent. - The daemon validates the lease scope before it provisions anything, gives an allocation back when the requester has left, and keeps the original error if giving it back fails. --os-version must be a string. - The daemon advertises device-shape exactly when it has an allocator, and the Host service name is one shared constant. - The proxy lease device selection moves into its own module, loaded on demand, so connection-runtime.ts stays under 1,000 lines. --- packages/contracts/src/daemon-http.ts | 4 +- src/cli/commands/connection-runtime.ts | 99 ++--------------- .../host-device-shape-connection.test.ts | 57 +++++++--- .../commands/host-device-shape-connection.ts | 70 +++++++++--- src/cli/commands/proxy-lease-device.ts | 101 ++++++++++++++++++ src/cli/host/host-front-end.ts | 2 +- src/daemon/handlers/__tests__/lease.test.ts | 61 +++++++++++ src/daemon/handlers/lease.ts | 38 +++++-- src/daemon/host-shape-allocation.ts | 6 +- src/daemon/server/http-server.ts | 9 +- src/remote/remote-connection-state.ts | 3 + 11 files changed, 318 insertions(+), 132 deletions(-) create mode 100644 src/cli/commands/proxy-lease-device.ts diff --git a/packages/contracts/src/daemon-http.ts b/packages/contracts/src/daemon-http.ts index f4811fb2bf..b47d95d4eb 100644 --- a/packages/contracts/src/daemon-http.ts +++ b/packages/contracts/src/daemon-http.ts @@ -57,7 +57,7 @@ export function buildDaemonInstanceMismatchRpcResponse( export type DaemonHealthPayload = { ok: true; - service: 'agent-device-daemon' | 'agent-device-proxy' | 'agent-device-host'; + service: 'agent-device-daemon' | 'agent-device-proxy' | typeof DAEMON_HOST_SERVICE; version: string; rpcProtocolVersion: number; instanceId?: string; @@ -74,6 +74,8 @@ export type DaemonHealthPayload = { * 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( diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index fd561e2583..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,79 +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.', - ); - } - // Loaded on demand so the daemon client it probes with stays out of the CLI's eager closure. - const { resolveHostShapeLeaseState } = await import('./host-device-shape-connection.ts'); - const hostShapeState = await resolveHostShapeLeaseState(options.state, options.flags); - if (hostShapeState) return { state: hostShapeState }; - 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 index 2ec999732f..78190206d4 100644 --- a/src/cli/commands/host-device-shape-connection.test.ts +++ b/src/cli/commands/host-device-shape-connection.test.ts @@ -102,27 +102,58 @@ test('on a Host, --device sends the device type without resolving inventory', as 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(); }); -test('a Host that cannot allocate by shape is refused before any lease request', async (t) => { +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 daemonBaseUrl = await serveHealth(t, { - service: 'agent-device-host', - instanceId: 'host-2', - upstream: { service: 'agent-device-daemon', instanceId: 'daemon-2' }, - }); - const worker = recordingClient(); + 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); + } +}); - await expect( +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: workerFlags(daemonBaseUrl), + flags: { ...flags, device }, client: worker.client, - }), - ).rejects.toMatchObject({ details: { reason: 'host-shape-unsupported' } }); - expect(worker.allocations).toHaveLength(0); - expect(worker.inventoryReads()).toBe(0); + }); + + 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) => { diff --git a/src/cli/commands/host-device-shape-connection.ts b/src/cli/commands/host-device-shape-connection.ts index 41426761b5..0d61b492fd 100644 --- a/src/cli/commands/host-device-shape-connection.ts +++ b/src/cli/commands/host-device-shape-connection.ts @@ -1,40 +1,76 @@ +import type { Lease } from '@agent-device/contracts/client'; import type { CliFlags } from '@agent-device/contracts/command'; -import { DAEMON_HOST_DEVICE_SHAPE_FEATURE } from '@agent-device/contracts/daemon-http'; +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 HOST_SERVICE = 'agent-device-host'; +const HINT = 'Example: open com.example.app --platform ios --device "iPhone 16"'; /** - * On Host, `--device ""` is a shape Host allocates a fresh device for, not a name resolved - * against inventory (ADR 0021 §5). Returns the lease state to allocate with, or undefined for any - * endpoint that is not a Host. A Host that cannot allocate by shape is refused here, before any - * mutation (§8); plain proxy keeps resolving the device as before. + * 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 { - if (!requestsDeviceByType(flags)) return undefined; const health = await readRemoteDaemonHealthForFlags(flags); - if (health?.service !== HOST_SERVICE) return undefined; + 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 new AppError('UNSUPPORTED_OPERATION', 'This Host cannot allocate devices by type.', { + throw hostShapeError('UNSUPPORTED_OPERATION', 'This Host cannot allocate devices by type.', { reason: 'host-shape-unsupported', - daemonBaseUrl: flags.daemonBaseUrl, - hint: 'The Host daemon advertises no device-shape allocation; upgrade the Host or start it with its lease coordinator.', + hint: 'The Host daemon advertises no device-shape allocation; it needs its lease coordinator.', }); } - if (flags.platform !== 'ios' && flags.platform !== 'android') { - throw new AppError('INVALID_ARGS', 'A Host device type needs --platform ios or android.', { + 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: 'Example: open com.example.app --platform ios --device "iPhone 16"', + 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, }); } - return { ...state, platform: flags.platform, updatedAt: new Date().toISOString() }; + 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 requestsDeviceByType(flags: CliFlags): boolean { - return Boolean(flags.device?.trim()) && !flags.udid && !flags.serial; +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/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/host/host-front-end.ts b/src/cli/host/host-front-end.ts index edc2be5b3b..7ee0d30ff9 100644 --- a/src/cli/host/host-front-end.ts +++ b/src/cli/host/host-front-end.ts @@ -1,4 +1,5 @@ import { + DAEMON_HOST_SERVICE as HOST_SERVICE, DAEMON_HTTP_BASE_PATH, DAEMON_HTTP_PRINCIPAL_HEADER, DAEMON_HTTP_TENANT_HEADER, @@ -15,7 +16,6 @@ import { } from './request-policy.ts'; import type { HostServiceCredential } from './service-credential.ts'; -const HOST_SERVICE = 'agent-device-host'; const HEALTH_PATHS: ReadonlySet = new Set(['/health', `${DAEMON_HTTP_BASE_PATH}/health`]); /** diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index f208abfbc6..eb5deed69a 100644 --- a/src/daemon/handlers/__tests__/lease.test.ts +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -693,3 +693,64 @@ test('an allocation whose Host lease cannot be published is given back', async ( ); 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 506333bee7..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'; @@ -27,7 +28,13 @@ import { 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'; @@ -184,8 +191,9 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): 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) { - await allocator.release({ principal, runId, deviceKey }); - throw error; + return await giveBack(error); } } diff --git a/src/daemon/host-shape-allocation.ts b/src/daemon/host-shape-allocation.ts index e38538588a..d1c9a70c86 100644 --- a/src/daemon/host-shape-allocation.ts +++ b/src/daemon/host-shape-allocation.ts @@ -42,7 +42,11 @@ export function readHostShapeRequest(flags: DaemonRequest['flags']): ManagedShap if (flags?.udid !== undefined || flags?.serial !== undefined) { throw hostShapeInvalid('A Host lease is requested by device type, not by UDID or serial.'); } - const osVersion = flags?.providerOsVersion?.trim(); + 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 } : {}) }; } diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index 99bc991693..db5cbf8161 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -38,7 +38,7 @@ import { buildDaemonHealthPayload, DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, - type DaemonHealthFeature, + DAEMON_HOST_DEVICE_SHAPE_FEATURE, DAEMON_HTTP_TENANT_HEADER, } from '@agent-device/contracts/daemon-http'; import { readVersion } from '@agent-device/host-kit/version'; @@ -54,6 +54,7 @@ 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; @@ -590,8 +591,8 @@ export async function createDaemonHttpServer(options: { * rather than handed a daemon-host path. */ resolveRequestDiagnosticsPath?: (ref: DiagnosticsRecordRef) => string; - /** Capabilities `/health` advertises; the Host lease side adds device-shape with its allocator. */ - features?: readonly DaemonHealthFeature[]; + /** 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(); @@ -611,7 +612,7 @@ export async function createDaemonHttpServer(options: { instanceId, hostArch, leaseBackends, - ...(options.features?.length ? { features: options.features } : {}), + ...(options.hostShapeAllocator ? { features: [DAEMON_HOST_DEVICE_SHAPE_FEATURE] } : {}), }), ), ); 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) ); From d3b9856c2f15ee28860f7f5ff6c1c71c3d29868d Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 03:36:46 +0200 Subject: [PATCH 9/9] chore(gates): gate the additive Host device-shape health feature List DaemonHealthFeature, DAEMON_HOST_DEVICE_SHAPE_FEATURE and DAEMON_HOST_SERVICE in the wire manifest, and acknowledge the additive `features` field on the health payload, its builder and the client's health parser under ADR 0006. --- test/wire-compat/ledger.json | 35 +++++++++++++++++++---------------- test/wire-compat/surface.ts | 3 +++ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 09ba2b3038..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:5ba198a6820269e1b60f4098334dc1f5588d5850d201c2211088df184431a4ae", - "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", @@ -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": [ { @@ -351,28 +354,28 @@ }, { "declaration": "packages/contracts/src/daemon-http.ts#DaemonHealthPayload", - "digest": "sha256:5ba198a6820269e1b60f4098334dc1f5588d5850d201c2211088df184431a4ae", - "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." + "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', ),