Skip to content

Commit f654a39

Browse files
committed
feat(remote): give a proxied client the daemon's own failure envelope, cancellation, and version check
#2198 slice B. Direct-daemon and proxy execution over the same deterministic Simulator fixture now publish the same responses, and the three places where they did not are closed: - A client that disconnects mid-request behind the proxy now cancels the daemon request. The proxy's upstream fetch is bound to its client's connection, so the daemon's own disconnect cancellation (`markRequestCanceled`) fires exactly as it does for a direct client. - The proxy forwards `GET /sessions/<session>/requests/<id>/diagnostics` (#1801), so a remote client behind it localizes a failure's diagnostics record instead of reporting `logPathUnavailable: HTTP 404`. GET only; the route still enumerates nothing. - The client's ADR 0006 health check reads the `upstream` link a proxy's /health already nests: a proxy whose daemon speaks another RPC protocol fails at health, before the command RPC. The provider scenario harness exposes its request boundary so a scenario daemon can sit behind a real HTTP server and proxy; the new parity suite runs one script direct and proxied and compares the published responses with transport identity removed, and proves two proxied clients contending for one device fail at claim admission before any lifecycle call.
1 parent 7616ba2 commit f654a39

9 files changed

Lines changed: 722 additions & 25 deletions

File tree

src/__tests__/daemon-proxy.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
44
import http from 'node:http';
55
import { createDaemonProxyServer } from '../remote/daemon-proxy.ts';
66
import { createDaemonHttpServer } from '../daemon/server/http-server.ts';
7+
import { getRequestSignal } from '@agent-device/host-kit/request';
78
import { executeRunScriptHttpRequest } from '../daemon/adapters/maestro/run-script-http.ts';
89
import {
910
DAEMON_HTTP_NETWORK_ACCESS_HEADER,
@@ -173,6 +174,139 @@ test('proxy enforces public-only Maestro HTTP policy on a local daemon', async (
173174
}
174175
});
175176

177+
test('daemon proxy cancels the upstream daemon request when its client disconnects', async (t) => {
178+
if (await skipWhenLoopbackUnavailable(t)) return;
179+
180+
let upstreamStarted!: (requestId: string | undefined) => void;
181+
const started = new Promise<string | undefined>((resolve) => {
182+
upstreamStarted = resolve;
183+
});
184+
let upstreamCanceled!: (reason: string) => void;
185+
const canceled = new Promise<string>((resolve) => {
186+
upstreamCanceled = resolve;
187+
});
188+
const env = { ...process.env };
189+
delete env.AGENT_DEVICE_HTTP_AUTH_HOOK;
190+
delete env.AGENT_DEVICE_HTTP_AUTH_EXPORT;
191+
const daemon = await createDaemonHttpServer({
192+
token: 'daemon-secret',
193+
env,
194+
handleRequest: async (request) => {
195+
const requestId = request.meta?.requestId;
196+
const signal = getRequestSignal(requestId);
197+
upstreamStarted(requestId);
198+
if (!signal) {
199+
upstreamCanceled('no request signal was registered');
200+
} else if (signal.aborted) {
201+
upstreamCanceled('aborted');
202+
} else {
203+
signal.addEventListener('abort', () => upstreamCanceled('aborted'), { once: true });
204+
}
205+
await canceled;
206+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'request canceled' } };
207+
},
208+
});
209+
const proxy = createDaemonProxyServer({
210+
upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(daemon)}`,
211+
upstreamToken: 'daemon-secret',
212+
clientToken: 'proxy-secret',
213+
});
214+
215+
try {
216+
const proxyPort = await listenOnLoopback(proxy);
217+
const client = http.request({
218+
host: '127.0.0.1',
219+
port: proxyPort,
220+
method: 'POST',
221+
path: '/agent-device/rpc',
222+
headers: { 'content-type': 'application/json', authorization: 'Bearer proxy-secret' },
223+
});
224+
client.on('error', () => {});
225+
client.end(
226+
JSON.stringify({
227+
jsonrpc: '2.0',
228+
id: 'req-disconnect',
229+
method: 'agent_device.command',
230+
params: {
231+
token: 'proxy-secret',
232+
session: 'default',
233+
command: 'snapshot',
234+
positionals: [],
235+
flags: {},
236+
},
237+
}),
238+
);
239+
const requestId = await started;
240+
assert.match(String(requestId), /req-disconnect/);
241+
242+
client.destroy();
243+
244+
const timeout = new Promise<string>((resolve) => {
245+
setTimeout(() => resolve('upstream request was never canceled'), 5000).unref();
246+
});
247+
assert.equal(await Promise.race([canceled, timeout]), 'aborted');
248+
} finally {
249+
await closeLoopbackServer(proxy);
250+
await closeLoopbackServer(daemon);
251+
}
252+
});
253+
254+
test('daemon proxy forwards a request diagnostics record fetch and nothing else on that path', async (t) => {
255+
if (await skipWhenLoopbackUnavailable(t)) return;
256+
257+
const upstreamRequests: Array<{ method: string; url: string; auth: string }> = [];
258+
const upstream = http.createServer((req, res) => {
259+
upstreamRequests.push({
260+
method: req.method ?? '',
261+
url: req.url ?? '',
262+
auth: String(req.headers.authorization ?? ''),
263+
});
264+
res.setHeader('content-type', 'application/x-ndjson');
265+
res.end('{"phase":"request_failed"}\n');
266+
});
267+
const proxy = createDaemonProxyServer({
268+
upstreamBaseUrl: `http://127.0.0.1:${await listenOnLoopback(upstream)}`,
269+
upstreamToken: 'daemon-secret',
270+
clientToken: 'proxy-secret',
271+
});
272+
273+
try {
274+
const proxyPort = await listenOnLoopback(proxy);
275+
const record = `/agent-device/sessions/default/requests/req%3A1/diagnostics`;
276+
const headers = { authorization: 'Bearer proxy-secret' };
277+
278+
const fetched = await fetch(`http://127.0.0.1:${proxyPort}${record}`, { headers });
279+
assert.equal(fetched.status, 200);
280+
assert.equal(await fetched.text(), '{"phase":"request_failed"}\n');
281+
assert.deepEqual(upstreamRequests, [
282+
{
283+
method: 'GET',
284+
url: '/sessions/default/requests/req%3A1/diagnostics',
285+
auth: 'Bearer daemon-secret',
286+
},
287+
]);
288+
289+
const unauthenticated = await fetch(`http://127.0.0.1:${proxyPort}${record}`);
290+
assert.equal(unauthenticated.status, 401);
291+
const posted = await fetch(`http://127.0.0.1:${proxyPort}${record}`, {
292+
method: 'POST',
293+
headers,
294+
});
295+
assert.equal(posted.status, 404);
296+
const enumerated = await fetch(
297+
`http://127.0.0.1:${proxyPort}/agent-device/sessions/default/requests`,
298+
{
299+
headers,
300+
},
301+
);
302+
assert.equal(enumerated.status, 404);
303+
assert.equal(upstreamRequests.length, 1, 'only the record fetch reaches the daemon');
304+
} finally {
305+
await closeLoopbackServer(proxy);
306+
await closeLoopbackServer(upstream);
307+
}
308+
});
309+
176310
test('daemon proxy rejects unauthenticated rpc requests', async (t) => {
177311
if (await skipWhenLoopbackUnavailable(t)) return;
178312

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import assert from 'node:assert/strict';
2+
import http from 'node:http';
3+
import { test } from 'vitest';
4+
import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http';
5+
import { readRemoteDaemonHealth } from '../daemon-client-transport.ts';
6+
import {
7+
closeLoopbackServer,
8+
listenOnLoopback,
9+
skipWhenLoopbackUnavailable,
10+
} from '../../../__tests__/test-utils/loopback.ts';
11+
12+
/**
13+
* ADR 0006 health compatibility across every link a command RPC crosses. `sendToDaemon`
14+
* runs this check before the RPC (daemon-client.test.ts pins that order); these cases pin
15+
* what the check itself accepts and refuses when a proxy sits in front of the daemon.
16+
*/
17+
18+
const DAEMON_LINK = { ok: true, service: 'agent-device-daemon', version: '98.0.0' } as const;
19+
20+
async function withHealthServer<T>(
21+
payload: Record<string, unknown>,
22+
run: (baseUrl: string) => Promise<T>,
23+
): Promise<T> {
24+
const server = http.createServer((req, res) => {
25+
assert.equal(req.url, '/agent-device/health');
26+
res.setHeader('content-type', 'application/json');
27+
res.end(JSON.stringify(payload));
28+
});
29+
try {
30+
const port = await listenOnLoopback(server);
31+
return await run(`http://127.0.0.1:${port}/agent-device`);
32+
} finally {
33+
await closeLoopbackServer(server);
34+
}
35+
}
36+
37+
function proxyHealth(upstreamRpcProtocolVersion: number): Record<string, unknown> {
38+
return {
39+
ok: true,
40+
service: 'agent-device-proxy',
41+
version: '99.0.0',
42+
rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION,
43+
upstream: { ...DAEMON_LINK, rpcProtocolVersion: upstreamRpcProtocolVersion },
44+
};
45+
}
46+
47+
test('a proxy whose daemon speaks the same protocol passes with the upstream link readable', async (t) => {
48+
if (await skipWhenLoopbackUnavailable(t)) return;
49+
50+
await withHealthServer(proxyHealth(DAEMON_RPC_PROTOCOL_VERSION), async (baseUrl) => {
51+
const health = await readRemoteDaemonHealth({ baseUrl, token: 'proxy-token', pid: 0 });
52+
assert.equal(health.reachable, true);
53+
assert.equal(health.service, 'agent-device-proxy');
54+
assert.deepEqual(health.upstream, {
55+
service: 'agent-device-daemon',
56+
version: '98.0.0',
57+
rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION,
58+
});
59+
});
60+
});
61+
62+
test('a proxy whose daemon speaks another protocol is refused, naming the daemon link', async (t) => {
63+
if (await skipWhenLoopbackUnavailable(t)) return;
64+
65+
await withHealthServer(proxyHealth(DAEMON_RPC_PROTOCOL_VERSION + 1), async (baseUrl) => {
66+
await assert.rejects(
67+
readRemoteDaemonHealth({ baseUrl, token: 'proxy-token', pid: 0 }),
68+
(error: unknown) => {
69+
const details = (error as { code?: string; details?: Record<string, unknown> }).details;
70+
assert.equal((error as { code?: string }).code, 'COMMAND_FAILED');
71+
assert.match(String((error as Error).message), /RPC protocol is incompatible/);
72+
assert.equal(details?.remoteService, 'agent-device-daemon');
73+
assert.equal(details?.remoteVersion, '98.0.0');
74+
assert.equal(details?.remoteRpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION + 1);
75+
assert.equal(details?.supportedRpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION);
76+
return true;
77+
},
78+
);
79+
});
80+
});
81+
82+
test('a daemon health payload without an upstream link parses as before', async (t) => {
83+
if (await skipWhenLoopbackUnavailable(t)) return;
84+
85+
await withHealthServer(
86+
{ ...DAEMON_LINK, rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION },
87+
async (baseUrl) => {
88+
const health = await readRemoteDaemonHealth({ baseUrl, token: 'daemon-token', pid: 0 });
89+
assert.equal(health.reachable, true);
90+
assert.equal(health.upstream, undefined);
91+
assert.equal(health.rpcProtocolVersion, DAEMON_RPC_PROTOCOL_VERSION);
92+
},
93+
);
94+
});

src/daemon/client/daemon-client-transport.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,15 @@ export type RemoteDaemonHealth = {
3636
service?: string;
3737
version?: string;
3838
rpcProtocolVersion?: number;
39+
/** The daemon behind a proxy, as the proxy's health reported it. */
40+
upstream?: RemoteDaemonHealthLink;
3941
};
4042

43+
type RemoteDaemonHealthLink = Pick<
44+
RemoteDaemonHealth,
45+
'service' | 'version' | 'rpcProtocolVersion'
46+
>;
47+
4148
export async function canConnect(
4249
info: DaemonInfo,
4350
preference: DaemonTransportPreference,
@@ -86,17 +93,21 @@ function canConnectHttp(info: DaemonInfo): Promise<boolean> {
8693
export async function readRemoteDaemonHealth(info: DaemonInfo): Promise<RemoteDaemonHealth> {
8794
const health = await readDaemonHttpHealth(info);
8895
if (!info.baseUrl || !health.reachable) return health;
89-
if (
90-
typeof health.rpcProtocolVersion === 'number' &&
91-
health.rpcProtocolVersion !== DAEMON_RPC_PROTOCOL_VERSION
92-
) {
96+
// Every link a command RPC crosses has to speak the client's protocol: a proxy that reports a
97+
// skewed daemon behind it fails here, before the RPC, exactly like a skewed proxy does.
98+
const incompatible = [health, health.upstream].find(
99+
(link) =>
100+
typeof link?.rpcProtocolVersion === 'number' &&
101+
link.rpcProtocolVersion !== DAEMON_RPC_PROTOCOL_VERSION,
102+
);
103+
if (incompatible) {
93104
throw new AppError('COMMAND_FAILED', 'Remote daemon RPC protocol is incompatible', {
94105
daemonBaseUrl: info.baseUrl,
95106
clientVersion: readVersion(),
96-
remoteVersion: health.version,
97-
remoteService: health.service,
107+
remoteVersion: incompatible.version,
108+
remoteService: incompatible.service,
98109
supportedRpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION,
99-
remoteRpcProtocolVersion: health.rpcProtocolVersion,
110+
remoteRpcProtocolVersion: incompatible.rpcProtocolVersion,
100111
hint: 'Upgrade agent-device on the client or remote host so both support the same daemon RPC protocol.',
101112
});
102113
}
@@ -156,22 +167,29 @@ async function readDaemonHttpHealth(info: DaemonInfo): Promise<RemoteDaemonHealt
156167

157168
function readHealthPayload(body: string): Omit<RemoteDaemonHealth, 'reachable' | 'statusCode'> {
158169
try {
159-
const parsed = JSON.parse(body) as {
160-
service?: unknown;
161-
version?: unknown;
162-
rpcProtocolVersion?: unknown;
163-
};
170+
const parsed = JSON.parse(body) as { upstream?: unknown };
171+
const upstream =
172+
parsed.upstream && typeof parsed.upstream === 'object'
173+
? readHealthLink(parsed.upstream as Record<string, unknown>)
174+
: undefined;
164175
return {
165-
service: typeof parsed.service === 'string' ? parsed.service : undefined,
166-
version: typeof parsed.version === 'string' ? parsed.version : undefined,
167-
rpcProtocolVersion:
168-
typeof parsed.rpcProtocolVersion === 'number' ? parsed.rpcProtocolVersion : undefined,
176+
...readHealthLink(parsed as Record<string, unknown>),
177+
...(upstream ? { upstream } : {}),
169178
};
170179
} catch {
171180
return {};
172181
}
173182
}
174183

184+
function readHealthLink(parsed: Record<string, unknown>): RemoteDaemonHealthLink {
185+
return {
186+
service: typeof parsed.service === 'string' ? parsed.service : undefined,
187+
version: typeof parsed.version === 'string' ? parsed.version : undefined,
188+
rpcProtocolVersion:
189+
typeof parsed.rpcProtocolVersion === 'number' ? parsed.rpcProtocolVersion : undefined,
190+
};
191+
}
192+
175193
export async function sendRequest(
176194
info: DaemonInfo,
177195
req: DaemonRequest,

0 commit comments

Comments
 (0)