Skip to content

Commit 856f32e

Browse files
committed
feat: add agent-device proxy command
1 parent c281484 commit 856f32e

26 files changed

Lines changed: 1299 additions & 23 deletions

CONTEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Scenario transcript: command-level integration flow that describes user-visible behavior through daemon commands.
99
- In-process provider scenario harness: integration runner that invokes the daemon request handler directly without opening an HTTP listener.
1010
- HTTP contract test: narrow test that verifies JSON-RPC transport, auth, and response finalization over the daemon HTTP boundary.
11+
- 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.
1112
- Interactor: semantic interface between command dispatch and platform behavior.
1213
- Platform module: platform-specific implementation behind the Interactor.
1314
- Target: selected automation destination, such as mobile, tv, or desktop.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# ADR 0006: Daemon RPC Protocol Version
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
`agent-device` can run a client on one machine and reach a daemon on another machine through the
10+
HTTP JSON-RPC transport, including `agent-device proxy` in front of a local macOS daemon. Client and
11+
remote host package versions can differ. Many version skews are safe because the client sends a
12+
stable command request envelope and the daemon executes only the requested command.
13+
14+
Package semver is too strict for this boundary: a 0.15 client and 0.17 remote daemon can often
15+
interoperate. At the same time, the client needs a cheap way to fail before sending command RPC when
16+
the transport protocol itself is no longer compatible.
17+
18+
## Decision
19+
20+
The daemon HTTP `/health` payload advertises a `rpcProtocolVersion` integer alongside service and
21+
package version metadata. Remote clients compare this value before sending JSON-RPC requests.
22+
23+
Package `version` is diagnostic only and must not be used as a compatibility gate. Missing
24+
`rpcProtocolVersion` is treated as a legacy remote daemon and is allowed unless a later security or
25+
protocol decision explicitly retires legacy compatibility.
26+
27+
`rpcProtocolVersion` changes only when an older client and newer daemon, or newer client and older
28+
daemon, cannot safely communicate over the HTTP RPC boundary for existing commands. Bump it for
29+
breaking changes to:
30+
31+
- HTTP route requirements for `/health`, `/rpc`, `/upload`, or `/artifacts/*`.
32+
- Authentication semantics required to authorize RPC, upload, or artifact requests.
33+
- JSON-RPC envelope shape, method naming, request id handling, or command request projection.
34+
- Response, error, artifact, upload, or progress-stream framing that existing clients parse.
35+
- Existing command request or response contracts when the old side would misinterpret the payload
36+
rather than fail clearly.
37+
38+
Do not bump it for additive changes:
39+
40+
- New commands.
41+
- New optional request fields or flags that older daemons can ignore or reject with a normal
42+
command error.
43+
- New optional response fields that older clients can ignore.
44+
- Package version changes, refactors, or implementation-only daemon/proxy changes.
45+
46+
When a single command needs finer-grained compatibility in the future, prefer command-level feature
47+
or capability metadata over bumping the whole RPC protocol, unless the shared transport contract is
48+
also affected.
49+
50+
## Alternatives Considered
51+
52+
- Gate by package version: simple, but rejects compatible version skew and makes proxy usage brittle
53+
across frequent releases.
54+
- Do not check compatibility: maximizes compatibility but fails later, after the client has sent an
55+
RPC payload that a remote daemon may parse incorrectly.
56+
- Full schema negotiation: more precise, but too much machinery for the current JSON-RPC boundary.
57+
58+
## Consequences
59+
60+
Protocol-breaking changes must update `DAEMON_RPC_PROTOCOL_VERSION`, tests that assert `/health`
61+
metadata, and at least one remote-client regression test that proves mismatched protocols fail before
62+
command RPC.
63+
64+
Legacy remote daemons without `rpcProtocolVersion` remain reachable. This keeps the first release of
65+
the proxy compatible with older HTTP daemons, but it means absence of the marker is not proof of
66+
compatibility.

scripts/integration-progress-model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ function summarizeProviderScenarioFlagExclusions() {
271271
'shardSplit',
272272
'searchPath',
273273
'stepsFile',
274+
'proxyHost',
275+
'proxyPort',
274276
],
275277
},
276278
{

src/__tests__/cli-config.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,39 @@ test('interaction commands preserve remote config defaults', async () => {
179179
fs.rmSync(root, { recursive: true, force: true });
180180
});
181181

182+
test('normal config can point commands at a direct remote daemon proxy', async () => {
183+
const { root, home, project } = makeTempWorkspace();
184+
fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true });
185+
fs.writeFileSync(
186+
path.join(project, 'agent-device.json'),
187+
JSON.stringify({
188+
daemonBaseUrl: 'https://example.trycloudflare.com/agent-device',
189+
daemonAuthToken: 'proxy-token',
190+
}),
191+
'utf8',
192+
);
193+
194+
const result = await runCliCapture(['devices', '--json'], {
195+
cwd: project,
196+
env: { HOME: home },
197+
});
198+
199+
assert.equal(result.code, null);
200+
assert.equal(result.calls.length, 1);
201+
assert.equal(result.calls[0]?.command, 'devices');
202+
assert.equal(
203+
result.calls[0]?.flags?.daemonBaseUrl,
204+
'https://example.trycloudflare.com/agent-device',
205+
);
206+
assert.equal(result.calls[0]?.flags?.daemonAuthToken, 'proxy-token');
207+
assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'platform'), false);
208+
assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'remoteConfig'), false);
209+
assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'tenant'), false);
210+
assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'runId'), false);
211+
212+
fs.rmSync(root, { recursive: true, force: true });
213+
});
214+
182215
test('explicit --config path overrides default config discovery', async () => {
183216
const { root, home, project } = makeTempWorkspace();
184217
fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true });

src/__tests__/daemon-proxy.test.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import http from 'node:http';
4+
import { createDaemonProxyServer } from '../daemon-proxy.ts';
5+
import { DAEMON_RPC_PROTOCOL_VERSION } from '../daemon/http-health.ts';
6+
import {
7+
closeLoopbackServer,
8+
listenOnLoopback,
9+
skipWhenLoopbackUnavailable,
10+
} from './test-utils/index.ts';
11+
12+
test('daemon proxy forwards rpc requests with upstream daemon token', async (t) => {
13+
if (await skipWhenLoopbackUnavailable(t)) return;
14+
15+
let upstreamAuth = '';
16+
let upstreamTokenHeader = '';
17+
let upstreamBody: Record<string, any> | undefined;
18+
const upstream = http.createServer((req, res) => {
19+
if (req.url === '/health') {
20+
res.setHeader('content-type', 'application/json');
21+
res.end(JSON.stringify({ ok: true }));
22+
return;
23+
}
24+
assert.equal(req.url, '/rpc');
25+
upstreamAuth = String(req.headers.authorization ?? '');
26+
upstreamTokenHeader = String(req.headers['x-agent-device-token'] ?? '');
27+
let body = '';
28+
req.setEncoding('utf8');
29+
req.on('data', (chunk) => {
30+
body += chunk;
31+
});
32+
req.on('end', () => {
33+
upstreamBody = JSON.parse(body) as Record<string, any>;
34+
res.setHeader('content-type', 'application/json');
35+
res.end(
36+
JSON.stringify({
37+
jsonrpc: '2.0',
38+
id: upstreamBody.id,
39+
result: { ok: true, data: { via: 'proxy' } },
40+
}),
41+
);
42+
});
43+
});
44+
45+
const proxy = createDaemonProxyServer({
46+
upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`,
47+
upstreamToken: 'daemon-secret',
48+
clientToken: 'proxy-secret',
49+
});
50+
51+
try {
52+
const proxyPort = await listenOnLoopback(proxy);
53+
const response = await fetch(`http://127.0.0.1:${proxyPort}/agent-device/rpc`, {
54+
method: 'POST',
55+
headers: {
56+
'content-type': 'application/json',
57+
authorization: 'Bearer proxy-secret',
58+
},
59+
body: JSON.stringify({
60+
jsonrpc: '2.0',
61+
id: 'req-1',
62+
method: 'agent_device.command',
63+
params: {
64+
token: 'proxy-secret',
65+
session: 'default',
66+
command: 'devices',
67+
positionals: [],
68+
flags: {},
69+
},
70+
}),
71+
});
72+
73+
assert.equal(response.status, 200);
74+
assert.deepEqual(await response.json(), {
75+
jsonrpc: '2.0',
76+
id: 'req-1',
77+
result: { ok: true, data: { via: 'proxy' } },
78+
});
79+
assert.equal(upstreamAuth, 'Bearer daemon-secret');
80+
assert.equal(upstreamTokenHeader, 'daemon-secret');
81+
assert.equal(upstreamBody?.params?.token, 'daemon-secret');
82+
assert.equal(upstreamBody?.params?.command, 'devices');
83+
} finally {
84+
await closeLoopbackServer(proxy);
85+
await closeLoopbackServer(upstream);
86+
}
87+
});
88+
89+
test('daemon proxy rejects unauthenticated rpc requests', async (t) => {
90+
if (await skipWhenLoopbackUnavailable(t)) return;
91+
92+
let upstreamCalled = false;
93+
const upstream = http.createServer((_req, res) => {
94+
upstreamCalled = true;
95+
res.end('{}');
96+
});
97+
const proxy = createDaemonProxyServer({
98+
upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`,
99+
upstreamToken: 'daemon-secret',
100+
clientToken: 'proxy-secret',
101+
});
102+
103+
try {
104+
const proxyPort = await listenOnLoopback(proxy);
105+
const response = await fetch(`http://127.0.0.1:${proxyPort}/rpc`, {
106+
method: 'POST',
107+
headers: { 'content-type': 'application/json' },
108+
body: JSON.stringify({
109+
jsonrpc: '2.0',
110+
id: 'req-unauthorized',
111+
method: 'agent_device.command',
112+
params: { command: 'devices' },
113+
}),
114+
});
115+
116+
assert.equal(response.status, 401);
117+
const payload = (await response.json()) as { error?: { message?: string } };
118+
assert.equal(payload.error?.message, 'Invalid proxy token');
119+
assert.equal(upstreamCalled, false);
120+
} finally {
121+
await closeLoopbackServer(proxy);
122+
await closeLoopbackServer(upstream);
123+
}
124+
});
125+
126+
test('daemon proxy leaves health endpoint unauthenticated', async (t) => {
127+
if (await skipWhenLoopbackUnavailable(t)) return;
128+
129+
let upstreamAuth = '';
130+
let upstreamTokenHeader = '';
131+
const upstream = http.createServer((req, res) => {
132+
assert.equal(req.url, '/health');
133+
upstreamAuth = String(req.headers.authorization ?? '');
134+
upstreamTokenHeader = String(req.headers['x-agent-device-token'] ?? '');
135+
res.setHeader('content-type', 'application/json');
136+
res.end(JSON.stringify({ ok: true }));
137+
});
138+
const proxy = createDaemonProxyServer({
139+
upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`,
140+
upstreamToken: 'daemon-secret',
141+
clientToken: 'proxy-secret',
142+
});
143+
144+
try {
145+
const proxyPort = await listenOnLoopback(proxy);
146+
const response = await fetch(`http://127.0.0.1:${proxyPort}/agent-device/health`);
147+
assert.equal(response.status, 200);
148+
const payload = (await response.json()) as Record<string, any>;
149+
assert.equal(payload.ok, true);
150+
assert.equal(payload.service, 'agent-device-proxy');
151+
assert.equal(typeof payload.version, 'string');
152+
assert.equal(payload.rpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION);
153+
assert.deepEqual(payload.upstream, { ok: true });
154+
assert.equal(upstreamAuth, 'Bearer daemon-secret');
155+
assert.equal(upstreamTokenHeader, 'daemon-secret');
156+
} finally {
157+
await closeLoopbackServer(proxy);
158+
await closeLoopbackServer(upstream);
159+
}
160+
});
161+
162+
test('daemon proxy streams uploads and artifact downloads with upstream daemon token', async (t) => {
163+
if (await skipWhenLoopbackUnavailable(t)) return;
164+
165+
let uploadAuth = '';
166+
let uploadTokenHeader = '';
167+
let uploadArtifactType = '';
168+
let uploadArtifactFilename = '';
169+
let uploadBody = '';
170+
let artifactAuth = '';
171+
let artifactTokenHeader = '';
172+
const upstream = http.createServer((req, res) => {
173+
if (req.method === 'POST' && req.url === '/upload') {
174+
uploadAuth = String(req.headers.authorization ?? '');
175+
uploadTokenHeader = String(req.headers['x-agent-device-token'] ?? '');
176+
uploadArtifactType = String(req.headers['x-artifact-type'] ?? '');
177+
uploadArtifactFilename = String(req.headers['x-artifact-filename'] ?? '');
178+
req.setEncoding('utf8');
179+
req.on('data', (chunk) => {
180+
uploadBody += chunk;
181+
});
182+
req.on('end', () => {
183+
res.setHeader('content-type', 'application/json');
184+
res.end(JSON.stringify({ ok: true, uploadId: 'upload-1' }));
185+
});
186+
return;
187+
}
188+
189+
assert.equal(req.method, 'GET');
190+
assert.equal(req.url, '/artifacts/shot-1?download=1');
191+
artifactAuth = String(req.headers.authorization ?? '');
192+
artifactTokenHeader = String(req.headers['x-agent-device-token'] ?? '');
193+
res.setHeader('content-type', 'image/png');
194+
res.setHeader('content-disposition', 'attachment; filename="shot.png"');
195+
res.setHeader('x-request-id', 'upstream-request-1');
196+
res.write('png-');
197+
res.end('body');
198+
});
199+
const proxy = createDaemonProxyServer({
200+
upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`,
201+
upstreamToken: 'daemon-secret',
202+
clientToken: 'proxy-secret',
203+
});
204+
205+
try {
206+
const proxyPort = await listenOnLoopback(proxy);
207+
const upload = await fetch(`http://127.0.0.1:${proxyPort}/agent-device/upload`, {
208+
method: 'POST',
209+
headers: {
210+
authorization: 'Bearer proxy-secret',
211+
'x-artifact-type': 'file',
212+
'x-artifact-filename': 'demo.apk',
213+
'content-type': 'application/octet-stream',
214+
},
215+
body: Buffer.from('fake-apk'),
216+
});
217+
assert.equal(upload.status, 200);
218+
assert.deepEqual(await upload.json(), { ok: true, uploadId: 'upload-1' });
219+
assert.equal(uploadAuth, 'Bearer daemon-secret');
220+
assert.equal(uploadTokenHeader, 'daemon-secret');
221+
assert.equal(uploadArtifactType, 'file');
222+
assert.equal(uploadArtifactFilename, 'demo.apk');
223+
assert.equal(uploadBody, 'fake-apk');
224+
225+
const artifact = await fetch(
226+
`http://127.0.0.1:${proxyPort}/agent-device/artifacts/shot-1?download=1`,
227+
{ headers: { authorization: 'Bearer proxy-secret' } },
228+
);
229+
assert.equal(artifact.status, 200);
230+
assert.equal(await artifact.text(), 'png-body');
231+
assert.equal(artifact.headers.get('content-type'), 'image/png');
232+
assert.match(artifact.headers.get('content-disposition') ?? '', /shot\.png/);
233+
assert.equal(artifact.headers.get('x-request-id'), 'upstream-request-1');
234+
assert.equal(artifactAuth, 'Bearer daemon-secret');
235+
assert.equal(artifactTokenHeader, 'daemon-secret');
236+
} finally {
237+
await closeLoopbackServer(proxy);
238+
await closeLoopbackServer(upstream);
239+
}
240+
});

src/cli.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
6464
'close',
6565
'disconnect',
6666
'metro',
67+
'proxy',
6768
'session',
6869
]);
6970

@@ -447,7 +448,13 @@ function resolveActiveConnectionDefaults(options: {
447448
flags: Partial<CliFlags>;
448449
runtime?: SessionRuntimeHints;
449450
} | null {
450-
if (options.command === 'connect' || options.command === 'connection') return null;
451+
if (
452+
options.command === 'connect' ||
453+
options.command === 'connection' ||
454+
options.command === 'proxy'
455+
) {
456+
return null;
457+
}
451458
const defaults = resolveRemoteConnectionDefaults({
452459
stateDir: options.stateDir,
453460
session: options.session,
@@ -467,7 +474,7 @@ function shouldMaterializeRemoteConnection(command: string): boolean {
467474
}
468475

469476
function shouldResolveRemoteAuth(command: string): boolean {
470-
return command !== 'auth' && command !== 'connection';
477+
return command !== 'auth' && command !== 'connection' && command !== 'proxy';
471478
}
472479

473480
function shouldWarnOpenMayMissRemoteRuntime(options: {

0 commit comments

Comments
 (0)