UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

220 lines (219 loc) • 9.62 kB
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-<day>.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-<day>.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, i) => `remote-${DAYS[day]}${i === 0 ? '' : `.${i}`}.log`; /** Every name the log can use: 7 days × DEVICE_LOG_FILES. */ export const deviceLogNames = () => 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() { 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) { 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 = [ // "Received tool call <id>: <name> <args JSON> metadata: <JSON>": 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: <email>", and the sign-in link in "Please visit: <link>". */ 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) { 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) { 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) { 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 class DeviceLog { constructor(options = {}) { /** The UTC weekday being written; -1 before the first write. */ this.day = -1; this.size = -1; this.failures = 0; /** The next printed line is the sign-in link or code. */ this.skipNextLine = false; this.dir = options.dir ?? getDeviceLogDir(); this.now = options.now ?? Date.now; this.maxBytes = options.maxBytes ?? DEVICE_LOG_MAX_BYTES; } file(i) { return path.join(this.dir, deviceLogName(this.day, i)); } /** One console call's arguments: each of its lines, cleaned. */ record(args) { if (this.failures >= MAX_WRITE_FAILURES) return; let lines; 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) { 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. */ startDay(day, now) { 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. */ rotate() { 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']; /** * 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 = {}) { const log = new DeviceLog(options); const originals = CONSOLE_METHODS.map((method) => [method, console[method]]); for (const [method, original] of originals) { console[method] = (...args) => { 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; }; }