diff --git a/package-lock.json b/package-lock.json index 8bcd4a903..cddadac80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,8 @@ "shx": "^0.3.4", "ts-node": "^10.9.2", "tsx": "^4.21.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "ws": "^8.18.3" }, "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 68a940f53..54ed00129 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "shx": "^0.3.4", "ts-node": "^10.9.2", "tsx": "^4.21.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "ws": "^8.18.3" } } diff --git a/src/config-manager.ts b/src/config-manager.ts index 0cd893f54..4cb89f2d5 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -76,7 +76,7 @@ export function isTelemetryDisabledValue(value: unknown): boolean { * PowerShell 5's Set-Content -Encoding UTF8) put U+FEFF first, which * JSON.parse rejects although the config is complete. */ -function parseConfig(text: string): ServerConfig { +export function parseConfig(text: string): ServerConfig { return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text); } diff --git a/src/npm-scripts/remote.ts b/src/npm-scripts/remote.ts index b7d97c9e9..0ea82233c 100644 --- a/src/npm-scripts/remote.ts +++ b/src/npm-scripts/remote.ts @@ -1,6 +1,7 @@ import { MCPDevice, getRemoteDeviceConfigPath, removeRemoteDeviceConfig } from '../remote-device/device.js'; import os from 'os'; import { captureRemote } from '../utils/capture.js'; +import { startDeviceLog } from '../remote-device/diagnostics/device-log.js'; const BLUE = '\x1b[34m'; const RESET = '\x1b[0m'; @@ -27,6 +28,9 @@ Usage: Options: --logout Remove saved local Remote MCP credentials and exit + --report Save a diagnostics zip for support, send it, and exit + (the device doesn't start and nothing signs in) + --no-upload With --report: only save the zip, don't send it --no-persist-session Do not reuse or save authentication for this run --disable-no-sleep Do not prevent sleep while the remote device is running --debug Enable verbose debug logging @@ -36,6 +40,7 @@ Examples: npx @wonderwhy-er/desktop-commander@latest remote npx @wonderwhy-er/desktop-commander@latest remote --debug npx @wonderwhy-er/desktop-commander@latest remote --logout + npx @wonderwhy-er/desktop-commander@latest remote --report Note: --logout removes local credentials only. Revoke the device in the Remote MCP @@ -54,6 +59,12 @@ Note: } return; } + if (process.argv.includes('--report')) { + // Before the device: the report works when sign-in is broken, and never opens it + const { runReport } = await import('../remote-device/diagnostics/report.js'); + await runReport(); + return; + } printRemoteHeader(); // --persist-session is kept as an accepted no-op so existing invocations @@ -70,6 +81,10 @@ Note: if (!verbose) { console.debug = () => { }; } + // The device's status history for `remote --report`. Wraps the console as + // it is now, so the terminal stays the same, but debug status lines are + // kept even without --debug. + startDeviceLog(); console.debug('[DEBUG] Platform:', os.platform()); await captureRemote('remote_device_command_started', { diff --git a/src/remote-device/diagnostics/device-log.ts b/src/remote-device/diagnostics/device-log.ts new file mode 100644 index 000000000..41b3c9894 --- /dev/null +++ b/src/remote-device/diagnostics/device-log.ts @@ -0,0 +1,237 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { format } from 'util'; +import { VERSION } from '../../version.js'; +import { redact } from './redact.js'; + +/** + * The remote device log: ~/.desktop-commander-device/remote-.log, the + * device's history that `remote --report` packs for support. + * + * `remote` passes the device's console output through startDeviceLog(). Every + * line is written, masked by redact() and timestamped in UTC; only the private + * kinds below are dropped. Each UTC weekday has its own files: remote-mon.log + * rotates at 1 MB into remote-mon.1.log and remote-mon.2.log. The first write + * on a weekday whose file is over a day old (last week's) removes that day's + * files first, so the log keeps at most 7 days × 3 files, 21 MB. + */ + +export const DEVICE_LOG_MAX_BYTES = 1024 * 1024; +/** How many files the log keeps per day: remote-.log and its rotated older copies. */ +export const DEVICE_LOG_FILES = 3; +/** The UTC weekdays as the file names spell them; Date.getUTCDay() indexes it. */ +const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; +const DAY_MS = 24 * 60 * 60 * 1000; +/** remote-mon.log, then remote-mon.1.log, remote-mon.2.log (older); `day` is the UTC weekday, 0 = Sunday. */ +export const deviceLogName = (day: number, i: number) => `remote-${DAYS[day]}${i === 0 ? '' : `.${i}`}.log`; +/** Every name the log can use: 7 days × DEVICE_LOG_FILES. */ +export const deviceLogNames = (): string[] => + DAYS.flatMap((_, day) => Array.from({ length: DEVICE_LOG_FILES }, (_, i) => deviceLogName(day, i))); +const MAX_LINE_CHARS = 1000; +/** A multi-line print (a stack, a config dump) is cut after this many lines. */ +const MAX_LINES_PER_CALL = 40; +/** Consecutive write failures after which the log gives up for this run. */ +const MAX_WRITE_FAILURES = 5; + +export function getDeviceLogDir(): string { + return path.join(os.homedir(), '.desktop-commander-device'); +} + +// --- the private kinds: the only lines that don't reach the log as printed ------------------- + +/** " name" when there is one: an empty name, or one that isn't a name, is left out. */ +function toolName(name: string | undefined): string { + const trimmed = (name ?? '').trim(); + return trimmed ? ` ${trimmed}` : ''; +} + +/** + * Tool calls keep the tool's name and the outcome; their arguments, metadata, + * results and error details go. Matched on a print's first line by its fixed + * words only, so an empty name, or one with a space, can't make a print slip + * past (device.ts prints whatever name it gets). The whole print becomes this + * one line, so a result's own lines never reach the log. null: the print goes + * entirely. + */ +const TOOL_CALLS: Array<[RegExp, ((match: RegExpMatchArray) => string) | null]> = [ + // "Received tool call : metadata: ": the name is + // kept only if it looks like one, never the JSON that follows an empty name + [/^Received tool call (\S*):(?: ([\w.-]+)(?=\s|$))?/, (m) => `Received tool call ${m[1]}:${toolName(m[2])}`], + // Anchored on the words after the name, so any name (with a colon, too) is taken whole + [/^Tool call (.*?) ?completed:?$/, (m) => `Tool call${toolName(m[1])} completed`], + [/^Tool call (.*?) ?failed:/, (m) => `Tool call${toolName(m[1])} failed`], + [/^Calling MCP tool: ?(.*?) ?args:/, (m) => `Calling MCP tool${m[1] ? `:${toolName(m[1])}` : ''}`], + [/^Calling MCP tool:/, () => 'Calling MCP tool'], + [/^Error executing tool ?([^:]*?):/, (m) => `Error executing tool${toolName(m[1])}`], + [/^Tool call error details/, null], +]; + +/** The ready block's "User: ", and the sign-in link in "Please visit: ". */ +const PRIVATE_LINES = [/^User:\s/, /^Please visit:/]; + +/** The sign-in link and the code are each printed alone, on the line after these. */ +const BEFORE_PRIVATE_LINE = [/Verify this device in your browser:$/, /Make sure the code matches:$/]; + +/** + * Error objects are written as their name and message only. Printed whole they + * carry their properties: a spawn error's `spawnargs` hold the session tokens + * of the offline update script. + */ +function plain(arg: unknown): unknown { + return arg instanceof Error ? `${arg.name}: ${arg.message}` : arg; +} + +/** Whitespace, bullets, arrows, check marks, emoji and "[DEBUG]" before the text. */ +const LEADING = /^(?:\s|\[DEBUG\]|[-–•→✓✗]|\p{Extended_Pictographic}|️|‍)+/u; + +function toolCall(text: string): { line: string | null } | null { + for (const [pattern, keep] of TOOL_CALLS) { + const match = text.match(pattern); + if (match) return { line: keep ? redact(keep(match)).slice(0, MAX_LINE_CHARS) : null }; + } + return null; +} + +/** + * One line as the log keeps it: masked, or null for a private one. The report + * runs every stored line through this again. + */ +export function cleanLine(line: string): string | null { + const text = line.replace(LEADING, '').trimEnd(); + if (!text) return null; + const tool = toolCall(text); + if (tool) return tool.line; + if (PRIVATE_LINES.some((pattern) => pattern.test(text))) return null; + return redact(text).slice(0, MAX_LINE_CHARS); +} + +export interface DeviceLogOptions { + /** The folder for the log files; ~/.desktop-commander-device by default. */ + dir?: string; + /** The clock (ms), for the timestamps and the day; Date.now by default. Tests set it. */ + now?: () => number; + /** The size at which a file rotates; DEVICE_LOG_MAX_BYTES by default. Tests set it. */ + maxBytes?: number; +} + +export class DeviceLog { + private readonly dir: string; + private readonly now: () => number; + private readonly maxBytes: number; + /** The UTC weekday being written; -1 before the first write. */ + private day = -1; + private size = -1; + private failures = 0; + /** The next printed line is the sign-in link or code. */ + private skipNextLine = false; + + constructor(options: DeviceLogOptions = {}) { + this.dir = options.dir ?? getDeviceLogDir(); + this.now = options.now ?? Date.now; + this.maxBytes = options.maxBytes ?? DEVICE_LOG_MAX_BYTES; + } + + private file(i: number): string { + return path.join(this.dir, deviceLogName(this.day, i)); + } + + /** One console call's arguments: each of its lines, cleaned. */ + record(args: unknown[]): void { + if (this.failures >= MAX_WRITE_FAILURES) return; + let lines: string[]; + try { + const [first, ...rest] = args.map(plain); + lines = format(first, ...rest).split(/\r?\n/).filter((line) => line.trim() !== ''); + } catch { + return; + } + if (lines.length === 0) return; + const tool = toolCall(lines[0].replace(LEADING, '').trimEnd()); + if (tool) { + if (tool.line) this.write(tool.line); + return; + } + for (const line of lines.slice(0, MAX_LINES_PER_CALL)) { + if (this.skipNextLine) { + this.skipNextLine = false; + continue; + } + const text = cleanLine(line); + if (text === null) continue; + if (BEFORE_PRIVATE_LINE.some((pattern) => pattern.test(text))) this.skipNextLine = true; + this.write(text); + } + } + + /** Appends one line as is: callers pass text that is already masked. */ + write(text: string): void { + if (this.failures >= MAX_WRITE_FAILURES) return; + const now = this.now(); + const line = `${new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z')} ${text}\n`; + const bytes = Buffer.byteLength(line); + try { + const day = new Date(now).getUTCDay(); + if (day !== this.day) this.startDay(day, now); + if (this.size < 0) { + fs.mkdirSync(this.dir, { recursive: true }); + this.size = fs.existsSync(this.file(0)) ? fs.statSync(this.file(0)).size : 0; + } + if (this.size > 0 && this.size + bytes > this.maxBytes) { + this.rotate(); + this.size = 0; + } + fs.appendFileSync(this.file(0), line, { mode: 0o600 }); + this.size += bytes; + this.failures = 0; + } catch { + // The log must never break the device: skip the line, give up after a few in a row + this.failures++; + this.size = -1; + } + } + + /** + * The first write of a UTC weekday: if that weekday's file is over a day + * old, it is last week's, and its files go before today's lines start. + */ + private startDay(day: number, now: number): void { + const today = path.join(this.dir, deviceLogName(day, 0)); + if (fs.existsSync(today) && now - fs.statSync(today).mtimeMs > DAY_MS) { + for (let i = 0; i < DEVICE_LOG_FILES; i++) fs.rmSync(path.join(this.dir, deviceLogName(day, i)), { force: true }); + } + this.day = day; + this.size = -1; + } + + /** Drops the day's oldest file and moves each other one a step older: remote-mon.log becomes remote-mon.1.log. */ + private rotate(): void { + fs.rmSync(this.file(DEVICE_LOG_FILES - 1), { force: true }); + for (let i = DEVICE_LOG_FILES - 2; i >= 0; i--) { + if (fs.existsSync(this.file(i))) fs.renameSync(this.file(i), this.file(i + 1)); + } + } +} + +const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug'] as const; + +/** + * Passes console output through the device log for this `remote` run. Each + * method still does what it did (so a console.debug that `remote` silenced + * stays silent), then the log records the call. Returns a function that puts + * the console back. + */ +export function startDeviceLog(options: DeviceLogOptions = {}): () => void { + const log = new DeviceLog(options); + const originals = CONSOLE_METHODS.map((method) => [method, console[method]] as const); + for (const [method, original] of originals) { + console[method] = (...args: unknown[]) => { + original.apply(console, args); + log.record(args); + }; + } + log.write(`Remote started (Desktop Commander ${VERSION}, Node ${process.versions.node}, ${process.platform})`); + return () => { + for (const [method, original] of originals) console[method] = original; + }; +} diff --git a/src/remote-device/diagnostics/redact.ts b/src/remote-device/diagnostics/redact.ts new file mode 100644 index 000000000..251510ef3 --- /dev/null +++ b/src/remote-device/diagnostics/redact.ts @@ -0,0 +1,77 @@ +import fs from 'fs'; +import os from 'os'; + +/** + * The one masker for the remote diagnostics: every line the device log keeps + * and everything `remote --report` writes from free text passes through here. + * + * Masks JWTs, `access_token` / `refresh_token` / `apikey` / `password` values + * after `=` or `:`, `Bearer …` values, emails, UUIDs (device and user ids), + * the home folder (→ `~`), the user name and the host name. + */ + +const SECRET_KEYS = 'access_token|refresh_token|provider_token|provider_refresh_token|apikey|api_key|password|passwd|secret|client_secret'; + +/** `key=value`, `key: value`, `"key":"value"` — the key stays, the value goes. */ +const KEY_VALUE = new RegExp(`(["']?)\\b(${SECRET_KEYS})\\1(\\s*[:=]\\s*)(["']?)[^"'\\s&,;}]+`, 'gi'); +const BEARER = /\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi; +const JWT = /\beyJ[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]*){0,2}/g; +const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; +const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi; + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * The home folder as it may appear: as is and as its real path (on macOS a + * temp folder sits under the /var -> /private/var symlink, and execPath is the + * real one), each with forward slashes and JSON-escaped. Only a whole path + * matches: not "/backup/Users/x" nor "/Users/xy" for "/Users/x". + */ +function homePattern(home: string): RegExp | null { + if (home.length < 2) return null; + const roots = [home]; + try { + roots.push(fs.realpathSync.native(home)); + } catch { /* no such folder: the plain form only */ } + const forms = new Set(roots.flatMap((root) => [root, root.replace(/\\/g, '/'), root.replace(/\\/g, '\\\\')])); + // Longest first, so the JSON-escaped and real forms are not half-matched by a shorter one + const alternatives = [...forms].sort((a, b) => b.length - a.length).map(escapeRegExp); + const pathChar = '[A-Za-z0-9_.-]'; + return new RegExp(`(?'); + out = out.replace(BEARER, '$1'); + out = out.replace(JWT, ''); + out = out.replace(EMAIL, ''); + out = out.replace(UUID, ''); + // "name.local" and the bare "name" a Mac also goes by + const hostname = os.hostname(); + for (const name of new Set([hostname, hostname.split('.')[0]])) { + const host = wordPattern(name); + if (host) out = out.replace(host, ''); + } + const user = wordPattern(safeUserName()); + if (user) out = out.replace(user, ''); + return out; +} diff --git a/src/remote-device/diagnostics/report.ts b/src/remote-device/diagnostics/report.ts new file mode 100644 index 000000000..ab28dee1b --- /dev/null +++ b/src/remote-device/diagnostics/report.ts @@ -0,0 +1,792 @@ +import { exec, execFile } from 'child_process'; +import dns from 'dns'; +import fs from 'fs'; +import net from 'net'; +import { createRequire } from 'module'; +import os from 'os'; +import path from 'path'; +import tls from 'tls'; +import { fileURLToPath } from 'url'; +import PizZip from 'pizzip'; +import { parseConfig } from '../../config-manager.js'; +import { VERSION } from '../../version.js'; +import { deviceLogNames, getDeviceLogDir, cleanLine } from './device-log.js'; +import { redact } from './redact.js'; +import { savedUserId, uploadReport } from './upload.js'; + +/** + * `desktop-commander remote --report`: a diagnostics zip a user sends to + * support. It doesn't start the device and never opens sign-in, so it works + * when sign-in is broken. After saving, it uploads the zip (upload.ts) unless + * --no-upload, and prints the report id to give support. + * + * Only listed facts go in: versions (npm included), how Node runs (the Node + * executable and the entry script, home folder as ~), the clock skew from the + * server's Date header, network timings (server, Supabase REST, realtime + * websocket), the device id and yes/no facts about the rest of device.json, + * telemetryEnabled and clientId from config.json, and the device log + * re-filtered and re-masked. Paths and error texts pass through redact(). + * Tokens, emails, the user name and tool arguments or results never go in. + */ + +const DEFAULT_SERVER_URL = 'https://mcp.desktopcommander.app'; +const CONNECT_TIMEOUT_MS = 4000; +const REQUEST_TIMEOUT_MS = 5000; +const MCP_INFO_ROUNDS = 5; +const NPM_TIMEOUT_MS = 5000; +const PROCESS_LIST_TIMEOUT_MS = 10000; +/** The npm package, and the MCPB bundle (named from manifest.json). */ +const PACKAGE_NAMES = new Set(['@wonderwhy-er/desktop-commander', 'desktop-commander']); +/** `desktop-commander ` is not the MCP server. */ +const NOT_THE_SERVER = new Set(['remote', 'setup', 'remove']); +/** A rotated log file is 1 MB at most; read no more than this of a larger one. */ +const MAX_LOG_READ_BYTES = 2 * 1024 * 1024; + +type NodeKind = 'nvm' | 'fnm' | 'Volta' | 'asdf' | 'mise' | 'Homebrew' | "Claude Desktop's bundled Node" | 'global install' | 'unknown'; + +interface HostTiming { + host: string; + dnsMs?: number; + /** TLS handshake for https, TCP connect for http. */ + connect?: { kind: 'TLS' | 'TCP'; ms: number }; + error?: string; +} + +export interface DiagnosticsReport { + format: 1; + createdAt: string; + versions: { + desktopCommander: string; + node: string; + /** null: npm not found */ + npm: string | null; + os: { name: string; release: string; arch: string }; + /** process.execPath, home folder as ~ */ + nodePath: string; + nodeKind: NodeKind; + /** process.argv[1], home folder as ~ */ + entryPath: string; + runKind: 'MCPB' | 'npx' | 'dev checkout' | 'global npm' | 'unknown'; + }; + clock: { serverAheadSeconds: number | null; error?: string }; + network: { + server: HostTiming & { mcpInfo: { ms: number[]; statuses: number[]; error?: string } }; + supabase: (HostTiming & { + rest: { status?: number; ms?: number; error?: string }; + realtime: { openedMs?: number; heartbeat: boolean; heartbeatMs?: number; error?: string }; + }) | { error: string }; + proxy: { httpsProxy: boolean; httpProxy: boolean }; + }; + device: { + deviceJson: boolean; + parses: boolean; + /** In full: support finds the device by it. null if missing or not id-shaped. */ + id: string | null; + session: boolean; + accessToken: boolean; + refreshToken: boolean; + /** When device.json was last saved (its modification time), ISO in UTC. */ + savedAt: string | null; + }; + settings: { telemetryEnabled: boolean | null; clientId: string | null }; + deviceLog: { files: number; lines: number; first: string | null; last: string | null; lastText: string | null }; + /** Copies of the MCP server running now, and the earliest start (ISO). null: the process list failed. */ + desktopCommanderMcp: { running: number | null; since: string | null; error?: string }; +} + +interface LogPart { name: string; text: string } + +// --- small helpers --------------------------------------------------------------- + +/** Short reasons for the network errors a user's machine typically gives. */ +const NETWORK_REASONS: Record = { + ECONNREFUSED: 'connection refused', + ECONNRESET: 'connection reset', + ETIMEDOUT: 'timed out', + UND_ERR_CONNECT_TIMEOUT: 'timed out', + ENOTFOUND: 'name not found', + EAI_AGAIN: 'name lookup failed', + EHOSTUNREACH: 'host unreachable', + ENETUNREACH: 'network unreachable', +}; + +function errorText(error: unknown): string { + const err = error as any; + if (err?.name === 'TimeoutError' || err?.name === 'AbortError') return 'timed out'; + // fetch() wraps the socket error in `cause` + const code = String(err?.code ?? err?.cause?.code ?? ''); + if (NETWORK_REASONS[code]) return NETWORK_REASONS[code]; + if (/CERT|SELF_SIGNED/.test(code)) return `certificate problem (${code})`; + const cause = err?.cause?.message; + const message = String(err?.message ?? error); + const firstLine = (cause && !message.includes(cause) ? `${message} (${cause})` : message).split(/\r?\n/)[0]; + return redact(firstLine).slice(0, 200); +} + +function elapsed(startedAt: bigint): number { + return Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6); +} + +function withTimeout(promise: Promise, ms: number): Promise { + let timer: NodeJS.Timeout; + return Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timed out')), ms); }), + ]).finally(() => clearTimeout(timer)); +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +function yesNo(value: boolean): string { + return value ? 'yes' : 'no'; +} + +// --- versions ------------------------------------------------------------------------- + +function runKind(): DiagnosticsReport['versions']['runKind'] { + if (process.env.MCP_DXT) return 'MCPB'; + const here = fileURLToPath(import.meta.url); + const parts = here.split(/[\\/]/); + if (parts.includes('_npx')) return 'npx'; + // dist/remote-device/diagnostics/report.js -> the package root + const root = path.resolve(path.dirname(here), '..', '..', '..'); + if (fs.existsSync(path.join(root, '.git')) || fs.existsSync(path.join(root, 'src', 'version.ts'))) return 'dev checkout'; + if (parts.includes('node_modules')) return 'global npm'; + return 'unknown'; +} + +/** + * Which installer the Node executable comes from, read from its path. A path + * none of these match is "unknown": the report shows the path itself anyway. + */ +export function nodeKind(execPath: string): NodeKind { + const p = execPath.replace(/\\/g, '/').toLowerCase(); + if (/\/(\.nvm|nvm|nvm4w)\//.test(p)) return 'nvm'; + if (/\/(\.fnm|fnm|fnm_multishells)\//.test(p)) return 'fnm'; + if (/\/(\.volta|volta)\//.test(p)) return 'Volta'; + if (p.includes('/.asdf/')) return 'asdf'; + if (/\/mise\/installs\//.test(p)) return 'mise'; + if (/\/(claude\.app|anthropicclaude)\//.test(p)) return "Claude Desktop's bundled Node"; + if (/^\/(opt\/homebrew|usr\/local\/cellar|home\/linuxbrew)\//.test(p)) return 'Homebrew'; + if (p.includes('/program files/nodejs/') || /^\/usr\/(local\/)?bin\//.test(p)) return 'global install'; + return 'unknown'; +} + +function npmVersion(): Promise { + return new Promise((resolve) => { + // A fixed command line through the shell: npm is npm.cmd on Windows, which needs one + exec('npm --version', { + timeout: NPM_TIMEOUT_MS, + windowsHide: true, + // --version needs no registry: keep npm's update check off the network + env: { ...process.env, npm_config_update_notifier: 'false' }, + }, (error, stdout) => { + const version = String(stdout).trim(); + resolve(!error && /^\d+\.\d+\.\d+\S*$/.test(version) ? version : null); + }); + }); +} + +function macosVersion(): Promise { + return new Promise((resolve) => { + execFile('sw_vers', ['-productVersion'], { timeout: 2000 }, (error, stdout) => { + resolve(error ? null : String(stdout).trim() || null); + }); + }); +} + +async function osName(): Promise { + if (process.platform === 'win32') return os.version() || 'Windows'; + if (process.platform === 'darwin') { + const version = await macosVersion(); + return version ? `macOS ${version}` : 'macOS'; + } + if (process.platform === 'linux') { + try { + const release = fs.readFileSync('/etc/os-release', 'utf8'); + const pretty = release.match(/^PRETTY_NAME="?([^"\n]*)"?$/m)?.[1]; + if (pretty) return pretty; + } catch { /* fall through */ } + return 'Linux'; + } + return os.type(); +} + +// --- network ---------------------------------------------------------------------------- + +/** DNS, then the TLS handshake (https) or TCP connect (http). `name` is how the report shows the host. */ +async function timeHost(url: URL, name: string): Promise { + const host = url.hostname; + const result: HostTiming = { host: name }; + try { + const startedAt = process.hrtime.bigint(); + await withTimeout(dns.promises.lookup(host), CONNECT_TIMEOUT_MS); + result.dnsMs = elapsed(startedAt); + } catch (error) { + result.error = `DNS failed (${errorText(error)})`; + return result; + } + const secure = url.protocol === 'https:' || url.protocol === 'wss:'; + const port = Number(url.port) || (secure ? 443 : 80); + try { + const startedAt = process.hrtime.bigint(); + await new Promise((resolve, reject) => { + const socket = secure + ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host }, () => { socket.destroy(); resolve(); }) + : net.connect({ host, port }, () => { socket.destroy(); resolve(); }); + socket.setTimeout(CONNECT_TIMEOUT_MS, () => { socket.destroy(); reject(new Error('timed out')); }); + socket.on('error', reject); + }); + result.connect = { kind: secure ? 'TLS' : 'TCP', ms: elapsed(startedAt) }; + } catch (error) { + result.error = `${secure ? 'TLS' : 'TCP'} failed (${errorText(error)})`; + } + return result; +} + +async function checkMcpInfo(serverUrl: string) { + const ms: number[] = []; + const statuses: number[] = []; + const offsets: number[] = []; + let info: { supabaseUrl?: string; supabasePublishableKey?: string; diagnosticsUrl?: unknown } | null = null; + let error: string | undefined; + for (let round = 0; round < MCP_INFO_ROUNDS; round++) { + const sentAt = Date.now(); + const startedAt = process.hrtime.bigint(); + try { + const response = await fetch(`${serverUrl}/api/mcp-info`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + const body = await response.text(); + ms.push(elapsed(startedAt)); + statuses.push(response.status); + const serverMs = Date.parse(response.headers.get('date') ?? ''); + if (!Number.isNaN(serverMs)) offsets.push(serverMs - (sentAt + Date.now()) / 2); + if (response.ok && !info) { + try { info = JSON.parse(body); } catch { error = 'mcp-info is not JSON'; } + } + } catch (err) { + error = errorText(err); + // Stop at the first failure: four more timeouts would only make the user wait + break; + } + } + return { ms, statuses, offsets, info, error }; +} + +async function checkRest(supabaseUrl: string, key: string) { + const startedAt = process.hrtime.bigint(); + try { + const response = await fetch(`${supabaseUrl}/rest/v1/`, { + headers: { apikey: key }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + await response.arrayBuffer(); + return { status: response.status, ms: elapsed(startedAt) }; + } catch (error) { + return { error: errorText(error) }; + } +} + +/** Node 22+ has WebSocket built in; older Node uses `ws`, which realtime-js depends on. */ +function webSocketClass(): any { + if (typeof (globalThis as any).WebSocket === 'function') return (globalThis as any).WebSocket; + try { + return createRequire(import.meta.url)('ws'); + } catch { + return null; + } +} + +function checkRealtime(supabaseUrl: string, key: string): Promise<{ openedMs?: number; heartbeat: boolean; heartbeatMs?: number; error?: string }> { + const WebSocketClass = webSocketClass(); + if (!WebSocketClass) return Promise.resolve({ heartbeat: false, error: 'no WebSocket in this Node version' }); + const url = `${supabaseUrl.replace(/^http/, 'ws')}/realtime/v1/websocket?apikey=${encodeURIComponent(key)}&vsn=1.0.0`; + return new Promise((resolve) => { + const result: { openedMs?: number; heartbeat: boolean; heartbeatMs?: number; error?: string } = { heartbeat: false }; + const startedAt = process.hrtime.bigint(); + let heartbeatAt: bigint; + let socket: any; + let done = false; + const finish = (error?: string) => { + // The close that follows a finished check is not a failure + if (done) return; + done = true; + clearTimeout(timer); + if (error) result.error = error; + try { socket?.close(); } catch { /* already closed */ } + resolve(result); + }; + const timer = setTimeout(() => finish(result.openedMs === undefined ? 'opening timed out' : 'heartbeat not answered in time'), REQUEST_TIMEOUT_MS); + try { + socket = new WebSocketClass(url); + } catch (error) { + finish(errorText(error)); + return; + } + socket.onopen = () => { + result.openedMs = elapsed(startedAt); + heartbeatAt = process.hrtime.bigint(); + socket.send(JSON.stringify({ topic: 'phoenix', event: 'heartbeat', payload: {}, ref: '1' })); + }; + socket.onmessage = (event: any) => { + try { + const message = JSON.parse(String(event.data)); + if (message.ref === '1' && message.event === 'phx_reply') { + result.heartbeat = message.payload?.status === 'ok'; + result.heartbeatMs = elapsed(heartbeatAt); + finish(result.heartbeat ? undefined : 'heartbeat refused'); + } + } catch { /* not ours */ } + }; + socket.onerror = (event: any) => finish(event?.message ? errorText(event) : 'connection failed'); + socket.onclose = (event: any) => finish(`closed (${event?.code ?? '?'})`); + }); +} + +// --- Desktop Commander MCP running now ---------------------------------------------------- + +interface ProcessInfo { pid: number; startedAt: number; command: string } + +/** "[[dd-]hh:]mm:ss" from ps, in seconds. */ +function elapsedSeconds(etime: string): number { + const [days, clock] = etime.includes('-') ? etime.split('-') : ['0', etime]; + const parts = clock.split(':').map(Number); + while (parts.length < 3) parts.unshift(0); + return Number(days) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]; +} + +/** + * Every process with its command line and start time. process.ts's list uses + * tasklist on Windows, which has no command lines, hence Win32_Process here. + */ +function listProcesses(): Promise { + const options = { timeout: PROCESS_LIST_TIMEOUT_MS, windowsHide: true, maxBuffer: 32 * 1024 * 1024 }; + return new Promise((resolve, reject) => { + if (process.platform === 'win32') { + const script = '[Console]::OutputEncoding = [Text.Encoding]::UTF8; Get-CimInstance Win32_Process | ForEach-Object { ' + + '"$($_.ProcessId)`t$(if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString(\'o\') })`t$($_.CommandLine)" }'; + execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], options, (error, stdout) => { + if (error) return reject(error); + resolve(String(stdout).split(/\r?\n/).map((line) => { + const [pid, created, ...command] = line.split('\t'); + return { pid: Number(pid), startedAt: Date.parse(created), command: command.join('\t') }; + }).filter((p) => p.pid > 0 && p.command)); + }); + return; + } + execFile('ps', ['-A', '-ww', '-o', 'pid=,etime=,command='], options, (error, stdout) => { + if (error) return reject(error); + const now = Date.now(); + resolve(String(stdout).split('\n').flatMap((line) => { + const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/); + return match ? [{ pid: Number(match[1]), startedAt: now - elapsedSeconds(match[2]) * 1000, command: match[3] }] : []; + })); + }); + }); +} + +/** Whether `file` is Desktop Commander's dist/index.js: a bin link is followed, then the package.json beside dist/ named. */ +function isDesktopCommanderEntry(file: string, packageNames: Map): boolean { + let entry: string; + try { + entry = fs.realpathSync(file); + } catch { + return false; + } + if (path.basename(entry) !== 'index.js' || path.basename(path.dirname(entry)) !== 'dist') return false; + const root = path.dirname(path.dirname(entry)); + if (!packageNames.has(root)) { + try { + packageNames.set(root, JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name ?? null); + } catch { + packageNames.set(root, null); + } + } + return PACKAGE_NAMES.has(packageNames.get(root) ?? ''); +} + +/** + * Processes running Desktop Commander's MCP server: a command line with its + * dist/index.js (started by an MCP client, npx, or `remote`'s local MCP), not + * followed by `remote` / `setup` / `remove`, and not this report. A shell + * wrapping it names only a bin, which doesn't resolve, so it isn't counted + * twice. Only the count and the earliest start leave this function. + */ +async function desktopCommanderMcp(): Promise { + let processes: ProcessInfo[]; + try { + processes = await listProcesses(); + } catch (error) { + return { running: null, since: null, error: `could not list processes (${errorText(error)})` }; + } + const packageNames = new Map(); + const servers = processes.filter(({ pid, command }) => { + if (pid === process.pid) return false; + const tokens = (command.match(/"[^"]*"|\S+/g) ?? []).map((token) => token.replace(/^"|"$/g, '')); + // The first token is the executable; the entry is any later one + const at = tokens.findIndex((token, i) => i > 0 && /(\.js|desktop-commander(\.cmd)?)$/i.test(token) && + isDesktopCommanderEntry(token, packageNames)); + return at > 0 && !NOT_THE_SERVER.has(tokens[at + 1] ?? ''); + }); + const starts = servers.map((p) => p.startedAt).filter((t) => !Number.isNaN(t)); + return { + running: servers.length, + since: starts.length ? new Date(Math.min(...starts)).toISOString() : null, + }; +} + +function proxySet(...names: string[]): boolean { + return names.some((name) => Boolean(process.env[name])); +} + +// --- device state, settings, device log -------------------------------------------------- + +function deviceState(home: string): DiagnosticsReport['device'] { + const file = path.join(home, '.desktop-commander-device', 'device.json'); + const state: DiagnosticsReport['device'] = { + deviceJson: false, parses: false, id: null, session: false, + accessToken: false, refreshToken: false, savedAt: null, + }; + let text: string; + try { + state.savedAt = new Date(fs.statSync(file).mtimeMs).toISOString(); + text = fs.readFileSync(file, 'utf8'); + state.deviceJson = true; + } catch { + return state; + } + try { + const config = JSON.parse(text); + state.parses = true; + // Only an id-shaped value: whatever else a hand-edited file holds stays out + const id = config?.deviceId; + state.id = typeof id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null; + const session = config?.session; + state.session = Boolean(session) && typeof session === 'object'; + state.accessToken = typeof session?.access_token === 'string' && session.access_token.length > 0; + state.refreshToken = typeof session?.refresh_token === 'string' && session.refresh_token.length > 0; + } catch { /* parses stays false */ } + return state; +} + +function settings(home: string): DiagnosticsReport['settings'] { + try { + // As Desktop Commander reads it: a file saved with a UTF-8 BOM too + const config = parseConfig(fs.readFileSync(path.join(home, '.claude-server-commander', 'config.json'), 'utf8')); + const clientId = typeof config?.clientId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(config.clientId) ? config.clientId : null; + return { + telemetryEnabled: typeof config?.telemetryEnabled === 'boolean' ? config.telemetryEnabled : null, + clientId, + }; + } catch { + return { telemetryEnabled: null, clientId: null }; + } +} + +function readTail(file: string): string { + const size = fs.statSync(file).size; + if (size <= MAX_LOG_READ_BYTES) return fs.readFileSync(file, 'utf8'); + const fd = fs.openSync(file, 'r'); + try { + const buffer = Buffer.alloc(MAX_LOG_READ_BYTES); + fs.readSync(fd, buffer, 0, MAX_LOG_READ_BYTES, size - MAX_LOG_READ_BYTES); + return buffer.toString('utf8'); + } finally { + fs.closeSync(fd); + } +} + +const LOG_LINE = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ) {2}(.*)$/; + +/** A rotated file's number: remote-mon.2.log is 2, remote-mon.log 0. */ +const rotation = (name: string) => Number(name.match(/\.(\d+)\.log$/)?.[1] ?? 0); + +/** + * The device log files (the 21 fixed names that exist), oldest first by + * modification time, each timestamped line cleaned and masked again. + */ +function deviceLog(dir: string): { parts: LogPart[]; summary: DiagnosticsReport['deviceLog'] } { + const parts: LogPart[] = []; + const summary: DiagnosticsReport['deviceLog'] = { files: 0, lines: 0, first: null, last: null, lastText: null }; + const files = deviceLogNames().flatMap((name) => { + try { + return [{ name, mtime: fs.statSync(path.join(dir, name)).mtimeMs }]; + } catch { + return []; + } + }).sort((a, b) => a.mtime - b.mtime || rotation(b.name) - rotation(a.name)); + for (const { name } of files) { + let raw: string; + try { + raw = readTail(path.join(dir, name)); + } catch { + continue; + } + const kept: string[] = []; + for (const line of raw.split(/\r?\n/)) { + const match = line.match(LOG_LINE); + const text = match && cleanLine(match[2]); + if (!match || !text) continue; + kept.push(`${match[1]} ${text}`); + summary.first ??= match[1]; + summary.last = match[1]; + summary.lastText = text; + } + summary.files++; + summary.lines += kept.length; + parts.push({ name, text: kept.length ? kept.join('\n') + '\n' : '' }); + } + return { parts, summary }; +} + +// --- the report --------------------------------------------------------------------------- + +/** Hosts on this machine: an http address there never crosses the network (a local stand-in). */ +const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]']; + +/** The diagnostics Worker's address from /api/mcp-info: https, or http on this machine only. */ +function uploadUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + try { + const url = new URL(value); + return url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK_HOSTS.includes(url.hostname)) ? value : null; + } catch { + return null; + } +} + +export async function collectReport(): Promise<{ report: DiagnosticsReport; logParts: LogPart[]; diagnosticsUrl: string | null }> { + const home = os.homedir(); + const now = Date.now(); + const serverUrl = (process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL).replace(/\/+$/, ''); + + const server = new URL(serverUrl); + const [name, npm, mcp, serverTiming] = await Promise.all([ + osName(), npmVersion(), desktopCommanderMcp(), timeHost(server, server.host), + ]); + const mcpInfo = await checkMcpInfo(serverUrl); + + let supabase: DiagnosticsReport['network']['supabase']; + const supabaseUrl = mcpInfo.info?.supabaseUrl?.replace(/\/+$/, ''); + const key = mcpInfo.info?.supabasePublishableKey; + if (supabaseUrl && key) { + // Shown as "Supabase": the project's address isn't needed to read the report + const timing = await timeHost(new URL(supabaseUrl), 'Supabase'); + const rest = await checkRest(supabaseUrl, key); + const realtime = await checkRealtime(supabaseUrl, key); + supabase = { ...timing, rest, realtime }; + } else { + supabase = { error: mcpInfo.error ? 'skipped: no answer from /api/mcp-info' : 'skipped: /api/mcp-info gave no Supabase address' }; + } + + const log = deviceLog(getDeviceLogDir()); + const report: DiagnosticsReport = { + format: 1, + createdAt: new Date(now).toISOString(), + versions: { + desktopCommander: VERSION, + node: process.versions.node, + npm, + os: { name, release: os.release(), arch: os.arch() }, + // Paths with the home folder as ~: the user name never appears, the rest of the path does + nodePath: redact(process.execPath), + nodeKind: nodeKind(process.execPath), + entryPath: process.argv[1] ? redact(process.argv[1]) : 'unknown', + runKind: runKind(), + }, + clock: mcpInfo.offsets.length + ? { serverAheadSeconds: Math.round(median(mcpInfo.offsets) / 1000) } + : { serverAheadSeconds: null, error: 'no Date header from the server' }, + network: { + server: { ...serverTiming, mcpInfo: { ms: mcpInfo.ms, statuses: mcpInfo.statuses, error: mcpInfo.error } }, + supabase, + proxy: { httpsProxy: proxySet('HTTPS_PROXY', 'https_proxy'), httpProxy: proxySet('HTTP_PROXY', 'http_proxy') }, + }, + device: deviceState(home), + settings: settings(home), + deviceLog: log.summary, + desktopCommanderMcp: mcp, + }; + return { report, logParts: log.parts, diagnosticsUrl: uploadUrl(mcpInfo.info?.diagnosticsUrl) }; +} + +function networkOk(network: DiagnosticsReport['network']): boolean { + const { server, supabase } = network; + if (server.error || server.mcpInfo.error || server.mcpInfo.ms.length < MCP_INFO_ROUNDS) return false; + if (server.mcpInfo.statuses.some((status) => status !== 200)) return false; + if ('rest' in supabase) { + const { rest, realtime } = supabase; + return !supabase.error && !rest.error && (rest.status ?? 0) < 500 && realtime.heartbeat; + } + return false; +} + +function hostLine(timing: HostTiming): string { + const parts: string[] = []; + if (timing.dnsMs !== undefined) parts.push(`DNS ${timing.dnsMs} ms`); + if (timing.connect) parts.push(`${timing.connect.kind} ${timing.connect.ms} ms`); + if (timing.error) parts.push(timing.error); + return parts.join(' · '); +} + +/** + * The REST probe has no sign-in, so any HTTP answer (401 included) means + * Supabase is reachable; only a 5xx is the server's own failure. report.json + * keeps the raw status. + */ +function restText(rest: { status?: number; ms?: number; error?: string }): string { + if (rest.error || rest.status === undefined) return `not reachable (${rest.error ?? 'no answer'})`; + if (rest.status >= 500) return `answered with a server error (${rest.status}) in ${rest.ms} ms`; + return `reachable in ${rest.ms} ms`; +} + +function localTimestamp(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const offset = -date.getTimezoneOffset(); + const hours = Math.trunc(Math.abs(offset) / 60); + const minutes = Math.abs(offset) % 60; + const zone = `UTC${offset < 0 ? '-' : '+'}${hours}${minutes ? `:${pad(minutes)}` : ''}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())} (${zone})`; +} + +/** An ISO time (UTC) to the minute: "2026-10-05 14:21". */ +const utcMinute = (iso: string) => iso.slice(0, 16).replace('T', ' '); + +/** "running (2 copies, since 14:02)": the time alone if it's today, else with the date. */ +function mcpText(mcp: DiagnosticsReport['desktopCommanderMcp']): string { + if (mcp.running === null) return `unknown (${mcp.error})`; + if (mcp.running === 0) return 'not running'; + const copies = mcp.running === 1 ? '1 copy' : `${mcp.running} copies`; + if (!mcp.since) return `running (${copies})`; + const pad = (n: number) => String(n).padStart(2, '0'); + const since = new Date(mcp.since); + const day = `${since.getFullYear()}-${pad(since.getMonth() + 1)}-${pad(since.getDate())}`; + const time = `${pad(since.getHours())}:${pad(since.getMinutes())}`; + const today = new Date(); + const isToday = day === `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`; + return `running (${copies}, since ${isToday ? time : `${day} ${time}`})`; +} + +export function formatReport(report: DiagnosticsReport): string { + const label = (name: string) => name.padEnd(14); + const indent = ' '.repeat(14); + const lines: string[] = []; + const { versions, clock, network, device, settings: config, deviceLog: log } = report; + + lines.push(`Desktop Commander diagnostics — ${localTimestamp(new Date(report.createdAt))}`); + lines.push('This file contains: versions, how Node runs (paths, with the home folder as ~), clock, network checks, ' + + 'the device id and whether sign-in data exists (yes/no), device status history.'); + lines.push('It does not contain: tokens, passwords, emails, the user name, tool arguments or results.'); + lines.push(''); + + lines.push(`${label('Versions')}Desktop Commander ${versions.desktopCommander} · Node ${versions.node} · ` + + `npm ${versions.npm ?? 'not found'} · ${versions.os.name} (${versions.os.release}) ${versions.os.arch}`); + lines.push(`${label('Node')}${versions.nodePath} (${versions.nodeKind})`); + lines.push(`${label('Running')}${versions.entryPath} (${versions.runKind})`); + lines.push(`${label('MCP')}${mcpText(report.desktopCommanderMcp)}`); + + const ahead = clock.serverAheadSeconds; + lines.push(`${label('Clock')}${ahead === null ? `unknown (${clock.error})` + : Math.abs(ahead) <= 1 ? 'device matches the server (within 1 s, from the server\'s Date header)' + : `device is ${Math.abs(ahead)} s ${ahead > 0 ? 'behind' : 'ahead of'} the server (from the server's Date header)`}`); + + const { server, supabase, proxy } = network; + const info = server.mcpInfo; + const infoText = info.ms.length + ? `/api/mcp-info ${info.ms.length}× ${Math.min(...info.ms)}/${median(info.ms)}/${Math.max(...info.ms)} ms (min/median/max)` + + (info.statuses.some((s) => s !== 200) ? `, statuses ${info.statuses.join(', ')}` : '') + + (info.error ? `, then failed: ${info.error}` : '') + : `/api/mcp-info failed: ${info.error}`; + lines.push(`${label('Network')}${server.host}: ${[hostLine(server), infoText].filter(Boolean).join(' · ')}`); + if ('rest' in supabase) { + const rt = supabase.realtime; + const realtime = rt.openedMs === undefined + ? `realtime websocket not opened (${rt.error})` + : `realtime websocket opened in ${rt.openedMs} ms, ${rt.heartbeat ? 'heartbeat answered' : `heartbeat ${rt.error}`}`; + lines.push(`${indent}Supabase: ${[hostLine(supabase), restText(supabase.rest), realtime].filter(Boolean).join(' · ')}`); + } else { + lines.push(`${indent}Supabase: ${supabase.error}`); + } + lines.push(`${indent}Proxy: HTTPS_PROXY ${proxy.httpsProxy ? 'set' : 'not set'} · HTTP_PROXY ${proxy.httpProxy ? 'set' : 'not set'}`); + + const saved = device.savedAt === null ? '' : `, saved ${utcMinute(device.savedAt)} UTC`; + lines.push(`${label('Device')}${!device.deviceJson ? 'signed-in data: no (no device.json)' + : !device.parses ? `signed-in data: device.json does not parse${saved}` + // Signed in means a token is really there, not just a session object + : `id ${device.id ?? 'none'} · signed-in data: ${yesNo(device.accessToken || device.refreshToken)} ` + + `(access token: ${yesNo(device.accessToken)}, refresh token: ${yesNo(device.refreshToken)})${saved}`}`); + + const telemetry = config.telemetryEnabled === null ? 'not set' : config.telemetryEnabled ? 'on' : 'off'; + lines.push(`${label('Settings')}telemetry: ${telemetry} · client id: ${config.clientId ?? 'none'}` + + (config.clientId ? ' (lets us find this device\'s telemetry)' : '')); + + lines.push(`${label('Device log')}${log.lines === 0 + ? (log.files ? `${log.files} file(s), no lines` : 'none yet (it is written while `remote` runs)') + : `${log.lines.toLocaleString('en-US')} lines from ${utcMinute(log.first!)} to ${utcMinute(log.last!)} UTC; last: "${log.lastText}"`}`); + + return lines.join('\n') + '\n'; +} + +function zipName(home: string, date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`; + for (let n = 1; ; n++) { + const file = path.join(home, `desktop-commander-report-${stamp}${n > 1 ? `-${n}` : ''}.zip`); + if (!fs.existsSync(file)) return file; + } +} + +/** Collects the report and writes the zip to the home folder; returns its bytes for the upload too. */ +export async function writeReport(): Promise<{ file: string; bytes: number; zip: Buffer; report: DiagnosticsReport; diagnosticsUrl: string | null }> { + const { report, logParts, diagnosticsUrl } = await collectReport(); + const zip = new PizZip(); + zip.file('report.txt', formatReport(report)); + zip.file('report.json', JSON.stringify(report, null, 2) + '\n'); + for (const part of logParts) zip.file(`device-log/${part.name}`, part.text); + const buffer: Buffer = zip.generate({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }); + const file = zipName(os.homedir(), new Date(report.createdAt)); + // Owner-only, like the device log + fs.writeFileSync(file, buffer, { flag: 'wx', mode: 0o600 }); + return { file, bytes: buffer.length, zip: buffer, report, diagnosticsUrl }; +} + +/** `remote --report`: the terminal side. */ +export async function runReport(): Promise { + console.log('Collecting diagnostics… (about 10 s)'); + let result: Awaited>; + try { + result = await writeReport(); + } catch (error) { + console.error(`❌ Could not save the diagnostics report: ${errorText(error)}`); + process.exitCode = 1; + return; + } + const { report, file, bytes, zip, diagnosticsUrl } = result; + const mark = (ok: boolean) => (ok ? '✓' : '✗'); + const log = report.deviceLog; + console.log(' ' + [ + `${mark(true)} versions`, + `${mark(report.clock.serverAheadSeconds !== null)} clock`, + `${mark(networkOk(report.network))} network`, + `${mark(true)} device state`, + // No log yet is a fact, not a failed check + `${mark(true)} device log (${log.files ? `${log.files} file${log.files === 1 ? '' : 's'}` : 'none yet'})`, + ].join(' ')); + console.log(`Saved: ${file} (${Math.max(1, Math.round(bytes / 1024))} KB)`); + console.log('It holds no passwords, tokens, emails or file contents; you can open it and check.'); + if (process.argv.includes('--no-upload')) { + console.log('Reply to your support conversation with this file attached.'); + return; + } + try { + const id = await uploadReport(zip, { diagnosticsUrl, userId: savedUserId(os.homedir()), deviceId: report.device.id }); + console.log(`Sent to Desktop Commander support. Report id: ${id}`); + console.log('Give this id to support. We keep it for 7 days, then delete it.'); + } catch (error) { + // The zip is saved either way + console.log(`Not sent (${errorText(error)}). Attach the zip to your support conversation instead.`); + } +} diff --git a/src/remote-device/diagnostics/upload.ts b/src/remote-device/diagnostics/upload.ts new file mode 100644 index 000000000..26a8da9ab --- /dev/null +++ b/src/remote-device/diagnostics/upload.ts @@ -0,0 +1,65 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Sends the diagnostics zip to Desktop Commander's diagnostics Worker, which + * stores it for 7 days and answers with a report id the user gives support. + * + * The address comes only from the server's /api/mcp-info `diagnosticsUrl`, so + * the Worker can move without a release. When the server names none, nothing + * is uploaded and the zip stays saved. + * The ids only sort the upload into a folder: the user id is the saved access + * token's `sub`, read locally like blocking-offline-update.js reads `exp`, and + * the device id is device.json's. Nothing is refreshed, nothing signs in, and + * device.json is only read. + */ + +const UPLOAD_TIMEOUT_MS = 30_000; +const ID_SHAPED = /^[A-Za-z0-9_-]{1,64}$/; +/** The longest piece of the Worker's error message that is shown. */ +const MAX_MESSAGE_CHARS = 200; + +/** The signed-in user's id from ~/.desktop-commander-device/device.json, or null. */ +export function savedUserId(home: string): string | null { + try { + const config = JSON.parse(fs.readFileSync(path.join(home, '.desktop-commander-device', 'device.json'), 'utf8')); + const token = config?.session?.access_token; + if (typeof token !== 'string') return null; + const { sub } = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')); + return typeof sub === 'string' && ID_SHAPED.test(sub) ? sub : null; + } catch { + return null; + } +} + +export interface UploadOptions { + /** From /api/mcp-info; null when the server names none, and then nothing is uploaded. */ + diagnosticsUrl: string | null; + userId: string | null; + deviceId: string | null; + timeoutMs?: number; +} + +/** POSTs the zip and resolves the report id; rejects with a short reason. */ +export async function uploadReport(zip: Buffer, options: UploadOptions): Promise { + const url = options.diagnosticsUrl; + if (!url) throw new Error('the server named no upload address'); + const headers: Record = { 'Content-Type': 'application/zip' }; + if (options.userId) headers['X-DC-User-Id'] = options.userId; + if (options.deviceId) headers['X-DC-Device-Id'] = options.deviceId; + const response = await fetch(url, { + method: 'POST', + headers, + body: new Uint8Array(zip), + signal: AbortSignal.timeout(options.timeoutMs ?? UPLOAD_TIMEOUT_MS), + }); + const body = await response.json().catch(() => null); + if (!response.ok) { + // The Worker answers errors as {code, message}: show its message, on one short line + const message = typeof body?.message === 'string' ? body.message.replace(/\s+/g, ' ').trim().slice(0, MAX_MESSAGE_CHARS) : ''; + throw new Error(`the server answered ${response.status}${message ? `: ${message}` : ''}`); + } + const id = body?.id; + if (typeof id !== 'string' || !ID_SHAPED.test(id)) throw new Error('the server gave no report id'); + return id; +} diff --git a/test/helpers/remote-device.js b/test/helpers/remote-device.js index 96c17db73..08d479e4a 100644 --- a/test/helpers/remote-device.js +++ b/test/helpers/remote-device.js @@ -51,9 +51,13 @@ export function startDevice(env, args = []) { return device; } -/** `desktop-commander remote --logout`, as the user runs it; resolves with its exit code and output */ -export function runLogout(env) { - const child = spawn(process.execPath, [path.join(PROJECT_ROOT, 'dist/index.js'), 'remote', '--logout'], { +/** + * `desktop-commander remote `, as the user runs it; resolves with its exit + * code and output. `execPath` is the Node that runs it (this one by default); + * `timeoutMs` kills it after that long (none by default). + */ +export function runRemote(env, args, { execPath = process.execPath, timeoutMs } = {}) { + const child = spawn(execPath, [path.join(PROJECT_ROOT, 'dist/index.js'), 'remote', ...args], { cwd: PROJECT_ROOT, env, stdio: ['ignore', 'pipe', 'pipe'], @@ -62,9 +66,16 @@ export function runLogout(env) { let output = ''; child.stdout.on('data', (data) => { output += data; }); child.stderr.on('data', (data) => { output += data; }); - return new Promise((resolve) => child.on('close', (code) => resolve({ code, output }))); + const timer = timeoutMs === undefined ? null : setTimeout(() => child.kill(), timeoutMs); + return new Promise((resolve) => child.on('close', (code) => { + if (timer) clearTimeout(timer); + resolve({ code, output }); + })); } +/** `desktop-commander remote --logout`, as the user runs it; resolves with its exit code and output */ +export const runLogout = (env) => runRemote(env, ['--logout']); + /** Waits until `predicate` holds or the device exits; returns the predicate's last value */ export async function waitFor(device, predicate, timeoutMs) { const deadline = Date.now() + timeoutMs; diff --git a/test/helpers/remote-stand-in.js b/test/helpers/remote-stand-in.js index aa0efcf2b..b24303645 100644 --- a/test/helpers/remote-stand-in.js +++ b/test/helpers/remote-stand-in.js @@ -1,4 +1,5 @@ import http from 'http'; +import { WebSocketServer } from 'ws'; /** * A local stand-in for the services a remote device talks to, so a real @@ -26,6 +27,16 @@ import http from 'http'; * mcp_remote_calls table, behind the access token. * - Realtime: the websocket is refused. The device then reports itself * registered but not reachable, which is enough for the session under test. + * + * For `remote --report`, which only probes these services and uploads a zip, + * options that are all off by default: + * - serverAheadSec: every answer's Date header runs that many seconds ahead + * - diagnostics: /api/mcp-info names this stand-in's POST /diagnostics as the + * upload address (diagnosticsUrl), and each upload there is kept in + * `uploads` and answered with `reportId`, as the diagnostics Worker does + * - realtime: the websocket opens and phoenix heartbeats are answered + * A test can also point mcp-info at another Supabase (supabaseUrl) and set the + * REST root's status (restRootStatus). Every request is kept in `requests`. */ const USER = { @@ -40,9 +51,13 @@ const USER = { const base64url = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); -export async function startRemoteStandIn({ accessTtlSec = 3600, reuseIntervalSec = 0 } = {}) { +export async function startRemoteStandIn({ + accessTtlSec = 3600, reuseIntervalSec = 0, serverAheadSec = 0, diagnostics = false, realtime = false, +} = {}) { const deviceId = 'b6f0a1c2-0000-4000-8000-000000000695'; const anonKey = 'stand-in-anon-key'; + /** Set by failUploads(): { count, status, message } */ + let uploadFailure = null; /** session id -> { counter, tokens (by generation), revoked, lastRefreshedAt } */ const sessions = new Map(); /** refresh token -> { sessionId, generation } */ @@ -65,8 +80,23 @@ export async function startRemoteStandIn({ accessTtlSec = 3600, reuseIntervalSec deviceFlowRequests: 0, /** Requests this stand-in has no answer for */ unexpected: [], + /** Every request, in order: { method, url, apikey }; a websocket upgrade has method UPGRADE */ + requests: [], accessTtlSec, reuseIntervalSec, + serverAheadSec, + /** The Supabase address mcp-info names; null: this stand-in */ + supabaseUrl: null, + /** The status the REST root (/rest/v1/) answers; null: as any table without a session, 401 */ + restRootStatus: null, + /** The upload address mcp-info names (with `diagnostics`: this stand-in's /diagnostics); undefined: none */ + diagnosticsUrl: undefined, + /** The report id an upload is answered with */ + reportId: 'R7KQ2M4X', + /** Every upload to /diagnostics: { url, headers, body (a Buffer) } */ + uploads: [], + /** Phoenix heartbeats answered on the realtime websocket */ + heartbeatsAnswered: 0, /** * A session as a completed device authorization hands it over. @@ -108,6 +138,14 @@ export async function startRemoteStandIn({ accessTtlSec = 3600, reuseIntervalSec failRefreshStatus = status; }, + /** + * The next `count` uploads answer `status`, with the Worker's + * { code, message } when `message` is given and an empty object when not + */ + failUploads(count, status, message = null) { + uploadFailure = { count, status, message }; + }, + /** * Device lookups get no answer until release(). A starting device looks its * saved device up after loading device.json and before saving it again, so @@ -135,6 +173,8 @@ export async function startRemoteStandIn({ accessTtlSec = 3600, reuseIntervalSec }, close() { + for (const client of sockets?.clients ?? []) client.terminate(); + sockets?.close(); server.closeAllConnections?.(); return new Promise((resolve) => server.close(() => resolve())); }, @@ -211,12 +251,33 @@ export async function startRemoteStandIn({ accessTtlSec = 3600, reuseIntervalSec }]; } - function handle(request, body) { + /** The diagnostics Worker: { id } for a stored report, or its error */ + function upload(request, raw) { + standIn.uploads.push({ url: request.url, headers: request.headers, body: raw }); + if (uploadFailure?.count > 0) { + uploadFailure.count--; + const { status, message } = uploadFailure; + return [status, message === null ? {} : { code: status, message }]; + } + return [200, { id: standIn.reportId }]; + } + + function handle(request, body, raw) { const url = new URL(request.url, standIn.url); const route = `${request.method} ${url.pathname}`; if (route === 'GET /api/mcp-info') { - return [200, { supabaseUrl: standIn.url, supabasePublishableKey: anonKey }]; + return [200, { + supabaseUrl: standIn.supabaseUrl ?? standIn.url, + supabasePublishableKey: anonKey, + diagnosticsUrl: standIn.diagnosticsUrl, + }]; + } + if (diagnostics && route === 'POST /diagnostics') { + return upload(request, raw); + } + if (standIn.restRootStatus !== null && route === 'GET /rest/v1/') { + return [standIn.restRootStatus, {}]; } if (route === 'POST /device/start' || route === 'POST /device/poll') { standIn.deviceFlowRequests++; @@ -255,12 +316,16 @@ export async function startRemoteStandIn({ accessTtlSec = 3600, reuseIntervalSec } const server = http.createServer((request, response) => { - let body = ''; - request.setEncoding('utf8'); - request.on('data', (chunk) => { body += chunk; }); + standIn.requests.push({ method: request.method, url: request.url, apikey: request.headers.apikey }); + const chunks = []; + request.on('data', (chunk) => { chunks.push(chunk); }); request.on('end', () => { + const raw = Buffer.concat(chunks); const respond = () => { - const [status, payload] = handle(request, body); + const [status, payload] = handle(request, raw.toString('utf8'), raw); + if (standIn.serverAheadSec) { + response.setHeader('Date', new Date(Date.now() + standIn.serverAheadSec * 1000).toUTCString()); + } response.writeHead(status, { 'Content-Type': 'application/json', // Error bodies carry `code`, the shape auth-js reads from API version 2024-01-01 on @@ -277,12 +342,28 @@ export async function startRemoteStandIn({ accessTtlSec = 3600, reuseIntervalSec respond(); }); }); - // Realtime is out of scope: refuse the websocket instead of leaving it hanging - server.on('upgrade', (request, socket) => { - socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); + // Realtime is out of scope unless asked for: refuse the websocket instead of leaving it hanging + const sockets = realtime ? new WebSocketServer({ noServer: true }) : null; + server.on('upgrade', (request, socket, head) => { + standIn.requests.push({ method: 'UPGRADE', url: request.url, apikey: request.headers.apikey }); + if (!sockets || new URL(request.url, standIn.url).pathname !== '/realtime/v1/websocket') { + socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); + return; + } + sockets.handleUpgrade(request, socket, head, (ws) => { + ws.on('message', (data) => { + let message; + try { message = JSON.parse(String(data)); } catch { return; } + if (message.topic === 'phoenix' && message.event === 'heartbeat') { + standIn.heartbeatsAnswered++; + ws.send(JSON.stringify({ topic: 'phoenix', event: 'phx_reply', payload: { status: 'ok', response: {} }, ref: message.ref })); + } + }); + }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); standIn.url = `http://127.0.0.1:${server.address().port}`; + if (diagnostics) standIn.diagnosticsUrl = `${standIn.url}/diagnostics`; return standIn; } diff --git a/test/integration/remote-report-upload.js b/test/integration/remote-report-upload.js new file mode 100644 index 000000000..c19593a0f --- /dev/null +++ b/test/integration/remote-report-upload.js @@ -0,0 +1,99 @@ +/** + * Integration test: a `remote --report` zip uploads to the deployed + * diagnostics Worker. + * + * The CLI uploads only to the address the server's /api/mcp-info names, and + * production names one only once DIAGNOSTICS_URL is set there. So the test + * passes the Worker's address itself (WORKER_URL): it saves a report with the + * built CLI against the live services (MCP_SERVER_URL can point elsewhere), + * then uploads those bytes with uploadReport(). It never uses the real home + * folder: the home the runner gives (outside one, it skips) holds a + * device.json that looks like a real sign-in, with ids fixed and reserved for + * tests, never real Supabase ids: device 00000000-0000-0000-0000-000000000001, + * and a fake, unsigned access token whose payload has sub + * 00000000-0000-0000-0000-000000000000 (only `sub` is read; nothing signs in + * or refreshes). Uploads land like any report, under + * //-.zip. + * + * 1. remote --report --no-upload: exit 0, the zip in the home, no "Sent" + * line. + * 2. That zip, uploaded to WORKER_URL: a report id of 8 characters. + * 3. A non-zip uploaded to WORKER_URL: refused with the Worker's message, + * "the server answered 415: …". + * + * Three POSTs: the Worker allows 5 a minute per IP. Prints the report id so it + * can be found in the bucket. Runs with the integration tests: + * `npm run test:integration`, or alone: + * npm run build && node test/integration/run-all-integration-tests.js remote-report-upload.js + */ + +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import { runRemote, writeDeviceConfig } from '../helpers/remote-device.js'; +import { isTestHome } from '../helpers/test-env.js'; +import { runIfMain, skip } from '../helpers/run-if-main.js'; + +/** Reserved for tests: never a real Supabase user or device. */ +const TEST_USER_ID = '00000000-0000-0000-0000-000000000000'; +const TEST_DEVICE_ID = '00000000-0000-0000-0000-000000000001'; +/** The deployed diagnostics Worker: the test's own address, as the CLI takes none built in. */ +const WORKER_URL = 'https://diagnostics.ds-c09.workers.dev'; +/** The Worker's report ids: 8 characters without 0, 1, I and O. */ +const REPORT_ID = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}$/; +const RUN_TIMEOUT_MS = 120_000; + +/** A JWT-shaped token: the CLI decodes the payload's `sub` and never verifies it. */ +function fakeAccessToken(sub) { + return [{ alg: 'none', typ: 'JWT' }, { sub, exp: 1 }] + .map((part) => Buffer.from(JSON.stringify(part)).toString('base64url')) + .join('.') + '.unsigned'; +} + +function savedZip(result, home) { + const saved = result.output.match(/Saved: (.+\.zip) \(/)?.[1]; + assert.ok(saved, `a "Saved:" line:\n${result.output}`); + assert.ok(saved.startsWith(home), `the zip is in the test's home: ${saved}`); + assert.ok(fs.existsSync(saved), `the zip exists: ${saved}`); + return saved; +} + +async function runTests() { + if (!isTestHome()) { + skip('remote-report-upload.js writes device.json in the home folder: run it through node test/integration/run-all-integration-tests.js'); + return true; + } + const { uploadReport } = await import('../../dist/remote-device/diagnostics/upload.js'); + console.log(`Worker: ${WORKER_URL}${process.env.MCP_SERVER_URL ? `, server: ${process.env.MCP_SERVER_URL}` : ''}`); + const home = os.homedir(); + writeDeviceConfig(home, { + deviceId: TEST_DEVICE_ID, + session: { access_token: fakeAccessToken(TEST_USER_ID), refresh_token: 'test-refresh-token' }, + }); + const options = { diagnosticsUrl: WORKER_URL, userId: TEST_USER_ID, deviceId: TEST_DEVICE_ID }; + + console.log('\n[Case 1] remote --report --no-upload saves the zip'); + const kept = await runRemote(process.env, ['--report', '--no-upload'], { timeoutMs: RUN_TIMEOUT_MS }); + assert.strictEqual(kept.code, 0, `exit code ${kept.code}:\n${kept.output}`); + const zip = savedZip(kept, home); + assert.ok(!/Sent to|Not sent/.test(kept.output), `no upload line:\n${kept.output}`); + assert.match(kept.output, /Reply to your support conversation with this file attached\./); + console.log('[Case 1] PASS - saved, not sent'); + + console.log('\n[Case 2] the zip uploads to the Worker'); + const id = await uploadReport(fs.readFileSync(zip), options); + assert.match(id, REPORT_ID, `a report id from the Worker: ${id}`); + console.log(`[Case 2] PASS - report id ${id}`); + + console.log('\n[Case 3] a non-zip is refused with the Worker\'s message'); + await assert.rejects(uploadReport(Buffer.from('not a zip'), options), (error) => { + assert.match(error.message, /^the server answered 415: \S/, error.message); + console.log(`[Case 3] PASS - ${error.message}`); + return true; + }); + + console.log(`\nAll assertions passed. Report id: ${id}`); + return true; +} + +runIfMain(import.meta.url, runTests); diff --git a/test/test-redact.js b/test/test-redact.js new file mode 100644 index 000000000..654d15770 --- /dev/null +++ b/test/test-redact.js @@ -0,0 +1,153 @@ +#!/usr/bin/env node + +/** + * redact() is the one masker the remote diagnostics pass every line through: + * the device log (remote-.log) and `remote --report`. A planted secret of each + * kind it covers must never come out of it, and a plain status line must come + * out unchanged. + * + * Kinds: JWTs (eyJ…), access_token / refresh_token / apikey / password values + * after `=` or `:` (query strings, JSON, prose), `Bearer …`, emails, UUIDs, the + * home folder (→ `~`), the user name and the host name. + * + * redact() reads os.homedir() on every call, and only reads: the cases use the + * home the runner gives. One case needs a home that isn't given by its real path, + * so it sets HOME / USERPROFILE itself for its duration. + * + * Runs as part of `npm test`, or standalone: + * node test/run-all-tests.js test/test-redact.js + */ +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runIfMain, skip, SKIPPED } from './helpers/run-if-main.js'; + +const JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJwbGFudGVkLXN1YmplY3QifQ.c2lnbmF0dXJlLXBsYW50ZWQtMTIz'; +const UUID = '3f2b8c1e-9a4d-4e7b-8c2f-1a2b3c4d5e6f'; +const EMAIL = 'planted.person+tag@example.co.uk'; + +/** Fails naming the secret and the output it leaked into. */ +function assertGone(output, secret, what) { + assert(!output.toLowerCase().includes(secret.toLowerCase()), `${what} came out: ${JSON.stringify(output)}`); +} + +async function runTests() { + const { redact } = await import('../dist/remote-device/diagnostics/redact.js'); + const failures = []; + + async function test(name, fn) { + try { + console.log(`${await fn() === SKIPPED ? '- skipped:' : '✅ PASS '} ${name}`); + } catch (error) { + failures.push(name); + console.error(`🔴 FAIL ${name}\n ${error.message}`); + } + } + + await test('a JWT is masked, alone and inside a sentence', () => { + const out = redact(`session refresh failed for token ${JWT} (expired)`); + assertGone(out, 'eyJ', 'the JWT'); + assertGone(out, 'c2lnbmF0dXJlLXBsYW50ZWQtMTIz', 'the JWT signature'); + assert(out.includes('session refresh failed for token'), `the text around it is kept: ${out}`); + }); + + await test('access_token / refresh_token / apikey / password values are masked after = and :', () => { + const cases = [ + ['query string', 'GET /realtime/v1/websocket?apikey=sb_publishable_PLANTEDkey123&vsn=1.0.0', 'sb_publishable_PLANTEDkey123'], + ['JSON', '{"access_token":"plantedAccessValue1","refresh_token":"plantedRefreshValue2"}', 'plantedAccessValue1'], + ['JSON, second key', '{"access_token":"plantedAccessValue1","refresh_token":"plantedRefreshValue2"}', 'plantedRefreshValue2'], + ['prose with a colon', 'login failed, password: hunter2planted', 'hunter2planted'], + ['key=value', 'retrying with refresh_token=plantedRefreshValue3 now', 'plantedRefreshValue3'], + ['upper case key', 'APIKEY=PlantedUpperKey9', 'PlantedUpperKey9'], + ]; + for (const [what, input, secret] of cases) { + assertGone(redact(input), secret, `the ${what} value`); + } + }); + + await test('a Bearer header value is masked', () => { + const out = redact('Authorization: Bearer plantedOpaqueBearer.Value-42'); + assertGone(out, 'plantedOpaqueBearer', 'the bearer value'); + }); + + await test('an email is masked', () => { + const out = redact(`Session set successfully, user: ${EMAIL}`); + assertGone(out, EMAIL, 'the email'); + assertGone(out, 'planted.person', 'the email local part'); + }); + + await test('a UUID is masked, in lower and upper case', () => { + const out = redact(`Presence tracked (device ${UUID} visible as online) user:${UUID.toUpperCase()}`); + assertGone(out, UUID, 'the UUID'); + }); + + await test('the home folder becomes ~, with either slash and JSON-escaped', () => { + const home = os.homedir(); + const file = path.join(home, 'projects', 'secret-plan.txt'); + const inputs = [ + `ENOENT: no such file or directory, open '${file}'`, + `open ${file.replace(/\\/g, '/')}`, + `{"path":${JSON.stringify(file)}}`, + ]; + for (const input of inputs) { + const out = redact(input); + assertGone(out, home, 'the home folder'); + assertGone(out, home.replace(/\\/g, '/'), 'the home folder (forward slashes)'); + assert(out.includes('~'), `the home folder becomes ~: ${out}`); + } + }); + + // The runner's home is given by its real path, so this case makes a home + // that isn't: on macOS the temporary folder is under /var, a link to /private/var + await test('the home folder\'s real path becomes ~ too (on macOS the temp folder is under a symlink)', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-test-redact-home-')); + const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = home; + process.env.USERPROFILE = home; + try { + assert.strictEqual(os.homedir(), home, 'os.homedir() should follow HOME/USERPROFILE'); + const real = fs.realpathSync(home); + const out = redact(`${real}${path.sep}.nvm${path.sep}node`); + assert.strictEqual(out, `~${path.sep}.nvm${path.sep}node`); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + await test('the home folder is matched as a whole path, not inside a longer one', () => { + const home = os.homedir(); + for (const input of [`/backup${home.replace(/\\/g, '/')}/x`, `${home}-other${path.sep}x`, `${home}lt${path.sep}x`]) { + const out = redact(input); + assert(!out.includes('~'), `${JSON.stringify(input)} became ${JSON.stringify(out)}`); + } + }); + + // redact() leaves a name under 3 characters alone (redact.ts), so such a name can't be checked + await test('the user name is masked', () => { + const user = os.userInfo().username; + if (user.length < 3) return skip('the user name is under 3 characters, which redact() leaves alone'); + assertGone(redact(`owner ${user} here`), user, 'the user name'); + }); + + await test('the host name is masked', () => { + const host = os.hostname(); + if (host.length < 3) return skip('the host name is under 3 characters, which redact() leaves alone'); + assertGone(redact(`running on ${host} now`), host, 'the host name'); + }); + + await test('a plain status line comes out unchanged', () => { + const line = "Channel subscribed (recovered after 2 attempts) — socket=open(1) ch=joined attempt=0"; + assert.strictEqual(redact(line), line); + const debug = "Channel reads 'joined' but no confirmed heartbeat in 81s - forcing recreate — socket=open(1) ch=joined attempt=3"; + assert.strictEqual(redact(debug), debug); + }); + + console.log(`\n${failures.length ? '🔴' : '✅'} redact: ${failures.length} failing test(s).`); + return failures.length === 0; +} + +runIfMain(import.meta.url, runTests); diff --git a/test/test-remote-device-log.js b/test/test-remote-device-log.js new file mode 100644 index 000000000..282ec1f17 --- /dev/null +++ b/test/test-remote-device-log.js @@ -0,0 +1,398 @@ +#!/usr/bin/env node + +/** + * The remote device log (~/.desktop-commander-device/remote-.log): the + * device's history that `remote --report` packs. `remote` passes the device's + * console output through it. Every line is written, with a UTC timestamp and + * masked by redact(); only the private kinds are dropped. Each UTC weekday has + * its own files: remote-mon.log rotates at 1 MB into remote-mon.1.log and + * remote-mon.2.log. The first write on a weekday whose files are over a day old + * (last week's) removes them first, so the log never holds more than 21 files. + * + * The private kinds, each printed here as the device prints it, with a planted + * secret that must be nowhere in the log: + * - tool calls: the tool's name and whether it succeeded stay; its arguments, + * metadata, results and error details go; + * - the user's email in the ready block; + * - the sign-in link and code; + * - error objects dumped whole: only their name and message stay (a spawn + * error's `spawnargs` carry the session tokens). + * + * Debug lines are kept even without --debug, while the terminal stays exactly + * as before: a console.debug that `remote` silenced prints nothing. + * + * Every log here is written to a temporary folder (createTempDir()); the home is + * only read, for the default folder and for the masking. + * + * Runs as part of `npm test`, or standalone: + * node test/run-all-tests.js test/test-remote-device-log.js + */ +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createTempDir } from './helpers/test-env.js'; +import { runIfMain } from './helpers/run-if-main.js'; + +const MB = 1024 * 1024; +const TIMESTAMP = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ {2}/; + +/** A log file's text; by default the folder's one current-day file (remote-.log), whatever the day. */ +function readLog(dir, name) { + if (name === undefined) { + const today = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^remote-[a-z]{3}\.log$/.test(f)) : []; + assert(today.length <= 1, `one current-day file expected, found: ${today.join(', ')}`); + name = today[0]; + if (!name) return ''; + } + const file = path.join(dir, name); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +// A Monday (UTC), so its files are remote-mon.* +const MONDAY = Date.parse('2026-10-05T10:00:00Z'); +/** A clock the test moves: the log takes its time from it. */ +function clockAt(ms) { + const clock = { ms, now: () => clock.ms }; + return clock; +} + +/** Fails naming the secret and the log it leaked into. */ +function assertAbsent(text, secret, what) { + assert(!text.includes(secret), `${what} reached the log:\n${text}`); +} + +const DEVICE_ID = '3f2b8c1e-9a4d-4e7b-8c2f-1a2b3c4d5e6f'; +const EMAIL = 'planted.person@example.com'; + +async function runTests() { + const { DeviceLog, startDeviceLog, getDeviceLogDir, DEVICE_LOG_FILES, deviceLogName, deviceLogNames } = + await import('../dist/remote-device/diagnostics/device-log.js'); + const root = createTempDir('dc-test-device-log-'); + let dirCount = 0; + const freshDir = () => fs.mkdtempSync(path.join(root, `log-${++dirCount}-`)); + const failures = []; + + async function test(name, fn) { + try { + await fn(); + console.log(`✅ PASS ${name}`); + } catch (error) { + failures.push(name); + console.error(`🔴 FAIL ${name}\n ${error.message}`); + } + } + + try { + await test('the log folder defaults to ~/.desktop-commander-device', () => { + assert.strictEqual(getDeviceLogDir(), path.join(os.homedir(), '.desktop-commander-device')); + }); + + await test('lines are kept, one per line, with a UTC timestamp and without the leading emoji', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + log.record(['✅ Channel subscribed (recovered after 1 attempt) — socket=open(1) ch=joined attempt=0']); + log.record(['⏱️ Channel subscription timed out, Reconnecting... — socket=open(1) ch=errored attempt=1']); + log.record(['🔌 Device marked as offline']); + log.record([' - 🔌 Connected to Remote MCP']); + const lines = readLog(dir).trimEnd().split('\n'); + assert.strictEqual(lines.length, 4, `4 lines expected:\n${lines.join('\n')}`); + for (const line of lines) assert.match(line, TIMESTAMP, `timestamped: ${line}`); + assert.match(lines[0], /Z {2}Channel subscribed \(recovered after 1 attempt\) — socket=open\(1\)/); + assert.match(lines[1], /Z {2}Channel subscription timed out/); + assert.match(lines[2], /Z {2}Device marked as offline$/); + assert.match(lines[3], /Z {2}Connected to Remote MCP$/); + }); + + await test('every other line is kept too, masked: no list of known lines', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + log.record(['│ Return to ChatGPT or Claude and continue your conversation.']); + log.record(['⏳ Subscribing to tool call channel...']); + log.record([`👋 Presence tracked (device ${DEVICE_ID} visible as online)`]); + log.record([`[DEBUG] Creating channel: user:${DEVICE_ID}`]); + log.record([` - ⏳ Connecting to Local Desktop Commander MCP using: ${process.execPath} ${path.join(os.homedir(), 'dist', 'index.js')}`]); + const text = readLog(dir); + assert.match(text, /Z {2}│ Return to ChatGPT or Claude and continue your conversation\.\n/); + assert.match(text, /Z {2}Subscribing to tool call channel\.\.\.\n/); + assert.match(text, /Z {2}Presence tracked \(device visible as online\)\n/); + assert.match(text, /Z {2}Creating channel: user:\n/); + assert.match(text, /Z {2}Connecting to Local Desktop Commander MCP using: /); + assertAbsent(text, DEVICE_ID, 'the device id'); + assertAbsent(text, os.homedir(), 'the home folder'); + }); + + await test('tool calls keep the tool name and the outcome; arguments, metadata, results and errors go', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + log.record([`🔧 Received tool call call-1: read_file {"path":"C:/planted-arg/plan.txt"} metadata: {"conversation":"planted-meta"}`]); + log.record([`✅ Tool call read_file completed:\r\n {"content":[{"type":"text","text":"planted-result"}]}`]); + log.record([`❌ Tool call write_file failed:`, 'EACCES planted-error-detail']); + log.record(['[DEBUG] Calling MCP tool:', 'start_search', 'args:', JSON.stringify({ pattern: 'planted-debug-arg' }).substring(0, 100)]); + log.record(['Error executing tool start_search:', new Error('planted-tool-error')]); + log.record(['[DEBUG] Tool call error details:', new Error('planted-error-object')]); + const text = readLog(dir); + for (const secret of ['planted-arg', 'planted-meta', 'planted-result', 'planted-error-detail', 'planted-debug-arg', 'planted-tool-error', 'planted-error-object']) { + assertAbsent(text, secret, secret); + } + assert.match(text, /Z {2}Received tool call call-1: read_file\n/); + assert.match(text, /Z {2}Tool call read_file completed\n/); + assert.match(text, /Z {2}Tool call write_file failed\n/); + assert.match(text, /Z {2}Calling MCP tool: start_search\n/); + assert.match(text, /Z {2}Error executing tool start_search\n/); + }); + + await test('an empty, spaced or colon tool name still drops the arguments, results and error details', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + const names = { empty: '', spaced: 'read file', colon: 'a:b' }; + for (const [tag, name] of Object.entries(names)) { + // As device.ts and desktop-commander-integration.ts print them + log.record([`🔧 Received tool call call-${tag}: ${name} {"path":"planted-${tag}-arg"} metadata: {"m":"planted-${tag}-meta"}`]); + log.record([`✅ Tool call ${name} completed:\r\n {"content":"planted-${tag}-result"}`]); + log.record([`❌ Tool call ${name} failed:`, `planted-${tag}-error-detail`]); + log.record(['[DEBUG] Calling MCP tool:', name, 'args:', JSON.stringify({ pattern: `planted-${tag}-debug-arg` })]); + log.record([`Error executing tool ${name}:`, new Error(`planted-${tag}-tool-error`)]); + } + const text = readLog(dir); + for (const tag of Object.keys(names)) { + for (const what of ['arg', 'meta', 'result', 'error-detail', 'debug-arg', 'tool-error']) { + assertAbsent(text, `planted-${tag}-${what}`, `the ${tag} name's ${what}`); + } + } + assert.match(text, /Z {2}Received tool call call-empty:\n/, text); + assert.match(text, /Z {2}Tool call completed\n/); + assert.match(text, /Z {2}Tool call failed\n/); + assert.match(text, /Z {2}Calling MCP tool\n/); + assert.match(text, /Z {2}Error executing tool\n/); + assert.match(text, /Z {2}Tool call read file completed\n/, 'a name with a space is kept'); + assert.match(text, /Z {2}Tool call a:b completed\n/, 'a name with a colon is kept'); + assert.match(text, /Z {2}Tool call a:b failed\n/); + assert.match(text, /Z {2}Calling MCP tool: a:b\n/); + }); + + await test('a tool result with a line inside cannot smuggle it in', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + log.record([`✅ Tool call read_file completed:\r\n✅ Channel subscribed leaked-file-content`]); + log.record(['\n⚠️ Remote session expired and could not be renewed.']); + const text = readLog(dir); + assertAbsent(text, 'leaked-file-content', 'a line of the result'); + assert.match(text, /Z {2}Remote session expired and could not be renewed\.\n/, `a leading newline is fine:\n${text}`); + }); + + await test('the ready block keeps everything but the email', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + log.record(['✅ Device ready:']); + log.record([` - User: ${EMAIL}`]); + log.record([` - Device ID: ${DEVICE_ID}`]); + log.record([`[DEBUG] Session set successfully, user: ${EMAIL}`]); + const text = readLog(dir); + assertAbsent(text, EMAIL, 'the email'); + assertAbsent(text, 'planted.person', 'the email local part'); + assert.match(text, /Z {2}Device ready:\n/); + assert.doesNotMatch(text, /Z {2}User:/, 'the User line goes'); + assert.match(text, /Z {2}Device ID: +\n/); + }); + + await test('the sign-in link and code never reach it, as the authenticator prints them', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + log.record(['📋 Please complete authentication:\n']); + log.record([' 1. Verify this device in your browser:']); + log.record([' https://mcp.desktopcommander.app/device?user_code=PLNT-CODE\n']); + log.record([' 2. Make sure the code matches:']); + log.record([' PLNT-CODE\n']); + log.record([' Code expires in 10 minutes.\n']); + log.record([' - Could not open browser automatically.']); + log.record([' - Please visit: https://mcp.desktopcommander.app/device?user_code=PLNT-CODE\n']); + log.record([' - ⏳ Waiting for authorization...\n']); + const text = readLog(dir); + assertAbsent(text, 'PLNT-CODE', 'the code'); + assertAbsent(text, 'user_code', 'the sign-in link'); + assert.match(text, /Z {2}1\. Verify this device in your browser:\n/); + assert.match(text, /Z {2}2\. Make sure the code matches:\n/); + assert.match(text, /Z {2}Code expires in 10 minutes\.\n/); + assert.match(text, /Z {2}Waiting for authorization\.\.\.\n/); + }); + + await test('an error object is written as its name and message only: a spawn error\'s arguments carry tokens', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + const spawnError = Object.assign(new Error('spawnSync node ETIMEDOUT'), { + code: 'ETIMEDOUT', + spawnargs: ['script.js', DEVICE_ID, 'https://x.supabase.co', 'sb_publishable_PLANTEDkey', 'eyJplanted.access.token', 'plantedRefresh99'], + }); + log.record(['[DEBUG] spawn error:', spawnError]); + const text = readLog(dir); + for (const secret of ['plantedRefresh99', 'PLANTEDkey', 'eyJ', 'spawnargs']) assertAbsent(text, secret, secret); + assert.match(text, /Z {2}spawn error: Error: spawnSync node ETIMEDOUT\n/); + }); + + await test('kept lines are masked', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir }); + log.record([`❌ Channel error: refresh failed access_token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.c2ln for ${EMAIL} — socket=closed(3) ch=errored attempt=2`]); + log.record([`💾 Found persisted session for device ${DEVICE_ID}`]); + const text = readLog(dir); + assertAbsent(text, 'eyJ', 'a JWT'); + assertAbsent(text, EMAIL, 'the email'); + assertAbsent(text, DEVICE_ID, 'the device id'); + assert.match(text, /Z {2}Channel error: refresh failed/); + assert.match(text, /Z {2}Found persisted session for device \n/); + }); + + await test('the files are named by UTC weekday: remote-mon.log, remote-mon.1.log, remote-mon.2.log; 21 names in all', () => { + assert.strictEqual(DEVICE_LOG_FILES, 3); + assert.deepStrictEqual([0, 1, 2].map((i) => deviceLogName(1, i)), ['remote-mon.log', 'remote-mon.1.log', 'remote-mon.2.log']); + assert.deepStrictEqual([0, 6].map((day) => deviceLogName(day, 0)), ['remote-sun.log', 'remote-sat.log']); + const names = deviceLogNames(); + assert.strictEqual(names.length, 21); + assert.strictEqual(new Set(names).size, 21, 'no name twice'); + }); + + await test('it writes to the current UTC weekday\'s file, with the line timestamped by the same clock', () => { + const dir = freshDir(); + const clock = clockAt(MONDAY); + new DeviceLog({ dir, now: clock.now }).record(['✅ Channel subscribed']); + assert.deepStrictEqual(fs.readdirSync(dir), ['remote-mon.log']); + assert.match(readLog(dir, 'remote-mon.log'), /^2026-10-05T10:00:00Z {2}Channel subscribed\n$/); + }); + + await test('within a day it rotates at 1 MB and, past the last file, keeps exactly DEVICE_LOG_FILES files, oldest last', () => { + const dir = freshDir(); + const log = new DeviceLog({ dir, now: clockAt(MONDAY).now }); + const filler = 'x'.repeat(900); + let written = 0; + // Enough for DEVICE_LOG_FILES + 2 files: the rotation runs past the last file twice + for (let i = 0; written < (DEVICE_LOG_FILES + 1.6) * MB; i++) { + log.record([`❌ Channel error: n${String(i).padStart(6, '0')} ${filler}`]); + written += 950; + } + log.record(['✅ Channel subscribed (the newest line)']); + const expected = Array.from({ length: DEVICE_LOG_FILES }, (_, i) => deviceLogName(1, i)); + assert.deepStrictEqual(fs.readdirSync(dir).sort(), [...expected].sort(), `files: ${fs.readdirSync(dir).join(', ')}`); + for (const name of expected) { + const size = fs.statSync(path.join(dir, name)).size; + assert(size <= MB, `${name} is ${size} bytes, over 1 MB`); + assert(size > MB / 2 || name === deviceLogName(1, 0), `${name} holds a full file's worth: ${size} bytes`); + } + assert.match(readLog(dir), /Channel subscribed \(the newest line\)\n$/, 'the newest line is at the end of remote-mon.log'); + // Each older file starts with older lines: n… grows from the last file to remote-mon.log's predecessor + const first = (name) => Number(readLog(dir, name).match(/n(\d{6})/)[1]); + for (let i = 1; i < DEVICE_LOG_FILES - 1; i++) { + assert(first(deviceLogName(1, i + 1)) < first(deviceLogName(1, i)), `${deviceLogName(1, i + 1)} holds older lines than ${deviceLogName(1, i)}`); + } + }); + + await test('a new day starts its own file; the day before stays as it was', () => { + const dir = freshDir(); + const clock = clockAt(MONDAY); + const log = new DeviceLog({ dir, now: clock.now }); + log.record(['✅ Monday line']); + clock.ms = MONDAY + DAY_MS; + log.record(['✅ Tuesday line']); + assert.deepStrictEqual(fs.readdirSync(dir).sort(), ['remote-mon.log', 'remote-tue.log']); + assert.match(readLog(dir, 'remote-mon.log'), /Z {2}Monday line\n$/); + assert.match(readLog(dir, 'remote-tue.log'), /^2026-10-06T10:00:00Z {2}Tuesday line\n$/); + }); + + await test('the first write on a weekday removes that weekday\'s files from last week, and only those', () => { + const dir = freshDir(); + const lastWeek = new Date(MONDAY - 7 * DAY_MS); + for (const name of [deviceLogName(1, 0), deviceLogName(1, 1), deviceLogName(1, 2)]) { + fs.writeFileSync(path.join(dir, name), `2026-09-28T10:00:00Z last week (${name})\n`); + fs.utimesSync(path.join(dir, name), lastWeek, lastWeek); + } + // Tuesday's file from 6 days ago belongs to another weekday: it stays + const sixDaysAgo = new Date(MONDAY - 6 * DAY_MS); + fs.writeFileSync(path.join(dir, 'remote-tue.log'), '2026-09-29T10:00:00Z last Tuesday\n'); + fs.utimesSync(path.join(dir, 'remote-tue.log'), sixDaysAgo, sixDaysAgo); + new DeviceLog({ dir, now: clockAt(MONDAY).now }).record(['✅ This Monday']); + assert.deepStrictEqual(fs.readdirSync(dir).sort(), ['remote-mon.log', 'remote-tue.log'], 'remote-mon.1.log and .2.log are gone'); + assert.match(readLog(dir, 'remote-mon.log'), /^2026-10-05T10:00:00Z {2}This Monday\n$/, 'last week\'s lines are gone'); + assert.match(readLog(dir, 'remote-tue.log'), /last Tuesday/); + }); + + await test('a weekday\'s files under a day old are kept: a device started again the same day appends', () => { + const dir = freshDir(); + const earlierToday = new Date(MONDAY - 2 * 60 * 60 * 1000); + fs.writeFileSync(path.join(dir, 'remote-mon.log'), '2026-10-05T08:00:00Z earlier today\n'); + fs.utimesSync(path.join(dir, 'remote-mon.log'), earlierToday, earlierToday); + new DeviceLog({ dir, now: clockAt(MONDAY).now }).record(['✅ After a restart']); + assert.match(readLog(dir, 'remote-mon.log'), /earlier today\n.*Z {2}After a restart\n$/s); + }); + + await test('two weeks of daily rotation never leave more than 21 files, all of them the fixed names', () => { + const dir = freshDir(); + // Small files so each day rotates past its last file; the clock starts now, as the files' times do + const clock = clockAt(Date.now()); + const log = new DeviceLog({ dir, now: clock.now, maxBytes: 300 }); + for (let d = 0; d < 14; d++) { + clock.ms += DAY_MS; + for (let i = 0; i < 20; i++) log.record([`❌ Channel error: day ${d} line ${i} ${'x'.repeat(60)}`]); + assert(fs.readdirSync(dir).length <= 21, `day ${d}: ${fs.readdirSync(dir).length} files`); + } + const files = fs.readdirSync(dir).sort(); + assert.deepStrictEqual(files, [...deviceLogNames()].sort(), `files: ${files.join(', ')}`); + // The oldest week is gone: every file holds lines from the last 7 days only + for (const name of files) assert.doesNotMatch(readLog(dir, name), /day [0-6] line/, `${name} still holds the first week`); + }); + + await test('startDeviceLog: the terminal is unchanged, and silenced debug lines still reach the log', () => { + const dir = freshDir(); + const saved = { log: console.log, debug: console.debug, warn: console.warn, error: console.error, info: console.info }; + const printed = []; + const realWrite = process.stdout.write; + const realErrWrite = process.stderr.write; + let stop; + try { + // What `remote` does without --debug, before it starts the log + console.debug = () => { }; + process.stdout.write = (chunk, ...rest) => { printed.push(String(chunk)); return true; }; + process.stderr.write = (chunk, ...rest) => { printed.push(String(chunk)); return true; }; + stop = startDeviceLog({ dir }); + console.log('✅ Channel subscribed'); + console.debug(`[DEBUG] ⚠️ Channel reads 'joined' but no confirmed heartbeat in 81s - forcing recreate — socket=open(1) ch=joined attempt=0`); + console.debug('[DEBUG] Reconnect backoff: 1200ms'); + console.log(`🔧 Received tool call c-2: list_directory {"path":"/secret"} metadata: {}`); + console.error('❌ Presence track not acknowledged (timed out) — attempt 1/3'); + } finally { + stop?.(); + process.stdout.write = realWrite; + process.stderr.write = realErrWrite; + Object.assign(console, saved); + } + assert.deepStrictEqual(printed, [ + '✅ Channel subscribed\n', + `🔧 Received tool call c-2: list_directory {"path":"/secret"} metadata: {}\n`, + '❌ Presence track not acknowledged (timed out) — attempt 1/3\n', + ], 'the terminal shows exactly what it showed before'); + const text = readLog(dir); + assert.match(text, /Z {2}Channel subscribed\n/); + assert.match(text, /Z {2}Channel reads 'joined' but no confirmed heartbeat in 81s/); + assert.match(text, /Z {2}Reconnect backoff: 1200ms\n/); + assert.match(text, /Z {2}Received tool call c-2: list_directory\n/); + assert.match(text, /Z {2}Presence track not acknowledged \(timed out\) — attempt 1\/3\n/); + assertAbsent(text, '/secret', 'a tool argument'); + }); + + await test('stop() puts the console back', () => { + const dir = freshDir(); + const before = console.log; + const stop = startDeviceLog({ dir }); + assert.notStrictEqual(console.log, before, 'console.log is wrapped while the log runs'); + stop(); + assert.strictEqual(console.log, before); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + + console.log(`\n${failures.length ? '🔴' : '✅'} remote device log: ${failures.length} failing test(s).`); + return failures.length === 0; +} + +runIfMain(import.meta.url, runTests); diff --git a/test/test-remote-report.js b/test/test-remote-report.js new file mode 100644 index 000000000..854d0fe8f --- /dev/null +++ b/test/test-remote-report.js @@ -0,0 +1,580 @@ +#!/usr/bin/env node + +/** + * `desktop-commander remote --report`: one command a user runs to give support + * a diagnostics zip. It must not start the device and must never open sign-in, + * so it also works when the user's sign-in is broken. + * + * The zip lands in the home folder as desktop-commander-report-.zip and + * holds report.txt (readable), report.json (the same facts) and device-log/ + * (the device log's remote-.log files, oldest first, masked again). It records versions (npm included), + * how Node runs (the Node executable and the entry script, the home folder + * shown as ~, and what kind of install each is), the clock skew read from the + * server's Date header, network timings to the server, Supabase REST and the + * realtime websocket, whether Desktop Commander MCP is running right now + * (count and earliest start, from the process list), the device id and yes/no + * facts about the rest of device.json, and only telemetryEnabled and clientId + * from config.json. + * + * Everything runs against the local stand-in (helpers/remote-stand-in.js), with + * its options for the report: its Date header runs 90 s ahead of this machine, + * it answers the realtime heartbeat and it takes uploads. No network. + * Planted secrets (the session's tokens in device.json, an email, a JWT and the + * home path in the device log, another setting in config.json, the publishable + * key) must never appear anywhere in the zip, nor the user name. The device + * id appears once, in the Device section; inside log lines it stays masked. + * + * After saving, the report uploads the zip (unless --no-upload) to the + * diagnosticsUrl that /api/mcp-info names, and only there: https, or http on + * this machine (the stand-in names its own /diagnostics). With none, it + * keeps the zip and says the server named no upload address. The upload + * carries the user id (the saved token's `sub`) and the device id, and the + * terminal prints the report id; a failure keeps the zip and says "Not sent", + * with the Worker's message when it sends one ({code, message}). + * + * The files are planted in the home the runner gives (outside one, the test + * skips); the cases that need another home get one from createTestEnv(). It + * never reads the real home folders. + * + * Runs as part of `npm test`, or standalone: + * node test/run-all-tests.js test/test-remote-report.js + */ +import assert from 'node:assert'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import PizZip from 'pizzip'; +import { deviceConfigPath, runRemote, writeDeviceConfig } from './helpers/remote-device.js'; +import { startRemoteStandIn } from './helpers/remote-stand-in.js'; +import { createTestEnv, isTestHome } from './helpers/test-env.js'; +import { runIfMain, skip, SKIPPED } from './helpers/run-if-main.js'; + +const SERVER_AHEAD_S = 90; +const EMAIL = 'planted.person@example.com'; +const CLIENT_ID = 'client-id-planted-0042'; +const OTHER_SETTING = 'other-setting-must-not-appear'; +/** The old `remote` ignores --report and starts the device: bound it */ +const RUN_TIMEOUT_MS = 60_000; + +/** A file from the zip a run says it saved. */ +function zipFile(result, name) { + const saved = result.output.match(/Saved: (.+\.zip) \(/)?.[1]; + assert(saved, `no "Saved:" line:\n${result.output}`); + return new PizZip(fs.readFileSync(saved)).file(name).asText(); +} + +/** A package folder whose dist/index.js only waits: it stands in for an installed copy in the process list. */ +function fakePackage(folder, name) { + fs.mkdirSync(path.join(folder, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(folder, 'package.json'), JSON.stringify({ name, version: '0.0.0', type: 'module' })); + fs.writeFileSync(path.join(folder, 'dist', 'index.js'), 'setInterval(() => {}, 1 << 30);\n'); + return path.join(folder, 'dist', 'index.js'); +} + +async function runTests() { + if (!isTestHome()) { + skip('test-remote-report.js plants device.json and config.json in the home folder: run it through node test/run-all-tests.js'); + return true; + } + const home = os.homedir(); + const standIn = await startRemoteStandIn({ serverAheadSec: SERVER_AHEAD_S, diagnostics: true, realtime: true }); + const UPLOAD_URL = standIn.diagnosticsUrl; + /** `env` pointed at the stand-in, with no proxy */ + const reportEnv = (env) => ({ + ...env, + MCP_SERVER_URL: standIn.url, + HTTPS_PROXY: '', https_proxy: '', HTTP_PROXY: '', http_proxy: '', + }); + const env = reportEnv(process.env); + /** `remote --report ` with `runEnv`; `execPath` is the Node that runs it */ + const runReport = (runEnv, args = [], { execPath } = {}) => + runRemote(runEnv, ['--report', ...args], { execPath, timeoutMs: RUN_TIMEOUT_MS }); + /** Another home for one case: `fn` gets it and an env for it, and it is removed after */ + async function inOtherHome(fn) { + const other = createTestEnv(); + try { + return await fn({ home: other.home, env: reportEnv(other.env) }); + } finally { + other.cleanup(); + } + } + const failures = []; + + async function test(name, fn) { + try { + console.log(`${await fn() === SKIPPED ? '- skipped:' : '✅ PASS '} ${name}`); + } catch (error) { + failures.push(name); + console.error(`🔴 FAIL ${name}\n ${error.message}`); + } + } + + try { + // --- the planted home --------------------------------------------------------- + + const deviceDir = path.join(home, '.desktop-commander-device'); + const configDir = path.join(home, '.claude-server-commander'); + const session = standIn.login(); + writeDeviceConfig(home, { deviceId: standIn.deviceId, session }); + const deviceJson = deviceConfigPath(home); + const savedAt = new Date(Date.now() - 3 * 60 * 60 * 1000); + fs.utimesSync(deviceJson, savedAt, savedAt); + fs.mkdirSync(path.join(deviceDir, 'device.json.lock')); + // A log as an older or hand-edited version might have left it: raw secrets in kept lines. One file per UTC weekday: + // Thursday 1 Oct, then Monday 5 Oct, with modification times in that order + fs.writeFileSync(path.join(deviceDir, 'remote-thu.log'), [ + '2026-10-01T09:12:00Z Starting MCP Device...', + `2026-10-01T09:12:05Z Channel error: refresh failed for ${EMAIL} token ${session.access_token} — socket=closed(3) ch=errored attempt=1`, + `2026-10-01T09:12:06Z Persisted session invalid: ENOENT ${path.join(home, 'secret-project', 'plan.txt')}`, + `2026-10-01T09:12:07Z Channel error: device ${standIn.deviceId} not joined — socket=open(1) ch=errored attempt=2`, + ].join('\n') + '\n'); + fs.writeFileSync(path.join(deviceDir, 'remote-mon.log'), [ + '2026-10-05T14:20:44Z Device marked as offline', + `2026-10-05T14:20:45Z Received tool call c-9: read_file {"path":"planted-tool-arg"}`, + '2026-10-05T14:21:03Z Channel subscribed (recovered after 1 attempt) — socket=open(1) ch=joined attempt=0', + ].join('\n') + '\n'); + fs.utimesSync(path.join(deviceDir, 'remote-thu.log'), new Date('2026-10-01T09:12:07Z'), new Date('2026-10-01T09:12:07Z')); + fs.utimesSync(path.join(deviceDir, 'remote-mon.log'), new Date('2026-10-05T14:21:03Z'), new Date('2026-10-05T14:21:03Z')); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ + telemetryEnabled: false, + clientId: CLIENT_ID, + allowedDirectories: [path.join(home, 'secret-project')], + defaultShell: OTHER_SETTING, + }, null, 2)); + const deviceJsonBefore = fs.readFileSync(deviceJson); + const deviceJsonMtimeBefore = fs.statSync(deviceJson).mtimeMs; + + // --- the first run -------------------------------------------------------------- + + const started = Date.now(); + const run = await runReport(env); + const seconds = ((Date.now() - started) / 1000).toFixed(1); + const zips = fs.readdirSync(home).filter((name) => /^desktop-commander-report-.*\.zip$/.test(name)); + let zip = null; + const entries = {}; + if (zips.length === 1) { + zip = new PizZip(fs.readFileSync(path.join(home, zips[0]))); + for (const name of Object.keys(zip.files)) { + if (!zip.files[name].dir) entries[name] = zip.file(name).asText(); + } + } + const reportJson = entries['report.json'] ? JSON.parse(entries['report.json']) : null; + const reportTxt = entries['report.txt'] ?? ''; + const everything = Object.values(entries).join('\n'); + + await test(`remote --report exits 0 and says where the zip is (${seconds} s)`, () => { + assert.strictEqual(run.code, 0, `exit code ${run.code}; output:\n${run.output}`); + assert.match(run.output, /Collecting diagnostics/); + assert.match(run.output, /✓ versions {3}✓ clock {3}✓ network {3}✓ device state {3}✓ device log \(2 files\)/, run.output); + assert(run.output.includes(`Saved: ${path.join(home, zips[0] ?? 'desktop-commander-report-')}`), run.output); + assert.match(run.output, /It holds no passwords, tokens, emails or file contents; you can open it and check\./); + assert(!/warning/i.test(run.output), `no warning on the terminal:\n${run.output}`); + }); + + await test('it uploads the saved zip with the user and device ids, and prints the report id', () => { + assert.strictEqual(standIn.uploads.length, 1, `one upload, got ${standIn.uploads.length}`); + const [upload] = standIn.uploads; + assert(upload.body.equals(fs.readFileSync(path.join(home, zips[0]))), 'the same bytes as the saved zip'); + assert.strictEqual(upload.headers['content-type'], 'application/zip'); + assert.strictEqual(upload.headers['x-dc-user-id'], standIn.userId, 'the user id is the saved token\'s sub'); + assert.strictEqual(upload.headers['x-dc-device-id'], standIn.deviceId); + assert.match(run.output, new RegExp(`\\nSent to Desktop Commander support\\. Report id: ${standIn.reportId}\\n`), run.output); + assert.match(run.output, /\nGive this id to support\. We keep it for 7 days, then delete it\.\n/); + assert(!run.output.includes('Reply to your support conversation'), 'sent: no "attach it yourself" line'); + }); + + await test('the zip is in the home folder, named by date, with report.txt, report.json and the device log', () => { + assert.strictEqual(zips.length, 1, `one zip expected in the home folder, found: ${zips.join(', ') || 'none'}`); + assert.match(zips[0], /^desktop-commander-report-\d{4}-\d\d-\d\d-\d{4}\.zip$/); + assert.deepStrictEqual(Object.keys(entries).sort(), + ['device-log/remote-mon.log', 'device-log/remote-thu.log', 'report.json', 'report.txt']); + }); + + await test('the zip is readable by its owner only (0o600, like the device log)', () => { + if (process.platform === 'win32') return skip('the zip\'s file mode (0o600) is not checked on Windows, which has no such mode'); + const mode = fs.statSync(path.join(home, zips[0])).mode & 0o777; + assert.strictEqual(mode.toString(8), '600'); + }); + + await test('report.txt says what it contains and what it does not', () => { + assert.match(reportTxt, /^Desktop Commander diagnostics — \d{4}-\d\d-\d\d \d\d:\d\d:\d\d \(UTC[+-]\d+(:\d\d)?\)\n/); + assert.match(reportTxt, /\nThis file contains: versions, how Node runs \(paths, with the home folder as ~\), clock, network checks, the device id and whether sign-in data exists \(yes\/no\), device status history\.\n/); + assert.match(reportTxt, /\nIt does not contain: tokens, passwords, emails, the user name, tool arguments or results\.\n/); + for (const label of ['Versions', 'Node', 'Running', 'MCP', 'Clock', 'Network', 'Device', 'Settings', 'Device log']) { + assert.match(reportTxt, new RegExp(`\\n${label} +\\S`), `a "${label}" line`); + } + }); + + await test('versions include npm, and how Node runs shows both paths and their kinds', () => { + assert.match(reportTxt, /\nVersions +Desktop Commander \d+\.\d+\.\d+ · Node \d+\.\d+\.\d+ · npm (\d+\.\d+\.\d+|not found) · \S/, reportTxt); + assert.match(reportJson?.versions?.npm ?? '', /^\d+\.\d+\.\d+/, 'npm is on PATH here, so its version is known'); + const nodeLine = reportTxt.match(/\nNode +(.*)\n/)?.[1] ?? ''; + assert.match(nodeLine, /node(\.exe)? \((global install|nvm|fnm|Volta|asdf|mise|Homebrew|Claude Desktop's bundled Node|unknown)\)$/, nodeLine); + assert.strictEqual(reportJson?.versions?.nodePath, nodeLine.replace(/ \([^)]*\)$/, '')); + // Run from this checkout: the entry script is dist/index.js, the kind a dev checkout + const runningLine = reportTxt.match(/\nRunning +(.*)\n/)?.[1] ?? ''; + assert.match(runningLine, /[\\/]dist[\\/]index\.js \(dev checkout\)$/, runningLine); + assert.strictEqual(reportJson?.versions?.entryPath, runningLine.replace(/ \([^)]*\)$/, '')); + assert.strictEqual(reportJson?.versions?.runKind, 'dev checkout'); + }); + + await test('the Node kind: known installers and global installs by path; anything else is "unknown"', async () => { + const { nodeKind } = await import('../dist/remote-device/diagnostics/report.js'); + for (const [execPath, kind] of [ + ['C:\\Program Files\\nodejs\\node.exe', 'global install'], + ['/usr/local/bin/node', 'global install'], + ['/usr/bin/node', 'global install'], + ['/Users/u/.nvm/versions/node/v20.11.0/bin/node', 'nvm'], + ['/opt/homebrew/Cellar/node/22.1.0/bin/node', 'Homebrew'], + ['/Applications/Claude.app/Contents/Resources/node', "Claude Desktop's bundled Node"], + // Paths none of the rules know, e.g. an app's own Node + ['C:\\Users\\u\\AppData\\Local\\SomeApp\\runtime\\node.exe', 'unknown'], + ['/opt/some-app/runtime/bin/node', 'unknown'], + ]) { + assert.strictEqual(nodeKind(execPath), kind, execPath); + } + }); + + await test('the clock skew comes from the server Date header: 90 s ahead means the device is 90 s behind', () => { + const ahead = reportJson?.clock?.serverAheadSeconds; + assert(typeof ahead === 'number' && Math.abs(ahead - SERVER_AHEAD_S) <= 3, `serverAheadSeconds = ${ahead}`); + assert.match(reportTxt, /\nClock +device is (8[7-9]|9[0-3]) s behind the server/, reportTxt); + }); + + await test('the network checks reach the stand-in: mcp-info 5 times, REST with the key, websocket heartbeat', () => { + const { requests } = standIn; + assert.strictEqual(requests.filter((r) => r.url === '/api/mcp-info').length, 5, JSON.stringify(requests)); + const rest = requests.filter((r) => r.url === '/rest/v1/'); + assert.strictEqual(rest.length, 1, JSON.stringify(requests)); + assert.strictEqual(rest[0].apikey, standIn.anonKey); + assert.strictEqual(standIn.heartbeatsAnswered, 1); + assert.strictEqual(reportJson?.network?.supabase?.realtime?.heartbeat, true); + assert.match(reportTxt, /\/api\/mcp-info 5× \d+\/\d+\/\d+ ms \(min\/median\/max\)/); + assert.match(reportTxt, /Proxy: HTTPS_PROXY not set/); + }); + + await test('the Supabase check: a 401 (no sign-in) reads "reachable", and report.json keeps the raw status', () => { + assert.strictEqual(reportJson?.network?.supabase?.rest?.status, 401); + assert.match(reportTxt, /\n {14}Supabase: DNS \d+ ms · TCP \d+ ms · reachable in \d+ ms · realtime websocket opened in \d+ ms, heartbeat answered\n/, reportTxt); + assert(!/REST|\(401\)/.test(reportTxt), 'the status code stays in report.json'); + }); + + await test('device.json gives the device id, only yes/no facts and when it was saved; config.json telemetry and the client id', () => { + // When it was saved: device.json's modification time, as ISO in report.json and in UTC in report.txt + const savedIso = new Date(deviceJsonMtimeBefore).toISOString(); + assert.deepStrictEqual(reportJson?.device, { + deviceJson: true, parses: true, id: standIn.deviceId, session: true, + accessToken: true, refreshToken: true, savedAt: savedIso, + }); + assert.deepStrictEqual(reportJson?.settings, { telemetryEnabled: false, clientId: CLIENT_ID }); + const savedUtc = `${savedIso.slice(0, 10)} ${savedIso.slice(11, 16)} UTC`; + assert(reportTxt.includes(`\nDevice id ${standIn.deviceId} · signed-in data: yes (access token: yes, refresh token: yes), saved ${savedUtc}\n`), reportTxt); + // A device's save and `remote --logout` hold device.json.lock while they write or remove + // device.json (device.ts): the report says nothing about the planted one + assert(!/lock left behind/i.test(reportTxt) && !(entries['report.json'] ?? '').includes('lockLeftBehind'), 'no lock in the report'); + assert.match(reportTxt, new RegExp(`\\nSettings +telemetry: off · client id: ${CLIENT_ID}`)); + }); + + await test('the device id appears once in each report file, in the Device section, and stays masked in the log', () => { + const count = (text) => text.split(standIn.deviceId).length - 1; + assert.strictEqual(count(reportTxt), 1, 'report.txt'); + assert.match(reportTxt.split('\n').find((line) => line.includes(standIn.deviceId)) ?? '', /^Device {8}id /); + assert.strictEqual(count(entries['report.json'] ?? ''), 1, 'report.json'); + const logs = Object.entries(entries).filter(([name]) => name.startsWith('device-log/')); + for (const [name, text] of logs) assert.strictEqual(count(text), 0, name); + assert.match(entries['device-log/remote-thu.log'] ?? '', /Channel error: device not joined/); + }); + + await test('the device log part counts the lines and keeps a tool call\'s name, not its arguments', () => { + const log = entries['device-log/remote-mon.log'] ?? ''; + assert.match(log, /Z {2}Received tool call c-9: read_file\n/); + assert(!log.includes('planted-tool-arg'), `a tool argument is dropped:\n${log}`); + assert.match(log, /Channel subscribed \(recovered after 1 attempt\)/); + assert.match(reportTxt, /\nDevice log +7 lines from 2026-10-01 09:12 to 2026-10-05 14:21 UTC; last: "Channel subscribed/, reportTxt); + }); + + await test('every existing device log file goes into the zip (21 names), oldest first by modification time', async () => { + const { deviceLogNames } = await import('../dist/remote-device/diagnostics/device-log.js'); + const names = deviceLogNames(); + assert.strictEqual(names.length, 21, 'the files the report must pack'); + await inOtherHome(async (other) => { + const logDir = path.join(other.home, '.desktop-commander-device'); + fs.mkdirSync(logDir, { recursive: true }); + // Written in an order that is not the names' order: every 8th name, wrapping, oldest first + const order = names.map((_, k) => names[(k * 8) % names.length]); + order.forEach((name, k) => { + const at = new Date(Date.UTC(2026, 8, 10 + k, 12)); + fs.writeFileSync(path.join(logDir, name), `${at.toISOString().replace(/\.\d{3}Z$/, 'Z')} Channel subscribed (file ${name})\n`); + fs.utimesSync(path.join(logDir, name), at, at); + }); + const result = await runReport(other.env, ['--no-upload']); + assert.strictEqual(result.code, 0, result.output); + const saved = result.output.match(/Saved: (.+\.zip) \(/)?.[1]; + const logEntries = Object.keys(new PizZip(fs.readFileSync(saved)).files).filter((name) => name.startsWith('device-log/')); + assert.deepStrictEqual(logEntries, order.map((name) => `device-log/${name}`), 'all files, oldest first'); + for (const name of names) { + assert.match(zipFile(result, `device-log/${name}`), new RegExp(`Channel subscribed \\(file ${name.replace(/\./g, '\\.')}\\)`)); + } + assert.match(zipFile(result, 'report.txt'), /\nDevice log +21 lines from 2026-09-10 12:00 to 2026-09-30 12:00 UTC/); + assert.match(result.output, /device log \(21 files\)/); + }); + }); + + await test('nothing private is in the zip', () => { + assert(zip, 'no zip to check'); + const planted = { + 'the access token': session.access_token, + 'a JWT': 'eyJ', + 'the refresh token': session.refresh_token, + 'the email': EMAIL, + 'the publishable key': standIn.anonKey, + 'the user id (only in the upload header)': standIn.userId, + 'a tool argument': 'planted-tool-arg', + 'another config setting': OTHER_SETTING, + 'the home folder': home, + 'the home folder (forward slashes)': home.replace(/\\/g, '/'), + 'the home folder (JSON-escaped)': JSON.stringify(home).slice(1, -1), + 'the host name': os.hostname(), + }; + for (const [what, secret] of Object.entries(planted)) { + assert(!everything.toLowerCase().includes(secret.toLowerCase()), `${what} is in the zip`); + } + }); + + // redact() leaves a name under 3 characters alone (redact.ts), so such a name can't be checked + await test('the user name is not in the zip', () => { + const user = os.userInfo().username; + if (user.length < 3) return skip('the user name is under 3 characters, which redact() leaves alone'); + assert(zip, 'no zip to check'); + assert(!everything.toLowerCase().includes(user.toLowerCase()), 'the user name is in the zip'); + }); + + await test('it never starts sign-in and never writes the device files', () => { + const signIn = standIn.requests.filter((r) => r.url.startsWith('/device/') || r.url.startsWith('/auth/')); + assert.deepStrictEqual(signIn, [], 'no sign-in or session request'); + assert(fs.readFileSync(deviceJson).equals(deviceJsonBefore), 'device.json unchanged'); + assert.strictEqual(fs.statSync(deviceJson).mtimeMs, deviceJsonMtimeBefore, 'device.json not rewritten'); + assert(!run.output.includes('Starting MCP Device'), 'the device does not start'); + }); + + await test('the Supabase check: a 5xx reads as a server error, and no answer as "not reachable"', async () => { + standIn.restRootStatus = 503; + const failing = await runReport(env); + standIn.restRootStatus = null; + const failingTxt = zipFile(failing, 'report.txt'); + assert.match(failingTxt, /\n {14}Supabase: .* · answered with a server error \(503\) in \d+ ms · realtime websocket/, failingTxt); + assert.strictEqual(JSON.parse(zipFile(failing, 'report.json')).network.supabase.rest.status, 503); + + // mcp-info names a Supabase address that nothing listens on + const closed = http.createServer(); + await new Promise((resolve) => closed.listen(0, '127.0.0.1', resolve)); + standIn.supabaseUrl = `http://127.0.0.1:${closed.address().port}`; + await new Promise((resolve) => closed.close(resolve)); + const gone = await runReport(env); + standIn.supabaseUrl = null; + const goneTxt = zipFile(gone, 'report.txt'); + assert.match(goneTxt, /\n {14}Supabase: .*\bnot reachable \([^)]+\)/, goneTxt); + assert(!/reachable in \d/.test(goneTxt), goneTxt); + }); + + await test('--no-upload sends nothing and says to attach the zip, as before', async () => { + const before = standIn.uploads.length; + const result = await runReport(env, ['--no-upload']); + assert.strictEqual(result.code, 0, result.output); + assert.strictEqual(standIn.uploads.length, before, 'no upload'); + assert.match(result.output, /\nReply to your support conversation with this file attached\.\n/, result.output); + assert(!/Sent to|Not sent/.test(result.output), result.output); + }); + + await test('without device.json the upload carries no id headers', async () => { + await inOtherHome(async (other) => { + const before = standIn.uploads.length; + const result = await runReport(other.env); + assert.strictEqual(result.code, 0, result.output); + assert.strictEqual(standIn.uploads.length, before + 1, 'one upload'); + const { headers } = standIn.uploads.at(-1); + assert(!('x-dc-user-id' in headers) && !('x-dc-device-id' in headers), JSON.stringify(headers)); + assert.match(result.output, new RegExp(`Report id: ${standIn.reportId}`), result.output); + }); + }); + + await test('a server error keeps the zip and says "Not sent"', async () => { + standIn.failUploads(1, 500); + const result = await runReport(env); + assert.strictEqual(result.code, 0, result.output); + const saved = result.output.match(/Saved: (.+\.zip) \(/)?.[1]; + assert(saved && fs.existsSync(saved), 'the zip is kept'); + assert.match(result.output, /\nNot sent \(the server answered 500\)\. Attach the zip to your support conversation instead\.\n/, result.output); + assert(!result.output.includes('Report id'), result.output); + }); + + await test('a refused upload shows the Worker\'s message ({code, message})', async () => { + standIn.failUploads(1, 429, 'too many reports, try again in a minute'); + const result = await runReport(env); + assert.strictEqual(result.code, 0, result.output); + assert.match(result.output, /\nNot sent \(the server answered 429: too many reports, try again in a minute\)\. Attach the zip to your support conversation instead\.\n/, result.output); + }); + + await test('an upload that never answers is aborted by its timeout', async () => { + const { uploadReport } = await import('../dist/remote-device/diagnostics/upload.js'); + const silent = http.createServer(() => { /* never answers */ }); + await new Promise((resolve) => silent.listen(0, '127.0.0.1', resolve)); + try { + const startedAt = Date.now(); + await assert.rejects( + uploadReport(Buffer.from('PK'), { diagnosticsUrl: `http://127.0.0.1:${silent.address().port}/`, userId: null, deviceId: null, timeoutMs: 300 }), + (error) => error.name === 'TimeoutError', + ); + assert(Date.now() - startedAt < 5000, 'aborted near the timeout'); + } finally { + silent.closeAllConnections(); + silent.close(); + } + }); + + await test('the upload goes to the server\'s diagnosticsUrl only: no address, no upload', async () => { + const upload = await import('../dist/remote-device/diagnostics/upload.js'); + assert(!('DEFAULT_DIAGNOSTICS_URL' in upload), 'no built-in address'); + const options = { userId: null, deviceId: null }; + await upload.uploadReport(Buffer.from('PK'), { ...options, diagnosticsUrl: `${UPLOAD_URL}?to=server` }); + assert.strictEqual(standIn.uploads.at(-1).url, '/diagnostics?to=server', 'the server\'s address'); + const before = standIn.uploads.length; + await assert.rejects(upload.uploadReport(Buffer.from('PK'), { ...options, diagnosticsUrl: null }), /^Error: the server named no upload address$/); + assert.strictEqual(standIn.uploads.length, before, 'nothing posted'); + }); + + await test('the server\'s diagnosticsUrl is used if https, or http on this machine; anything else is ignored', async () => { + // collectReport() runs in this process: it reads the runner's home, and the server from MCP_SERVER_URL + const { collectReport } = await import('../dist/remote-device/diagnostics/report.js'); + const saved = process.env.MCP_SERVER_URL; + process.env.MCP_SERVER_URL = standIn.url; + try { + for (const [served, expected] of [ + ['https://diagnostics.example.invalid/', 'https://diagnostics.example.invalid/'], + [UPLOAD_URL, UPLOAD_URL], + ['http://localhost:9/diagnostics', 'http://localhost:9/diagnostics'], + ['http://diagnostics.example.invalid/', null], + ['ftp://127.0.0.1/diagnostics', null], + [undefined, null], + ]) { + standIn.diagnosticsUrl = served; + assert.strictEqual((await collectReport()).diagnosticsUrl, expected, `served ${served}`); + } + } finally { + standIn.diagnosticsUrl = UPLOAD_URL; + if (saved === undefined) delete process.env.MCP_SERVER_URL; else process.env.MCP_SERVER_URL = saved; + } + }); + + await test('the server names no diagnosticsUrl: nothing is sent, the zip is kept, and the terminal says why', async () => { + const before = standIn.uploads.length; + standIn.diagnosticsUrl = undefined; + const result = await runReport(env); + standIn.diagnosticsUrl = UPLOAD_URL; + assert.strictEqual(result.code, 0, result.output); + assert.strictEqual(standIn.uploads.length, before, 'nothing posted'); + const saved = result.output.match(/Saved: (.+\.zip) \(/)?.[1]; + assert(saved && fs.existsSync(saved), 'the zip is kept'); + assert.match(result.output, /\nNot sent \(the server named no upload address\)\. Attach the zip to your support conversation instead\.\n/, result.output); + assert(!result.output.includes('Report id'), result.output); + }); + + await test('a session without tokens is not signed-in data', async () => { + await inOtherHome(async (other) => { + writeDeviceConfig(other.home, { deviceId: standIn.deviceId, session: {} }); + const result = await runReport(other.env, ['--no-upload']); + assert.strictEqual(result.code, 0, result.output); + const line = zipFile(result, 'report.txt').split('\n').find((l) => l.startsWith('Device ')) ?? ''; + assert.match(line, /· signed-in data: no \(access token: no, refresh token: no\)/, line); + }); + }); + + // #692: Desktop Commander reads a config.json saved as "UTF-8 with BOM" (Notepad, + // PowerShell 5's Set-Content -Encoding UTF8); the report must read the same settings + await test('a config.json saved with a UTF-8 BOM gives its telemetry and client id, as Desktop Commander reads it', async () => { + await inOtherHome(async (other) => { + const configPath = path.join(other.home, '.claude-server-commander', 'config.json'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + const saved = { telemetryEnabled: false, clientId: CLIENT_ID }; + fs.writeFileSync(configPath, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(JSON.stringify(saved, null, 2))])); + const result = await runReport(other.env, ['--no-upload']); + assert.strictEqual(result.code, 0, result.output); + assert.deepStrictEqual(JSON.parse(zipFile(result, 'report.json')).settings, saved, + 'with config.json saved as UTF-8 with BOM, the report misses the settings Desktop Commander reads'); + assert.match(zipFile(result, 'report.txt'), new RegExp(`\\nSettings +telemetry: off · client id: ${CLIENT_ID}`)); + }); + }); + + await test('Desktop Commander MCP: counts processes running its dist/index.js, not `remote`, not another app', async () => { + const dcScript = fakePackage(path.join(home, 'fake-dc'), '@wonderwhy-er/desktop-commander'); + const otherScript = fakePackage(path.join(home, 'other-app'), 'other-app'); + // Real copies may already run on this machine: compare with a run just before the stand-ins start + const before = JSON.parse(zipFile(await runReport(env), 'report.json')).desktopCommanderMcp; + assert(typeof before?.running === 'number', `report.json has desktopCommanderMcp.running: ${JSON.stringify(before)}`); + const startedAt = Date.now(); + const standIns = [ + spawn(process.execPath, [dcScript], { stdio: 'ignore' }), + spawn(process.execPath, [dcScript, 'remote'], { stdio: 'ignore' }), + spawn(process.execPath, [otherScript], { stdio: 'ignore' }), + ]; + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + const result = await runReport(env); + const after = JSON.parse(zipFile(result, 'report.json')).desktopCommanderMcp; + assert.strictEqual(after.running, before.running + 1, `only the stand-in MCP counts: before ${before.running}, after ${after.running}`); + assert(Date.parse(after.since) <= startedAt + 2000, `since is the earliest start: ${after.since}`); + const txt = zipFile(result, 'report.txt'); + const line = txt.split('\n').find((l) => l.startsWith('MCP ')) ?? ''; + const copies = after.running === 1 ? '1 copy' : `${after.running} copies`; + assert.match(line, new RegExp(`^MCP {11}running \\(${copies}, since (\\d{4}-\\d\\d-\\d\\d )?\\d\\d:\\d\\d\\)$`), txt); + assert(!txt.includes('fake-dc') && !zipFile(result, 'report.json').includes('fake-dc'), 'no paths or command lines'); + } finally { + for (const child of standIns) child.kill(); + } + }); + + await test('Desktop Commander MCP: "not running" with no copy, and the count and start time with some', async () => { + const { formatReport } = await import('../dist/remote-device/diagnostics/report.js'); + const lineOf = (mcp) => formatReport({ ...reportJson, desktopCommanderMcp: mcp }).split('\n').find((l) => l.startsWith('MCP ')); + assert.strictEqual(lineOf({ running: 0, since: null }), 'MCP not running'); + const today = new Date(); + today.setHours(14, 2, 0, 0); + assert.strictEqual(lineOf({ running: 2, since: today.toISOString() }), 'MCP running (2 copies, since 14:02)'); + }); + + await test('the home folder is shown as ~ in a path under it', async () => { + // node linked into the home stands for ~/.nvm/…/node + const nvmBin = path.join(home, '.nvm', 'versions', 'node', `v${process.versions.node}`, 'bin'); + fs.mkdirSync(nvmBin, { recursive: true }); + const nodeLink = path.join(nvmBin, path.basename(process.execPath)); + try { + fs.linkSync(process.execPath, nodeLink); + } catch { + fs.copyFileSync(process.execPath, nodeLink); // another volume: a copy + } + const again = await runReport(env, [], { execPath: nodeLink }); + assert.strictEqual(again.code, 0, again.output); + const txt = zipFile(again, 'report.txt'); + const expected = ['~', '.nvm', 'versions', 'node', `v${process.versions.node}`, 'bin', path.basename(process.execPath)].join(path.sep); + assert(txt.includes(`\nNode ${expected} (nvm)\n`), txt); + assert(!txt.includes(home), 'the home folder itself is not in the report'); + }); + + await test('after all the runs and uploads: still no sign-in or token refresh, and device.json unchanged', () => { + const signIn = standIn.requests.filter((r) => r.url.startsWith('/device/') || r.url.startsWith('/auth/')); + assert.deepStrictEqual(signIn, [], 'no sign-in or session request'); + assert(fs.readFileSync(deviceJson).equals(deviceJsonBefore), 'device.json unchanged'); + assert.strictEqual(fs.statSync(deviceJson).mtimeMs, deviceJsonMtimeBefore, 'device.json not rewritten'); + }); + } finally { + await standIn.close(); + } + + console.log(`\n${failures.length ? '🔴' : '✅'} remote report: ${failures.length} failing test(s).`); + return failures.length === 0; +} + +runIfMain(import.meta.url, runTests);