diff --git a/CONTEXT.md b/CONTEXT.md index e04c377cb..2d6e19f29 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,7 @@ - Scenario transcript: command-level integration flow that describes user-visible behavior through daemon commands. - In-process provider scenario harness: integration runner that invokes the daemon request handler directly without opening an HTTP listener. - HTTP contract test: narrow test that verifies JSON-RPC transport, auth, and response finalization over the daemon HTTP boundary. +- Daemon RPC protocol version: integer advertised by daemon/proxy `/health` and checked by remote clients before HTTP JSON-RPC; bump only for breaking transport/request/response compatibility across the remote daemon boundary. - Interactor: semantic interface between command dispatch and platform behavior. - Platform module: platform-specific implementation behind the Interactor. - Target: selected automation destination, such as mobile, tv, or desktop. diff --git a/docs/adr/0006-daemon-rpc-protocol-version.md b/docs/adr/0006-daemon-rpc-protocol-version.md new file mode 100644 index 000000000..dc5ea9d52 --- /dev/null +++ b/docs/adr/0006-daemon-rpc-protocol-version.md @@ -0,0 +1,66 @@ +# ADR 0006: Daemon RPC Protocol Version + +## Status + +Accepted + +## Context + +`agent-device` can run a client on one machine and reach a daemon on another machine through the +HTTP JSON-RPC transport, including `agent-device proxy` in front of a local macOS daemon. Client and +remote host package versions can differ. Many version skews are safe because the client sends a +stable command request envelope and the daemon executes only the requested command. + +Package semver is too strict for this boundary: a 0.15 client and 0.17 remote daemon can often +interoperate. At the same time, the client needs a cheap way to fail before sending command RPC when +the transport protocol itself is no longer compatible. + +## Decision + +The daemon HTTP `/health` payload advertises a `rpcProtocolVersion` integer alongside service and +package version metadata. Remote clients compare this value before sending JSON-RPC requests. + +Package `version` is diagnostic only and must not be used as a compatibility gate. Missing +`rpcProtocolVersion` is treated as a legacy remote daemon and is allowed unless a later security or +protocol decision explicitly retires legacy compatibility. + +`rpcProtocolVersion` changes only when an older client and newer daemon, or newer client and older +daemon, cannot safely communicate over the HTTP RPC boundary for existing commands. Bump it for +breaking changes to: + +- HTTP route requirements for `/health`, `/rpc`, `/upload`, or `/artifacts/*`. +- Authentication semantics required to authorize RPC, upload, or artifact requests. +- JSON-RPC envelope shape, method naming, request id handling, or command request projection. +- Response, error, artifact, upload, or progress-stream framing that existing clients parse. +- Existing command request or response contracts when the old side would misinterpret the payload + rather than fail clearly. + +Do not bump it for additive changes: + +- New commands. +- New optional request fields or flags that older daemons can ignore or reject with a normal + command error. +- New optional response fields that older clients can ignore. +- Package version changes, refactors, or implementation-only daemon/proxy changes. + +When a single command needs finer-grained compatibility in the future, prefer command-level feature +or capability metadata over bumping the whole RPC protocol, unless the shared transport contract is +also affected. + +## Alternatives Considered + +- Gate by package version: simple, but rejects compatible version skew and makes proxy usage brittle + across frequent releases. +- Do not check compatibility: maximizes compatibility but fails later, after the client has sent an + RPC payload that a remote daemon may parse incorrectly. +- Full schema negotiation: more precise, but too much machinery for the current JSON-RPC boundary. + +## Consequences + +Protocol-breaking changes must update `DAEMON_RPC_PROTOCOL_VERSION`, tests that assert `/health` +metadata, and at least one remote-client regression test that proves mismatched protocols fail before +command RPC. + +Legacy remote daemons without `rpcProtocolVersion` remain reachable. This keeps the first release of +the proxy compatible with older HTTP daemons, but it means absence of the marker is not proof of +compatibility. diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 1d29b5372..2c60d5332 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -271,6 +271,8 @@ function summarizeProviderScenarioFlagExclusions() { 'shardSplit', 'searchPath', 'stepsFile', + 'proxyHost', + 'proxyPort', ], }, { diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index fa6bc8632..de931f890 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -179,6 +179,39 @@ test('interaction commands preserve remote config defaults', async () => { fs.rmSync(root, { recursive: true, force: true }); }); +test('normal config can point commands at a direct remote daemon proxy', async () => { + const { root, home, project } = makeTempWorkspace(); + fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); + fs.writeFileSync( + path.join(project, 'agent-device.json'), + JSON.stringify({ + daemonBaseUrl: 'https://example.trycloudflare.com/agent-device', + daemonAuthToken: 'proxy-token', + }), + 'utf8', + ); + + const result = await runCliCapture(['devices', '--json'], { + cwd: project, + env: { HOME: home }, + }); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'devices'); + assert.equal( + result.calls[0]?.flags?.daemonBaseUrl, + 'https://example.trycloudflare.com/agent-device', + ); + assert.equal(result.calls[0]?.flags?.daemonAuthToken, 'proxy-token'); + assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'platform'), false); + assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'remoteConfig'), false); + assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'tenant'), false); + assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'runId'), false); + + fs.rmSync(root, { recursive: true, force: true }); +}); + test('explicit --config path overrides default config discovery', async () => { const { root, home, project } = makeTempWorkspace(); fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); diff --git a/src/__tests__/daemon-proxy.test.ts b/src/__tests__/daemon-proxy.test.ts new file mode 100644 index 000000000..85bd88df4 --- /dev/null +++ b/src/__tests__/daemon-proxy.test.ts @@ -0,0 +1,240 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { createDaemonProxyServer } from '../daemon-proxy.ts'; +import { DAEMON_RPC_PROTOCOL_VERSION } from '../daemon/http-health.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from './test-utils/index.ts'; + +test('daemon proxy forwards rpc requests with upstream daemon token', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + let upstreamAuth = ''; + let upstreamTokenHeader = ''; + let upstreamBody: Record | undefined; + const upstream = http.createServer((req, res) => { + if (req.url === '/health') { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + return; + } + assert.equal(req.url, '/rpc'); + upstreamAuth = String(req.headers.authorization ?? ''); + upstreamTokenHeader = String(req.headers['x-agent-device-token'] ?? ''); + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + upstreamBody = JSON.parse(body) as Record; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: upstreamBody.id, + result: { ok: true, data: { via: 'proxy' } }, + }), + ); + }); + }); + + const proxy = createDaemonProxyServer({ + upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`, + upstreamToken: 'daemon-secret', + clientToken: 'proxy-secret', + }); + + try { + const proxyPort = await listenOnLoopback(proxy); + const response = await fetch(`http://127.0.0.1:${proxyPort}/agent-device/rpc`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer proxy-secret', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'req-1', + method: 'agent_device.command', + params: { + token: 'proxy-secret', + session: 'default', + command: 'devices', + positionals: [], + flags: {}, + }, + }), + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + jsonrpc: '2.0', + id: 'req-1', + result: { ok: true, data: { via: 'proxy' } }, + }); + assert.equal(upstreamAuth, 'Bearer daemon-secret'); + assert.equal(upstreamTokenHeader, 'daemon-secret'); + assert.equal(upstreamBody?.params?.token, 'daemon-secret'); + assert.equal(upstreamBody?.params?.command, 'devices'); + } finally { + await closeLoopbackServer(proxy); + await closeLoopbackServer(upstream); + } +}); + +test('daemon proxy rejects unauthenticated rpc requests', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + let upstreamCalled = false; + const upstream = http.createServer((_req, res) => { + upstreamCalled = true; + res.end('{}'); + }); + const proxy = createDaemonProxyServer({ + upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`, + upstreamToken: 'daemon-secret', + clientToken: 'proxy-secret', + }); + + try { + const proxyPort = await listenOnLoopback(proxy); + const response = await fetch(`http://127.0.0.1:${proxyPort}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'req-unauthorized', + method: 'agent_device.command', + params: { command: 'devices' }, + }), + }); + + assert.equal(response.status, 401); + const payload = (await response.json()) as { error?: { message?: string } }; + assert.equal(payload.error?.message, 'Invalid proxy token'); + assert.equal(upstreamCalled, false); + } finally { + await closeLoopbackServer(proxy); + await closeLoopbackServer(upstream); + } +}); + +test('daemon proxy leaves health endpoint unauthenticated', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + let upstreamAuth = ''; + let upstreamTokenHeader = ''; + const upstream = http.createServer((req, res) => { + assert.equal(req.url, '/health'); + upstreamAuth = String(req.headers.authorization ?? ''); + upstreamTokenHeader = String(req.headers['x-agent-device-token'] ?? ''); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + const proxy = createDaemonProxyServer({ + upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`, + upstreamToken: 'daemon-secret', + clientToken: 'proxy-secret', + }); + + try { + const proxyPort = await listenOnLoopback(proxy); + const response = await fetch(`http://127.0.0.1:${proxyPort}/agent-device/health`); + assert.equal(response.status, 200); + const payload = (await response.json()) as Record; + assert.equal(payload.ok, true); + assert.equal(payload.service, 'agent-device-proxy'); + assert.equal(typeof payload.version, 'string'); + assert.equal(payload.rpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION); + assert.deepEqual(payload.upstream, { ok: true }); + assert.equal(upstreamAuth, 'Bearer daemon-secret'); + assert.equal(upstreamTokenHeader, 'daemon-secret'); + } finally { + await closeLoopbackServer(proxy); + await closeLoopbackServer(upstream); + } +}); + +test('daemon proxy streams uploads and artifact downloads with upstream daemon token', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + + let uploadAuth = ''; + let uploadTokenHeader = ''; + let uploadArtifactType = ''; + let uploadArtifactFilename = ''; + let uploadBody = ''; + let artifactAuth = ''; + let artifactTokenHeader = ''; + const upstream = http.createServer((req, res) => { + if (req.method === 'POST' && req.url === '/upload') { + uploadAuth = String(req.headers.authorization ?? ''); + uploadTokenHeader = String(req.headers['x-agent-device-token'] ?? ''); + uploadArtifactType = String(req.headers['x-artifact-type'] ?? ''); + uploadArtifactFilename = String(req.headers['x-artifact-filename'] ?? ''); + req.setEncoding('utf8'); + req.on('data', (chunk) => { + uploadBody += chunk; + }); + req.on('end', () => { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true, uploadId: 'upload-1' })); + }); + return; + } + + assert.equal(req.method, 'GET'); + assert.equal(req.url, '/artifacts/shot-1?download=1'); + artifactAuth = String(req.headers.authorization ?? ''); + artifactTokenHeader = String(req.headers['x-agent-device-token'] ?? ''); + res.setHeader('content-type', 'image/png'); + res.setHeader('content-disposition', 'attachment; filename="shot.png"'); + res.setHeader('x-request-id', 'upstream-request-1'); + res.write('png-'); + res.end('body'); + }); + const proxy = createDaemonProxyServer({ + upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`, + upstreamToken: 'daemon-secret', + clientToken: 'proxy-secret', + }); + + try { + const proxyPort = await listenOnLoopback(proxy); + const upload = await fetch(`http://127.0.0.1:${proxyPort}/agent-device/upload`, { + method: 'POST', + headers: { + authorization: 'Bearer proxy-secret', + 'x-artifact-type': 'file', + 'x-artifact-filename': 'demo.apk', + 'content-type': 'application/octet-stream', + }, + body: Buffer.from('fake-apk'), + }); + assert.equal(upload.status, 200); + assert.deepEqual(await upload.json(), { ok: true, uploadId: 'upload-1' }); + assert.equal(uploadAuth, 'Bearer daemon-secret'); + assert.equal(uploadTokenHeader, 'daemon-secret'); + assert.equal(uploadArtifactType, 'file'); + assert.equal(uploadArtifactFilename, 'demo.apk'); + assert.equal(uploadBody, 'fake-apk'); + + const artifact = await fetch( + `http://127.0.0.1:${proxyPort}/agent-device/artifacts/shot-1?download=1`, + { headers: { authorization: 'Bearer proxy-secret' } }, + ); + assert.equal(artifact.status, 200); + assert.equal(await artifact.text(), 'png-body'); + assert.equal(artifact.headers.get('content-type'), 'image/png'); + assert.match(artifact.headers.get('content-disposition') ?? '', /shot\.png/); + assert.equal(artifact.headers.get('x-request-id'), 'upstream-request-1'); + assert.equal(artifactAuth, 'Bearer daemon-secret'); + assert.equal(artifactTokenHeader, 'daemon-secret'); + } finally { + await closeLoopbackServer(proxy); + await closeLoopbackServer(upstream); + } +}); diff --git a/src/cli.ts b/src/cli.ts index 8c58cd591..1b9657e43 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -64,6 +64,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([ 'close', 'disconnect', 'metro', + 'proxy', 'session', ]); @@ -447,7 +448,13 @@ function resolveActiveConnectionDefaults(options: { flags: Partial; runtime?: SessionRuntimeHints; } | null { - if (options.command === 'connect' || options.command === 'connection') return null; + if ( + options.command === 'connect' || + options.command === 'connection' || + options.command === 'proxy' + ) { + return null; + } const defaults = resolveRemoteConnectionDefaults({ stateDir: options.stateDir, session: options.session, @@ -467,7 +474,7 @@ function shouldMaterializeRemoteConnection(command: string): boolean { } function shouldResolveRemoteAuth(command: string): boolean { - return command !== 'auth' && command !== 'connection'; + return command !== 'auth' && command !== 'connection' && command !== 'proxy'; } function shouldWarnOpenMayMissRemoteRuntime(options: { diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts new file mode 100644 index 000000000..b6e79bc58 --- /dev/null +++ b/src/cli/commands/proxy.ts @@ -0,0 +1,107 @@ +import { randomBytes } from 'node:crypto'; +import { createDaemonProxyServer } from '../../daemon-proxy.ts'; +import { ensureDaemon, resolveClientSettings } from '../../daemon-client-lifecycle.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { writeCommandOutput } from './shared.ts'; +import type { ClientCommandHandler } from './router-types.ts'; + +type ProxyStartup = { + proxyBaseUrl: string; + agentDeviceBaseUrl: string; + token: string; + upstreamBaseUrl: string; + stateDir: string; +}; + +export const proxyCommand: ClientCommandHandler = async ({ positionals, flags }) => { + if (positionals.length > 0) { + throw new AppError('INVALID_ARGS', 'proxy does not accept positional arguments.'); + } + const startup = await startProxy(flags); + writeCommandOutput(flags, startup, () => renderProxyStartup(startup)); + await waitForever(); + return true; +}; + +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 token = resolveProxyClientToken(flags); + const server = createDaemonProxyServer({ + upstreamBaseUrl, + upstreamToken: daemon.info.token, + 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 proxyBaseUrl = `http://${formatHostForUrl(address.address)}:${address.port}`; + return { + proxyBaseUrl, + agentDeviceBaseUrl: `${proxyBaseUrl}/agent-device`, + token, + upstreamBaseUrl, + 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; +} + +function renderProxyStartup(startup: ProxyStartup): string { + return [ + `agent-device proxy listening on ${startup.proxyBaseUrl}`, + `daemon base URL: ${startup.agentDeviceBaseUrl}`, + `daemon auth token: ${startup.token}`, + 'treat the daemon auth token as a secret; anyone with it can control the proxied daemon', + `upstream local daemon: ${startup.upstreamBaseUrl}`, + `state dir: ${startup.stateDir}`, + '', + 'Remote client example:', + `agent-device devices --daemon-base-url ${startup.agentDeviceBaseUrl} --daemon-auth-token ${startup.token}`, + ].join('\n'); +} + +function waitForever(): Promise { + return new Promise(() => {}); +} diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 1e2104e8a..7cdc1e1ce 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -6,6 +6,7 @@ import { } from '../../command-catalog.ts'; import { connectCommand, connectionCommand, disconnectCommand } from './connection.ts'; import { authCommand } from './auth.ts'; +import { proxyCommand } from './proxy.ts'; import { replayCommand } from './replay.ts'; import { screenshotCommand, diffCommand } from './screenshot.ts'; import type { ClientCommandHandlerMap, ClientCommandParams } from './router-types.ts'; @@ -21,6 +22,7 @@ const dedicatedCliCommandHandlers = { disconnect: disconnectCommand, connection: connectionCommand, auth: authCommand, + proxy: proxyCommand, replay: replayCommand, screenshot: screenshotCommand, diff: diffCommand, diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 3d11ecdc2..45957990f 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -65,6 +65,7 @@ const LOCAL_CLI_COMMANDS = { disconnect: 'disconnect', mcp: 'mcp', metro: 'metro', + proxy: 'proxy', reactDevtools: 'react-devtools', session: 'session', web: 'web', @@ -89,6 +90,7 @@ const MCP_UNEXPOSED_CLI_COMMANDS = commandSet( LOCAL_CLI_COMMANDS.connection, LOCAL_CLI_COMMANDS.disconnect, LOCAL_CLI_COMMANDS.mcp, + LOCAL_CLI_COMMANDS.proxy, LOCAL_CLI_COMMANDS.reactDevtools, LOCAL_CLI_COMMANDS.web, PUBLIC_COMMANDS.prepare, @@ -102,6 +104,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( LOCAL_CLI_COMMANDS.disconnect, LOCAL_CLI_COMMANDS.mcp, LOCAL_CLI_COMMANDS.metro, + LOCAL_CLI_COMMANDS.proxy, LOCAL_CLI_COMMANDS.reactDevtools, LOCAL_CLI_COMMANDS.session, LOCAL_CLI_COMMANDS.web, diff --git a/src/daemon-client-lifecycle.ts b/src/daemon-client-lifecycle.ts index 5f0cd5e38..893867dc6 100644 --- a/src/daemon-client-lifecycle.ts +++ b/src/daemon-client-lifecycle.ts @@ -32,7 +32,7 @@ import { type DaemonInfo, type DaemonStartupCleanupResult, } from './daemon-client-metadata.ts'; -import { canConnect } from './daemon-client-transport.ts'; +import { canConnect, readRemoteDaemonHealth } from './daemon-client-transport.ts'; export type DaemonClientSettings = { paths: DaemonPaths; @@ -153,7 +153,7 @@ async function ensureRemoteDaemon(settings: DaemonClientSettings): Promise { } function canConnectHttp(info: DaemonInfo): Promise { + return readDaemonHttpHealth(info).then((health) => health.reachable); +} + +export async function readRemoteDaemonHealth(info: DaemonInfo): Promise { + const health = await readDaemonHttpHealth(info); + if (!info.baseUrl || !health.reachable) return health; + if ( + typeof health.rpcProtocolVersion === 'number' && + health.rpcProtocolVersion !== DAEMON_RPC_PROTOCOL_VERSION + ) { + throw new AppError('COMMAND_FAILED', 'Remote daemon RPC protocol is incompatible', { + daemonBaseUrl: info.baseUrl, + clientVersion: readVersion(), + remoteVersion: health.version, + remoteService: health.service, + supportedRpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION, + remoteRpcProtocolVersion: health.rpcProtocolVersion, + hint: 'Upgrade agent-device on the client or remote host so both support the same daemon RPC protocol.', + }); + } + return health; +} + +function readDaemonHttpHealth(info: DaemonInfo): Promise { const endpoint = info.baseUrl ? buildDaemonHttpUrl(info.baseUrl, 'health') : info.httpPort ? `http://127.0.0.1:${info.httpPort}/health` : null; - if (!endpoint) return Promise.resolve(false); + if (!endpoint) return Promise.resolve({ reachable: false }); const url = new URL(endpoint); const transport = url.protocol === 'https:' ? https : http; const timeoutMs = info.baseUrl ? REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS : LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS; return new Promise((resolve) => { + const headers: Record = {}; + if (info.baseUrl && info.token) { + headers.authorization = `Bearer ${info.token}`; + headers['x-agent-device-token'] = info.token; + } const req = transport.request( { protocol: url.protocol, @@ -82,23 +121,53 @@ function canConnectHttp(info: DaemonInfo): Promise { path: url.pathname + url.search, method: 'GET', timeout: timeoutMs, + headers, }, (res) => { - res.resume(); - resolve((res.statusCode ?? 500) < 500); + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + const statusCode = res.statusCode ?? 500; + resolve({ + reachable: statusCode < 500, + statusCode, + ...readHealthPayload(body), + }); + }); }, ); req.on('timeout', () => { req.destroy(); - resolve(false); + resolve({ reachable: false }); }); req.on('error', () => { - resolve(false); + resolve({ reachable: false }); }); req.end(); }); } +function readHealthPayload(body: string): Omit { + try { + const parsed = JSON.parse(body) as { + service?: unknown; + version?: unknown; + rpcProtocolVersion?: unknown; + }; + return { + service: typeof parsed.service === 'string' ? parsed.service : undefined, + version: typeof parsed.version === 'string' ? parsed.version : undefined, + rpcProtocolVersion: + typeof parsed.rpcProtocolVersion === 'number' ? parsed.rpcProtocolVersion : undefined, + }; + } catch { + return {}; + } +} + export async function sendRequest( info: DaemonInfo, req: DaemonRequest, diff --git a/src/daemon-proxy.ts b/src/daemon-proxy.ts new file mode 100644 index 000000000..9cefd172f --- /dev/null +++ b/src/daemon-proxy.ts @@ -0,0 +1,308 @@ +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { randomUUID } from 'node:crypto'; +import { AppError, normalizeError } from './utils/errors.ts'; +import { timingSafeStringEqual } from './utils/timing-safe-equal.ts'; +import { buildDaemonHealthPayload } from './daemon/http-health.ts'; + +export type DaemonProxyOptions = { + upstreamBaseUrl: string; + upstreamToken: string; + clientToken: string; + maxRpcBodyBytes?: number; + upstreamTimeoutMs?: number; + fetchImpl?: typeof fetch; +}; + +const DEFAULT_MAX_RPC_BODY_BYTES = 1024 * 1024; +const DEFAULT_UPSTREAM_TIMEOUT_MS = 5 * 60 * 1000; +const DAEMON_PROXY_PREFIX = '/agent-device/'; +const FORWARDED_REQUEST_HEADERS = ['content-type', 'x-artifact-type', 'x-artifact-filename']; +const FORWARDED_RESPONSE_HEADERS = ['content-type', 'content-disposition', 'x-request-id']; + +export function createDaemonProxyServer(options: DaemonProxyOptions): http.Server { + const normalized = normalizeProxyOptions(options); + return http.createServer((req, res) => { + void handleProxyRequest(req, res, normalized).catch((error: unknown) => { + sendProxyError(res, error); + }); + }); +} + +async function handleProxyRequest( + req: IncomingMessage, + res: ServerResponse, + options: Required, +): Promise { + const route = resolveProxyRoute(req.url ?? '/'); + if (req.method === 'GET' && route === '/health') { + await sendProxyHealth(res, options); + return; + } + + if (!isSupportedDaemonRoute(route, req.method)) { + res.statusCode = 404; + res.end('Not found'); + return; + } + + let rpcBody: string | undefined; + if (route === '/rpc') { + rpcBody = (await readBodyBuffer(req, options.maxRpcBodyBytes)).toString('utf8'); + } + + if (!isAuthorized(req, options.clientToken, rpcBody)) { + sendUnauthorized(res, route, readJsonRpcId(rpcBody)); + return; + } + + await forwardProxyRequest({ req, res, route, options, rpcBody }); +} + +async function sendProxyHealth(res: ServerResponse, options: Required) { + const upstream = await readUpstreamHealth(options); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(buildDaemonHealthPayload('agent-device-proxy', { upstream }))); +} + +async function readUpstreamHealth(options: Required): Promise { + const upstreamUrl = new URL('health', `${options.upstreamBaseUrl}/`); + const response = await options.fetchImpl(upstreamUrl, { + method: 'GET', + headers: buildUpstreamHeaders({ headers: {} }, options.upstreamToken, '/health'), + signal: AbortSignal.timeout(options.upstreamTimeoutMs), + }); + const text = await response.text(); + try { + return text ? JSON.parse(text) : { ok: response.ok, status: response.status }; + } catch { + return { ok: response.ok, status: response.status }; + } +} + +async function forwardProxyRequest(params: { + req: IncomingMessage; + res: ServerResponse; + route: string; + options: Required; + rpcBody?: string; +}): Promise { + const { req, res, route, options, rpcBody } = params; + const upstreamUrl = buildUpstreamUrl(options.upstreamBaseUrl, route, req.url ?? '/'); + const method = req.method ?? 'GET'; + const headers = buildUpstreamHeaders(req, options.upstreamToken, route); + const body = resolveUpstreamBody(req, route, rpcBody, options.upstreamToken); + const response = await options.fetchImpl(upstreamUrl, { + method, + headers, + signal: AbortSignal.timeout(options.upstreamTimeoutMs), + ...(body ? { body, duplex: 'half' as const } : {}), + }); + + res.statusCode = response.status; + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = response.headers.get(name); + if (value) res.setHeader(name, value); + } + if (!res.hasHeader('x-request-id')) { + res.setHeader('x-request-id', resolveRequestId(req)); + } + + if (!response.body) { + res.end(); + return; + } + await pipeline(Readable.fromWeb(response.body as Parameters[0]), res); +} + +function normalizeProxyOptions(options: DaemonProxyOptions): Required { + const upstreamBaseUrl = normalizeBaseUrl(options.upstreamBaseUrl, 'upstreamBaseUrl'); + const upstreamToken = normalizeToken(options.upstreamToken, 'upstreamToken'); + const clientToken = normalizeToken(options.clientToken, 'clientToken'); + return { + upstreamBaseUrl, + upstreamToken, + clientToken, + maxRpcBodyBytes: options.maxRpcBodyBytes ?? DEFAULT_MAX_RPC_BODY_BYTES, + upstreamTimeoutMs: options.upstreamTimeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS, + fetchImpl: options.fetchImpl ?? fetch, + }; +} + +function normalizeBaseUrl(value: string, label: string): string { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('unsupported protocol'); + } + return parsed.toString().replace(/\/+$/, ''); + } catch (error) { + throw new AppError('INVALID_ARGS', `Invalid ${label}`, { [label]: value }, error); + } +} + +function normalizeToken(value: string, label: string): string { + const token = value.trim(); + if (!token) { + throw new AppError('INVALID_ARGS', `Proxy ${label} is required.`); + } + return token; +} + +function resolveProxyRoute(requestUrl: string): string { + const pathname = new URL(requestUrl, 'http://127.0.0.1').pathname; + if (pathname === '/agent-device') return '/'; + if (pathname.startsWith(DAEMON_PROXY_PREFIX)) { + return `/${pathname.slice(DAEMON_PROXY_PREFIX.length)}`; + } + return pathname; +} + +function isSupportedDaemonRoute(route: string, method: string | undefined): boolean { + if (route === '/rpc') return method === 'POST'; + if (route === '/upload') return method === 'POST'; + if (route.startsWith('/artifacts/')) return method === 'GET'; + return false; +} + +function buildUpstreamUrl(upstreamBaseUrl: string, route: string, rawUrl: string): URL { + const upstreamUrl = new URL(route.replace(/^\//, ''), `${upstreamBaseUrl}/`); + const rawSearchIndex = rawUrl.indexOf('?'); + if (rawSearchIndex >= 0) upstreamUrl.search = rawUrl.slice(rawSearchIndex); + return upstreamUrl; +} + +function buildUpstreamHeaders( + req: Pick, + upstreamToken: string, + route: string, +): Headers { + const headers = new Headers(); + for (const name of FORWARDED_REQUEST_HEADERS) { + const value = req.headers[name]; + if (typeof value === 'string' && value.trim()) headers.set(name, value); + } + if (route === '/rpc' && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + headers.set('authorization', `Bearer ${upstreamToken}`); + headers.set('x-agent-device-token', upstreamToken); + return headers; +} + +function resolveUpstreamBody( + req: IncomingMessage, + route: string, + rpcBody: string | undefined, + upstreamToken: string, +): BodyInit | undefined { + if (req.method === 'GET' || req.method === 'HEAD') return undefined; + if (route === '/rpc') return rewriteRpcToken(rpcBody ?? '', upstreamToken); + return req as unknown as BodyInit; +} + +function isAuthorized(req: IncomingMessage, expectedToken: string, rpcBody: string | undefined) { + const requestToken = resolveRequestToken(req, rpcBody); + return requestToken.length > 0 && timingSafeStringEqual(requestToken, expectedToken); +} + +function resolveRequestToken(req: IncomingMessage, rpcBody: string | undefined): string { + const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : ''; + if (authHeader.toLowerCase().startsWith('bearer ')) { + return authHeader.slice('bearer '.length); + } + const tokenHeader = req.headers['x-agent-device-token']; + if (typeof tokenHeader === 'string') return tokenHeader; + if (rpcBody) { + const bodyToken = readJsonRpcToken(rpcBody); + if (bodyToken) return bodyToken; + } + return ''; +} + +function rewriteRpcToken(body: string, upstreamToken: string): string { + const parsed = JSON.parse(body) as { params?: Record }; + parsed.params = { + ...(parsed.params ?? {}), + token: upstreamToken, + }; + return JSON.stringify(parsed); +} + +function readJsonRpcToken(body: string): string { + try { + const parsed = JSON.parse(body) as { params?: { token?: unknown } }; + return typeof parsed.params?.token === 'string' ? parsed.params.token : ''; + } catch { + return ''; + } +} + +function readJsonRpcId(body: string | undefined): unknown { + if (!body) return null; + try { + const parsed = JSON.parse(body) as { id?: unknown }; + return parsed.id ?? null; + } catch { + return null; + } +} + +function resolveRequestId(req: IncomingMessage): string { + const header = req.headers['x-request-id']; + if (typeof header === 'string' && header.trim()) return header.trim().slice(0, 128); + return randomUUID(); +} + +async function readBodyBuffer(req: IncomingMessage, maxBodyBytes: number): Promise { + const chunks: Buffer[] = []; + let bodyBytes = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bodyBytes += buffer.length; + if (bodyBytes > maxBodyBytes) { + throw new AppError('INVALID_ARGS', 'Proxy request body is too large.'); + } + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + +function sendUnauthorized(res: ServerResponse, route: string, rpcId: unknown): void { + res.statusCode = 401; + res.setHeader('content-type', 'application/json'); + if (route === '/rpc') { + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: rpcId, + error: { + code: -32001, + message: 'Invalid proxy token', + data: normalizeError(new AppError('UNAUTHORIZED', 'Invalid proxy token')), + }, + }), + ); + return; + } + res.end( + JSON.stringify({ + ok: false, + error: 'Invalid proxy token', + code: 'UNAUTHORIZED', + }), + ); +} + +function sendProxyError(res: ServerResponse, error: unknown): void { + if (res.headersSent) { + res.destroy(error instanceof Error ? error : undefined); + return; + } + const normalized = normalizeError(error); + res.statusCode = normalized.code === 'INVALID_ARGS' ? 400 : 500; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: false, error: normalized.message, code: normalized.code })); +} diff --git a/src/daemon/http-health.ts b/src/daemon/http-health.ts new file mode 100644 index 000000000..5250ccb69 --- /dev/null +++ b/src/daemon/http-health.ts @@ -0,0 +1,25 @@ +import { readVersion } from '../utils/version.ts'; + +// See docs/adr/0006-daemon-rpc-protocol-version.md before changing this value. +export const DAEMON_RPC_PROTOCOL_VERSION = 1; + +export type DaemonHealthPayload = { + ok: true; + service: 'agent-device-daemon' | 'agent-device-proxy'; + version: string; + rpcProtocolVersion: number; + upstream?: unknown; +}; + +export function buildDaemonHealthPayload( + service: DaemonHealthPayload['service'], + options: { upstream?: unknown } = {}, +): DaemonHealthPayload { + return { + ok: true, + service, + version: readVersion(), + rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION, + ...(options.upstream !== undefined ? { upstream: options.upstream } : {}), + }; +} diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index 1d65175a7..6f4600a36 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -28,6 +28,7 @@ import { serializeDaemonRpcResponseEnvelope, shouldStreamRequestProgress, } from './request-progress-protocol.ts'; +import { buildDaemonHealthPayload } from './http-health.ts'; type JsonRpcRequest = JsonRpcRequestEnvelope; @@ -498,7 +499,7 @@ export async function createDaemonHttpServer(options: { if (req.method === 'GET' && req.url === '/health') { res.statusCode = 200; res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ ok: true })); + res.end(JSON.stringify(buildDaemonHealthPayload('agent-device-daemon'))); return; } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index ce1a6da80..7598b9a39 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -629,6 +629,28 @@ test('parseArgs accepts auth management subcommands', () => { assert.equal(login.flags.remoteConfig, './remote.json'); }); +test('parseArgs accepts proxy command flags', () => { + const parsed = parseArgs( + [ + 'proxy', + '--state-dir', + './tmp/ad-state', + '--host', + '0.0.0.0', + '--port', + '4310', + '--daemon-auth-token', + 'proxy-secret', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'proxy'); + assert.equal(parsed.flags.stateDir, './tmp/ad-state'); + assert.equal(parsed.flags.proxyHost, '0.0.0.0'); + assert.equal(parsed.flags.proxyPort, 4310); + assert.equal(parsed.flags.daemonAuthToken, 'proxy-secret'); +}); + test('parseArgs recognizes explicit config file flag', () => { const parsed = parseArgs(['open', 'settings', '--config', './agent-device.json'], { strictFlags: true, @@ -1402,7 +1424,17 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); assert.match(help, /Script flow, per-command config/); + assert.match(help, /Direct proxy flow for a remote Mac/); + assert.match(help, /agent-device proxy --port 4310/); + assert.match( + help, + /--daemon-base-url https:\/\/example\.trycloudflare\.com\/agent-device --daemon-auth-token /, + ); + assert.match(help, /store daemonBaseUrl and daemonAuthToken in normal agent-device\.json/); + assert.match(help, /Keep platform selection on each command or workflow/); assert.match(help, /same --remote-config to every operational command/); + assert.match(help, /do not use agent-device auth for this direct proxy flow/); + assert.match(help, /Do not use --remote-config unless you are using the tenant\/run\/lease/); assert.match(help, /Do not use --config as a remote profile flag/); assert.match(help, /install-from-source --github-actions-artifact org\/repo:artifact/); }); @@ -1740,6 +1772,7 @@ test('usage renders concise commands inline with descriptions', () => { assert.match(help, / metro\s{2,}Prepare Metro reachability for React Native\/Expo apps/); assert.match(help, / perf\s{2,}Check runtime metrics, frames, memory, CPU profiles/); assert.match(help, / react-devtools\s{2,}Inspect React Native components, props, hooks/); + assert.match(help, / proxy\s{2,}Expose a local daemon through cloudflared, ngrok/); assert.match(help, / batch --steps \| --steps-file \s{2,}Run multiple commands/); assert.match(help, / test \.\.\.\s{2,}Run replay test suites/); assert.match(help, / screenshot \[path\]\s{2,}Capture screenshot with optional desktop/); @@ -1752,6 +1785,21 @@ test('usage renders concise commands inline with descriptions', () => { assert.doesNotMatch(help, /agent-device-proxy/); }); +test('proxy command help describes tunnel usage', () => { + const help = usageForCommand('proxy'); + 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, /--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\/\*/); + assert.match(help, /https:\/\/example\.trycloudflare\.com\/agent-device/); + assert.match(help, /does not use agent-device auth/); + assert.doesNotMatch(help, /agent-device-proxy/); +}); + test('connect command help lists lease id in usage and flags', () => { const help = usageForCommand('connect'); if (help === null) throw new Error('Expected command help text'); diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index ed4b2fec4..888db5a46 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -817,6 +817,60 @@ test('sendToDaemon uses explicit remote daemon base URL and auth token', async ( } }); +test('sendToDaemon rejects remote daemon RPC protocol mismatches before RPC', async () => { + const seenPaths: string[] = []; + let rpcCalled = false; + const restoreHttpRequest = mockEventHttpRequest(({ options, res }) => { + seenPaths.push(String(options.path ?? '')); + if (options.method === 'GET') { + res.emit( + 'data', + JSON.stringify({ + ok: true, + service: 'agent-device-proxy', + version: '99.0.0', + rpcProtocolVersion: 999, + }), + ); + res.emit('end'); + return; + } + + rpcCalled = true; + emitJsonRpcResult(res, 'req-incompatible', { ok: true, data: {} }); + }); + + try { + await withRemoteDaemonEnv(async () => { + let error: unknown; + try { + await sendToDaemon({ + session: 'default', + command: 'remote-smoke', + positionals: ['ping'], + flags: {}, + meta: { requestId: 'req-incompatible' }, + }); + } catch (caught) { + error = caught; + } + + assert.ok(error instanceof Error); + assert.equal((error as any).code, 'COMMAND_FAILED'); + assert.match(error.message, /Remote daemon RPC protocol is incompatible/); + assert.equal((error as any).details?.remoteService, 'agent-device-proxy'); + assert.equal((error as any).details?.remoteVersion, '99.0.0'); + assert.equal((error as any).details?.remoteRpcProtocolVersion, 999); + assert.equal(typeof (error as any).details?.supportedRpcProtocolVersion, 'number'); + }); + + assert.deepEqual(seenPaths, ['/agent-device/health']); + assert.equal(rpcCalled, false); + } finally { + restoreHttpRequest(); + } +}); + test('sendToDaemon sends lease helpers as top-level JSON-RPC methods over HTTP', async () => { const rpcRequests: Record[] = []; const originalHttpRequest = http.request; diff --git a/src/utils/args.ts b/src/utils/args.ts index 5da1cebd3..ab025bc96 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -4,6 +4,7 @@ import { applyCommandDefaults, getCommandSchema, getFlagDefinition, + getFlagDefinitions, type CliFlags, type FlagDefinition, type FlagKey, @@ -70,7 +71,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { if (isLegacyIgnoredSnapshotShortFlag(command, token)) { continue; } - const definition = resolveFlagDefinition(token); + const definition = resolveFlagDefinition(token, command); if (shouldPassThroughReactDevtoolsFlag(command, definition)) { positionals.push(arg); continue; @@ -116,7 +117,15 @@ function shouldPassThroughReactDevtoolsFlag( return !isFlagSupportedForCommand(definition.key, command); } -function resolveFlagDefinition(token: string): FlagDefinition | undefined { +function resolveFlagDefinition(token: string, command: string | null): FlagDefinition | undefined { + const definitions = getFlagDefinitions().filter((definition) => definition.names.includes(token)); + if (definitions.length <= 1) return definitions[0] ?? getFlagDefinition(token); + if (command) { + const commandDefinition = definitions.find((definition) => + isFlagSupportedForCommand(definition.key, command), + ); + if (commandDefinition) return commandDefinition; + } return getFlagDefinition(token); } diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 71665012d..18da3d3a2 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -75,6 +75,25 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'Start the official stdio MCP server. It exposes structured command tools backed by the agent-device client.', summary: 'Start MCP server', }, + proxy: { + usageOverride: + 'proxy [--host ] [--port ] [--daemon-auth-token ] [--state-dir ]', + listUsageOverride: 'proxy', + helpDescription: `Expose the local daemon HTTP contract through a tunnel-friendly reverse proxy. + +Run this on the host that has access to simulators/devices, then point another machine at the printed daemon base URL with --daemon-base-url or AGENT_DEVICE_DAEMON_BASE_URL. + +The proxy starts or reuses a local HTTP daemon, accepts /health, /rpc, /upload, and /artifacts/*, and also accepts the same routes under /agent-device/*. Health is unauthenticated for reachability probes. Other routes require the generated bearer token printed at startup, or the explicit --daemon-auth-token value when provided. The proxy rewrites authorized client requests to the upstream daemon token instead of exposing the local daemon token. + +Use the /agent-device base path when connecting through cloudflared, ngrok, or another shared origin. Treat the bearer token as a secret; anyone with it can control the proxied daemon. This direct proxy flow does not use agent-device auth. + +Examples: + agent-device proxy --port 4310 + cloudflared tunnel --url http://127.0.0.1:4310 + agent-device devices --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token `, + summary: 'Expose a local daemon through cloudflared, ngrok, or another HTTP tunnel', + allowedFlags: ['proxyHost', 'proxyPort', 'daemonAuthToken', 'stateDir'], + }, 'react-devtools': { usageOverride: 'react-devtools [...args]', listUsageOverride: 'react-devtools', diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 86f9e6f03..e8c62fee2 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -33,6 +33,8 @@ export type CliFlags = RemoteConfigMetroOptions & daemonAuthToken?: string; daemonTransport?: DaemonTransportPreference; daemonServerMode?: DaemonServerMode; + proxyHost?: string; + proxyPort?: number; tenant?: string; sessionIsolation?: SessionIsolationMode; runId?: string; @@ -226,7 +228,8 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--daemon-auth-token'], type: 'string', usageLabel: '--daemon-auth-token ', - usageDescription: 'Remote HTTP daemon auth token (sent as request token and bearer header)', + usageDescription: + 'Remote HTTP daemon or proxy auth token (sent as request token and bearer header)', }, { key: 'daemonTransport', @@ -244,6 +247,22 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--daemon-server-mode socket|http|dual', usageDescription: 'Daemon server mode used when spawning daemon', }, + { + key: 'proxyHost', + names: ['--host'], + type: 'string', + usageLabel: '--host ', + usageDescription: 'Proxy: host interface to bind (default: 127.0.0.1)', + }, + { + key: 'proxyPort', + names: ['--port'], + type: 'int', + min: 1, + max: 65535, + usageLabel: '--port ', + usageDescription: 'Proxy: TCP port to bind (default: 0, choose a free port)', + }, { key: 'tenant', names: ['--tenant'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 216c33fe3..22679fc58 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -576,10 +576,21 @@ Script flow, per-command config: agent-device snapshot --remote-config ./remote-config.json agent-device disconnect --remote-config ./remote-config.json +Direct proxy flow for a remote Mac: + On the Mac with simulator/device access: + agent-device proxy --port 4310 + cloudflared tunnel --url http://127.0.0.1:4310 + On the remote client: + agent-device devices --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token + agent-device devices + Rules: connect and disconnect are top-level commands. Do not write agent-device remote connect or agent-device remote disconnect. Use connect without --remote-config when the cloud control plane owns the connection profile. Prefer --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. + Use agent-device proxy for direct tunnel access to a Mac you control. Copy the printed daemon base URL and daemon auth token; do not use agent-device auth for this direct proxy flow. + For repeated direct proxy commands, store daemonBaseUrl and daemonAuthToken in normal agent-device.json CLI config. Keep platform selection on each command or workflow. Do not use --remote-config unless you are using the tenant/run/lease remote connection flow. + Keep the proxy token secret. Anyone with the token can control the proxied daemon. Do not use --config as a remote profile flag. --config loads CLI defaults; --remote-config selects remote daemon/profile settings. For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. diff --git a/test/integration/provider-scenarios/daemon-http-server.test.ts b/test/integration/provider-scenarios/daemon-http-server.test.ts index 2519d115b..881a52d66 100644 --- a/test/integration/provider-scenarios/daemon-http-server.test.ts +++ b/test/integration/provider-scenarios/daemon-http-server.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { test } from 'vitest'; import { AppError } from '../../../src/utils/errors.ts'; import { trackDownloadableArtifact } from '../../../src/daemon/artifact-tracking.ts'; +import { DAEMON_RPC_PROTOCOL_VERSION } from '../../../src/daemon/http-health.ts'; import { createDaemonHttpServer } from '../../../src/daemon/http-server.ts'; import { emitRequestProgress } from '../../../src/daemon/request-progress.ts'; import { getRequestSignal, isRequestCanceled } from '../../../src/daemon/request-cancel.ts'; @@ -82,7 +83,11 @@ test('Provider-backed integration daemon HTTP server maps RPC methods, auth, and const health = await fetch(`http://127.0.0.1:${port}/health`); assert.equal(health.status, 200); - assert.deepEqual(await health.json(), { ok: true }); + const healthPayload = (await health.json()) as Record; + assert.equal(healthPayload.ok, true); + assert.equal(healthPayload.service, 'agent-device-daemon'); + assert.equal(typeof healthPayload.version, 'string'); + assert.equal(healthPayload.rpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION); const command = await callRpc(port, { jsonrpc: '2.0', diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index f926fae0f..bc80b2c3b 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { test } from 'vitest'; import { createAgentDeviceClient } from '../../../src/client.ts'; +import { createDaemonProxyServer } from '../../../src/daemon-proxy.ts'; import { normalizeAgentDeviceError } from '../../../src/utils/errors.ts'; import { closeLoopbackServer, @@ -16,8 +17,10 @@ type RemoteRpcRequest = { id: unknown; method?: string; params?: { + token?: string; command?: string; positionals?: unknown[]; + flags?: Record; meta?: { clientArtifactPaths?: Record; installSource?: unknown; @@ -31,6 +34,10 @@ type UploadRequest = { body: Buffer; }; +type ProxyUpstreamRpcRequest = RemoteRpcRequest & { + headers: http.IncomingHttpHeaders; +}; + type RemoteClient = ReturnType; type RemotePaths = { @@ -96,6 +103,79 @@ function createRemoteDaemonServer(paths: { screenshotPath: string }): { }; } +function createProxyUpstreamDaemonServer(): { + server: http.Server; + rpcRequests: ProxyUpstreamRpcRequest[]; +} { + const rpcRequests: ProxyUpstreamRpcRequest[] = []; + const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/health') { + assert.equal(req.headers.authorization, 'Bearer upstream-token'); + assert.equal(req.headers['x-agent-device-token'], 'upstream-token'); + writeJson(res, 200, { ok: true }); + return; + } + if (req.method !== 'POST' || req.url !== '/rpc') { + res.writeHead(404); + res.end('not found'); + return; + } + assert.equal(req.headers.authorization, 'Bearer upstream-token'); + assert.equal(req.headers['x-agent-device-token'], 'upstream-token'); + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const payload = JSON.parse(body) as RemoteRpcRequest; + rpcRequests.push({ ...payload, headers: req.headers }); + assert.equal(payload.params?.token, 'upstream-token'); + writeJson(res, 200, { + jsonrpc: '2.0', + id: payload.id, + result: { + ok: true, + data: responseDataForProxyRpc(payload), + }, + }); + }); + }); + return { server, rpcRequests }; +} + +function responseDataForProxyRpc(payload: RemoteRpcRequest): Record { + if (payload.params?.command === 'devices') { + return { + devices: [ + { + platform: 'ios', + id: 'remote-ios-1', + name: 'Remote iPhone', + kind: 'simulator', + target: 'mobile', + booted: true, + }, + ], + }; + } + if (payload.params?.command === 'session_list') { + return { + sessions: [ + { + name: 'remote-session', + platform: 'ios', + id: 'remote-ios-1', + device: 'Remote iPhone', + target: 'mobile', + createdAt: 123, + }, + ], + }; + } + return {}; +} + function handleRemoteDaemonRequest( req: http.IncomingMessage, res: http.ServerResponse, @@ -133,15 +213,24 @@ function writeArtifactResponse( function remoteRoute( req: http.IncomingMessage, ): 'health' | 'screenshot-artifact' | 'recording-artifact' | 'upload' | 'rpc' | undefined { - const method = req.method; const url = req.url ?? ''; - if (method === 'GET' && url.startsWith('/health')) return 'health'; - if (method === 'GET' && url.startsWith('/artifacts/shot-1')) return 'screenshot-artifact'; - if (method === 'GET' && url.startsWith('/artifacts/recording-1')) { - return 'recording-artifact'; - } - if (method === 'POST' && url === '/upload') return 'upload'; - if (method === 'POST' && url === '/rpc') return 'rpc'; + if (req.method === 'GET') return remoteGetRoute(url); + if (req.method === 'POST') return remotePostRoute(url); + return undefined; +} + +function remoteGetRoute( + url: string, +): 'health' | 'screenshot-artifact' | 'recording-artifact' | undefined { + if (url.startsWith('/health')) return 'health'; + if (url.startsWith('/artifacts/shot-1')) return 'screenshot-artifact'; + if (url.startsWith('/artifacts/recording-1')) return 'recording-artifact'; + return undefined; +} + +function remotePostRoute(url: string): 'upload' | 'rpc' | undefined { + if (url === '/upload') return 'upload'; + if (url === '/rpc') return 'rpc'; return undefined; } @@ -501,6 +590,69 @@ test('Provider-backed integration remote daemon client materializes artifacts an } }); +test('Provider-backed integration daemon proxy forwards remote client RPC commands', async (t) => { + if (await skipWhenLoopbackUnavailable(t, 'daemon proxy integration coverage')) { + return; + } + + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-proxy-client-')); + const upstream = createProxyUpstreamDaemonServer(); + let proxyServer: http.Server | undefined; + + try { + const upstreamPort = await listenOnLoopback(upstream.server); + proxyServer = createDaemonProxyServer({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + upstreamToken: 'upstream-token', + clientToken: 'proxy-token', + }); + const proxyPort = await listenOnLoopback(proxyServer); + const client = createAgentDeviceClient({ + daemonBaseUrl: `http://127.0.0.1:${proxyPort}/agent-device`, + daemonAuthToken: 'proxy-token', + stateDir, + }); + + await assertProxyClientRpcPassThrough(client, upstream.rpcRequests); + } finally { + if (proxyServer) await closeLoopbackServer(proxyServer); + await closeLoopbackServer(upstream.server); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +async function assertProxyClientRpcPassThrough( + client: RemoteClient, + rpcRequests: ProxyUpstreamRpcRequest[], +): Promise { + const devices = await client.devices.list({ platform: 'ios' }); + assert.equal(devices[0]?.id, 'remote-ios-1'); + assert.equal(devices[0]?.name, 'Remote iPhone'); + + const sessions = await client.sessions.list(); + assert.equal(sessions[0]?.name, 'remote-session'); + assert.equal(sessions[0]?.device.id, 'remote-ios-1'); + + assertProxyUpstreamRpcRequests(rpcRequests); +} + +function assertProxyUpstreamRpcRequests(rpcRequests: ProxyUpstreamRpcRequest[]): void { + assert.equal(rpcRequests.length, 2); + const [devicesRpc, sessionsRpc] = rpcRequests; + assert.ok(devicesRpc); + assert.ok(sessionsRpc); + assert.equal(devicesRpc.method, 'agent_device.command'); + assert.equal(devicesRpc.params?.command, 'devices'); + assert.equal(devicesRpc.params?.flags?.platform, 'ios'); + assert.equal(devicesRpc.params?.token, 'upstream-token'); + assert.equal(devicesRpc.headers.authorization, 'Bearer upstream-token'); + + assert.equal(sessionsRpc.method, 'agent_device.command'); + assert.equal(sessionsRpc.params?.command, 'session_list'); + assert.equal(sessionsRpc.params?.token, 'upstream-token'); + assert.equal(sessionsRpc.headers['x-agent-device-token'], 'upstream-token'); +} + test('Provider-backed integration remote daemon client normalizes artifact download failures after successful RPC', async (t) => { if (await skipWhenLoopbackUnavailable(t, 'remote daemon artifact failure coverage')) { return; diff --git a/test/integration/smoke-daemon-http.test.ts b/test/integration/smoke-daemon-http.test.ts index 843bfa81e..3bce00bbe 100644 --- a/test/integration/smoke-daemon-http.test.ts +++ b/test/integration/smoke-daemon-http.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { DAEMON_RPC_PROTOCOL_VERSION } from '../../src/daemon/http-health.ts'; import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts'; import { stopProcessForTakeover } from '../../src/utils/process-identity.ts'; import { runCliJson } from './test-helpers.ts'; @@ -42,7 +43,11 @@ test('daemon HTTP transport starts from CLI and accepts a command RPC', async (t const health = await fetch(`http://127.0.0.1:${info.httpPort}/health`); assert.equal(health.status, 200); - assert.deepEqual(await health.json(), { ok: true }); + const healthPayload = (await health.json()) as Record; + assert.equal(healthPayload.ok, true); + assert.equal(healthPayload.service, 'agent-device-daemon'); + assert.equal(typeof healthPayload.version, 'string'); + assert.equal(healthPayload.rpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION); const rpc = await callCommandRpc(info, 'session_list'); assert.equal(rpc.status, 200); diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index 48f80815f..ef721c318 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -39,6 +39,11 @@ "type": "file", "label": "Configuration" }, + { + "name": "remote-proxy", + "type": "file", + "label": "Remote Proxy" + }, { "name": "security-trust", "type": "file", diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 6a8aa1d34..7dcd45edb 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -86,6 +86,7 @@ agent-device app-switcher - Tenant-scoped daemon runs can pass `--tenant`, `--session-isolation tenant`, `--run-id`, and `--lease-id` to enforce lease admission. - Remote daemon clients can pass `--daemon-base-url http(s)://host:port[/base-path]` to skip local daemon discovery/startup and call a remote HTTP daemon directly. - Use `--daemon-auth-token ` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for explicit service/API-token automation against non-loopback remote daemon URLs; the client sends it in both the JSON-RPC request token and HTTP auth headers. +- Use [Remote Proxy](/docs/remote-proxy) when you need to run `agent-device proxy` on a Mac with simulator/device access and drive it from another machine through cloudflared, ngrok, or another HTTP tunnel. - For human cloud access, `connect` can discover a cloud connection profile, while `connect --remote-config ...` uses a local profile. Both refresh a stored CLI session into a short-lived `adc_agent_...` token when needed. If no CLI session exists, interactive shells start login automatically; CI and non-interactive shells fail with API-token setup instructions. Use `--no-login` to disable implicit login. `AGENT_DEVICE_CLOUD_BASE_URL` is the bridge/control-plane API origin; its `/api-keys` route may redirect to the dashboard for token creation. - For remote `connect` and `connect --remote-config` flows, see [Remote Metro workflow](#remote-metro-workflow). - Android React Native relaunch flows require an installed package name for `open --relaunch`; install/reinstall the APK first, then relaunch by package. `open --relaunch` is rejected because runtime hints are written through the installed app sandbox. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md new file mode 100644 index 000000000..a2f7de99b --- /dev/null +++ b/website/docs/docs/remote-proxy.md @@ -0,0 +1,84 @@ +--- +title: Remote Proxy +description: Run agent-device on a Mac with simulator or device access and control it from another machine through an HTTP tunnel. +--- + +# Remote Proxy + +Use `agent-device proxy` when the machine running your agent cannot access the iOS simulator, Android emulator, or physical device directly, but another Mac can. The proxy runs on the device host, fronts the local daemon over HTTP, and lets a remote `agent-device` client call it through cloudflared, ngrok, or another tunnel. + +This is a direct bearer-token flow. It does not use `agent-device auth`. + +## Host Machine + +On the Mac with simulator or device access: + +```bash +agent-device proxy --port 4310 +``` + +The command prints a `daemon base URL` and `daemon auth token`. Keep the token secret; anyone with it can control the proxied daemon. + +Expose the proxy with your tunnel: + +```bash +cloudflared tunnel --url http://127.0.0.1:4310 +# or +ngrok http 4310 +``` + +By default the proxy binds `127.0.0.1`. Use `--host 0.0.0.0` only when you intentionally want the proxy reachable on the host network. + +## Remote Client + +On the machine running the agent, use the public tunnel origin with the `/agent-device` base path: + +```bash +export AGENT_DEVICE_DAEMON_BASE_URL="https://example.trycloudflare.com/agent-device" +export AGENT_DEVICE_DAEMON_AUTH_TOKEN="" + +agent-device devices --platform ios +agent-device open MyApp --platform ios +agent-device snapshot --platform ios +``` + +You can also pass the values per command: + +```bash +agent-device devices \ + --daemon-base-url https://example.trycloudflare.com/agent-device \ + --daemon-auth-token +``` + +For repeated use, put the remote client settings in normal CLI config: + +```json +{ + "daemonBaseUrl": "https://example.trycloudflare.com/agent-device", + "daemonAuthToken": "" +} +``` + +With `agent-device.json` in the working directory, normal commands pick up those defaults: + +```bash +agent-device devices +agent-device open MyApp +agent-device snapshot +``` + +Do not commit a config file that contains a live `daemonAuthToken`. + +## What Is Exposed + +The proxy allows only the daemon HTTP contract: `/health`, `/rpc`, `/upload`, and `/artifacts/*`, with the same routes also available under `/agent-device/*`. Health checks are unauthenticated; command, upload, and artifact routes require the bearer token. + +The proxy validates the client token and rewrites authorized upstream requests to the local daemon token. The local daemon still validates its own token, so the daemon token is not exposed to remote clients. + +## Compatibility + +Remote clients read `/health` before issuing commands and compare the daemon RPC protocol version. Keep the client and proxy versions reasonably close; patch-level differences should normally work, but incompatible RPC protocol versions fail before commands run. + +## Cleanup + +Stop the tunnel and the `agent-device proxy` process when the remote session is done. Restarting the proxy generates a fresh token unless you supplied `--daemon-auth-token` explicitly.