diff --git a/.fallowrc.json b/.fallowrc.json index a39923f0a0..685eb04c7b 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -220,7 +220,7 @@ }, { "comment": "Dedicated CLI command handlers are reached only through the dynamic `import()` table `dedicatedCliCommandHandlerLoaders` in src/cli/commands/router.ts, which --production analysis cannot follow to a consumer. Same shape as the daemon route-handler entry above; that table is what enumerates this list, so add/remove here whenever a loader is added/removed.", - "file": "src/cli/commands/{auth,connection,daemon,device,plugins,proxy,recording,replay,screenshot,takeover}.ts", + "file": "src/cli/commands/{auth,connection,daemon,device,host,plugins,proxy,recording,replay,screenshot,takeover}.ts", "exports": [ "authCommand", "pluginsCommand", @@ -229,6 +229,7 @@ "connectionCommand", "daemonCommand", "deviceCommand", + "hostCommand", "proxyCommand", "recordingCommand", "replayCommand", diff --git a/packages/command-registry/src/__tests__/device-claim-policy.test.ts b/packages/command-registry/src/__tests__/device-claim-policy.test.ts index 82b8b503af..4df3670e2e 100644 --- a/packages/command-registry/src/__tests__/device-claim-policy.test.ts +++ b/packages/command-registry/src/__tests__/device-claim-policy.test.ts @@ -61,6 +61,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set 'daemon', 'debug', 'disconnect', + 'host', 'human_control', 'install-from-source', 'lease_allocate', diff --git a/packages/command-registry/src/flag-definitions-connection.ts b/packages/command-registry/src/flag-definitions-connection.ts index 2b6fc8fbda..6a872b96b2 100644 --- a/packages/command-registry/src/flag-definitions-connection.ts +++ b/packages/command-registry/src/flag-definitions-connection.ts @@ -76,7 +76,7 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--host'], type: 'string', usageLabel: '--host ', - usageDescription: 'Proxy: host interface to bind (default: 127.0.0.1)', + usageDescription: 'Proxy and host: interface to bind (default: 127.0.0.1)', projectConfig: false, recorded: false, }, @@ -87,7 +87,25 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ min: 1, max: 65535, usageLabel: '--port ', - usageDescription: 'Proxy: TCP port to bind (default: 0, choose a free port)', + usageDescription: 'Proxy and host: TCP port to bind (default: 0, choose a free port)', + projectConfig: false, + recorded: false, + }, + { + key: 'hostTlsCert', + names: ['--tls-cert'], + type: 'string', + usageLabel: '--tls-cert ', + usageDescription: 'Host: PEM certificate to serve HTTPS (requires --tls-key)', + projectConfig: false, + recorded: false, + }, + { + key: 'hostTlsKey', + names: ['--tls-key'], + type: 'string', + usageLabel: '--tls-key ', + usageDescription: 'Host: PEM private key to serve HTTPS (requires --tls-cert)', projectConfig: false, recorded: false, }, diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 8b0ce02337..61352092ba 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1760,6 +1760,17 @@ export const RAW_COMMAND_DESCRIPTORS = [ mcpExposed: false, platformExecution: NO_PLATFORM_EXECUTION, }, + { + name: 'host', + deviceClaimPolicy: 'none', + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/host.ts'] as const } : {}), + catalog: { group: 'local-cli' }, + recordsSessionAction: false, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: false, + mcpExposed: false, + platformExecution: NO_PLATFORM_EXECUTION, + }, { name: 'proxy', deviceClaimPolicy: 'none', diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index 8cd4412922..70c643581f 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -43,6 +43,8 @@ export type CliFlags = CloudProviderProfileFields & daemonServerMode?: DaemonServerMode; proxyHost?: string; proxyPort?: number; + hostTlsCert?: string; + hostTlsKey?: string; tenant?: string; sessionIsolation?: SessionIsolationMode; runId?: string; diff --git a/scripts/__tests__/help-conformance-topic-coverage.test.ts b/scripts/__tests__/help-conformance-topic-coverage.test.ts index 80a55664fb..830ff6c25d 100644 --- a/scripts/__tests__/help-conformance-topic-coverage.test.ts +++ b/scripts/__tests__/help-conformance-topic-coverage.test.ts @@ -12,6 +12,7 @@ const WAIVED_TOPICS: Record = { cdp: 'JS-heap forensics niche; add cases when heap-guidance regressions show up in practice.', commands: 'Derived command/configuration reference, not a planning loop; catalog completeness is structurally tested.', + host: 'Operator setup for the Host front-end, not a planning loop; no worker-planning case is defined yet.', macos: 'macOS surface guidance is thin and stable; no observed planning regressions yet.', maestro: 'Compatibility reference, not a planning loop; conformance is oracle-tested instead.', 'physical-device': 'Needs device-specific setup guidance; no portable planning task defined yet.', diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 3a1be8a0b8..a5ae1cf814 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -332,6 +332,11 @@ function summarizeProviderScenarioFlagExclusions() { 'stale', ], }, + { + name: 'Host front-end TLS options', + owner: 'Host server tests (src/cli/host/host-server.test.ts)', + keys: ['hostTlsCert', 'hostTlsKey'], + }, { name: 'daemon lifecycle control', owner: 'daemon CLI lifecycle tests', diff --git a/src/cli.ts b/src/cli.ts index 0bdbb3de5c..a84f8b0d08 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -103,6 +103,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([ 'plugins', 'device', 'disconnect', + 'host', 'metro', 'proxy', 'session', @@ -726,6 +727,7 @@ function resolveActiveConnectionDefaults(options: { options.command === 'connection' || options.command === 'daemon' || options.command === 'plugins' || + options.command === 'host' || options.command === 'proxy' ) { return null; @@ -755,6 +757,7 @@ function shouldResolveRemoteAuth(command: string): boolean { command !== 'daemon' && command !== 'plugins' && command !== 'device' && + command !== 'host' && command !== 'proxy' ); } diff --git a/src/cli/commands/host.test.ts b/src/cli/commands/host.test.ts new file mode 100644 index 0000000000..ff5d0fed6d --- /dev/null +++ b/src/cli/commands/host.test.ts @@ -0,0 +1,142 @@ +import { test, vi, type TestContext } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import type net from 'node:net'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { createTestClient } from '../../__tests__/remote-connection.fixtures.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { ensureDaemon } from '../../daemon-client/daemon-client-lifecycle.ts'; +import { hostCommand } from './host.ts'; + +const servedHosts = vi.hoisted(() => [] as net.Server[]); + +vi.mock('../../daemon-client/daemon-client-lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), + ensureDaemon: vi.fn(), +})); + +vi.mock('../host/local-daemon.ts', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + listenOnTcp: async (server: net.Server, bind: { host: string; port: number }) => { + servedHosts.push(server); + return await original.listenOnTcp(server, bind); + }, + waitForever: async () => {}, + }; +}); + +const DAEMON_TOKEN = 'daemon-token-never-leaves-host'; + +function startHost(stateDir: string, extraFlags: Record = {}) { + return hostCommand({ + positionals: [], + flags: { json: true, help: false, version: false, stateDir, ...extraFlags }, + client: createTestClient(), + }); +} + +async function refusalBeforeDaemon(run: Promise): Promise { + vi.mocked(ensureDaemon).mockClear(); + try { + await run; + } catch (error) { + assert.ok(error instanceof AppError, `expected AppError, got ${String(error)}`); + assert.equal(vi.mocked(ensureDaemon).mock.calls.length, 0, 'no daemon starts'); + return error.details?.reason; + } + assert.fail('expected host to refuse to start'); +} + +function tlsFiles(stateDir: string) { + const hostTlsCert = path.join(stateDir, 'cert.pem'); + const hostTlsKey = path.join(stateDir, 'key.pem'); + fs.writeFileSync(hostTlsCert, 'not a certificate\n'); + fs.writeFileSync(hostTlsKey, 'not a key\n', { mode: 0o600 }); + return { hostTlsCert, hostTlsKey }; +} + +test('a malformed or insecure credential stops host before any daemon starts', async () => { + for (const mode of [0o600, 0o644]) { + const stateDir = mkdtempForTestSync('agent-device-host-start-'); + const hostDir = path.join(stateDir, 'host'); + fs.mkdirSync(hostDir, { mode: 0o700 }); + const file = path.join(hostDir, 'service-credential.json'); + fs.writeFileSync(file, '{}\n', { mode }); + fs.chmodSync(file, mode); + + const expected = mode === 0o600 ? 'host-credential-invalid' : 'host-credential-insecure'; + assert.equal(await refusalBeforeDaemon(startHost(stateDir)), expected); + } +}); + +test('TLS problems are typed refusals before any daemon starts', async () => { + const cases: Array<[string, (stateDir: string) => Record]> = [ + ['host-tls-incomplete', (stateDir) => ({ hostTlsCert: path.join(stateDir, 'cert.pem') })], + [ + 'host-tls-unreadable', + (stateDir) => ({ + hostTlsCert: path.join(stateDir, 'missing-cert.pem'), + hostTlsKey: path.join(stateDir, 'missing-key.pem'), + }), + ], + ['host-tls-invalid', tlsFiles], + ['host-tls-required', () => ({ proxyHost: '0.0.0.0' })], + ]; + for (const [reason, flags] of cases) { + const stateDir = mkdtempForTestSync('agent-device-host-start-'); + assert.equal(await refusalBeforeDaemon(startHost(stateDir, flags(stateDir))), reason, reason); + } +}); + +async function startServingHost(t: TestContext, stateDir: string) { + const output = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await startHost(stateDir); + return JSON.parse(String(output.mock.calls.at(-1)?.[0])).data; + } finally { + output.mockRestore(); + for (const server of servedHosts.splice(0)) t.onTestFinished(() => closeLoopbackServer(server)); + } +} + +function rpc(baseUrl: string, token: string): Promise { + return fetch(`${baseUrl}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent_device.command', params: {} }), + }); +} + +test('a started host serves health and forwards with the daemon token, also after a restart', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const daemonAuthorizations: Array = []; + const daemon = http.createServer((req, res) => { + if (req.url?.endsWith('/rpc')) daemonAuthorizations.push(req.headers.authorization); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true, data: {} } })); + }); + const httpPort = await listenOnLoopback(daemon); + t.onTestFinished(() => closeLoopbackServer(daemon)); + vi.mocked(ensureDaemon).mockResolvedValue({ info: { httpPort, token: DAEMON_TOKEN } } as never); + const stateDir = mkdtempForTestSync('agent-device-host-start-'); + + const first = await startServingHost(t, stateDir); + const restarted = await startServingHost(t, stateDir); + const health = await fetch(`${restarted.agentDeviceBaseUrl}/health`); + + assert.equal(health.status, 200); + assert.equal((await health.json()).ok, true); + assert.equal(restarted.token, undefined, 'the token shows only on the start that creates it'); + assert.equal((await rpc(restarted.agentDeviceBaseUrl, 'not-the-service-token')).status, 401); + assert.equal((await rpc(restarted.agentDeviceBaseUrl, first.token)).status, 200); + assert.deepEqual(daemonAuthorizations, [`Bearer ${DAEMON_TOKEN}`]); +}); diff --git a/src/cli/commands/host.ts b/src/cli/commands/host.ts new file mode 100644 index 0000000000..54a49ae482 --- /dev/null +++ b/src/cli/commands/host.ts @@ -0,0 +1,176 @@ +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import tls from 'node:tls'; +import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { resolveUserPath } from '@agent-device/host-kit/file'; +import { AppError } from '@agent-device/kernel/errors'; +import { supportsColor } from '../../commands/output/color.ts'; +import { createHostServer, type HostTlsMaterial } from '../host/host-server.ts'; +import { prepareHostServiceCredential } from '../host/service-credential.ts'; +import { + ensureLocalHttpDaemon, + formatHostForUrl, + formatOutputValue, + listenOnTcp, + resolveBindAddress, + resolveLocalHttpDaemonSettings, + waitForever, +} from '../host/local-daemon.ts'; +import { writeCommandOutput } from './shared.ts'; +import type { ClientCommandHandler } from './router-types.ts'; + +type HostStartup = { + hostBaseUrl: string; + agentDeviceBaseUrl: string; + listenAddress: string; + principal: string; + credentialFile: string; + /** Present only on the start that created the credential, so restart logs never repeat it. */ + token?: string; + upstreamBaseUrl: string; + stateDir: string; +}; + +const WILDCARD_ADDRESSES = new Set(['0.0.0.0', '::']); + +export const hostCommand: ClientCommandHandler = async ({ positionals, flags }) => { + if (positionals.length > 0) { + throw new AppError('INVALID_ARGS', 'host does not accept positional arguments.'); + } + const startup = await startHost(flags); + await writeCommandOutput(flags, startup, () => renderHostStartup(startup)); + await waitForever(); + return true; +}; + +async function startHost(flags: CliFlags): Promise { + // Every Host-side refusal happens before a daemon is started or reused. + const settings = resolveLocalHttpDaemonSettings({ command: 'host', stateDir: flags.stateDir }); + const bind = resolveBindAddress(flags); + const tlsMaterial = readHostTlsMaterial(flags); + if (!tlsMaterial && !isLoopbackHost(bind.host)) { + throw new AppError('INVALID_ARGS', `host needs TLS to listen on ${bind.host}.`, { + reason: 'host-tls-required', + hint: 'Pass --tls-cert and --tls-key, or keep the default 127.0.0.1 bind behind a TLS tunnel.', + }); + } + const prepared = prepareHostServiceCredential(path.join(settings.paths.baseDir, 'host')); + const { upstreamBaseUrl, upstreamToken, stateDir } = await ensureLocalHttpDaemon( + 'host', + settings, + ); + const server = createHostServer({ + upstreamBaseUrl, + upstreamToken, + credential: prepared.credential, + tls: tlsMaterial, + }); + const address = await listenOnTcp(server, bind); + try { + prepared.publish(); + } catch (error) { + server.close(); + throw error; + } + const scheme = tlsMaterial ? 'https' : 'http'; + const advertised = formatHostForUrl(advertisedHost(bind.host, address.address)); + const hostBaseUrl = `${scheme}://${advertised}:${address.port}`; + return { + hostBaseUrl, + agentDeviceBaseUrl: buildDaemonHttpBaseUrl(hostBaseUrl), + listenAddress: `${formatHostForUrl(address.address)}:${address.port}`, + principal: prepared.credential.principal, + credentialFile: prepared.credentialFile, + ...(prepared.created ? { token: prepared.credential.token } : {}), + upstreamBaseUrl, + stateDir, + }; +} + +function isLoopbackHost(host: string): boolean { + const bare = host.replace(/^\[(.*)\]$/, '$1').toLowerCase(); + return bare === 'localhost' || bare === '::1' || /^127\./.test(bare); +} + +/** + * The address workers dial: the name the operator bound to, the machine's name for a wildcard + * bind (which nobody can dial), or the bound literal address. + */ +function advertisedHost(requestedHost: string, boundAddress: string): string { + if (WILDCARD_ADDRESSES.has(boundAddress)) return os.hostname(); + return net.isIP(requestedHost.replace(/^\[(.*)\]$/, '$1')) === 0 ? requestedHost : boundAddress; +} + +function readHostTlsMaterial(flags: CliFlags): HostTlsMaterial | undefined { + const certPath = flags.hostTlsCert?.trim(); + const keyPath = flags.hostTlsKey?.trim(); + if (!certPath && !keyPath) return undefined; + if (!certPath || !keyPath) { + throw new AppError('INVALID_ARGS', 'host needs both --tls-cert and --tls-key to serve HTTPS.', { + reason: 'host-tls-incomplete', + }); + } + const material = { + cert: readTlsFile(certPath, '--tls-cert'), + key: readTlsFile(keyPath, '--tls-key'), + }; + try { + tls.createSecureContext(material); + } catch (error) { + throw new AppError( + 'INVALID_ARGS', + 'host cannot use the TLS certificate and key.', + { + reason: 'host-tls-invalid', + hint: 'Pass a PEM certificate with --tls-cert and its matching PEM private key with --tls-key.', + }, + error, + ); + } + return material; +} + +function readTlsFile(rawPath: string, flag: string): Buffer { + const resolved = resolveUserPath(rawPath); + try { + return fs.readFileSync(resolved); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `host cannot read the ${flag} file.`, + { + reason: 'host-tls-unreadable', + path: resolved, + hint: `Check that ${resolved} exists and the Host user can read it.`, + }, + error, + ); + } +} + +function renderHostStartup(startup: HostStartup): string { + const useColor = supportsColor(); + const boundSuffix = startup.hostBaseUrl.endsWith(`//${startup.listenAddress}`) + ? '' + : ` (bound to ${startup.listenAddress})`; + const hostUrl = formatOutputValue(startup.hostBaseUrl, 'cyan', useColor); + const credentialLines = startup.token + ? [ + `Service credential created: ${startup.credentialFile}`, + `Token: ${formatOutputValue(startup.token, 'yellow', useColor)} (shown once; read it from the credential file later)`, + ] + : [`Service credential: ${startup.credentialFile}`]; + const workerToken = startup.token ?? ''; + return [ + `${formatOutputValue('✓', 'green', useColor)} Host listening at ${hostUrl}${boundSuffix}`, + '', + ...credentialLines, + `Principal: ${startup.principal}`, + '', + 'Workers connect with:', + ` agent-device connect proxy --daemon-base-url ${startup.agentDeviceBaseUrl} --daemon-auth-token ${workerToken}`, + ].join('\n'); +} diff --git a/src/cli/commands/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..ac08f9fef2 --- /dev/null +++ b/src/cli/host/host-server.test.ts @@ -0,0 +1,123 @@ +import { test, type TestContext } from 'vitest'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { createHostServer, type HostTlsMaterial } from './host-server.ts'; +import { prepareHostServiceCredential } from './service-credential.ts'; + +const UPSTREAM_TOKEN = 'daemon-token-never-leaves-host'; + +type UpstreamCall = { url: string; authorization: string | undefined; body: string }; + +async function startUpstreamDaemon(t: TestContext) { + const calls: UpstreamCall[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + calls.push({ url: req.url ?? '', authorization: req.headers.authorization, body }); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true, data: {} } })); + }); + }); + const port = await listenOnLoopback(server); + t.onTestFinished(() => closeLoopbackServer(server)); + return { calls, upstreamBaseUrl: `http://127.0.0.1:${port}` }; +} + +async function startHost( + t: TestContext, + options: { upstreamBaseUrl: string; hostDir: string; tls?: HostTlsMaterial }, +) { + const prepared = prepareHostServiceCredential(options.hostDir); + prepared.publish(); + const { credential } = prepared; + const server = createHostServer({ + upstreamBaseUrl: options.upstreamBaseUrl, + upstreamToken: UPSTREAM_TOKEN, + credential, + tls: options.tls, + }); + const port = await listenOnLoopback(server); + t.onTestFinished(() => closeLoopbackServer(server)); + return { token: credential.token, server, port, baseUrl: `http://127.0.0.1:${port}` }; +} + +test('host answers unserved routes with 404', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const upstream = await startUpstreamDaemon(t); + const host = await startHost(t, { + upstreamBaseUrl: upstream.upstreamBaseUrl, + hostDir: path.join(mkdtempForTestSync('agent-device-host-'), 'host'), + }); + const authorization = `Bearer ${host.token}`; + + for (const route of ['/agent-device/nope', '/admin/leases', '/']) { + const response = await fetch(`${host.baseUrl}${route}`, { headers: { authorization } }); + assert.equal(response.status, 404, route); + } + assert.equal(upstream.calls.length, 0); +}); + +function generateSelfSignedCertificate(dir: string): HostTlsMaterial | undefined { + const certPath = path.join(dir, 'cert.pem'); + const keyPath = path.join(dir, 'key.pem'); + try { + const subject = ['-subj', '/CN=127.0.0.1', '-keyout', keyPath, '-out', certPath]; + execFileSync( + 'openssl', + ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', ...subject], + { stdio: 'ignore' }, + ); + } catch { + return undefined; + } + return { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) }; +} + +test('host serves HTTPS when given a certificate and key', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const dir = mkdtempForTestSync('agent-device-host-tls-'); + const tls = generateSelfSignedCertificate(dir); + if (!tls) { + t.skip('openssl is not available to generate a test certificate'); + return; + } + const upstream = await startUpstreamDaemon(t); + const host = await startHost(t, { + upstreamBaseUrl: upstream.upstreamBaseUrl, + hostDir: path.join(dir, 'host'), + tls, + }); + + const status = await new Promise((resolve, reject) => { + const req = https.request( + { + host: '127.0.0.1', + port: host.port, + path: '/agent-device/rpc', + method: 'POST', + rejectUnauthorized: false, + headers: { authorization: `Bearer ${host.token}`, 'content-type': 'application/json' }, + }, + (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }, + ); + req.on('error', reject); + req.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent_device.command', params: {} })); + }); + + assert.equal(status, 200); + assert.equal(upstream.calls.length, 1); +}); diff --git a/src/cli/host/host-server.ts b/src/cli/host/host-server.ts new file mode 100644 index 0000000000..71adf97925 --- /dev/null +++ b/src/cli/host/host-server.ts @@ -0,0 +1,27 @@ +import http from 'node:http'; +import https from 'node:https'; +import { createDaemonProxy, createDaemonProxyRequestListener } from '@agent-device/proxy'; +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/local-daemon.ts b/src/cli/host/local-daemon.ts new file mode 100644 index 0000000000..c5cef68389 --- /dev/null +++ b/src/cli/host/local-daemon.ts @@ -0,0 +1,100 @@ +import type net from 'node:net'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { AppError } from '@agent-device/kernel/errors'; +import { colorize } from '../../commands/output/color.ts'; +import { + ensureDaemon, + resolveClientSettings, + type DaemonClientSettings, +} from '../../daemon-client/daemon-client-lifecycle.ts'; + +export type LocalDaemonUpstream = Readonly<{ + upstreamBaseUrl: string; + upstreamToken: string; + stateDir: string; +}>; + +/** + * The local HTTP daemon Host forwards to over loopback. An empty `daemonBaseUrl` masks + * `AGENT_DEVICE_DAEMON_BASE_URL`, so Host never chains to another remote daemon. Resolving + * starts nothing, so Host can refuse its own configuration before a daemon exists. + */ +export function resolveLocalHttpDaemonSettings(params: { + command: string; + stateDir: string | undefined; +}): DaemonClientSettings { + return resolveClientSettings({ + session: 'default', + command: params.command, + positionals: [], + flags: { + stateDir: params.stateDir, + daemonBaseUrl: '', + daemonTransport: 'http', + daemonServerMode: 'http', + }, + }); +} + +export async function ensureLocalHttpDaemon( + command: string, + settings: DaemonClientSettings, +): Promise { + const daemon = await ensureDaemon(settings); + return { + upstreamBaseUrl: resolveLocalDaemonBaseUrl(command, daemon.info.httpPort), + upstreamToken: daemon.info.token, + stateDir: settings.paths.baseDir, + }; +} + +function resolveLocalDaemonBaseUrl(command: string, httpPort: number | undefined): string { + if (!httpPort) { + throw new AppError('COMMAND_FAILED', 'Local daemon HTTP endpoint is unavailable.', { + hint: `Retry after cleaning daemon state, or run ${command} with a fresh --state-dir.`, + }); + } + return `http://127.0.0.1:${httpPort}`; +} + +/** The bind address `--host`/`--port` name; loopback and a free port by default. */ +export function resolveBindAddress(flags: Pick): { + host: string; + port: number; +} { + return { host: flags.proxyHost?.trim() || '127.0.0.1', port: flags.proxyPort ?? 0 }; +} + +export async function listenOnTcp( + server: net.Server, + bind: { host: string; port: number }, +): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(bind.port, bind.host, () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new AppError('COMMAND_FAILED', 'Host did not bind to a TCP address.'); + } + return address; +} + +export function formatHostForUrl(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} + +export function formatOutputValue( + value: string, + format: Parameters[1], + useColor: boolean, +): string { + return useColor ? colorize(value, format, { validateStream: false }) : value; +} + +export function waitForever(): Promise { + return new Promise(() => {}); +} diff --git a/src/cli/host/service-credential.test.ts b/src/cli/host/service-credential.test.ts new file mode 100644 index 0000000000..7f27dfbbf8 --- /dev/null +++ b/src/cli/host/service-credential.test.ts @@ -0,0 +1,110 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { prepareHostServiceCredential } from './service-credential.ts'; + +function hostDir(): string { + return path.join(mkdtempForTestSync('agent-device-host-credential-'), 'host'); +} + +function publishedCredential(dir: string) { + const prepared = prepareHostServiceCredential(dir); + prepared.publish(); + return prepared; +} + +function refusalReason(run: () => unknown): unknown { + try { + run(); + } catch (error) { + assert.ok(error instanceof AppError, `expected AppError, got ${String(error)}`); + return error.details?.reason; + } + assert.fail('expected the credential to be refused'); +} + +test('a new credential reaches disk only when Host publishes it', () => { + const dir = hostDir(); + const prepared = prepareHostServiceCredential(dir); + + assert.equal(prepared.created, true); + assert.equal(fs.existsSync(prepared.credentialFile), false); + prepared.publish(); + + assert.equal(prepared.credentialFile, path.join(dir, 'service-credential.json')); + assert.match(prepared.credential.token, /^[0-9a-f]{64}$/); + assert.equal(prepared.credential.principal, `host-svc-${prepared.credential.credentialId}`); + assert.equal(fs.statSync(prepared.credentialFile).mode & 0o777, 0o600); + assert.equal(fs.statSync(dir).mode & 0o777, 0o700); +}); + +test('a restart reuses the same credential instead of issuing a new token', () => { + const dir = hostDir(); + const first = publishedCredential(dir); + const afterRestart = prepareHostServiceCredential(dir); + + assert.equal(afterRestart.created, false); + assert.deepEqual(afterRestart.credential, first.credential); +}); + +test('a credential file readable by group or others refuses to start', () => { + const dir = hostDir(); + const { credentialFile } = publishedCredential(dir); + fs.chmodSync(credentialFile, 0o644); + + assert.equal( + refusalReason(() => prepareHostServiceCredential(dir)), + 'host-credential-insecure', + ); +}); + +test('a credential directory open to group or others refuses to start', () => { + const dir = hostDir(); + publishedCredential(dir); + fs.chmodSync(dir, 0o755); + + assert.equal( + refusalReason(() => prepareHostServiceCredential(dir)), + 'host-credential-insecure', + ); +}); + +test('a credential that is a link is refused with a typed reason', () => { + const dir = hostDir(); + const { credentialFile } = publishedCredential(dir); + const target = `${credentialFile}.real`; + fs.renameSync(credentialFile, target); + fs.symlinkSync(target, credentialFile); + + assert.equal( + refusalReason(() => prepareHostServiceCredential(dir)), + 'host-credential-insecure', + ); +}); + +test('a malformed credential file is refused and never regenerated', () => { + const dir = hostDir(); + const { credentialFile } = publishedCredential(dir); + fs.writeFileSync(credentialFile, '{"version":1,"token":"short"}\n', { mode: 0o600 }); + + assert.equal( + refusalReason(() => prepareHostServiceCredential(dir)), + 'host-credential-invalid', + ); + assert.equal(fs.readFileSync(credentialFile, 'utf8'), '{"version":1,"token":"short"}\n'); +}); + +test('a credential another start published first is a typed refusal, never an overwrite', () => { + const dir = hostDir(); + const late = prepareHostServiceCredential(dir); + const winner = publishedCredential(dir); + + assert.equal( + refusalReason(() => late.publish()), + 'host-credential-raced', + ); + assert.deepEqual(prepareHostServiceCredential(dir).credential, winner.credential); +}); diff --git a/src/cli/host/service-credential.ts b/src/cli/host/service-credential.ts new file mode 100644 index 0000000000..dc1cb943bd --- /dev/null +++ b/src/cli/host/service-credential.ts @@ -0,0 +1,192 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { openVerifiedFileForRead, publishDurableFileSync } from '@agent-device/host-kit/file'; +import { AppError } from '@agent-device/kernel/errors'; + +/** The one service credential Host v1 accepts (ADR 0021 §6), mapped to a server-controlled principal. */ +export type HostServiceCredential = Readonly<{ + credentialId: string; + token: string; + principal: string; + createdAt: string; +}>; + +export type HostServiceCredentialLoad = Readonly<{ + credential: HostServiceCredential; + credentialFile: string; + /** True when this start generated the credential; it reaches disk only through `publish`. */ + created: boolean; + /** + * Writes a generated credential. Host calls it once it is serving, so a start that fails + * earlier leaves no credential behind and the next start shows the token it creates. + */ + publish(): void; +}>; + +const CREDENTIAL_FILE_NAME = 'service-credential.json'; +const CREDENTIAL_FILE_VERSION = 1; +const CREDENTIAL_ID_PATTERN = /^[0-9a-f]{16}$/; +const TOKEN_PATTERN = /^[0-9a-f]{64}$/; +const NON_EMPTY_PATTERN = /\S/; +const GROUP_OR_OTHER_ACCESS = 0o077; + +function hostPrincipalForCredential(credentialId: string): string { + return `host-svc-${credentialId}`; +} + +/** + * Returns the persisted credential, or a new one to publish once Host is serving. An existing + * file is never replaced: regenerating it would silently lock out every worker holding the token. + */ +export function prepareHostServiceCredential(hostDir: string): HostServiceCredentialLoad { + const credentialFile = path.join(hostDir, CREDENTIAL_FILE_NAME); + ensurePrivateDirectory(hostDir); + const existing = readCredential(credentialFile); + if (existing) { + return { credential: existing, credentialFile, created: false, publish: () => {} }; + } + const credential = generateCredential(); + return { + credential, + credentialFile, + created: true, + publish: () => publishCredential(credentialFile, credential), + }; +} + +function publishCredential(credentialFile: string, credential: HostServiceCredential): void { + try { + publishDurableFileSync({ + destination: credentialFile, + contents: `${JSON.stringify({ version: CREDENTIAL_FILE_VERSION, ...credential }, null, 2)}\n`, + mode: 0o600, + publish: 'link-exclusive', + }); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== 'EEXIST') throw error; + throw new AppError( + 'COMMAND_FAILED', + 'Another Host start created the service credential first.', + { + reason: 'host-credential-raced', + path: credentialFile, + hint: 'Run one Host per state dir, then restart this Host to use the stored credential.', + }, + ); + } +} + +function generateCredential(): HostServiceCredential { + const credentialId = crypto.randomBytes(8).toString('hex'); + return { + credentialId, + token: crypto.randomBytes(32).toString('hex'), + principal: hostPrincipalForCredential(credentialId), + createdAt: new Date().toISOString(), + }; +} + +function ensurePrivateDirectory(hostDir: string): void { + fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(hostDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw insecureCredentialError(hostDir, `${hostDir} must be a real directory, not a link.`); + } + if (!isPrivateToCurrentUser(stat)) throw insecureCredentialError(hostDir, privateHint(hostDir)); +} + +function readCredential(credentialFile: string): HostServiceCredential | undefined { + const descriptor = openCredentialFile(credentialFile); + if (descriptor === undefined) return undefined; + try { + if (!isPrivateToCurrentUser(fs.fstatSync(descriptor))) { + throw insecureCredentialError(credentialFile, privateHint(credentialFile)); + } + return parseCredential(fs.readFileSync(descriptor, 'utf8'), credentialFile); + } finally { + fs.closeSync(descriptor); + } +} + +function openCredentialFile(credentialFile: string): number | undefined { + try { + return openVerifiedFileForRead(credentialFile); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'EACCES' || code === 'EPERM') { + throw new AppError( + 'COMMAND_FAILED', + 'Host service credential file is not readable.', + { + reason: 'host-credential-unreadable', + path: credentialFile, + hint: `Make ${credentialFile} readable by the Host user (chmod 600).`, + }, + error, + ); + } + throw insecureCredentialError( + credentialFile, + `${credentialFile} must be a regular file, not a link or directory.`, + ); + } +} + +function parseCredential(contents: string, credentialFile: string): HostServiceCredential { + const credential = readCredentialFields(parseJsonRecord(contents)); + if (!credential) throw invalidCredentialError(credentialFile); + return credential; +} + +function parseJsonRecord(contents: string): Record | undefined { + try { + const parsed: unknown = JSON.parse(contents); + return parsed && typeof parsed === 'object' ? (parsed as Record) : undefined; + } catch { + return undefined; + } +} + +function readCredentialFields( + record: Record | undefined, +): HostServiceCredential | undefined { + if (record?.version !== CREDENTIAL_FILE_VERSION) return undefined; + const credentialId = matchingString(record.credentialId, CREDENTIAL_ID_PATTERN); + const token = matchingString(record.token, TOKEN_PATTERN); + const createdAt = matchingString(record.createdAt, NON_EMPTY_PATTERN); + if (!credentialId || !token || !createdAt) return undefined; + const principal = hostPrincipalForCredential(credentialId); + return record.principal === principal ? { credentialId, token, principal, createdAt } : undefined; +} + +function matchingString(value: unknown, pattern: RegExp): string | undefined { + return typeof value === 'string' && pattern.test(value) ? value : undefined; +} + +/** Platforms without POSIX ownership (no getuid) report synthetic mode bits, so only POSIX checks them. */ +function isPrivateToCurrentUser(stat: fs.Stats): boolean { + const uid = process.getuid?.(); + if (uid === undefined) return true; + return (stat.mode & GROUP_OR_OTHER_ACCESS) === 0 && stat.uid === uid; +} + +function privateHint(target: string): string { + return `Make ${target} owned by the Host user and inaccessible to group and others (chmod 700 for the directory, 600 for the file).`; +} + +function insecureCredentialError(target: string, hint: string): AppError { + return new AppError('COMMAND_FAILED', 'Host service credential is not private to this user.', { + reason: 'host-credential-insecure', + path: target, + hint, + }); +} + +function invalidCredentialError(credentialFile: string): AppError { + return new AppError('COMMAND_FAILED', 'Host service credential file is malformed.', { + reason: 'host-credential-invalid', + path: credentialFile, + hint: `Delete ${credentialFile} to create a new credential. Workers then need the new token.`, + }); +} diff --git a/src/commands/schema/cli-help-command-usage.test.ts b/src/commands/schema/cli-help-command-usage.test.ts index 1a1fd35766..7c4a247998 100644 --- a/src/commands/schema/cli-help-command-usage.test.ts +++ b/src/commands/schema/cli-help-command-usage.test.ts @@ -181,8 +181,8 @@ test('proxy command help describes tunnel usage', async () => { if (help === null) throw new Error('Expected command help text'); assert.match(help, /Usage:\s+agent-device proxy/); assert.match(help, /cloudflared tunnel --url http:\/\/127\.0\.0\.1:4310/); - assert.match(help, /--host \s+Proxy: host interface to bind/); - assert.match(help, /--port \s+Proxy: TCP port to bind/); + assert.match(help, /--host \s+Proxy and host: interface to bind/); + assert.match(help, /--port \s+Proxy and host: TCP port to bind/); assert.match(help, /--daemon-auth-token \s+Remote HTTP daemon or proxy auth token/); assert.match(help, /--state-dir \s+Daemon state directory/); assert.match(help, /\/agent-device\/\*/); diff --git a/src/commands/schema/cli-help-host.ts b/src/commands/schema/cli-help-host.ts new file mode 100644 index 0000000000..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 2364272b00..085b0378dd 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -447,6 +447,18 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /install-from-source --github-actions-artifact org\/repo:artifact/); }); +test('usageForCommand resolves host help topic', async () => { + const help = await usageForCommand('host'); + if (help === null) throw new Error('Expected host help text'); + assert.match(help, /^agent-device \S+ — host/); + assert.match(help, /host\/service-credential\.json \(mode 0600, directory 0700\)/); + assert.match(help, /--tls-cert --tls-key /); + assert.match( + help, + /GET \/health is public\. Every other route needs the service token \(401 without it\);\s+unserved routes get 404\./, + ); +}); + test('usageForCommand resolves physical-device help topic', async () => { const help = await usageForCommand('physical-device'); if (help === null) throw new Error('Expected physical-device help text'); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 0dc5d845df..ee18ef11d8 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -24,6 +24,7 @@ import { qaReportHelpTopics, WAIT_FAILURE_CONTRACT, } from './cli-help-workflows.ts'; +import { hostHelpTopics } from './cli-help-host.ts'; import { renderCliHelpOverview } from './cli-help-overview.ts'; import { foldableHelpTopic } from '../system/index.ts'; @@ -694,6 +695,7 @@ Rules: For remote Android and iOS bridge React DevTools, run agent-device react-devtools normally. The CLI opens the needed local service tunnel for the DevTools daemon and keeps it alive until agent-device react-devtools stop or disconnect. Use --debug when remote connection or transport errors need diagnostic ids and remote log hints.`, }, + ...hostHelpTopics, macos: { summary: 'macOS desktop, frontmost-app, and menu bar surfaces', body: `agent-device help macos diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 25e68ffbd1..603d754d66 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -144,6 +144,15 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'Start the official stdio MCP server. It exposes structured command tools backed by the agent-device client.', }, }, + host: { + text: { + summary: 'Serve the local daemon to remote verification workers', + description: + 'Run the Host front-end: start or reuse the local HTTP daemon and serve it to remote workers, authenticated by one persistent service credential stored under the state dir. See help host.', + }, + listUsageOverride: 'host', + allowedFlags: ['proxyHost', 'proxyPort', 'hostTlsCert', 'hostTlsKey', 'stateDir'], + }, proxy: { text: { summary: 'Expose a local daemon through an HTTP tunnel', diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 9deea25dae..e5492b5b17 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, 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. +- 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