UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

635 lines (634 loc) • 27.3 kB
import fs from 'fs/promises'; import path from 'path'; import { existsSync, watch } from 'fs'; import { mkdir } from 'fs/promises'; import lockfile from 'proper-lockfile'; 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 } 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 corrupt. 30 reads 10 ms apart span longer than any empty moment measured. const PARTIAL_CONFIG_WAIT_MS = 1000; 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 = 5000; // 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 function normalizeTelemetryEnabledValue(value) { if (typeof value !== 'string') { return value; } const normalized = value.trim().toLowerCase(); if (normalized === 'true') { return true; } if (normalized === 'false') { return false; } return value; } export function isTelemetryDisabledValue(value) { 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. */ export function parseConfig(text) { 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) { 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) { console.warn(message); process.stderr.write(`[WARNING] Desktop Commander: ${message}\n`); } function messageOf(error) { return error instanceof Error ? error.message : String(error); } /** * Singleton config manager for the server */ class ConfigManager { constructor() { this.config = {}; this.initialized = false; this._isFirstRun = false; // Track if this is the first run (config was just created) // Serializes all disk writes so concurrent saves can't corrupt config.json. this.writeChain = Promise.resolve(); // True while a coalesced background write is already queued (see scheduleSave). this.saveScheduled = false; this.pendingMutations = []; this.watcher = null; this.reloadTimer = null; // Background saves that failed in a row, and the timer that tries them again (see scheduleSave) this.failedSaves = 0; this.saveRetry = null; // Recovery events from before init() finished, sent when it does (see reportRecovery) this.heldRecoveryEvents = []; // Get user's home directory // Define config directory and file paths this.configPath = CONFIG_FILE; } /** * Initialize configuration - load from disk or create default. * Creation and legacy migration use the same cross-process mutation path as * normal writes so two processes starting together cannot clobber each other. */ async init() { if (this.initialized) return; try { const configDir = path.dirname(this.configPath); if (!existsSync(configDir)) { await mkdir(configDir, { recursive: true }); } 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. */ async loadStartupConfig() { let config; try { config = await this.readConfigFromDisk(); } catch (error) { if (error?.code === 'ENOENT') { let created = false; 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.`); } } 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. */ startWithoutSaving(config, warning) { // 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; } /** * Alias for init() to maintain backward compatibility */ async loadConfig() { return this.init(); } /** * Create default configuration */ getDefaultConfig() { return { blockedCommands: [ // Disk and partition management "mkfs", // Create a filesystem on a device "format", // Format a storage device (cross-platform) "mount", // Mount a filesystem "umount", // Unmount a filesystem "fdisk", // Manipulate disk partition tables "dd", // Convert and copy files, can write directly to disks "parted", // Disk partition manipulator "diskpart", // Windows disk partitioning utility // System administration and user management "sudo", // Execute command as superuser "su", // Substitute user identity "passwd", // Change user password "adduser", // Add a user to the system "useradd", // Create a new user "usermod", // Modify user account "groupadd", // Create a new group "chsh", // Change login shell "visudo", // Edit the sudoers file // System control "shutdown", // Shutdown the system "reboot", // Restart the system "halt", // Stop the system "poweroff", // Power off the system "init", // Change system runlevel // Network and security "iptables", // Linux firewall administration "firewall", // Generic firewall command "netsh", // Windows network configuration // Windows system commands "sfc", // System File Checker "bcdedit", // Boot Configuration Data editor "reg", // Windows registry editor "net", // Network/user/service management "sc", // Service Control manager "runas", // Execute command as another user "cipher", // Encrypt/decrypt files or wipe data "takeown" // Take ownership of files ], defaultShell: getDefaultShell(), allowedDirectories: [], telemetryEnabled: true, // Default to opt-out approach (telemetry on by default) fileWriteLineLimit: 50, // Default line limit for file write operations (changed from 100) fileReadLineLimit: 1000, // Default line limit for file read operations (changed from character-based) pendingWelcomeOnboarding: true, // New install flag - triggers A/B test for welcome page welcomeOnboardingEligible: true // Distinguishes new installs from migrated legacy configs }; } async readConfigFromDisk() { const deadline = Date.now() + PARTIAL_CONFIG_WAIT_MS; for (let failedReads = 1;; failedReads++) { try { return parseConfig(await fs.readFile(this.configPath, 'utf8')); } catch (error) { if (!(error instanceof SyntaxError)) throw error; if (Date.now() >= deadline && failedReads >= PARTIAL_CONFIG_MIN_FAILED_READS) throw error; await new Promise((resolve) => setTimeout(resolve, 10)); } } } /** * 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. */ async recoverCorruptConfig(phase) { 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; 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; } 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. */ reportRecovery(event) { if (this.initialized) void this.emitCorruptConfigTelemetry(event); else this.heldRecoveryEvents.push(event); } async emitCorruptConfigTelemetry(event) { try { const { capture } = await import('./utils/capture.js'); await capture('config_parse_error_recovered', event); } catch { // Recovery never depends on telemetry } } async writeConfigAtomically(config, beforeCommit) { await writeFileAtomic(this.configPath, JSON.stringify(config, null, 2), { beforeCommit }); } /** config.json's text, or null when there is none */ async readConfigText() { try { return await fs.readFile(this.configPath, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; } } async acquireConfigLock(onLost) { return lockfile.lock(this.configPath, { realpath: false, stale: 30000, update: 10000, 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.code})`); onLost?.(); }, }); } /** Runs `fn` holding the config lock; `lockLost()` tells whether the lock was lost meanwhile. */ async withConfigLock(fn) { let lost = false; const release = await this.acquireConfigLock(() => { lost = true; }); try { return await fn(() => lost); } finally { try { await release(); } catch (error) { // The atomic rename is the commit boundary. A release failure after it // must not make callers replay a mutation that already persisted. console.error('Failed to release config lock:', error); } } } /** * 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. */ async performConfigMutation(mutate) { 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; } } } async mutateLockedConfig(mutate, lockLost) { let latest; let existed = true; try { latest = await this.readConfigFromDisk(); } catch (error) { 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; } queueMutation(mutate) { this.pendingMutations.push(mutate); this.scheduleSave(); } /** Non-blocking, coalesced persistence for high-frequency state updates. */ scheduleSave() { if (this.saveScheduled || this.saveRetry) return; this.saveScheduled = true; const write = this.writeChain.then(async () => { this.saveScheduled = false; // A write in between may have saved them already if (this.pendingMutations.length === 0) return; try { // Every config write applies the queued changes first await this.performConfigMutation(() => { }); } catch (error) { // 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 */ retrySaves(ms) { if (this.saveRetry) clearTimeout(this.saveRetry); this.saveRetry = setTimeout(() => { this.saveRetry = null; this.scheduleSave(); }, ms); this.saveRetry.unref?.(); } startConfigWatcher() { if (this.watcher) return; try { const dir = path.dirname(this.configPath); const filename = path.basename(this.configPath); this.watcher = watch(dir, { persistent: false }, (_event, changed) => { if (changed && changed.toString() !== filename) return; if (this.reloadTimer) clearTimeout(this.reloadTimer); this.reloadTimer = setTimeout(() => void this.reloadConfigFromDisk(), 20); }); this.watcher.on('error', (error) => console.error('Config watcher error:', error)); } catch (error) { console.error('Failed to watch config:', error); } } async reloadConfigFromDisk() { try { let latest; 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); this.config = { ...latest, version: VERSION }; } catch (error) { if (error?.code !== 'ENOENT') console.error('Failed to reload config:', error); } } /** * Get the entire config */ async getConfig() { await this.init(); return { ...this.config }; } /** * Get a specific configuration value */ async getValue(key) { await this.init(); return this.config[key]; } /** * 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, value, options = {}) { await this.init(); if (key === 'telemetryEnabled') value = normalizeTelemetryEnabledValue(value); if (key === 'telemetryEnabled' && isTelemetryDisabledValue(value)) { const currentValue = this.config[key]; if (!isTelemetryDisabledValue(currentValue)) { const { capture } = await import('./utils/capture.js'); await capture('server_telemetry_opt_out', { reason: 'user_disabled', prev_value: currentValue }); } } 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; } /** * Update one value under the cross-process lock and return it once saved: the * data is flushed to disk before the rename; the folder flush after it is best effort. */ async updateValue(key, updater) { await this.init(); let updatedValue; const write = this.writeChain.then(() => this.performConfigMutation((latest) => { updatedValue = updater(latest[key]); latest[key] = updatedValue; })); this.writeChain = write.then(() => { }, () => { }); await write; return updatedValue; } /** * Set a value without waiting on disk. The queued operation is applied to the * latest on-disk config while holding the cross-process lock. */ async setValueNonBlocking(key, value) { await this.init(); this.config[key] = value; this.queueMutation((latest) => { latest[key] = value; }); } /** * Atomically update one value without blocking the caller on persistence. * The updater is replayed against the latest disk value under the lock, which * makes counter-style updates safe across multiple Desktop Commander processes. */ async updateValueNonBlocking(key, updater) { await this.init(); const next = updater(this.config[key]); this.config[key] = next; this.queueMutation((latest) => { latest[key] = updater(latest[key]); }); return next; } /** Update multiple configuration values at once. */ async updateConfig(updates) { await this.init(); const write = this.writeChain.then(() => this.performConfigMutation((latest) => { Object.assign(latest, updates); })); this.writeChain = write.then(() => { }, () => { }); return { ...(await write) }; } /** Reset configuration to defaults. This intentionally replaces all keys. */ async resetConfig() { await this.init(); const defaults = this.getDefaultConfig(); const write = this.writeChain.then(() => this.performConfigMutation((latest) => { for (const key of Object.keys(latest)) delete latest[key]; Object.assign(latest, defaults); })); this.writeChain = write.then(() => { }, () => { }); return { ...(await write) }; } /** * Check if this is the first run (config file was just created) */ isFirstRun() { return this._isFirstRun; } /** * Get or create a persistent client ID for analytics and A/B tests */ async getOrCreateClientId() { 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()); } } // Export singleton instance export const configManager = new ConfigManager();