|
| 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