Skip to content

Commit 3e15bce

Browse files
mihailtclaude
andcommitted
feat(remote): remote --report saves a diagnostics zip for support
Users report that Remote Desktop Commander shows offline while it runs, that calls time out, or that it disconnects often. Nothing on their machine records why: the device writes only to the terminal, without timestamps, mixed with tool arguments and results, the email and sign-in codes. `desktop-commander remote --report` collects what support needs into ~/desktop-commander-report-<date>.zip, sends it to support, and exits. It doesn't start the device and never opens sign-in, so it also works when sign-in is broken. - report.txt and report.json: versions (Desktop Commander, Node, npm, OS), the Node executable and the entry script (home folder as ~) with their install kinds, the clock skew from the server's Date header, DNS / TLS / mcp-info timings, whether Supabase REST is reachable (any HTTP answer; a 5xx is a server error) and a realtime websocket heartbeat, whether a proxy is set, whether Desktop Commander MCP is running now (count and earliest start, from the process list), the device id, yes/no facts about the saved session, and telemetryEnabled and clientId. - device-log/: the device's history. A normal `remote` run now passes its console output through diagnostics/device-log.ts, which writes every line, masked and timestamped, to ~/.desktop-commander-device/remote.log, rotating at 1 MB (DEVICE_LOG_FILES = 3 files, named by deviceLogName(), no list). It drops only the private kinds: a tool call's arguments and results (its name and outcome stay), the ready block's email, the sign-in link and code, and the properties of error objects (a spawn error's arguments carry the session tokens). Debug lines are kept without --debug; the terminal output doesn't change. - diagnostics/upload.ts: after saving, the zip is POSTed to DC_DIAGNOSTICS_URL (tests), else the diagnosticsUrl that /api/mcp-info names (https only), else https://diagnostics.ds-c09.workers.dev, with the user id (the saved access token's sub, decoded locally) and the device id as headers and a 30 s timeout. The terminal prints the report id the Worker answers, or "Not sent (<reason>)" and keeps the zip. --no-upload only saves. Nothing is refreshed, nothing signs in, device.json is only read. - diagnostics/redact.ts: one masker for JWTs, token / key / password values, Bearer values, emails, UUIDs, the home folder, the user name and the host name. New code is in src/remote-device/diagnostics/; device.ts and remote-channel.ts are unchanged. The zip is written with pizzip, already a dependency. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent ce3a404 commit 3e15bce

5 files changed

Lines changed: 1114 additions & 0 deletions

File tree

‎src/npm-scripts/remote.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { MCPDevice, getRemoteDeviceConfigPath } from '../remote-device/device.js
22
import fs from 'fs/promises';
33
import os from 'os';
44
import { captureRemote } from '../utils/capture.js';
5+
import { startDeviceLog } from '../remote-device/diagnostics/device-log.js';
56

67
const BLUE = '\x1b[34m';
78
const RESET = '\x1b[0m';
@@ -28,6 +29,9 @@ Usage:
2829
2930
Options:
3031
--logout Remove saved local Remote MCP credentials and exit
32+
--report Save a diagnostics zip for support, send it, and exit
33+
(the device doesn't start and nothing signs in)
34+
--no-upload With --report: only save the zip, don't send it
3135
--no-persist-session Do not reuse or save authentication for this run
3236
--disable-no-sleep Do not prevent sleep while the remote device is running
3337
--debug Enable verbose debug logging
@@ -37,6 +41,7 @@ Examples:
3741
npx @wonderwhy-er/desktop-commander@latest remote
3842
npx @wonderwhy-er/desktop-commander@latest remote --debug
3943
npx @wonderwhy-er/desktop-commander@latest remote --logout
44+
npx @wonderwhy-er/desktop-commander@latest remote --report
4045
4146
Note:
4247
--logout removes local credentials only. Revoke the device in the Remote MCP
@@ -55,6 +60,12 @@ Note:
5560
}
5661
return;
5762
}
63+
if (process.argv.includes('--report')) {
64+
// Before the device: the report works when sign-in is broken, and never opens it
65+
const { runReport } = await import('../remote-device/diagnostics/report.js');
66+
await runReport();
67+
return;
68+
}
5869
printRemoteHeader();
5970

6071
// --persist-session is kept as an accepted no-op so existing invocations
@@ -71,6 +82,10 @@ Note:
7182
if (!verbose) {
7283
console.debug = () => { };
7384
}
85+
// The device's status history for `remote --report`. Wraps the console as
86+
// it is now, so the terminal stays the same, but debug status lines are
87+
// kept even without --debug.
88+
startDeviceLog();
7489

7590
console.debug('[DEBUG] Platform:', os.platform());
7691
await captureRemote('remote_device_command_started', {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import fs from 'fs';
2+
import os from 'os';
3+
import path from 'path';
4+
import { format } from 'util';
5+
import { VERSION } from '../../version.js';
6+
import { redact } from './redact.js';
7+
8+
/**
9+
* The remote device log: ~/.desktop-commander-device/remote.log, the device's
10+
* history that `remote --report` packs for support.
11+
*
12+
* `remote` passes the device's console output through startDeviceLog(). Every
13+
* line is written, masked by redact() and timestamped in UTC; only the private
14+
* kinds below are dropped. The files rotate at 1 MB into remote.1.log and
15+
* remote.2.log.
16+
*/
17+
18+
export const DEVICE_LOG_MAX_BYTES = 1024 * 1024;
19+
/** How many files the log keeps: remote.log and its rotated older copies. */
20+
export const DEVICE_LOG_FILES = 3;
21+
/** remote.log, then remote.1.log, remote.2.log, … (older) */
22+
export const deviceLogName = (i: number) => (i === 0 ? 'remote.log' : `remote.${i}.log`);
23+
const MAX_LINE_CHARS = 1000;
24+
/** A multi-line print (a stack, a config dump) is cut after this many lines. */
25+
const MAX_LINES_PER_CALL = 40;
26+
/** Consecutive write failures after which the log gives up for this run. */
27+
const MAX_WRITE_FAILURES = 5;
28+
29+
export function getDeviceLogDir(): string {
30+
return path.join(os.homedir(), '.desktop-commander-device');
31+
}
32+
33+
// --- the private kinds: the only lines that don't reach the log as printed -------------------
34+
35+
/**
36+
* Tool calls keep the tool's name and the outcome; their arguments, metadata,
37+
* results and error details go. Matched on a print's first line, and the whole
38+
* print becomes this one line, so a result's own lines never reach the log.
39+
* null: the print goes entirely.
40+
*/
41+
const TOOL_CALLS: Array<[RegExp, ((match: RegExpMatchArray) => string) | null]> = [
42+
[/^Received tool call (\S+): (\S+)/, (m) => `Received tool call ${m[1]}: ${m[2]}`],
43+
[/^Tool call (\S+) completed/, (m) => `Tool call ${m[1]} completed`],
44+
[/^Tool call (\S+) failed/, (m) => `Tool call ${m[1]} failed`],
45+
[/^Calling MCP tool: (\S+)/, (m) => `Calling MCP tool: ${m[1]}`],
46+
[/^Error executing tool (\S+?):?(?:\s|$)/, (m) => `Error executing tool ${m[1]}`],
47+
[/^Tool call error details/, null],
48+
];
49+
50+
/** The ready block's "User: <email>", and the sign-in link in "Please visit: <link>". */
51+
const PRIVATE_LINES = [/^User:\s/, /^Please visit:/];
52+
53+
/** The sign-in link and the code are each printed alone, on the line after these. */
54+
const BEFORE_PRIVATE_LINE = [/Verify this device in your browser:$/, /Make sure the code matches:$/];
55+
56+
/**
57+
* Error objects are written as their name and message only. Printed whole they
58+
* carry their properties: a spawn error's `spawnargs` hold the session tokens
59+
* of the offline update script.
60+
*/
61+
function plain(arg: unknown): unknown {
62+
return arg instanceof Error ? `${arg.name}: ${arg.message}` : arg;
63+
}
64+
65+
/** Whitespace, bullets, arrows, check marks, emoji and "[DEBUG]" before the text. */
66+
const LEADING = /^(?:\s|\[DEBUG\]|[-–•→✓✗]|\p{Extended_Pictographic}|️|‍)+/u;
67+
68+
function toolCall(text: string): { line: string | null } | null {
69+
for (const [pattern, keep] of TOOL_CALLS) {
70+
const match = text.match(pattern);
71+
if (match) return { line: keep ? redact(keep(match)).slice(0, MAX_LINE_CHARS) : null };
72+
}
73+
return null;
74+
}
75+
76+
/**
77+
* One line as the log keeps it: masked, or null for a private one. The report
78+
* runs every stored line through this again.
79+
*/
80+
export function cleanLine(line: string): string | null {
81+
const text = line.replace(LEADING, '').trimEnd();
82+
if (!text) return null;
83+
const tool = toolCall(text);
84+
if (tool) return tool.line;
85+
if (PRIVATE_LINES.some((pattern) => pattern.test(text))) return null;
86+
return redact(text).slice(0, MAX_LINE_CHARS);
87+
}
88+
89+
export interface DeviceLogOptions {
90+
/** The folder for remote.log; ~/.desktop-commander-device by default. */
91+
dir?: string;
92+
}
93+
94+
export class DeviceLog {
95+
private readonly dir: string;
96+
private readonly file: string;
97+
private size = -1;
98+
private failures = 0;
99+
/** The next printed line is the sign-in link or code. */
100+
private skipNextLine = false;
101+
102+
constructor(options: DeviceLogOptions = {}) {
103+
this.dir = options.dir ?? getDeviceLogDir();
104+
this.file = path.join(this.dir, deviceLogName(0));
105+
}
106+
107+
/** One console call's arguments: each of its lines, cleaned. */
108+
record(args: unknown[]): void {
109+
if (this.failures >= MAX_WRITE_FAILURES) return;
110+
let lines: string[];
111+
try {
112+
const [first, ...rest] = args.map(plain);
113+
lines = format(first, ...rest).split(/\r?\n/).filter((line) => line.trim() !== '');
114+
} catch {
115+
return;
116+
}
117+
if (lines.length === 0) return;
118+
const tool = toolCall(lines[0].replace(LEADING, '').trimEnd());
119+
if (tool) {
120+
if (tool.line) this.write(tool.line);
121+
return;
122+
}
123+
for (const line of lines.slice(0, MAX_LINES_PER_CALL)) {
124+
if (this.skipNextLine) {
125+
this.skipNextLine = false;
126+
continue;
127+
}
128+
const text = cleanLine(line);
129+
if (text === null) continue;
130+
if (BEFORE_PRIVATE_LINE.some((pattern) => pattern.test(text))) this.skipNextLine = true;
131+
this.write(text);
132+
}
133+
}
134+
135+
/** Appends one line as is: callers pass text that is already masked. */
136+
write(text: string): void {
137+
if (this.failures >= MAX_WRITE_FAILURES) return;
138+
const line = `${new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')} ${text}\n`;
139+
const bytes = Buffer.byteLength(line);
140+
try {
141+
if (this.size < 0) {
142+
fs.mkdirSync(this.dir, { recursive: true });
143+
this.size = fs.existsSync(this.file) ? fs.statSync(this.file).size : 0;
144+
}
145+
if (this.size > 0 && this.size + bytes > DEVICE_LOG_MAX_BYTES) {
146+
this.rotate();
147+
this.size = 0;
148+
}
149+
fs.appendFileSync(this.file, line, { mode: 0o600 });
150+
this.size += bytes;
151+
this.failures = 0;
152+
} catch {
153+
// The log must never break the device: skip the line, give up after a few in a row
154+
this.failures++;
155+
this.size = -1;
156+
}
157+
}
158+
159+
/** Drops the oldest file and moves each other one a step older: remote.log becomes remote.1.log. */
160+
private rotate(): void {
161+
const file = (i: number) => path.join(this.dir, deviceLogName(i));
162+
fs.rmSync(file(DEVICE_LOG_FILES - 1), { force: true });
163+
for (let i = DEVICE_LOG_FILES - 2; i >= 0; i--) {
164+
if (fs.existsSync(file(i))) fs.renameSync(file(i), file(i + 1));
165+
}
166+
}
167+
}
168+
169+
const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug'] as const;
170+
171+
/**
172+
* Passes console output through the device log for this `remote` run. Each
173+
* method still does what it did (so a console.debug that `remote` silenced
174+
* stays silent), then the log records the call. Returns a function that puts
175+
* the console back.
176+
*/
177+
export function startDeviceLog(options: DeviceLogOptions = {}): () => void {
178+
const log = new DeviceLog(options);
179+
const originals = CONSOLE_METHODS.map((method) => [method, console[method]] as const);
180+
for (const [method, original] of originals) {
181+
console[method] = (...args: unknown[]) => {
182+
original.apply(console, args);
183+
log.record(args);
184+
};
185+
}
186+
log.write(`Remote started (Desktop Commander ${VERSION}, Node ${process.versions.node}, ${process.platform})`);
187+
return () => {
188+
for (const [method, original] of originals) console[method] = original;
189+
};
190+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import fs from 'fs';
2+
import os from 'os';
3+
4+
/**
5+
* The one masker for the remote diagnostics: every line the device log keeps
6+
* and everything `remote --report` writes from free text passes through here.
7+
*
8+
* Masks JWTs, `access_token` / `refresh_token` / `apikey` / `password` values
9+
* after `=` or `:`, `Bearer …` values, emails, UUIDs (device and user ids),
10+
* the home folder (→ `~`), the user name and the host name.
11+
*/
12+
13+
const SECRET_KEYS = 'access_token|refresh_token|provider_token|provider_refresh_token|apikey|api_key|password|passwd|secret|client_secret';
14+
15+
/** `key=value`, `key: value`, `"key":"value"` — the key stays, the value goes. */
16+
const KEY_VALUE = new RegExp(`(["']?)\\b(${SECRET_KEYS})\\1(\\s*[:=]\\s*)(["']?)[^"'\\s&,;}]+`, 'gi');
17+
const BEARER = /\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi;
18+
const JWT = /\beyJ[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]*){0,2}/g;
19+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
20+
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;
21+
22+
function escapeRegExp(text: string): string {
23+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
24+
}
25+
26+
/**
27+
* The home folder as it may appear: as is and as its real path (on macOS a
28+
* temp folder sits under the /var -> /private/var symlink, and execPath is the
29+
* real one), each with forward slashes and JSON-escaped. Only a whole path
30+
* matches: not "/backup/Users/x" nor "/Users/xy" for "/Users/x".
31+
*/
32+
function homePattern(home: string): RegExp | null {
33+
if (home.length < 2) return null;
34+
const roots = [home];
35+
try {
36+
roots.push(fs.realpathSync.native(home));
37+
} catch { /* no such folder: the plain form only */ }
38+
const forms = new Set(roots.flatMap((root) => [root, root.replace(/\\/g, '/'), root.replace(/\\/g, '\\\\')]));
39+
// Longest first, so the JSON-escaped and real forms are not half-matched by a shorter one
40+
const alternatives = [...forms].sort((a, b) => b.length - a.length).map(escapeRegExp);
41+
const pathChar = '[A-Za-z0-9_.-]';
42+
return new RegExp(`(?<!${pathChar})(?:${alternatives.join('|')})(?!${pathChar})`, process.platform === 'win32' ? 'gi' : 'g');
43+
}
44+
45+
/** A name as a whole word: "alice" in "C:/x/alice/y", not in "aliceb". */
46+
function wordPattern(word: string): RegExp | null {
47+
if (word.length < 3) return null;
48+
return new RegExp(`(?<![A-Za-z0-9])${escapeRegExp(word)}(?![A-Za-z0-9])`, 'gi');
49+
}
50+
51+
function safeUserName(): string {
52+
try {
53+
return os.userInfo().username;
54+
} catch {
55+
return '';
56+
}
57+
}
58+
59+
export function redact(text: string): string {
60+
let out = text;
61+
const home = homePattern(os.homedir());
62+
if (home) out = out.replace(home, '~');
63+
out = out.replace(KEY_VALUE, '$1$2$1$3$4<redacted>');
64+
out = out.replace(BEARER, '$1<redacted>');
65+
out = out.replace(JWT, '<jwt>');
66+
out = out.replace(EMAIL, '<email>');
67+
out = out.replace(UUID, '<id>');
68+
// "name.local" and the bare "name" a Mac also goes by
69+
const hostname = os.hostname();
70+
for (const name of new Set([hostname, hostname.split('.')[0]])) {
71+
const host = wordPattern(name);
72+
if (host) out = out.replace(host, '<host>');
73+
}
74+
const user = wordPattern(safeUserName());
75+
if (user) out = out.replace(user, '<user>');
76+
return out;
77+
}

0 commit comments

Comments
 (0)