Skip to content

Commit d0d5c85

Browse files
authored
fix: serve remote daemon request diagnostics to the caller (#1801) (#1814)
1 parent ef6ec29 commit d0d5c85

37 files changed

Lines changed: 1372 additions & 92 deletions

‎packages/kernel/src/errors.ts‎

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,24 @@ export function toAppErrorCode(
4444
return fallback;
4545
}
4646

47+
/**
48+
* Locator for one request's diagnostics record on the daemon host, in the
49+
* daemon's own vocabulary rather than as a filesystem path: `logPath` names
50+
* that same record as a path, which only a caller on the daemon host can read.
51+
* A remote caller fetches the record by this locator instead
52+
* (`GET /sessions/<session>/requests/<requestId>/diagnostics`).
53+
*/
54+
export type DiagnosticsRecordRef = {
55+
session: string;
56+
requestId: string;
57+
};
58+
4759
/**
4860
* Details bag for AppError. Free-form context is allowed, but these keys carry
4961
* meaning at normalize/render time and must keep their types:
5062
* - `hint` — overrides `defaultHintForCode`; re-wraps preserve an existing hint.
51-
* - `diagnosticId` / `logPath` — lifted onto the normalized error, stripped from details.
63+
* - `diagnosticId` / `logPath` / `logPathUnavailable` / `diagnosticsRecord` —
64+
* lifted onto the normalized error, stripped from details.
5265
* - `processExitError` + `stdout`/`stderr`/`exitCode` — marks a wrap of a real
5366
* process exit so normalizeError can surface the first meaningful stderr line;
5467
* build these via `execFailureDetails`/`requireExecSuccess` in src/utils/exec.ts
@@ -60,6 +73,8 @@ export type AppErrorDetails = Record<string, unknown> & {
6073
hint?: string;
6174
diagnosticId?: string;
6275
logPath?: string;
76+
logPathUnavailable?: string;
77+
diagnosticsRecord?: DiagnosticsRecordRef;
6378
retriable?: boolean;
6479
supportedOn?: string;
6580
processExitError?: boolean;
@@ -75,7 +90,22 @@ export type NormalizedError = {
7590
message: string;
7691
hint?: string;
7792
diagnosticId?: string;
93+
/**
94+
* Diagnostics record path **the reader of this error can open**. A daemon
95+
* renders its own host path here; a client talking to a REMOTE daemon
96+
* replaces it with the caller-local copy it fetched, or drops it and sets
97+
* `logPathUnavailable` (see `localizeRemoteDaemonError`). A path the reader
98+
* cannot open never belongs in this field (#1801).
99+
*/
78100
logPath?: string;
101+
/**
102+
* Why no readable `logPath` could be produced, e.g.
103+
* `remote daemon https://host, request 8f2c: 404`. Set only in place of
104+
* `logPath`, and never carries a daemon-host path.
105+
*/
106+
logPathUnavailable?: string;
107+
/** Locator the record can be fetched by when it lives on a remote daemon. */
108+
diagnosticsRecord?: DiagnosticsRecordRef;
79109
/**
80110
* Lifted from `details.retriable` when a throw site classified the failure as
81111
* clearly transient (or clearly not). Included only when set, so the default
@@ -96,7 +126,16 @@ export type DaemonError = {
96126
message: string;
97127
hint?: string;
98128
diagnosticId?: string;
129+
/** Path on the DAEMON host. Meaningful to a local caller only (#1801). */
99130
logPath?: string;
131+
/** Why no readable path is named; set by the client, never by the daemon. */
132+
logPathUnavailable?: string;
133+
/**
134+
* Additive locator (#1801) for the request diagnostics record `logPath`
135+
* names, so a remote caller can fetch it over the daemon API instead of
136+
* being handed a path on a filesystem it cannot read.
137+
*/
138+
diagnosticsRecord?: DiagnosticsRecordRef;
100139
details?: Record<string, unknown>;
101140
/** Additive retry and platform-support signals; absent when not derivable. */
102141
retriable?: boolean;
@@ -123,6 +162,8 @@ export function throwDaemonError(error: DaemonError): never {
123162
hint: error.hint,
124163
diagnosticId: error.diagnosticId,
125164
logPath: error.logPath,
165+
logPathUnavailable: error.logPathUnavailable,
166+
diagnosticsRecord: error.diagnosticsRecord,
126167
retriable: error.retriable,
127168
supportedOn: error.supportedOn,
128169
});
@@ -140,21 +181,27 @@ export function isAgentDeviceError(err: unknown): err is AppError {
140181
return err instanceof AppError;
141182
}
142183

184+
export type NormalizeErrorContext = {
185+
diagnosticId?: string;
186+
logPath?: string;
187+
diagnosticsRecord?: DiagnosticsRecordRef;
188+
};
189+
143190
export function normalizeAgentDeviceError(
144191
err: unknown,
145-
context: { diagnosticId?: string; logPath?: string } = {},
192+
context: NormalizeErrorContext = {},
146193
): NormalizedError {
147194
return normalizeError(err, context);
148195
}
149196

150-
export function normalizeError(
151-
err: unknown,
152-
context: { diagnosticId?: string; logPath?: string } = {},
153-
): NormalizedError {
197+
export function normalizeError(err: unknown, context: NormalizeErrorContext = {}): NormalizedError {
154198
const appErr = asAppError(err);
155199
const details = appErr.details ? redactDiagnosticData(appErr.details) : undefined;
156200
const diagnosticId = stringDetail(details, 'diagnosticId') ?? context.diagnosticId;
157201
const logPath = stringDetail(details, 'logPath') ?? context.logPath;
202+
const logPathUnavailable = stringDetail(details, 'logPathUnavailable');
203+
const diagnosticsRecord =
204+
readDiagnosticsRecordRef(details?.diagnosticsRecord) ?? context.diagnosticsRecord;
158205
const hint = stringDetail(details, 'hint') ?? defaultHintForCode(appErr.code);
159206
const retriable = booleanDetail(details, 'retriable') ?? retriableForErrorCode(appErr.code);
160207
const supportedOn = stringDetail(details, 'supportedOn');
@@ -167,6 +214,8 @@ export function normalizeError(
167214
hint,
168215
diagnosticId,
169216
logPath,
217+
...(logPathUnavailable !== undefined ? { logPathUnavailable } : {}),
218+
...(diagnosticsRecord !== undefined ? { diagnosticsRecord } : {}),
170219
// Typed-error signals stay absent unless confidently known (#939 wire shape).
171220
...(retriable !== undefined ? { retriable } : {}),
172221
...(supportedOn !== undefined ? { supportedOn } : {}),
@@ -226,6 +275,19 @@ function stringDetail(
226275
return typeof value === 'string' ? value : undefined;
227276
}
228277

278+
/**
279+
* Narrows an untrusted `diagnosticsRecord` — off the wire or out of a details
280+
* bag — to the locator type, or `undefined`. One reader, so a daemon payload
281+
* and a details bag can never be accepted on different terms.
282+
*/
283+
export function readDiagnosticsRecordRef(value: unknown): DiagnosticsRecordRef | undefined {
284+
if (!value || typeof value !== 'object') return undefined;
285+
const { session, requestId } = value as Partial<DiagnosticsRecordRef>;
286+
if (typeof session !== 'string' || typeof requestId !== 'string') return undefined;
287+
if (session.length === 0 || requestId.length === 0) return undefined;
288+
return { session, requestId };
289+
}
290+
229291
function booleanDetail(
230292
details: Record<string, unknown> | undefined,
231293
key: string,
@@ -242,6 +304,8 @@ function stripDiagnosticMeta(
242304
delete output.hint;
243305
delete output.diagnosticId;
244306
delete output.logPath;
307+
delete output.logPathUnavailable;
308+
delete output.diagnosticsRecord;
245309
delete output.retriable;
246310
delete output.supportedOn;
247311
return Object.keys(output).length > 0 ? output : undefined;
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* #1801: a caller driving a REMOTE daemon must never be handed a diagnostics
3+
* path on the daemon's filesystem. These run the real CLI against a real daemon
4+
* HTTP server on loopback — the only place the whole chain (daemon error →
5+
* locator → fetch → rendered line) is observable end to end.
6+
*/
7+
8+
import { test } from 'vitest';
9+
import assert from 'node:assert/strict';
10+
import fs from 'node:fs';
11+
import path from 'node:path';
12+
import { normalizeError, AppError } from '@agent-device/kernel/errors';
13+
import { createDaemonHttpServer } from '../daemon/server/http-server.ts';
14+
import { resolveSessionRequestLogPath } from '../daemon/session-store.ts';
15+
import { safeSessionName } from '../daemon/session-paths.ts';
16+
import type { DaemonRequest, DaemonResponse } from '../daemon/types.ts';
17+
import { runCliCapture, type CapturedCliRun } from './cli-capture.ts';
18+
import {
19+
closeLoopbackServer,
20+
listenOnLoopback,
21+
skipWhenLoopbackUnavailable,
22+
} from './test-utils/index.ts';
23+
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
24+
25+
const DAEMON_TOKEN = 'daemon-secret';
26+
const DAEMON_SESSION = 'cwd:abcdef0123456789:default';
27+
const RECORD_SENTINEL = 'REMOTE_RECORD_SENTINEL';
28+
29+
type RemoteRun = {
30+
run: CapturedCliRun;
31+
clientStateDir: string;
32+
daemonLogPath: string;
33+
requestId: string;
34+
};
35+
36+
/**
37+
* Runs `argv` against a loopback daemon that always fails the command and
38+
* writes the request's diagnostics record, exactly as the real router does.
39+
* `serveRecord: false` models a daemon with no diagnostics route (an older
40+
* release), so the fetch fails.
41+
*/
42+
async function runAgainstRemoteDaemon(
43+
argv: string[],
44+
options: { serveRecord: boolean },
45+
): Promise<RemoteRun> {
46+
const clientStateDir = mkdtempForTestSync('agent-device-remote-client-');
47+
const daemonStateDir = mkdtempForTestSync('agent-device-remote-daemon-');
48+
const daemonSessionsDir = path.join(daemonStateDir, 'sessions');
49+
const resolveRecordPath = (session: string, requestId: string | undefined): string =>
50+
resolveSessionRequestLogPath(path.join(daemonSessionsDir, safeSessionName(session)), requestId);
51+
let requestId = '';
52+
let daemonLogPath = '';
53+
54+
const handleRequest = async (req: DaemonRequest): Promise<DaemonResponse> => {
55+
requestId = req.meta?.requestId ?? '';
56+
daemonLogPath = resolveRecordPath(DAEMON_SESSION, requestId);
57+
fs.mkdirSync(path.dirname(daemonLogPath), { recursive: true });
58+
fs.writeFileSync(daemonLogPath, `{"phase":"request_failed","data":"${RECORD_SENTINEL}"}\n`);
59+
return {
60+
ok: false,
61+
error: normalizeError(new AppError('COMMAND_FAILED', 'wait timed out'), {
62+
logPath: daemonLogPath,
63+
diagnosticsRecord: { session: DAEMON_SESSION, requestId },
64+
}),
65+
};
66+
};
67+
68+
const server = await createDaemonHttpServer({
69+
token: DAEMON_TOKEN,
70+
handleRequest,
71+
...(options.serveRecord
72+
? { resolveRequestDiagnosticsPath: (ref) => resolveRecordPath(ref.session, ref.requestId) }
73+
: {}),
74+
});
75+
76+
try {
77+
const port = await listenOnLoopback(server);
78+
const run = await runCliCapture(argv, {
79+
useRealDaemonClient: true,
80+
env: {
81+
AGENT_DEVICE_STATE_DIR: clientStateDir,
82+
AGENT_DEVICE_DAEMON_BASE_URL: `http://127.0.0.1:${port}`,
83+
AGENT_DEVICE_DAEMON_AUTH_TOKEN: DAEMON_TOKEN,
84+
},
85+
});
86+
return { run, clientStateDir, daemonLogPath, requestId };
87+
} finally {
88+
await closeLoopbackServer(server);
89+
fs.rmSync(daemonStateDir, { recursive: true, force: true });
90+
}
91+
}
92+
93+
test('a remote failure names a record the caller can actually read', async (t) => {
94+
if (await skipWhenLoopbackUnavailable(t)) return;
95+
96+
const { run, clientStateDir, daemonLogPath, requestId } = await runAgainstRemoteDaemon(
97+
['clipboard', 'write', 'hello'],
98+
{ serveRecord: true },
99+
);
100+
try {
101+
const localPath = path.join(
102+
clientStateDir,
103+
'remote-diagnostics',
104+
safeSessionName(DAEMON_SESSION),
105+
'requests',
106+
`${requestId}.ndjson`,
107+
);
108+
assert.equal(run.code, 1);
109+
assert.match(run.stderr, new RegExp(`Diagnostics Log: ${escapeRegExp(localPath)}`));
110+
assert.equal(run.stderr.includes(daemonLogPath), false, 'daemon-host path must not be printed');
111+
assert.match(fs.readFileSync(localPath, 'utf8'), new RegExp(RECORD_SENTINEL));
112+
} finally {
113+
fs.rmSync(clientStateDir, { recursive: true, force: true });
114+
}
115+
});
116+
117+
test('--json carries the caller-local record path, never the daemon-host one', async (t) => {
118+
if (await skipWhenLoopbackUnavailable(t)) return;
119+
120+
const { run, clientStateDir, daemonLogPath } = await runAgainstRemoteDaemon(
121+
['clipboard', 'write', 'hello', '--json'],
122+
{ serveRecord: true },
123+
);
124+
try {
125+
const payload = JSON.parse(run.stdout) as { error: { logPath?: string } };
126+
assert.equal(payload.error.logPath?.startsWith(clientStateDir), true);
127+
assert.equal(fs.existsSync(payload.error.logPath ?? ''), true);
128+
assert.equal(run.stdout.includes(daemonLogPath), false);
129+
} finally {
130+
fs.rmSync(clientStateDir, { recursive: true, force: true });
131+
}
132+
});
133+
134+
test('--debug prints the fetched record inline and not the local daemon log', async (t) => {
135+
if (await skipWhenLoopbackUnavailable(t)) return;
136+
137+
const { run, clientStateDir } = await runAgainstRemoteDaemon(
138+
['clipboard', 'write', 'hello', '--debug'],
139+
{ serveRecord: true },
140+
);
141+
try {
142+
// A local daemon log in the caller's state dir must stay unread: it belongs
143+
// to a different daemon than the one that failed.
144+
assert.equal(run.stderr.includes('[daemon log]'), false);
145+
assert.match(run.stderr, /\[remote diagnostics\]/);
146+
assert.match(run.stderr, new RegExp(RECORD_SENTINEL));
147+
} finally {
148+
fs.rmSync(clientStateDir, { recursive: true, force: true });
149+
}
150+
});
151+
152+
test('an unfetchable remote record says so instead of naming a path', async (t) => {
153+
if (await skipWhenLoopbackUnavailable(t)) return;
154+
155+
const { run, clientStateDir, daemonLogPath, requestId } = await runAgainstRemoteDaemon(
156+
['clipboard', 'write', 'hello', '--json'],
157+
{ serveRecord: false },
158+
);
159+
try {
160+
const payload = JSON.parse(run.stdout) as {
161+
error: { logPath?: string; logPathUnavailable?: string };
162+
};
163+
// A path may still be named — the CLI's own client-side record, written on
164+
// this machine — but never the daemon host's, and never a record the fetch
165+
// failed to produce.
166+
assert.notEqual(payload.error.logPath, daemonLogPath);
167+
assert.equal(payload.error.logPath?.includes('remote-diagnostics') ?? false, false);
168+
assert.match(
169+
payload.error.logPathUnavailable ?? '',
170+
new RegExp(`^remote daemon http://127\\.0\\.0\\.1:\\d+, request ${requestId}: HTTP 404$`),
171+
);
172+
assert.equal(run.stdout.includes(daemonLogPath), false);
173+
} finally {
174+
fs.rmSync(clientStateDir, { recursive: true, force: true });
175+
}
176+
});
177+
178+
function escapeRegExp(value: string): string {
179+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
180+
}

0 commit comments

Comments
 (0)