From f173b22a4a76ff4695ac31f6563075567584be76 Mon Sep 17 00:00:00 2001 From: Vitaly Kuprin Date: Wed, 7 Oct 2026 02:12:17 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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',