Skip to content

Commit bf3f70e

Browse files
authored
Merge pull request #820 from wonderwhy-er/feat/remote-report-rc
feat(remote): remote --report in rc-v0.3.1, with its tests in the stack's structure
2 parents f44349c + 1770b83 commit bf3f70e

14 files changed

Lines changed: 2527 additions & 17 deletions

‎package-lock.json‎

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@
146146
"shx": "^0.3.4",
147147
"ts-node": "^10.9.2",
148148
"tsx": "^4.21.0",
149-
"typescript": "^5.3.3"
149+
"typescript": "^5.3.3",
150+
"ws": "^8.18.3"
150151
}
151152
}

‎src/config-manager.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export function isTelemetryDisabledValue(value: unknown): boolean {
7676
* PowerShell 5's Set-Content -Encoding UTF8) put U+FEFF first, which
7777
* JSON.parse rejects although the config is complete.
7878
*/
79-
function parseConfig(text: string): ServerConfig {
79+
export function parseConfig(text: string): ServerConfig {
8080
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
8181
}
8282

‎src/npm-scripts/remote.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { MCPDevice, getRemoteDeviceConfigPath, removeRemoteDeviceConfig } from '../remote-device/device.js';
22
import os from 'os';
33
import { captureRemote } from '../utils/capture.js';
4+
import { startDeviceLog } from '../remote-device/diagnostics/device-log.js';
45

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

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

7489
console.debug('[DEBUG] Platform:', os.platform());
7590
await captureRemote('remote_device_command_started', {
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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-<day>.log, the
10+
* device's 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. Each UTC weekday has its own files: remote-mon.log
15+
* rotates at 1 MB into remote-mon.1.log and remote-mon.2.log. The first write
16+
* on a weekday whose file is over a day old (last week's) removes that day's
17+
* files first, so the log keeps at most 7 days × 3 files, 21 MB.
18+
*/
19+
20+
export const DEVICE_LOG_MAX_BYTES = 1024 * 1024;
21+
/** How many files the log keeps per day: remote-<day>.log and its rotated older copies. */
22+
export const DEVICE_LOG_FILES = 3;
23+
/** The UTC weekdays as the file names spell them; Date.getUTCDay() indexes it. */
24+
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
25+
const DAY_MS = 24 * 60 * 60 * 1000;
26+
/** remote-mon.log, then remote-mon.1.log, remote-mon.2.log (older); `day` is the UTC weekday, 0 = Sunday. */
27+
export const deviceLogName = (day: number, i: number) => `remote-${DAYS[day]}${i === 0 ? '' : `.${i}`}.log`;
28+
/** Every name the log can use: 7 days × DEVICE_LOG_FILES. */
29+
export const deviceLogNames = (): string[] =>
30+
DAYS.flatMap((_, day) => Array.from({ length: DEVICE_LOG_FILES }, (_, i) => deviceLogName(day, i)));
31+
const MAX_LINE_CHARS = 1000;
32+
/** A multi-line print (a stack, a config dump) is cut after this many lines. */
33+
const MAX_LINES_PER_CALL = 40;
34+
/** Consecutive write failures after which the log gives up for this run. */
35+
const MAX_WRITE_FAILURES = 5;
36+
37+
export function getDeviceLogDir(): string {
38+
return path.join(os.homedir(), '.desktop-commander-device');
39+
}
40+
41+
// --- the private kinds: the only lines that don't reach the log as printed -------------------
42+
43+
/** " name" when there is one: an empty name, or one that isn't a name, is left out. */
44+
function toolName(name: string | undefined): string {
45+
const trimmed = (name ?? '').trim();
46+
return trimmed ? ` ${trimmed}` : '';
47+
}
48+
49+
/**
50+
* Tool calls keep the tool's name and the outcome; their arguments, metadata,
51+
* results and error details go. Matched on a print's first line by its fixed
52+
* words only, so an empty name, or one with a space, can't make a print slip
53+
* past (device.ts prints whatever name it gets). The whole print becomes this
54+
* one line, so a result's own lines never reach the log. null: the print goes
55+
* entirely.
56+
*/
57+
const TOOL_CALLS: Array<[RegExp, ((match: RegExpMatchArray) => string) | null]> = [
58+
// "Received tool call <id>: <name> <args JSON> metadata: <JSON>": the name is
59+
// kept only if it looks like one, never the JSON that follows an empty name
60+
[/^Received tool call (\S*):(?: ([\w.-]+)(?=\s|$))?/, (m) => `Received tool call ${m[1]}:${toolName(m[2])}`],
61+
// Anchored on the words after the name, so any name (with a colon, too) is taken whole
62+
[/^Tool call (.*?) ?completed:?$/, (m) => `Tool call${toolName(m[1])} completed`],
63+
[/^Tool call (.*?) ?failed:/, (m) => `Tool call${toolName(m[1])} failed`],
64+
[/^Calling MCP tool: ?(.*?) ?args:/, (m) => `Calling MCP tool${m[1] ? `:${toolName(m[1])}` : ''}`],
65+
[/^Calling MCP tool:/, () => 'Calling MCP tool'],
66+
[/^Error executing tool ?([^:]*?):/, (m) => `Error executing tool${toolName(m[1])}`],
67+
[/^Tool call error details/, null],
68+
];
69+
70+
/** The ready block's "User: <email>", and the sign-in link in "Please visit: <link>". */
71+
const PRIVATE_LINES = [/^User:\s/, /^Please visit:/];
72+
73+
/** The sign-in link and the code are each printed alone, on the line after these. */
74+
const BEFORE_PRIVATE_LINE = [/Verify this device in your browser:$/, /Make sure the code matches:$/];
75+
76+
/**
77+
* Error objects are written as their name and message only. Printed whole they
78+
* carry their properties: a spawn error's `spawnargs` hold the session tokens
79+
* of the offline update script.
80+
*/
81+
function plain(arg: unknown): unknown {
82+
return arg instanceof Error ? `${arg.name}: ${arg.message}` : arg;
83+
}
84+
85+
/** Whitespace, bullets, arrows, check marks, emoji and "[DEBUG]" before the text. */
86+
const LEADING = /^(?:\s|\[DEBUG\]|[-–•→✓✗]|\p{Extended_Pictographic}|️|‍)+/u;
87+
88+
function toolCall(text: string): { line: string | null } | null {
89+
for (const [pattern, keep] of TOOL_CALLS) {
90+
const match = text.match(pattern);
91+
if (match) return { line: keep ? redact(keep(match)).slice(0, MAX_LINE_CHARS) : null };
92+
}
93+
return null;
94+
}
95+
96+
/**
97+
* One line as the log keeps it: masked, or null for a private one. The report
98+
* runs every stored line through this again.
99+
*/
100+
export function cleanLine(line: string): string | null {
101+
const text = line.replace(LEADING, '').trimEnd();
102+
if (!text) return null;
103+
const tool = toolCall(text);
104+
if (tool) return tool.line;
105+
if (PRIVATE_LINES.some((pattern) => pattern.test(text))) return null;
106+
return redact(text).slice(0, MAX_LINE_CHARS);
107+
}
108+
109+
export interface DeviceLogOptions {
110+
/** The folder for the log files; ~/.desktop-commander-device by default. */
111+
dir?: string;
112+
/** The clock (ms), for the timestamps and the day; Date.now by default. Tests set it. */
113+
now?: () => number;
114+
/** The size at which a file rotates; DEVICE_LOG_MAX_BYTES by default. Tests set it. */
115+
maxBytes?: number;
116+
}
117+
118+
export class DeviceLog {
119+
private readonly dir: string;
120+
private readonly now: () => number;
121+
private readonly maxBytes: number;
122+
/** The UTC weekday being written; -1 before the first write. */
123+
private day = -1;
124+
private size = -1;
125+
private failures = 0;
126+
/** The next printed line is the sign-in link or code. */
127+
private skipNextLine = false;
128+
129+
constructor(options: DeviceLogOptions = {}) {
130+
this.dir = options.dir ?? getDeviceLogDir();
131+
this.now = options.now ?? Date.now;
132+
this.maxBytes = options.maxBytes ?? DEVICE_LOG_MAX_BYTES;
133+
}
134+
135+
private file(i: number): string {
136+
return path.join(this.dir, deviceLogName(this.day, i));
137+
}
138+
139+
/** One console call's arguments: each of its lines, cleaned. */
140+
record(args: unknown[]): void {
141+
if (this.failures >= MAX_WRITE_FAILURES) return;
142+
let lines: string[];
143+
try {
144+
const [first, ...rest] = args.map(plain);
145+
lines = format(first, ...rest).split(/\r?\n/).filter((line) => line.trim() !== '');
146+
} catch {
147+
return;
148+
}
149+
if (lines.length === 0) return;
150+
const tool = toolCall(lines[0].replace(LEADING, '').trimEnd());
151+
if (tool) {
152+
if (tool.line) this.write(tool.line);
153+
return;
154+
}
155+
for (const line of lines.slice(0, MAX_LINES_PER_CALL)) {
156+
if (this.skipNextLine) {
157+
this.skipNextLine = false;
158+
continue;
159+
}
160+
const text = cleanLine(line);
161+
if (text === null) continue;
162+
if (BEFORE_PRIVATE_LINE.some((pattern) => pattern.test(text))) this.skipNextLine = true;
163+
this.write(text);
164+
}
165+
}
166+
167+
/** Appends one line as is: callers pass text that is already masked. */
168+
write(text: string): void {
169+
if (this.failures >= MAX_WRITE_FAILURES) return;
170+
const now = this.now();
171+
const line = `${new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z')} ${text}\n`;
172+
const bytes = Buffer.byteLength(line);
173+
try {
174+
const day = new Date(now).getUTCDay();
175+
if (day !== this.day) this.startDay(day, now);
176+
if (this.size < 0) {
177+
fs.mkdirSync(this.dir, { recursive: true });
178+
this.size = fs.existsSync(this.file(0)) ? fs.statSync(this.file(0)).size : 0;
179+
}
180+
if (this.size > 0 && this.size + bytes > this.maxBytes) {
181+
this.rotate();
182+
this.size = 0;
183+
}
184+
fs.appendFileSync(this.file(0), line, { mode: 0o600 });
185+
this.size += bytes;
186+
this.failures = 0;
187+
} catch {
188+
// The log must never break the device: skip the line, give up after a few in a row
189+
this.failures++;
190+
this.size = -1;
191+
}
192+
}
193+
194+
/**
195+
* The first write of a UTC weekday: if that weekday's file is over a day
196+
* old, it is last week's, and its files go before today's lines start.
197+
*/
198+
private startDay(day: number, now: number): void {
199+
const today = path.join(this.dir, deviceLogName(day, 0));
200+
if (fs.existsSync(today) && now - fs.statSync(today).mtimeMs > DAY_MS) {
201+
for (let i = 0; i < DEVICE_LOG_FILES; i++) fs.rmSync(path.join(this.dir, deviceLogName(day, i)), { force: true });
202+
}
203+
this.day = day;
204+
this.size = -1;
205+
}
206+
207+
/** Drops the day's oldest file and moves each other one a step older: remote-mon.log becomes remote-mon.1.log. */
208+
private rotate(): void {
209+
fs.rmSync(this.file(DEVICE_LOG_FILES - 1), { force: true });
210+
for (let i = DEVICE_LOG_FILES - 2; i >= 0; i--) {
211+
if (fs.existsSync(this.file(i))) fs.renameSync(this.file(i), this.file(i + 1));
212+
}
213+
}
214+
}
215+
216+
const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug'] as const;
217+
218+
/**
219+
* Passes console output through the device log for this `remote` run. Each
220+
* method still does what it did (so a console.debug that `remote` silenced
221+
* stays silent), then the log records the call. Returns a function that puts
222+
* the console back.
223+
*/
224+
export function startDeviceLog(options: DeviceLogOptions = {}): () => void {
225+
const log = new DeviceLog(options);
226+
const originals = CONSOLE_METHODS.map((method) => [method, console[method]] as const);
227+
for (const [method, original] of originals) {
228+
console[method] = (...args: unknown[]) => {
229+
original.apply(console, args);
230+
log.record(args);
231+
};
232+
}
233+
log.write(`Remote started (Desktop Commander ${VERSION}, Node ${process.versions.node}, ${process.platform})`);
234+
return () => {
235+
for (const [method, original] of originals) console[method] = original;
236+
};
237+
}
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)