Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions docs/adr/0006-daemon-rpc-protocol-version.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ function summarizeProviderScenarioFlagExclusions() {
'shardSplit',
'searchPath',
'stepsFile',
'proxyHost',
'proxyPort',
],
},
{
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/cli-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
240 changes: 240 additions & 0 deletions src/__tests__/daemon-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> | 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<string, any>;
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<string, any>;
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);
}
});
11 changes: 9 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
'close',
'disconnect',
'metro',
'proxy',
'session',
]);

Expand Down Expand Up @@ -447,7 +448,13 @@ function resolveActiveConnectionDefaults(options: {
flags: Partial<CliFlags>;
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,
Expand All @@ -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: {
Expand Down
Loading
Loading