diff --git a/src/config-manager.ts b/src/config-manager.ts index ddf2e12f5..0cd893f54 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -8,15 +8,30 @@ import { VERSION } from './version.js'; import { CONFIG_FILE } from './config.js'; import { getDefaultShell } from './utils/shell.js'; import { writeFileAtomic } from './utils/atomic-write.js'; +import { backupCorruptConfig, buildRecoveredConfig, type RecoveryEvent, type RecoveryPhase } from './config-recovery.js'; // Desktop Commander 0.2.48 and older write config.json in place, so while such // a version runs alongside, the file can be empty or partly written for a // moment: up to ~260ms measured on Windows (#697). A file that does not parse // is read again until it does, for up to this long and at least this many // reads (a start that freezes spends the time without reading); then it counts -// as damaged. 30 reads 10 ms apart span longer than any empty moment measured. +// as corrupt. 30 reads 10 ms apart span longer than any empty moment measured. const PARTIAL_CONFIG_WAIT_MS = 1_000; const PARTIAL_CONFIG_MIN_FAILED_READS = 30; +// A failed background save is tried again after SAVE_RETRY_MS. If it fails again +// (a read-only file system, a full disk), it is tried every HELD_SAVE_RETRY_MS. +const SAVE_RETRY_MS = 250; +const HELD_SAVE_RETRY_MS = 5_000; +// How many times a config write runs again when its lock was lost, or +// config.json changed, before it committed +const MAX_CONFIG_WRITE_ATTEMPTS = 3; + +/** Thrown when the lock was lost, or config.json changed, before a config write committed */ +class ConfigChangedError extends Error { + constructor() { + super('config.json changed, or its lock was lost, before this write committed'); + } +} export interface ServerConfig { blockedCommands?: string[]; @@ -56,6 +71,36 @@ export function isTelemetryDisabledValue(value: unknown): boolean { return normalizeTelemetryEnabledValue(value) === false; } +/** + * Parses config.json's text. Editors saving "UTF-8 with BOM" (Notepad, + * 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 { + return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text); +} + +/** Marks a config written before the welcome page existed as an existing install, which never gets it */ +function migrateLegacyConfig(config: ServerConfig): void { + if (config['welcomeOnboardingEligible'] === undefined) { + config['welcomeOnboardingEligible'] = false; + config['pendingWelcomeOnboarding'] = false; + } +} + +/** + * Shows the user a warning, as a log notification (console.warn in the MCP + * server) and on stderr, which `remote` shows in its terminal. + */ +function warnUser(message: string): void { + console.warn(message); + process.stderr.write(`[WARNING] Desktop Commander: ${message}\n`); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** * Singleton config manager for the server */ @@ -71,6 +116,11 @@ class ConfigManager { private pendingMutations: Array<(config: ServerConfig) => void> = []; private watcher: FSWatcher | null = null; private reloadTimer: NodeJS.Timeout | null = null; + // Background saves that failed in a row, and the timer that tries them again (see scheduleSave) + private failedSaves = 0; + private saveRetry: NodeJS.Timeout | null = null; + // Recovery events from before init() finished, sent when it does (see reportRecovery) + private heldRecoveryEvents: RecoveryEvent[] = []; constructor() { // Get user's home directory @@ -92,39 +142,80 @@ class ConfigManager { await mkdir(configDir, { recursive: true }); } - try { - this.config = await this.readConfigFromDisk(); - this._isFirstRun = false; - - if (this.config['welcomeOnboardingEligible'] === undefined) { - await this.performConfigMutation((latest) => { - if (latest['welcomeOnboardingEligible'] === undefined) { - latest['welcomeOnboardingEligible'] = false; - latest['pendingWelcomeOnboarding'] = false; - } - }); - } - } catch (error: any) { - if (error?.code !== 'ENOENT') throw error; + this.config = await this.loadStartupConfig(); + this.config['version'] = VERSION; + this.initialized = true; + this.startConfigWatcher(); + } catch (error) { + console.error('Failed to initialize config:', error); + this.config = this.getDefaultConfig(); + this.initialized = true; + this.startConfigWatcher(); + } + for (const event of this.heldRecoveryEvents.splice(0)) void this.emitCorruptConfigTelemetry(event); + } + + /** + * Reads config.json at start. A missing one is created (a first run), a corrupt + * one is recovered, and one written before the welcome page existed is marked + * as an existing install. If it can't be read, recovered or saved, the session + * starts without saving it. + */ + private async loadStartupConfig(): Promise { + let config: ServerConfig; + try { + config = await this.readConfigFromDisk(); + } catch (error: any) { + if (error?.code === 'ENOENT') { let created = false; - await this.performConfigMutation((latest, existed) => { + config = await this.performConfigMutation((latest, existed) => { if (!existed) { Object.assign(latest, this.getDefaultConfig()); created = true; } }); this._isFirstRun = created; + } else if (error instanceof SyntaxError) { + try { + config = await this.withConfigLock(() => this.recoverCorruptConfig('startup')); + } catch (recoveryError) { + // Left as it is, the next start recovers it + const text = await fs.readFile(this.configPath, 'utf8').catch(() => ''); + return this.startWithoutSaving(buildRecoveredConfig(this.getDefaultConfig(), null, text), + `config.json could not be parsed, and replacing it failed (${messageOf(recoveryError)}). For this session Desktop Commander uses the settings still readable in it and the defaults for the rest; config.json is left as it is.`); + } + } else { + // There, but it can't be read (e.g. no permission): an existing install, + // so the welcome page stays off, and initialize has nothing to save for it + return this.startWithoutSaving(buildRecoveredConfig(this.getDefaultConfig(), null, ''), + `config.json could not be read (${messageOf(error)}). For this session Desktop Commander uses the default settings; config.json is left as it is.`); } + } - this.config['version'] = VERSION; - this.initialized = true; - this.startConfigWatcher(); - } catch (error) { - console.error('Failed to initialize config:', error); - this.config = this.getDefaultConfig(); - this.initialized = true; - this.startConfigWatcher(); + if (!this._isFirstRun && config['welcomeOnboardingEligible'] === undefined) { + try { + config = await this.performConfigMutation(migrateLegacyConfig); + } catch (error) { + // The settings read stay in effect, not the defaults, which allow every folder + migrateLegacyConfig(config); + this.pendingMutations.push(migrateLegacyConfig); + return this.startWithoutSaving(config, `config.json was read, but saving to it failed (${messageOf(error)}). ` + + `Desktop Commander uses the settings it read; changes to them can't be saved until config.json is writable.`); + } } + return config; + } + + /** + * Starts the session on `config` and leaves config.json as it is. `warning` + * tells the user why; saves are tried again every HELD_SAVE_RETRY_MS. + */ + private startWithoutSaving(config: ServerConfig, warning: string): ServerConfig { + // As after a second failed save, whose log line the warning stands for + this.failedSaves = 2; + this.retrySaves(HELD_SAVE_RETRY_MS); + warnUser(warning); + return config; } /** @@ -198,7 +289,7 @@ class ConfigManager { const deadline = Date.now() + PARTIAL_CONFIG_WAIT_MS; for (let failedReads = 1; ; failedReads++) { try { - return JSON.parse(await fs.readFile(this.configPath, 'utf8')); + return parseConfig(await fs.readFile(this.configPath, 'utf8')); } catch (error: any) { if (!(error instanceof SyntaxError)) throw error; if (Date.now() >= deadline && failedReads >= PARTIAL_CONFIG_MIN_FAILED_READS) throw error; @@ -207,33 +298,89 @@ class ConfigManager { } } - private async acquireConfigLock(): Promise<() => Promise> { + /** + * Recovers a corrupt config.json; the caller holds the config lock. It is backed + * up once, then rewritten as buildRecoveredConfig(). Returns the config now on disk. + */ + private async recoverCorruptConfig(phase: RecoveryPhase): Promise { + const bytes = await fs.readFile(this.configPath).catch((error) => { + if (error?.code === 'ENOENT') return Buffer.alloc(0); + throw error; + }); + const text = bytes.toString('utf8'); + let parseError: Error; + try { + const config = parseConfig(text); + // Another process recovered it while this one waited for the lock + this.reportRecovery({ phase, config_bytes: null, backup_created: false, recovered_by_other_process: true }); + return config; + } catch (error) { + parseError = error as Error; + } + + const backupName = await backupCorruptConfig(this.configPath, bytes); + const config = buildRecoveredConfig(this.getDefaultConfig(), this.initialized ? this.config : null, text); + await this.writeConfigAtomically(config); + this.config = { ...config, version: VERSION }; + console.error(`config.json could not be parsed (${parseError.message})${backupName ? `; kept as ${backupName}` : ''}; ` + + 'replaced with the settings still readable in it and the defaults for the rest.'); + this.reportRecovery({ phase, config_bytes: bytes.length, backup_created: backupName !== null, recovered_by_other_process: false }); + return config; + } + + /** Sends a recovery event. Telemetry reads the config, so events from before init() is done wait for it. */ + private reportRecovery(event: RecoveryEvent): void { + if (this.initialized) void this.emitCorruptConfigTelemetry(event); + else this.heldRecoveryEvents.push(event); + } + + private async emitCorruptConfigTelemetry(event: RecoveryEvent): Promise { + try { + const { capture } = await import('./utils/capture.js'); + await capture('config_parse_error_recovered', event); + } catch { + // Recovery never depends on telemetry + } + } + + private async writeConfigAtomically(config: ServerConfig, beforeCommit?: () => Promise): Promise { + await writeFileAtomic(this.configPath, JSON.stringify(config, null, 2), { beforeCommit }); + } + + /** config.json's text, or null when there is none */ + private async readConfigText(): Promise { + try { + return await fs.readFile(this.configPath, 'utf8'); + } catch (error: any) { + if (error?.code === 'ENOENT') return null; + throw error; + } + } + + private async acquireConfigLock(onLost?: () => void): Promise<() => Promise> { return lockfile.lock(this.configPath, { realpath: false, stale: 30_000, update: 10_000, - retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 } + retries: { retries: 100, factor: 1.2, minTimeout: 10, maxTimeout: 100 }, + // This process couldn't refresh the lock for 30 s (frozen: machine sleep, a + // suspended process, a blocked event loop) and another one took it over or + // removed it. The default throws from a timer, which ends the server; here it + // is logged, and a write not yet committed runs again under a new lock + // (performConfigMutation). Its release then fails (logged). + onCompromised: (error) => { + console.error(`The config lock was lost while held: ${error.message} (${(error as any).code})`); + onLost?.(); + }, }); } - private async performConfigMutation( - mutate: (config: ServerConfig, existed: boolean) => void - ): Promise { - const release = await this.acquireConfigLock(); + /** Runs `fn` holding the config lock; `lockLost()` tells whether the lock was lost meanwhile. */ + private async withConfigLock(fn: (lockLost: () => boolean) => Promise): Promise { + let lost = false; + const release = await this.acquireConfigLock(() => { lost = true; }); try { - let latest: ServerConfig; - let existed = true; - try { - latest = await this.readConfigFromDisk(); - } catch (error: any) { - if (error?.code !== 'ENOENT') throw error; - latest = {}; - existed = false; - } - mutate(latest, existed); - await writeFileAtomic(this.configPath, JSON.stringify(latest, null, 2)); - this.config = { ...latest, version: VERSION }; - return latest; + return await fn(() => lost); } finally { try { await release(); @@ -245,6 +392,72 @@ class ConfigManager { } } + /** + * Read config.json, apply `mutate`, write it back, all under the cross-process + * lock. If the lock was lost, or config.json changed, before the write + * committed, nothing was written: it runs again under a new lock, so the + * change lands on what the other process saved. + */ + private async performConfigMutation( + mutate: (config: ServerConfig, existed: boolean) => void + ): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await this.withConfigLock((lockLost) => this.mutateLockedConfig(mutate, lockLost)); + } catch (error) { + if (!(error instanceof ConfigChangedError) || attempt >= MAX_CONFIG_WRITE_ATTEMPTS) throw error; + } + } + } + + private async mutateLockedConfig( + mutate: (config: ServerConfig, existed: boolean) => void, + lockLost: () => boolean + ): Promise { + let latest: ServerConfig; + let existed = true; + try { + latest = await this.readConfigFromDisk(); + } catch (error: any) { + if (error instanceof SyntaxError) { + latest = await this.recoverCorruptConfig('mutation'); + } else if (error?.code === 'ENOENT') { + // Missing (a first start, or removed while running): start from the + // defaults, as `{}` would leave blockedCommands empty, blocking nothing + latest = this.getDefaultConfig(); + existed = false; + if (this.initialized) { + // Removed while running: still an existing install, which never gets the welcome page + latest['welcomeOnboardingEligible'] = false; + latest['pendingWelcomeOnboarding'] = false; + } + } else { + throw error; + } + } + // What config.json holds now. If it holds anything else when the write is + // about to commit, another process saved meanwhile. + const readText = await this.readConfigText(); + // Queued changes are older than this one, so they apply first; if this + // write fails, they are queued again + const queued = this.pendingMutations.splice(0); + try { + for (const apply of queued) apply(latest); + mutate(latest, existed); + await this.writeConfigAtomically(latest, async () => { + if (lockLost() || await this.readConfigText() !== readText) throw new ConfigChangedError(); + }); + } catch (error) { + this.pendingMutations.unshift(...queued); + throw error; + } + this.config = { ...latest, version: VERSION }; + // Saves work again, so queued changes needn't wait for a retry + this.failedSaves = 0; + if (this.saveRetry) this.retrySaves(0); + return latest; + } + private queueMutation(mutate: (config: ServerConfig) => void): void { this.pendingMutations.push(mutate); this.scheduleSave(); @@ -252,27 +465,42 @@ class ConfigManager { /** Non-blocking, coalesced persistence for high-frequency state updates. */ scheduleSave(): void { - if (this.saveScheduled) return; + if (this.saveScheduled || this.saveRetry) return; this.saveScheduled = true; const write = this.writeChain.then(async () => { this.saveScheduled = false; - const mutations = this.pendingMutations.splice(0); - if (mutations.length === 0) return; + // A write in between may have saved them already + if (this.pendingMutations.length === 0) return; try { - await this.performConfigMutation((latest) => { - for (const mutate of mutations) mutate(latest); - }); + // Every config write applies the queued changes first + await this.performConfigMutation(() => {}); } catch (error) { - // Persistence failed before commit, so keep these mutations for a later retry. - this.pendingMutations.unshift(...mutations); - console.error('Failed to save config (background), will retry:', error); - const retry = setTimeout(() => this.scheduleSave(), 250); - retry.unref?.(); + // The changes stay queued. A brief failure (a lock, a file scanner) is + // tried again soon, a lasting one (a read-only file system, a full disk) + // every few seconds, and logged once. + this.failedSaves++; + if (this.failedSaves === 1) { + console.error('Failed to save config (background), will retry:', error); + this.retrySaves(SAVE_RETRY_MS); + } else { + if (this.failedSaves === 2) console.error("config.json can't be written, so changes are kept and saved once it can:", error); + this.retrySaves(HELD_SAVE_RETRY_MS); + } } }); this.writeChain = write.catch(() => {}); } + /** Saves the queued changes in `ms`; until then scheduleSave() waits for it */ + private retrySaves(ms: number): void { + if (this.saveRetry) clearTimeout(this.saveRetry); + this.saveRetry = setTimeout(() => { + this.saveRetry = null; + this.scheduleSave(); + }, ms); + this.saveRetry.unref?.(); + } + private startConfigWatcher(): void { if (this.watcher) return; try { @@ -291,10 +519,15 @@ class ConfigManager { private async reloadConfigFromDisk(): Promise { try { - const latest = await this.readConfigFromDisk(); + let latest: ServerConfig; + try { + latest = await this.readConfigFromDisk(); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + latest = await this.withConfigLock(() => this.recoverCorruptConfig('watcher')); + } for (const mutate of this.pendingMutations) mutate(latest); - latest['version'] = VERSION; - this.config = latest; + this.config = { ...latest, version: VERSION }; } catch (error: any) { if (error?.code !== 'ENOENT') console.error('Failed to reload config:', error); } @@ -319,8 +552,10 @@ class ConfigManager { /** * Set a specific configuration value and wait until it is saved: the data is * flushed to disk before the rename; the folder flush after it is best effort. + * With holdIfNotSaved, a value whose save fails is still in effect, and is + * queued to be saved with later writes (the call still rejects). */ - async setValue(key: string, value: any): Promise { + async setValue(key: string, value: any, options: { holdIfNotSaved?: boolean } = {}): Promise { await this.init(); if (key === 'telemetryEnabled') value = normalizeTelemetryEnabledValue(value); @@ -335,6 +570,14 @@ class ConfigManager { const nextValue = value; const write = this.writeChain.then(() => this.performConfigMutation((latest) => { latest[key] = nextValue; + }).catch((error) => { + // Queued before the next write in the chain starts, so a later value of the + // same key is applied after it and wins + if (options.holdIfNotSaved) { + this.config[key] = nextValue; + this.queueMutation((latest) => { latest[key] = nextValue; }); + } + throw error; })); this.writeChain = write.then(() => {}, () => {}); await write; @@ -413,6 +656,11 @@ class ConfigManager { */ async getOrCreateClientId(): Promise { const { randomUUID } = await import('crypto'); + if (this.failedSaves > 0) { + // Saves fail until a write succeeds, so keep one for the session, saved with the queued changes + const clientId = this.config.clientId || randomUUID(); + return await this.updateValueNonBlocking('clientId', (current) => current || clientId); + } return await this.updateValue('clientId', (current) => current || randomUUID()); } } diff --git a/src/config-recovery.ts b/src/config-recovery.ts new file mode 100644 index 000000000..79d50ed30 --- /dev/null +++ b/src/config-recovery.ts @@ -0,0 +1,83 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { existsSync } from 'fs'; +import type { ServerConfig } from './config-manager.js'; + +/** Where a corrupt config.json was found: at start, by a write, or by the file watcher */ +export type RecoveryPhase = 'startup' | 'mutation' | 'watcher'; + +/** The config_parse_error_recovered event */ +export interface RecoveryEvent { + phase: RecoveryPhase; + config_bytes: number | null; + backup_created: boolean; + recovered_by_other_process: boolean; +} + +/** + * The settings still readable in a corrupt config.json: the longest beginning + * of it that is a JSON object once closed, so every complete top-level setting + * before the first error. {} when there is none. + */ +export function salvageSettings(text: string): ServerConfig { + const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + // A readable beginning can end at each comma between top-level settings, or at + // the brace that closes the object (anything after it is ignored) + const ends: number[] = []; + let depth = 0; + let inString = false; + let escaped = false; + for (let i = 0; i < body.length; i++) { + const char = body[i]; + if (inString) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + } else if (char === '"') { + inString = true; + } else if (char === '{' || char === '[') { + depth++; + } else if (char === '}' || char === ']') { + if (--depth === 0) { ends.push(i); break; } + } else if (char === ',' && depth === 1) { + ends.push(i); + } + } + for (const end of ends.reverse()) { + try { + return JSON.parse(body.slice(0, end) + '}'); + } catch { + // Not valid JSON up to here either, so try a shorter beginning + } + } + return {}; +} + +/** + * What a corrupt config.json is recovered as: the defaults, then the settings + * last read (while running), then the settings salvaged from it. The welcome + * page stays off, as for any existing install. + */ +export function buildRecoveredConfig(defaults: ServerConfig, lastRead: ServerConfig | null, text: string): ServerConfig { + const { version: _version, ...settings } = lastRead ?? {}; + return { ...defaults, ...settings, ...salvageSettings(text), welcomeOnboardingEligible: false, pendingWelcomeOnboarding: false }; +} + +/** + * Backs up a corrupt config.json as config.json.corrupt.., unless the + * newest backup already holds `bytes` (a recovery tried again after its write + * failed). Returns the backup's name, or null when there is no config.json. + */ +export async function backupCorruptConfig(configPath: string, bytes: Buffer): Promise { + if (!existsSync(configPath)) return null; + const folder = path.dirname(configPath); + const prefix = `${path.basename(configPath)}.corrupt.`; + const newest = (await fs.readdir(folder).catch(() => [] as string[])) + .filter((name) => name.startsWith(prefix)) + .sort((a, b) => parseInt(a.slice(prefix.length), 10) - parseInt(b.slice(prefix.length), 10)) + .pop(); + if (newest && (await fs.readFile(path.join(folder, newest)).catch(() => null))?.equals(bytes)) return newest; + const name = `${prefix}${Date.now()}.${process.pid}`; + await fs.copyFile(configPath, path.join(folder, name)); + return name; +} diff --git a/src/tools/config.ts b/src/tools/config.ts index 15949e199..eba9debe1 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -188,7 +188,8 @@ export async function setConfigValue(args: unknown) { valueToStore = numeric; } - await configManager.setValue(parsed.data.key, valueToStore); + // Not saved: in effect all the same, as the answer below says, and saved with the held changes + await configManager.setValue(parsed.data.key, valueToStore, { holdIfNotSaved: true }); // Get the updated configuration to show the user const updatedConfig = await configManager.getConfig(); console.error(`setConfigValue: Successfully set ${parsed.data.key} to ${JSON.stringify(valueToStore)}`); diff --git a/src/utils/atomic-write.ts b/src/utils/atomic-write.ts index 87d8a92c6..01238b099 100644 --- a/src/utils/atomic-write.ts +++ b/src/utils/atomic-write.ts @@ -24,6 +24,11 @@ const pendingWrites = new Map>(); export interface AtomicWriteOptions { encoding?: BufferEncoding; mode?: number; + /** + * A last check once the new content is on disk, before it replaces the file: + * if it throws, nothing is replaced and the write rejects with its error + */ + beforeCommit?: () => void | Promise; } async function writeThroughTempFile(filePath: string, data: string | Uint8Array, options: AtomicWriteOptions): Promise { @@ -36,6 +41,7 @@ async function writeThroughTempFile(filePath: string, data: string | Uint8Array, } finally { await handle.close(); } + await options.beforeCommit?.(); await renameWithRetry(tempPath, filePath); } finally { // After a successful rename the temp file is gone (ENOENT); after a failed diff --git a/test/helpers/config-child.js b/test/helpers/config-child.js new file mode 100644 index 000000000..43c85fdfd --- /dev/null +++ b/test/helpers/config-child.js @@ -0,0 +1,35 @@ +import path from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const DIST = pathToFileURL(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist')).href; + +/** + * Loads the built config manager (dist/config-manager.js) in a child process + * of its own, with `env` (a test home), so a test can make the file system + * fail the way it needs before the config manager uses it. + * + * The script sees `fs` (fs/promises, the object the config manager calls: + * patch its methods in `prelude`), `fsSync` (fs), `DIST` (the dist/ folder's + * URL, for more imports) and, in `body`, `configManager`. `body` prints its + * result as the last stdout line, as JSON. + * Returns { status, result (that JSON, or undefined), stdout, stderr }. + */ +export function runConfigManagerChild(env, { prelude = '', body }) { + const script = ` + import fs from 'fs/promises'; + import fsSync from 'fs'; + const DIST = ${JSON.stringify(DIST)}; + ${prelude} + const { configManager } = await import(DIST + '/config-manager.js'); + ${body}`; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { env, encoding: 'utf8', timeout: 60_000 }); + const last = (child.stdout ?? '').trim().split('\n').pop(); + let result; + try { + result = JSON.parse(last); + } catch { + // No JSON result line: the caller reports status, stdout and stderr instead + } + return { status: child.status, result, stdout: child.stdout ?? '', stderr: child.stderr ?? '' }; +} diff --git a/test/helpers/mcp-server.js b/test/helpers/mcp-server.js new file mode 100644 index 000000000..1b9f29ac7 --- /dev/null +++ b/test/helpers/mcp-server.js @@ -0,0 +1,44 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { closeClient } from './close-client.js'; + +const SERVER = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist', 'index.js'); + +/** + * Starts the built server (dist/index.js) the way `desktop-commander remote` + * starts its local MCP: client "desktop-commander-client", DC_REMOTE_DEVICE=true. + * Resolves once `initialize` has succeeded; rejects with the client's error + * otherwise (e.g. `MCP error -32603: …`, which `remote` reports as + * "Device startup failed"). + * + * The result collects what the server reports: `logs` (its console output, + * which reaches the client as log notifications: `{ level, data }`) and + * `stderr`. Call `close()` when done. + */ +export async function startServerLikeRemote(env, { timeout = 30_000 } = {}) { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER], + env: { ...env, DC_REMOTE_DEVICE: 'true' }, + stderr: 'pipe', + }); + const client = new Client({ name: 'desktop-commander-client', version: '1.0.0' }, { capabilities: {} }); + const server = { + client, + logs: [], + stderr: '', + close: () => closeClient(client), + }; + transport.stderr?.on('data', (chunk) => { server.stderr += chunk; }); + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { server.logs.push(notification.params); }); + try { + await client.connect(transport, { timeout }); + } catch (error) { + await server.close(); + throw error; + } + return server; +} diff --git a/test/repro/test-config-damaged-start.js b/test/repro/test-config-damaged-start.js new file mode 100644 index 000000000..5a3a29d23 --- /dev/null +++ b/test/repro/test-config-damaged-start.js @@ -0,0 +1,109 @@ +// Repro (#692): with a damaged ~/.claude-server-commander/config.json, +// `desktop-commander remote` fails every start with `MCP error -32603`, and +// keeps failing until the user moves the file aside. +// +// Reports: macOS 0.2.50, a truncated file: "Unexpected end of JSON input". +// Windows 0.2.51 (#697 comments), no other instance running: +// "Unexpected token '', ""... is not valid JSON". Node quotes the file's first +// 10 characters there, all invisible: a file of NUL bytes, as a crash +// mid-write leaves it (a BOM would show the `{` after it). +// +// Mechanism: init() cannot parse the file (after retrying it for 1 s), logs it +// and carries on with in-memory defaults; the file stays as it is. Those +// defaults say "new install, welcome page pending", so `initialize` clears the +// flag with setValue, whose mutation reads the same file again and throws: the +// client gets -32603 and `remote` shuts the device down. Every client fails the +// same way, not only `remote`. +// +// Measured on 4715bd4: 10 of 10 starts failed on Windows 11 and 10 of 10 on +// macOS 26, each after ~3.4-3.8 s (the 1 s read wait, twice), for all five +// kinds of damage below; config.json was never changed. +// +// Here each kind of damage is written to config.json in the temporary home, +// and the current build is started the way `remote` starts its local MCP +// (client "desktop-commander-client", DC_REMOTE_DEVICE=true), then asked for +// get_config. After each start the file is compared with what was written. +// +// Run: node test/repro/run-repro.js test-config-damaged-start.js +// (REPRO_RUNS=2 starts per kind of damage by default) +// Exit code: 1 if any start failed. +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { isTestHome } from '../helpers/test-env.js'; +import { startServerLikeRemote } from '../helpers/mcp-server.js'; +import { exitProcess } from '../../dist/utils/exit-process.js'; + +const RUNS = Number(process.env.REPRO_RUNS || 2); + +// This replaces config.json, so never in a real home +if (!isTestHome()) { + console.error('Run it through the repro runner: node test/repro/run-repro.js test-config-damaged-start.js'); + exitProcess(2); +} + +const configDir = path.join(os.homedir(), '.claude-server-commander'); +const configPath = path.join(configDir, 'config.json'); + +const saved = JSON.stringify({ + blockedCommands: ['rm'], + allowedDirectories: [os.tmpdir()], + telemetryEnabled: false, + pendingWelcomeOnboarding: false, + welcomeOnboardingEligible: false, +}, null, 2); + +const DAMAGE = { + // The issue's own minimal reproduction + truncated: Buffer.from('{"defaultShell":'), + empty: Buffer.alloc(0), + // A crash mid-write can leave the file's length with no data behind it + nul: Buffer.alloc(saved.length), + // An editor saving it as "UTF-8 with BOM" (Notepad, PowerShell 5 Set-Content -Encoding UTF8) + bom: Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(saved)]), + // A hand edit adding a Windows folder without doubling its backslashes + handEdit: Buffer.from(saved.replace(JSON.stringify(os.tmpdir()), '"C:\\Users\\me\\projects"')), +}; + +async function start() { + const started = Date.now(); + let server; + let error = ''; + try { + server = await startServerLikeRemote(process.env); + const result = await server.client.callTool({ name: 'get_config', arguments: {} }); + if (result.isError) error = `get_config: ${result.content?.[0]?.text}`; + } catch (e) { + error = e?.message ?? String(e); + } finally { + await server?.close(); + } + return { error, ms: Date.now() - started }; +} + +async function runRepro() { + let failed = 0; + let total = 0; + for (const [kind, bytes] of Object.entries(DAMAGE)) { + fs.rmSync(configDir, { recursive: true, force: true }); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, bytes); + for (let run = 1; run <= RUNS; run++) { + total++; + const { error, ms } = await start(); + if (error) failed++; + const after = fs.existsSync(configPath) ? fs.readFileSync(configPath) : null; + const file = after === null ? 'gone' : after.equals(bytes) ? 'unchanged' : `rewritten (${after.length} bytes)`; + const others = fs.readdirSync(configDir).filter((name) => name !== 'config.json'); + console.log(`${kind} start ${run}: ${error ? `FAILED after ${ms} ms: ${JSON.stringify(error)}` : `ok in ${ms} ms`}; config.json ${file}${others.length ? `; also in the folder: ${others.join(', ')}` : ''}`); + } + } + + console.log(failed > 0 + ? `REPRODUCED: ${failed} of ${total} starts with a damaged config.json failed` + : `NOT REPRODUCED: ${total} starts with a damaged config.json, none failed`); + exitProcess(failed > 0 ? 1 : 0); +} + +// Only in a test home: outside one, the check above refused to run +if (isTestHome()) await runRepro(); diff --git a/test/repro/test-config-lock-frozen-holder.js b/test/repro/test-config-lock-frozen-holder.js new file mode 100644 index 000000000..3b9b4605d --- /dev/null +++ b/test/repro/test-config-lock-frozen-holder.js @@ -0,0 +1,98 @@ +// Repro: a Desktop Commander process frozen for over 30 s while it holds the +// config lock dies once it runs again, if another Desktop Commander process +// wrote config.json meanwhile (seen as a failed test-search-without-ripgrep.js +// in a full Windows run: "Error: Unable to update lock within the stale +// threshold { code: 'ECOMPROMISED' }"). +// +// Mechanism: proper-lockfile refreshes a held lock every 10 s; one not +// refreshed for 30 s is stale, and the next process that wants it removes it +// and takes it. When the frozen holder runs again, its refresh finds the lock +// gone or no longer its own, and proper-lockfile's default reaction throws +// from a timer: an uncaught exception, which exits the process (the server's +// handler in index.ts exits too). Frozen alone, with nobody else writing, the +// holder survives. Freezes in real life: machine sleep, a suspended process +// (debugger, paused VM, Ctrl+Z), a test blocking its event loop in spawnSync. +// +// Measured on cbccab9 (holder frozen by spawnSync as soon as it holds the lock, +// the other process's writes waiting for it): the other process took the lock +// over after ~30 s and the holder died of ECOMPROMISED in 3 of 3 runs on +// Windows 11 and 3 of 3 on macOS 26. With two real servers frozen by +// SIGSTOP / NtSuspendProcess for 36 s the frozen one exited the same way on +// both; frozen alone it survived. +// +// Here the holder (its own config manager) starts a config write and, the +// moment it holds the lock, freezes itself in spawnSync, running a second +// process that keeps trying to write config.json (as a server's saves do) +// until it can. Then the holder runs again for 3 s. +// +// Run: node test/repro/run-repro.js test-config-lock-frozen-holder.js +// (about 35 s per run; REPRO_RUNS=1 by default) +// Exit code: 1 if the holder died. +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { isTestHome } from '../helpers/test-env.js'; +import { exitProcess } from '../../dist/utils/exit-process.js'; + +const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const CONFIG_MANAGER = pathToFileURL(path.join(PROJECT_ROOT, 'dist', 'config-manager.js')).href; +const RUNS = Number(process.env.REPRO_RUNS || 1); + +// This writes config.json, so never in a real home +if (!isTestHome()) { + console.error('Run it through the repro runner: node test/repro/run-repro.js test-config-lock-frozen-holder.js'); + exitProcess(2); +} + +// The other process: writes config.json, trying again while the lock is held +const WRITER = ` + const { configManager } = await import(${JSON.stringify(CONFIG_MANAGER)}); + for (;;) { + try { await configManager.setValue('writtenByOther', true); break; } + catch (error) { if (error.code !== 'ELOCKED') throw error; } + }`; + +// The holder: frozen in spawnSync from the moment it holds the lock +const holder = (out) => ` + import fs from 'fs'; + import { spawnSync } from 'child_process'; + const { configManager } = await import(${JSON.stringify(CONFIG_MANAGER)}); + await configManager.getConfig(); + const acquire = configManager.acquireConfigLock.bind(configManager); + let held; + const holding = new Promise((resolve) => { held = resolve; }); + configManager.acquireConfigLock = async () => { const release = await acquire(); held(); return release; }; + void configManager.setValue('writtenByHolder', true); + await holding; + const started = Date.now(); + const writer = spawnSync(process.execPath, ['--input-type=module', '-e', ${JSON.stringify(WRITER)}], { encoding: 'utf8', timeout: 90000 }); + fs.writeFileSync(${JSON.stringify(out)}, JSON.stringify({ frozenMs: Date.now() - started, writerStatus: writer.status, writerError: writer.stderr.slice(0, 300) })); + setTimeout(() => { fs.appendFileSync(${JSON.stringify(out)} + '.survived', 'yes'); process.exit(0); }, 3000);`; + +async function runRepro() { + const configDir = path.join(os.homedir(), '.claude-server-commander'); + let died = 0; + for (let run = 1; run <= RUNS; run++) { + fs.rmSync(configDir, { recursive: true, force: true }); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ telemetryEnabled: false, pendingWelcomeOnboarding: false, welcomeOnboardingEligible: false }, null, 2)); + const out = path.join(os.homedir(), `holder-${run}.json`); + const result = spawnSync(process.execPath, ['--input-type=module', '-e', holder(out)], { encoding: 'utf8', timeout: 150000 }); + let frozen = {}; + try { frozen = JSON.parse(fs.readFileSync(out, 'utf8')); } catch { /* the holder died before writing it */ } + const survived = fs.existsSync(`${out}.survived`); + if (!survived) died++; + const crash = (result.stderr.match(/Error[^\n]*|code: '[A-Z]+'/g) ?? []).slice(0, 2).join(' '); + console.log(`run ${run}: holder frozen ${frozen.frozenMs} ms (the other process ${frozen.writerStatus === 0 ? 'wrote config.json' : `failed: ${frozen.writerError}`}); holder ${survived ? 'survived' : `DIED (exit ${result.status}): ${crash}`}`); + } + + console.log(died > 0 + ? `REPRODUCED: the holder died in ${died} of ${RUNS} runs after being frozen inside the config lock while another process wrote config.json` + : `NOT REPRODUCED: the holder survived ${RUNS} of ${RUNS} runs after being frozen inside the config lock while another process wrote config.json`); + exitProcess(died > 0 ? 1 : 0); +} + +// Only in a test home: outside one, the check above refused to run +if (isTestHome()) await runRepro(); diff --git a/test/repro/test-config-old-writer.js b/test/repro/test-config-old-writer.js index c1bc87d1c..3b346180c 100644 --- a/test/repro/test-config-old-writer.js +++ b/test/repro/test-config-old-writer.js @@ -15,8 +15,8 @@ // // Run: node test/repro/run-repro.js test-config-old-writer.js // (REPRO_RUNS=5 starts by default) -// Exit code: 1 if any start failed, started without the config it was given -// ("Failed to initialize config": the server then runs on its defaults), or +// Exit code: 1 if any start failed, ran without the config it was given, +// replaced config.json as damaged (a config.json.corrupt.* copy appears), or // logged "Failed to reload config". import fs from 'fs'; import os from 'os'; @@ -69,9 +69,11 @@ async function runRepro() { } })(); + const corruptCopies = () => fs.readdirSync(path.dirname(configPath)).filter((name) => name.startsWith('config.json.corrupt.')); let failedStarts = 0; let reloadErrors = 0; for (let run = 1; run <= RUNS; run++) { + const copiesBefore = corruptCopies().length; let log = ''; const transport = new StdioClientTransport({ command: process.execPath, args: [SERVER], env: { ...process.env }, stderr: 'pipe' }); transport.stderr?.on('data', (chunk) => { log += chunk; }); @@ -82,14 +84,16 @@ async function runRepro() { try { await client.connect(transport, { timeout: 30_000 }); await sleep(STAY_UP_MS); + const { config: inEffect } = (await client.callTool({ name: 'get_config', arguments: {} })).structuredContent; + if (inEffect.writtenBy !== 'older-version') error = `runs without the config it was given (writtenBy: ${inEffect.writtenBy})`; } catch (e) { error = e?.message ?? String(e); } finally { await closeClient(client); } - // A start whose first read of config.json failed goes on with the defaults and - // answers normally, so it fails without an error the client sees - if (!error && /Failed to initialize config/.test(log)) error = 'started without its config ("Failed to initialize config")'; + // The old version's half-written file isn't damaged: it must not be replaced + const copies = corruptCopies(); + if (!error && copies.length > copiesBefore) error = `replaced config.json as damaged (${copies.at(-1)})`; if (error) failedStarts++; const reloads = (log.match(/Failed to reload config/g) ?? []).length; reloadErrors += reloads; diff --git a/test/test-config-bom.js b/test/test-config-bom.js new file mode 100644 index 000000000..4c651c80c --- /dev/null +++ b/test/test-config-bom.js @@ -0,0 +1,92 @@ +/** + * #692: a config.json saved as "UTF-8 with BOM" (Notepad, PowerShell 5's + * `Set-Content -Encoding UTF8`) still holds the user's whole config. Read as + * text, it starts with an invisible U+FEFF that JSON.parse rejects, so every + * start failed with `MCP error -32603: Unexpected token '', "{…"... is not + * valid JSON`. Desktop Commander must read it as the config it is: start, and + * apply the user's own settings, not treat the file as damaged. + * + * Starts the real server over stdio (dist/index.js) the way `remote` does. + */ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { createTestEnv } from './helpers/test-env.js'; +import { startServerLikeRemote } from './helpers/mcp-server.js'; +import { runIfMain } from './helpers/run-if-main.js'; + +async function run() { + const { env, home, cleanup } = createTestEnv(); + const configDir = path.join(home, '.claude-server-commander'); + const configPath = path.join(configDir, 'config.json'); + const projects = path.join(home, 'projects'); + const saved = { + blockedCommands: ['rm'], + allowedDirectories: [projects], + telemetryEnabled: false, + clientId: 'bom-test', + pendingWelcomeOnboarding: false, + welcomeOnboardingEligible: false, + }; + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(JSON.stringify(saved, null, 2))])); + + const failures = []; + const check = async (name, test) => { + try { + await test(); + console.log(`✓ ${name}`); + } catch (error) { + failures.push(name); + console.log(`✗ ${name}\n ${error.message}`); + } + }; + + let server; + try { + await check('a config saved with a BOM does not stop the server from starting', async () => { + try { + server = await startServerLikeRemote(env); + } catch (error) { + assert.fail(`with config.json saved as UTF-8 with BOM, the start failed: ${error.message}`); + } + }); + + await check("the user's own settings from the BOM file are in effect", async () => { + assert(server, 'the server did not start'); + const result = await server.client.callTool({ name: 'get_config', arguments: {} }); + const config = result.structuredContent?.config ?? {}; + for (const key of ['blockedCommands', 'allowedDirectories', 'telemetryEnabled', 'clientId']) { + assert.deepStrictEqual(config[key], saved[key], `with config.json saved as UTF-8 with BOM, ${key} is ${JSON.stringify(config[key])} instead of the user's ${JSON.stringify(saved[key])}`); + } + }); + + await check('the BOM file is not treated as damaged, and the next write keeps its settings', async () => { + assert(server, 'the server did not start'); + const result = await server.client.callTool({ name: 'set_config_value', arguments: { key: 'fileReadLineLimit', value: 500 } }); + assert.notStrictEqual(result.isError, true, `set_config_value failed: ${result.content?.[0]?.text}`); + // Anything beside config.json but its lock and a write's temp file + const others = fs.readdirSync(configDir) + .filter((name) => name.startsWith('config.json.') && !name.endsWith('.lock') && !name.endsWith('.tmp')); + assert.deepStrictEqual(others, [], `a config saved with a BOM was handled as damaged: ${others.join(', ')}`); + const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.strictEqual(onDisk.fileReadLineLimit, 500, 'the write must land'); + for (const key of ['blockedCommands', 'allowedDirectories', 'telemetryEnabled', 'clientId']) { + assert.deepStrictEqual(onDisk[key], saved[key], `after a write, config.json has ${key} ${JSON.stringify(onDisk[key])} instead of the user's ${JSON.stringify(saved[key])}`); + } + }); + } finally { + await server?.close(); + cleanup(); + } + + if (failures.length > 0) { + console.log(`${failures.length} of 3 cases failed`); + return false; + } + return true; +} + +runIfMain(import.meta.url, run); + +export default run; diff --git a/test/test-config-corrupt-concurrency.js b/test/test-config-corrupt-concurrency.js new file mode 100644 index 000000000..bdc03b7c9 --- /dev/null +++ b/test/test-config-corrupt-concurrency.js @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createTempDir } from './helpers/test-env.js'; + +const TEST_FILE = fileURLToPath(import.meta.url); +const TIMEOUT_MS = 5_000; + +async function worker() { + const { configManager } = await import('../dist/config-manager.js'); + const config = await configManager.getConfig(); + assert.equal(configManager.isFirstRun(), false); + assert.deepEqual(config.blockedCommands, ['rm', 'sudo']); + assert.deepEqual(config.allowedDirectories, ['/safe/project']); + process.send?.({ type: 'done' }); +} + +function runWorker(home) { + return new Promise((resolve, reject) => { + const child = fork(TEST_FILE, [], { + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + DC_CONFIG_CORRUPT_CONCURRENCY_WORKER: '1', + DESKTOP_COMMANDER_DISABLE_TELEMETRY: '1', + }, + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }); + // Settles only once the worker has exited, so its home can then be removed + const exited = new Promise((done) => child.once('exit', done)); + const timer = setTimeout(() => { + child.kill('SIGTERM'); + exited.then(() => reject(new Error('timeout waiting for concurrent corrupt-config worker'))); + }, TIMEOUT_MS); + + child.on('message', (message) => { + if (message.type !== 'done') return; + clearTimeout(timer); + child.kill('SIGTERM'); + exited.then(resolve); + }); + child.on('exit', (code) => { + if (code && code !== 0) { + clearTimeout(timer); + reject(new Error(`worker exited ${code}`)); + } + }); + }); +} + +async function parent() { + const home = createTempDir('dc-config-corrupt-concurrency-'); + try { + const dir = path.join(home, '.claude-server-commander'); + const configPath = path.join(dir, 'config.json'); + mkdirSync(dir, { recursive: true }); + const corrupt = '{"blockedCommands":["rm","sudo"],"allowedDirectories":["/safe/project"],"telemetryEnabled":false,"usageStats":{'; + writeFileSync(configPath, corrupt); + + // Both workers have exited, even when one fails, before the home is removed + const workers = await Promise.allSettled([runWorker(home), runWorker(home)]); + const failed = workers.find((result) => result.status === 'rejected'); + if (failed) throw failed.reason; + const base = path.basename(configPath); + const backups = readdirSync(dir).filter((name) => name.startsWith(`${base}.corrupt.`)); + assert.equal(backups.length, 1, 'only one process should preserve the shared corrupt config'); + assert.equal(readFileSync(path.join(dir, backups[0]), 'utf8'), corrupt); + + const finalConfig = JSON.parse(readFileSync(configPath, 'utf8')); + assert.deepEqual(finalConfig.blockedCommands, ['rm', 'sudo']); + assert.deepEqual(finalConfig.allowedDirectories, ['/safe/project']); + assert.equal(finalConfig.telemetryEnabled, false); + console.log('✓ concurrent corrupt-config startup recovers once and both processes start'); + } finally { + rmSync(home, { recursive: true, force: true }); + } +} + +if (process.env.DC_CONFIG_CORRUPT_CONCURRENCY_WORKER === '1') { + await worker(); +} else { + await parent(); +} diff --git a/test/test-config-corrupt-recovery.js b/test/test-config-corrupt-recovery.js new file mode 100644 index 000000000..c5015b166 --- /dev/null +++ b/test/test-config-corrupt-recovery.js @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const TEST_FILE = fileURLToPath(import.meta.url); +const TIMEOUT_MS = 5_000; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function worker() { + const { configManager } = await import('../dist/config-manager.js'); + const { CONFIG_FILE } = await import('../dist/config.js'); + const events = []; + configManager.emitCorruptConfigTelemetry = async (telemetry) => { events.push(telemetry); }; + + const firstCorrupt = readFileSync(CONFIG_FILE, 'utf8'); + const config = await configManager.getConfig(); + assert.equal(configManager.isFirstRun(), false, 'corrupt existing config is not a first run'); + assert.equal(config.welcomeOnboardingEligible, false); + assert.equal(config.pendingWelcomeOnboarding, false); + assert.equal(config.clientId, '11111111-1111-4111-8111-111111111111'); + assert.deepEqual(config.blockedCommands, ['rm', 'sudo']); + assert.deepEqual(config.allowedDirectories, ['/safe/project']); + assert.doesNotThrow(() => JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))); + assert.equal(events.length, 1); + assert.equal(events[0].phase, 'startup'); + assert.equal(events[0].config_bytes, Buffer.byteLength(firstCorrupt)); + assert.equal(events[0].backup_created, true); + assert.equal(events[0].recovered_by_other_process, false); + + const dir = path.dirname(CONFIG_FILE); + const base = path.basename(CONFIG_FILE); + const startupBackups = readdirSync(dir).filter((name) => name.startsWith(`${base}.corrupt.`)); + assert.equal(startupBackups.length, 1); + assert.equal(readFileSync(path.join(dir, startupBackups[0]), 'utf8'), firstCorrupt); + + // Corruption that appears after startup must also recover on the next durable mutation. + const secondCorrupt = '{"telemetryEnabled": false, BROKEN}'; + writeFileSync(CONFIG_FILE, secondCorrupt); + await configManager.setValue('__afterRecovery', 42); + const finalConfig = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); + assert.equal(finalConfig.__afterRecovery, 42); + assert.equal(finalConfig.telemetryEnabled, false, 'explicit telemetry opt-out survives recoverable malformed JSON'); + assert.deepEqual(finalConfig.blockedCommands, ['rm', 'sudo'], 'runtime recovery preserves last parsed blocklist'); + assert.deepEqual(finalConfig.allowedDirectories, ['/safe/project'], 'runtime recovery preserves last parsed allowlist'); + const mutationEvent = events.slice(1).find((event) => event.phase === 'mutation'); + assert.ok(mutationEvent, 'mutation should recover the corrupt config'); + assert.equal(mutationEvent.config_bytes, Buffer.byteLength(secondCorrupt)); + assert.equal(mutationEvent.backup_created, true); + assert.equal(mutationEvent.recovered_by_other_process, false); + + // A malformed external edit must be recovered by the file watcher even if no + // tool/config mutation happens afterward. Let watcher notifications from the + // previous mutation settle first; one process can legitimately observe the + // same corruption through both paths. + await sleep(200); + const beforeWatcherCorruption = events.length; + writeFileSync(CONFIG_FILE, '{"telemetryEnabled": false, "watcher": {'); + const watcherDeadline = Date.now() + TIMEOUT_MS; + let watcherEvent; + while (!watcherEvent && Date.now() < watcherDeadline) { + watcherEvent = events.slice(beforeWatcherCorruption).find((event) => event.phase === 'watcher'); + if (!watcherEvent) await sleep(25); + } + assert.ok(watcherEvent, 'watcher should report and recover the externally corrupted config'); + assert.equal(watcherEvent.backup_created, true); + assert.doesNotThrow(() => JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))); + + process.send?.({ type: 'done' }); +} + +async function parent() { + const home = mkdtempSync(path.join(os.tmpdir(), 'dc-config-corrupt-')); + const dir = path.join(home, '.claude-server-commander'); + const configPath = path.join(dir, 'config.json'); + mkdirSync(dir, { recursive: true }); + writeFileSync(configPath, '{"blockedCommands":["rm","sudo"],"allowedDirectories":["/safe/project"],"telemetryEnabled": true, "clientId": "11111111-1111-4111-8111-111111111111", "version": "0.2.48", "usageStats": {'); + + const child = fork(TEST_FILE, [], { + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + DC_CONFIG_CORRUPT_WORKER: '1', + DESKTOP_COMMANDER_DISABLE_TELEMETRY: '1', + }, + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }); + + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout waiting for corrupt config recovery test')), TIMEOUT_MS); + child.on('message', (message) => { + if (message.type === 'done') { clearTimeout(timer); resolve(); } + }); + child.on('exit', (code) => { + if (code && code !== 0) { clearTimeout(timer); reject(new Error(`worker exited ${code}`)); } + }); + }); + console.log('✓ corrupt config is backed up, recovered, classified, and observable at startup, mutation, and watcher time'); + } finally { + const exited = child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + await exited; + rmSync(home, { recursive: true, force: true }); + } +} + +if (process.env.DC_CONFIG_CORRUPT_WORKER === '1') await worker(); else await parent(); diff --git a/test/test-config-damaged-reset.js b/test/test-config-damaged-reset.js new file mode 100644 index 000000000..56e85579b --- /dev/null +++ b/test/test-config-damaged-reset.js @@ -0,0 +1,199 @@ +/** + * A damaged config.json (it can't be parsed, even after #773's wait) is + * replaced, keeping everything still readable in it: the longest beginning of + * the file that is a JSON object once closed, so every complete top-level + * setting before the damage, whatever its key. Every other setting gets its + * default; while running, the settings last read come between the two. The + * damaged file is kept as one config.json.corrupt.