From f5645c8e6d35ad88f37390d8c7eb5bebe1e2308b Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 14 Sep 2026 14:23:19 +0800 Subject: [PATCH 1/2] fix(storage): back up corrupt settings before restoring defaults Recover only JSON parse failures after preserving the original bytes in an exclusive owner-only backup. Keep read, normalization and migration errors outside recovery, and distinguish failures before publication from an unconfirmed default-settings publication without replaying the mutation. Report recovery through localized desktop notifications and the existing settings effects queue. Track renderer delivery separately from applied settings so silent recovery cannot consume the pending change event. Cover byte preservation, permissions, fault boundaries, callback failures, queued mutations and desktop refresh behavior. Refs #4285 Generated-by: OpenAI Codex --- .../__tests__/client-settings-effects.test.ts | 34 +- .../main/__tests__/settings-recovery.test.ts | 228 ++++++++++ .../__tests__/startup-storage-repair.test.ts | 2 + .../src/main/client-settings-effects.ts | 14 +- apps/desktop/src/main/early-window.ts | 21 +- apps/desktop/src/main/runtime-host-boot.ts | 2 + apps/desktop/src/main/settings-recovery.ts | 155 +++++++ docs/windows-test-inventory.md | 9 +- .../settings-store-onboarding.test.ts | 17 +- .../__tests__/settings-store-recovery.test.ts | 426 ++++++++++++++++++ packages/storage/src/settings-store.ts | 173 ++++++- 11 files changed, 1045 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/settings-recovery.test.ts create mode 100644 apps/desktop/src/main/settings-recovery.ts create mode 100644 packages/storage/src/__tests__/settings-store-recovery.test.ts diff --git a/apps/desktop/src/main/__tests__/client-settings-effects.test.ts b/apps/desktop/src/main/__tests__/client-settings-effects.test.ts index 0dcdeda897..5c89e31c44 100644 --- a/apps/desktop/src/main/__tests__/client-settings-effects.test.ts +++ b/apps/desktop/src/main/__tests__/client-settings-effects.test.ts @@ -48,6 +48,7 @@ test('applies each client settings snapshot once across local writes and file wa }); assert.equal(await effects.refresh(false), true); + assert.equal(await effects.refresh(true), true); // First renderer delivery. assert.equal(await effects.refresh(true), false); current = { @@ -59,12 +60,43 @@ test('applies each client settings snapshot once across local writes and file wa assert.deepEqual(keepAwake, [false, true]); assert.equal(botApplications, 1); - assert.equal(rendererEvents, 1); + assert.equal(rendererEvents, 2); // The shipped default is already on screen before the first snapshot is // read, so a run that never leaves it must not touch the OS icon at all. assert.deepEqual(appIcons, []); }); +test('silent refreshes retain an undelivered renderer change without repeating effects', async () => { + let current = createDefaultSettings(); + const keepAwake: boolean[] = []; + const deliveredLocales: string[] = []; + const effects = createClientSettingsEffects({ + settingsStore: { get: async () => current }, + applyWorkHub: async () => undefined, + applyKeepSystemAwake: async (enabled) => { keepAwake.push(enabled); }, + applyBotSettings: async () => undefined, + applyAppIcon: async () => undefined, + systemPrefersDark: () => false, + observeLocale: () => undefined, + emitExternalChanged: () => { deliveredLocales.push(current.personalization.uiLocale); }, + }); + await effects.refresh(false); + current = { + ...current, + system: { keepSystemAwake: true }, + personalization: { ...current.personalization, uiLocale: 'zh-CN' }, + }; + assert.equal(await effects.refresh(false), true); + assert.equal(await effects.refresh(false), false); + assert.deepEqual(deliveredLocales, []); + // A later write supersedes the silently applied snapshot before delivery. + current = { ...current, personalization: { ...current.personalization, uiLocale: 'zh-TW' } }; + assert.equal(await effects.refresh(true), true); + assert.equal(await effects.refresh(true), false); + assert.deepEqual(deliveredLocales, ['zh-TW']); + assert.deepEqual(keepAwake, [false, true]); +}); + test('applies a chosen app icon once, and again only when the choice changes', async () => { let current = createDefaultSettings(); const appIcons: string[] = []; diff --git a/apps/desktop/src/main/__tests__/settings-recovery.test.ts b/apps/desktop/src/main/__tests__/settings-recovery.test.ts new file mode 100644 index 0000000000..645812cd55 --- /dev/null +++ b/apps/desktop/src/main/__tests__/settings-recovery.test.ts @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import fs, { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, type TestContext } from 'node:test'; +import { UI_LOCALES } from '@maka/core/ui-locale'; +import { createDefaultSettings } from '@maka/core/settings'; +import { + createSettingsStore, + SettingsRecoveryCommitUnknownError, + type CorruptSettingsRecovery, +} from '@maka/storage/settings-store'; +import { createClientSettingsEffects } from '../client-settings-effects.js'; +import { createSettingsRecoveryReporter, settingsRecoveryCopy } from '../settings-recovery.js'; + +const event: CorruptSettingsRecovery = { + settingsPath: '/profile/settings.json', + backupPath: '/profile/settings.json.corrupt-1000-fixture', + outcome: 'recovered', +}; +const turn = () => new Promise((resolve) => setImmediate(resolve)); + +function harness(options: { e2e?: boolean; supported?: boolean; throws?: 'support' | 'create' | 'show' } = {}) { + const notices: { title: string; body: string }[] = []; + const logs: string[] = []; + const failures: (() => void)[] = []; + let supports = 0; + const reporter = createSettingsRecoveryReporter({ + e2e: options.e2e ?? false, + locale: () => 'en', + log: (message) => { logs.push(message); }, + notifications: { + isSupported() { + supports += 1; + if (options.throws === 'support') throw new Error('unsupported'); + return options.supported ?? true; + }, + create(copy, failed) { + if (options.throws === 'create') throw new Error('create failed'); + failures.push(failed); + return { show() { + if (options.throws === 'show') throw new Error('show failed'); + notices.push(copy); + } }; + }, + }, + }); + return { reporter, notices, logs, failures, supports: () => supports }; +} + +test('localized recovery copy names the backup, privacy review and uncertain outcome', () => { + for (const locale of UI_LOCALES) { + const recovered = settingsRecoveryCopy(event, locale); + const unknown = settingsRecoveryCopy({ ...event, outcome: 'commit-unknown' }, locale); + assert.ok(recovered.body.includes(event.backupPath)); + assert.ok(unknown.body.includes(event.backupPath)); + assert.notEqual(recovered.title, unknown.title); + assert.match(recovered.body, /Incognito|隐身|無痕/u); + assert.doesNotMatch(recovered.body + unknown.body, /now off|已关闭|已關閉/u); + assert.match(unknown.body, /unconfirmed|未确认|未確認/u); + const failed = settingsRecoveryCopy({ ...event, outcome: 'commit-unknown' }, locale, true); + assert.match(failed.body, /unconfirmed|未确认|未確認/u); + assert.match(failed.body, /Restart|重启|重新啟動/u); + } +}); + +test('early recovery is logged and reported immediately, then reread when effects become ready', async () => { + const h = harness(); + let refreshes = 0; + h.reporter.onRecovery(event); + assert.equal(h.notices.length, 1); + assert.ok(h.logs[0].includes(event.backupPath)); + assert.equal(refreshes, 0); + const effects = { refresh: async (notify: boolean) => { assert.equal(notify, true); refreshes += 1; return true; } }; + h.reporter.setEffects(effects); + await turn(); + assert.equal(refreshes, 1); + h.reporter.setEffects(effects); + await turn(); + assert.equal(refreshes, 1); +}); + +for (const options of [{ e2e: true }, { supported: false }]) { + test('notification suppression does not suppress recovery diagnostics or effects', async () => { + const h = harness(options); + let refreshed = false; + h.reporter.setEffects({ refresh: async () => { refreshed = true; return false; } }); + h.reporter.onRecovery(event); + await turn(); + assert.equal(h.notices.length, 0); + assert.equal(refreshed, true); + assert.ok(h.logs.some((line) => line.includes(event.backupPath))); + if (options.e2e) assert.equal(h.supports(), 0); + }); +} + +for (const phase of ['support', 'create', 'show'] as const) { + test(`notification ${phase} failure is isolated`, async () => { + const h = harness({ throws: phase }); + h.reporter.onRecovery(event); + await turn(); + assert.ok(h.logs.some((line) => line.includes('notification failed'))); + }); +} + +test('asynchronous native notification failure is logged without throwing', () => { + const h = harness(); + h.reporter.onRecovery(event); + assert.doesNotThrow(() => h.failures[0]()); + assert.ok(h.logs.some((line) => line.includes('notification failed'))); +}); + +test('refresh failure keeps the publication warning and does not leak the failing effect error', async () => { + const h = harness(); + h.reporter.setEffects({ refresh: async () => { throw new Error('secret effect detail'); } }); + h.reporter.onRecovery({ ...event, outcome: 'commit-unknown' }); + await turn(); + assert.equal(h.notices.length, 2); + assert.match(h.notices[1].body, /unconfirmed/u); + assert.match(h.notices[1].body, /Restart/u); + assert.equal(JSON.stringify(h).includes('secret effect detail'), false); + assert.ok(h.logs.some((line) => line.includes('refresh failed'))); +}); + +async function realStore(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'maka-desktop-settings-recovery-')); + t.after(async () => { + t.mock.restoreAll(); + syncBuiltinESMExports(); + await rm(root, { recursive: true, force: true }); + }); + const h = harness(); + const store = createSettingsStore(root, { onCorruptRecovery: h.reporter.onRecovery }); + const observed: string[] = []; + const bots: unknown[] = []; + const keepAwake: boolean[] = []; + let changes = 0; + const effects = createClientSettingsEffects({ + settingsStore: store, + systemPrefersDark: () => false, + applyWorkHub: async () => {}, + applyKeepSystemAwake: async (value) => { keepAwake.push(value); }, + applyBotSettings: async (value) => { bots.push(value); }, + applyAppIcon: async () => {}, + observeLocale: (settings) => { observed.push(settings.personalization.uiLocale); }, + emitExternalChanged: () => { changes += 1; }, + }); + return { root, path: join(root, 'settings.json'), h, store, effects, observed, bots, keepAwake, changes: () => changes }; +} + +for (const notifyRenderer of [false, true]) { + test(`recovery during effects.refresh(${notifyRenderer}) releases both queues and notifies the renderer once`, { timeout: 5_000 }, async (t) => { + const { path, h, effects, observed, bots, keepAwake, changes, store } = await realStore(t); + await store.update({ personalization: { uiLocale: 'zh-CN' }, system: { keepSystemAwake: true } }); + await effects.refresh(false); + h.reporter.setEffects(effects); + await writeFile(path, '{"secret":"never print this"'); + await effects.refresh(notifyRenderer); + await turn(); + await effects.refresh(true); // Barrier behind the callback's queued refresh. + assert.deepEqual(observed, ['zh-CN', 'auto', 'auto', 'auto']); + assert.deepEqual(keepAwake, [true, false]); + assert.equal(bots.length, 1); // Bots did not change, so effects deduplicate them. + assert.equal(changes(), 1); + assert.equal(h.notices.length, 1); + assert.equal(h.logs.join('').includes('never print this'), false); + }); +} + +test('recovery before effects initialization refreshes the latest file including a subsequent mutation', async (t) => { + const { path, h, effects, store, observed } = await realStore(t); + await writeFile(path, ''); + await store.update({ personalization: { uiLocale: 'zh-TW' } }); + h.reporter.setEffects(effects); + await turn(); + await effects.refresh(true); + assert.ok(observed.every((locale) => locale === 'zh-TW')); + assert.equal(h.notices.length, 1); +}); + +test('published reset failure is independently reported and consumers reread without replaying a mutation', { + skip: process.platform === 'win32', timeout: 5_000, +}, async (t) => { + const { root, path, h, effects, store, observed } = await realStore(t); + await store.update({ personalization: { uiLocale: 'zh-CN' } }); + await effects.refresh(false); + h.reporter.setEffects(effects); + await writeFile(path, '{bad'); + const originalOpen = fs.open; + let directoryCount = 0; + t.mock.method(fs, 'open', async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === root && ++directoryCount === 2) { + t.mock.method(handle, 'sync', async () => { throw new Error('injected reset fence failure'); }); + } + return handle; + }); + syncBuiltinESMExports(); + let patched = false; + await assert.rejects(store.updateIf(() => { patched = true; return true; }, { personalization: { uiLocale: 'en' } }), SettingsRecoveryCommitUnknownError); + await turn(); + await effects.refresh(true); + assert.equal(patched, false); + assert.equal(observed.at(-1), 'auto'); + assert.match(h.notices[0].body, /unconfirmed/u); + assert.equal(h.notices.length, 1); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), createDefaultSettings()); +}); diff --git a/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts b/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts index 59ead83c04..324b6e5e6a 100644 --- a/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts +++ b/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts @@ -33,6 +33,7 @@ import type { BrowserMessageBoxAppearance } from '../browser-message-box.js'; import { showMessageBoxWithDiagnostics } from '../native-diagnostic-dialog.js'; import { getNativeDiagnosticDialogCopy } from '../native-diagnostic-dialog-copy.js'; import { resolveDesktopStorageRoot } from '../storage-root-startup.js'; +import { createSettingsRecoveryReporter } from '../settings-recovery.js'; import { startupStep } from '../startup-step.js'; import { resolveWindowRevealMode } from '../window-reveal.js'; @@ -118,6 +119,7 @@ for (const accept of [false, true]) { assert.equal(await readFile(markerPath, 'utf8'), staleMarker); return { response: accept ? 0 : 1, checkboxChecked: false }; }, + createSettingsRecoveryReporter, createSettingsStore: () => { settingsOpened = true; throw stopped; }, }; const completion = runInNewContext(`${boot}\nmodule.exports.default()`, { diff --git a/apps/desktop/src/main/client-settings-effects.ts b/apps/desktop/src/main/client-settings-effects.ts index 82a1795f80..75e744616e 100644 --- a/apps/desktop/src/main/client-settings-effects.ts +++ b/apps/desktop/src/main/client-settings-effects.ts @@ -49,6 +49,7 @@ interface ClientSettingsEffectDependencies { export function createClientSettingsEffects( dependencies: ClientSettingsEffectDependencies, ): ClientSettingsEffects { + let appliedSettingsFingerprint: string | undefined; let rendererFingerprint: string | undefined; let botFingerprint: string | undefined; let keepSystemAwake: boolean | undefined; @@ -67,6 +68,7 @@ export function createClientSettingsEffects( const settings = await load(); const nextRendererFingerprint = JSON.stringify(settings); const nextBotFingerprint = JSON.stringify(settings.botChat); + const settingsChanged = nextRendererFingerprint !== appliedSettingsFingerprint; const rendererChanged = nextRendererFingerprint !== rendererFingerprint; const keepAwakeChanged = settings.system.keepSystemAwake !== keepSystemAwake; const botChanged = nextBotFingerprint !== botFingerprint; @@ -99,9 +101,15 @@ export function createClientSettingsEffects( await dependencies.applyAppIcon(nextAppIcon); appIcon = nextAppIcon; } - rendererFingerprint = nextRendererFingerprint; - if (notifyRenderer && rendererChanged) dependencies.emitExternalChanged(); - return rendererChanged || keepAwakeChanged || botChanged || appIconChanged; + appliedSettingsFingerprint = nextRendererFingerprint; + const rendererNotified = notifyRenderer && rendererChanged; + // A silent refresh may apply recovered settings before the recovery + // callback runs. Only an actual delivery consumes the renderer change. + if (rendererNotified) { + dependencies.emitExternalChanged(); + rendererFingerprint = nextRendererFingerprint; + } + return settingsChanged || rendererNotified || keepAwakeChanged || botChanged || appIconChanged; }); tail = run.then( () => undefined, diff --git a/apps/desktop/src/main/early-window.ts b/apps/desktop/src/main/early-window.ts index 6bdd04df4f..858d515c7a 100644 --- a/apps/desktop/src/main/early-window.ts +++ b/apps/desktop/src/main/early-window.ts @@ -31,6 +31,7 @@ import { type MessageBoxOptions, type MessageBoxReturnValue, nativeTheme, + Notification, } from "electron"; import { resolveSystemUiLocale } from "@maka/core/ui-locale"; import { resolveStorageRoot } from "@maka/storage/root-authority"; @@ -57,7 +58,8 @@ import { showMessageBoxWithDiagnostics, } from "./native-diagnostic-dialog.js"; import { resolveShellEnv } from "./shell-env.js"; -import { revealMode } from "./startup-context.js"; +import { createSettingsRecoveryReporter } from "./settings-recovery.js"; +import { isIsolatedE2e, revealMode } from "./startup-context.js"; import { resolveDesktopStorageRoot } from "./storage-root-startup.js"; import { startupStep } from "./startup-step.js"; import { isDarkAppearance } from "./theme-source.js"; @@ -174,7 +176,22 @@ if (!resolvedLocalStorageRoot) { throw new Error("Desktop storage root resolution did not complete"); } export const startupLocalStorageRoot = resolvedLocalStorageRoot; -export const settingsStore = createSettingsStore(workspaceRoot); +export const settingsRecovery = createSettingsRecoveryReporter({ + e2e: isIsolatedE2e, + locale: () => resolveSystemUiLocale(app.getPreferredSystemLanguages()), + notifications: { + isSupported: () => Notification.isSupported(), + create: (copy, failed) => { + const notification = new Notification(copy); + notification.on('failed', failed); + return notification; + }, + }, + log: (message) => console.warn(message), +}); +export const settingsStore = createSettingsStore(workspaceRoot, { + onCorruptRecovery: settingsRecovery.onRecovery, +}); export const desktopLocale = createDesktopLocaleAuthority({ readSettings: () => settingsStore.get(), preferredSystemLanguages: () => app.getPreferredSystemLanguages(), diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index a9210204d1..42cd2ee130 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -92,6 +92,7 @@ import { mainWindowController, mainWindowDelegates, quitCoordinator, + settingsRecovery, settingsStore, shellEnvReady, showDesktopMessageBox, @@ -844,6 +845,7 @@ const clientSettingsEffects = createClientSettingsEffects({ sendActiveRuntimeHostEvent("settings:externalChanged", { ts: Date.now() }); }, }); +settingsRecovery.setEffects(clientSettingsEffects); // An OS appearance flip changes no setting, so nothing else would notice it. // Only the icon depends on the answer, and `refresh` re-resolves it and // no-ops when the resolved tile is the one already applied — which is the diff --git a/apps/desktop/src/main/settings-recovery.ts b/apps/desktop/src/main/settings-recovery.ts new file mode 100644 index 0000000000..65e61239af --- /dev/null +++ b/apps/desktop/src/main/settings-recovery.ts @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import type { CorruptSettingsRecovery } from '@maka/storage/settings-store'; +import type { ClientSettingsEffects } from './client-settings-effects.js'; + +interface RecoveryCopy { + title: string; + body: string; +} + +type RecoveryStatus = CorruptSettingsRecovery['outcome'] | 'refresh-failed'; + +const COPY = { + 'zh-CN': { + recovered: { + title: '设置已恢复为默认值', + body: '设置文件损坏,已恢复默认设置。请检查隐身模式、隐私和其他偏好。', + }, + 'commit-unknown': { + title: '设置已重置,保存状态待确认', + body: '默认设置已写入,但磁盘同步失败,持久保存状态尚未确认。请检查隐身模式和隐私设置。', + }, + 'refresh-failed': { + title: '运行中的设置刷新失败', + body: '设置文件已重置,但运行中的设置未能全部刷新。请重启应用并检查隐私设置。', + }, + backup: '原始文件备份:', + }, + 'zh-TW': { + recovered: { + title: '設定已還原為預設值', + body: '設定檔案損毀,已還原預設設定。請檢查無痕模式、隱私與其他偏好。', + }, + 'commit-unknown': { + title: '設定已重設,儲存狀態待確認', + body: '預設設定已寫入,但磁碟同步失敗,尚未確認是否持久儲存。請檢查無痕模式與隱私設定。', + }, + 'refresh-failed': { + title: '執行中的設定更新失敗', + body: '設定檔案已重設,但執行中的設定未能全部更新。請重新啟動應用程式並檢查隱私設定。', + }, + backup: '原始檔案備份:', + }, + en: { + recovered: { + title: 'Settings restored to defaults', + body: 'The settings file was invalid and has been reset. Please review Incognito, privacy and other preferences.', + }, + 'commit-unknown': { + title: 'Settings reset; save status uncertain', + body: 'Default settings were written, but disk synchronization failed and durability is unconfirmed. Please review Incognito and privacy settings.', + }, + 'refresh-failed': { + title: 'Running settings could not be refreshed', + body: 'The settings file was reset, but some running settings could not be refreshed. Restart the app and review your privacy settings.', + }, + backup: 'Original file backup: ', + }, +} satisfies UiCatalog & { backup: string }>; + +export function settingsRecoveryCopy( + recovery: CorruptSettingsRecovery, + locale: UiLocale, + refreshFailed = false, +): RecoveryCopy { + const copy = COPY[locale]; + const message = copy[refreshFailed ? 'refresh-failed' : recovery.outcome]; + // Preserve the durability warning when reporting a separate refresh failure. + const warning = refreshFailed && recovery.outcome === 'commit-unknown' + ? ` ${copy['commit-unknown'].body}` + : ''; + return { + title: message.title, + body: `${message.body}${warning} ${copy.backup}${recovery.backupPath}`, + }; +} + +interface SettingsRecoveryReporterDependencies { + readonly e2e: boolean; + readonly locale: () => UiLocale; + readonly notifications: { + isSupported(): boolean; + create(copy: RecoveryCopy, failed: () => void): { show(): void }; + }; + /** Receives only the result and paths, never JSON contents or parser errors. */ + readonly log: (message: string) => void; +} + +/** Observes recovery without becoming a second settings authority. Notification + * delivery is best-effort; refresh reuses the existing effects queue and rereads + * the file after the storage operation releases its own queue. */ +export function createSettingsRecoveryReporter(deps: SettingsRecoveryReporterDependencies) { + let effects: Pick | undefined; + let pending: CorruptSettingsRecovery | undefined; + + const log = (message: string): void => { + try { deps.log(message); } catch { /* Diagnostics must not block recovery. */ } + }; + const notify = (recovery: CorruptSettingsRecovery, refreshFailed = false): void => { + if (deps.e2e) return; + const failed = () => log(`[settings-recovery] notification failed; backup=${recovery.backupPath}`); + try { + if (!deps.notifications.isSupported()) { + log(`[settings-recovery] notifications unavailable; backup=${recovery.backupPath}`); + return; + } + deps.notifications.create(settingsRecoveryCopy(recovery, deps.locale(), refreshFailed), failed).show(); + } catch { + failed(); + } + }; + const refresh = (): void => { + const target = effects; + const recovery = pending; + if (!target || !recovery) return; + pending = undefined; + // Never await this from onRecovery: refresh can itself be the read that + // discovers corruption, and both storage and effects serialize operations. + void Promise.resolve().then(() => target.refresh(true)).catch(() => { + log(`[settings-recovery] refresh failed; outcome=${recovery.outcome}; backup=${recovery.backupPath}; restart the app`); + notify(recovery, true); + }); + }; + + return { + onRecovery(recovery: CorruptSettingsRecovery): void { + log(`[settings-recovery] outcome=${recovery.outcome}; settings=${recovery.settingsPath}; backup=${recovery.backupPath}`); + notify(recovery); + pending = recovery; + refresh(); + }, + setEffects(value: Pick): void { + effects = value; + refresh(); + }, + }; +} diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 34654f5397..e4934d2c67 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -16,10 +16,10 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| | windows-backend-gap | 27 | -| portable-candidate | 35 | +| portable-candidate | 40 | | platform-contract | 38 | -Total Windows-excluded declarations: **100** +Total Windows-excluded declarations: **105** ## Inventory @@ -32,6 +32,7 @@ Total Windows-excluded declarations: **100** | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP cancelled install does not start a new connection during post-rename reconciliation | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/project-context-root.test.ts` rejects a session cwd without read and traversal access | `process.platform === 'win32' ? 'POSIX permissions are required to make the session cwd inaccessible' : process.getuid?.() === 0` | | platform-contract | `apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts` reports create_failed without opening when a Skill directory parent is not writable | `process.platform === 'win32' ? 'POSIX permissions are required to make the Skill directory parent read-only' : process.getuid?.() === 0` | +| portable-candidate | `apps/desktop/src/main/__tests__/settings-recovery.test.ts` published reset failure is independently reported and consumers reread without replaying a mutation | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` imports the login PATH without importing application control variables | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` keeps the inherited PATH and does not log shell stderr when capture fails | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` kills login-shell descendants when capture times out | `process.platform === 'win32'` | @@ -116,6 +117,10 @@ Total Windows-excluded declarations: **100** | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` successor recovery removes credentials orphaned by an interrupted connection removal | `process.platform === 'win32' ? 'POSIX permissions are required to inject a persistence failure' : false` | | platform-contract | `packages/storage/src/__tests__/runtime-policy-stores.test.ts` fails closed on final symlinks, FIFOs, and oversized documents without changing bytes | `process.platform === 'win32'` | | portable-candidate | `packages/storage/src/__tests__/settings-store-onboarding.test.ts` preserves a restrictive umask-derived settings.json mode and leaves no temp file behind | `process.platform === 'win32'` | +| portable-candidate | `packages/storage/src/__tests__/settings-store-recovery.test.ts` private backups do not change the normal settings umask policy | `process.platform === 'win32'` | +| portable-candidate | `packages/storage/src/__tests__/settings-store-recovery.test.ts` backup ${phase} failure preserves source and reports the original cause | `process.platform === 'win32' && (phase === 'chmod' \|\| phase === 'directory')` | +| portable-candidate | `packages/storage/src/__tests__/settings-store-recovery.test.ts` refuses a preexisting backup ${planted} without deleting it | `process.platform === 'win32' && planted === 'symlink'` | +| portable-candidate | `packages/storage/src/__tests__/settings-store-recovery.test.ts` post-publication failure remains commit-unknown through ${mutation} | `process.platform === 'win32'` | | portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` rejects a symlink instead of following it | `process.platform === 'win32' ? 'POSIX no-follow semantics are required' : false` | | portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` hardenDirectory creates a 0700 directory chain | `process.platform === 'win32'` | | portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` hardenDirectory re-chmods a pre-existing world-accessible directory to 0700 | `process.platform === 'win32'` | diff --git a/packages/storage/src/__tests__/settings-store-onboarding.test.ts b/packages/storage/src/__tests__/settings-store-onboarding.test.ts index 7a99587d7e..d15fbd00be 100644 --- a/packages/storage/src/__tests__/settings-store-onboarding.test.ts +++ b/packages/storage/src/__tests__/settings-store-onboarding.test.ts @@ -330,7 +330,7 @@ describe('SettingsStore.get file recovery', () => { } }); - it('creates defaults only when settings.json is missing', async () => { + it('creates defaults without a backup when settings.json is missing', async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-defaults-')); try { const store = createSettingsStore(workspaceRoot); @@ -345,21 +345,6 @@ describe('SettingsStore.get file recovery', () => { } }); - it('rejects corrupt settings.json without overwriting user settings bytes', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-corrupt-')); - try { - const store = createSettingsStore(workspaceRoot); - const settingsPath = join(workspaceRoot, 'settings.json'); - const corrupt = '{"appearance":{"theme":"dark"}'; - await writeFile(settingsPath, corrupt, 'utf8'); - - await assert.rejects(() => store.get(), SyntaxError); - assert.equal(await readFile(settingsPath, 'utf8'), corrupt); - } finally { - await rm(workspaceRoot, { recursive: true, force: true }); - } - }); - it('preserves a restrictive umask-derived settings.json mode and leaves no temp file behind', { skip: process.platform === 'win32', }, async () => { diff --git a/packages/storage/src/__tests__/settings-store-recovery.test.ts b/packages/storage/src/__tests__/settings-store-recovery.test.ts new file mode 100644 index 0000000000..e6ed242a4b --- /dev/null +++ b/packages/storage/src/__tests__/settings-store-recovery.test.ts @@ -0,0 +1,426 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import fs, { + chmod, + mkdtemp, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, type TestContext } from 'node:test'; +import { createDefaultSettings } from '@maka/core/settings'; +import { + createSettingsStore, + SettingsRecoveryError, + SettingsRecoveryCommitUnknownError, + type CorruptSettingsRecovery, + type SettingsStoreOptions, +} from '../settings-store.js'; +import { AtomicFileWriteCommitUnknownError } from '../atomic-file-write.js'; + +const corrupt = Buffer.from('{"appearance":{"theme":"dark"},"secret":"do-not-log"'); +const turn = () => new Promise((resolve) => setImmediate(resolve)); + +async function fixture(t: TestContext, options?: SettingsStoreOptions) { + const root = await mkdtemp(join(tmpdir(), 'maka-settings-recovery-')); + t.after(async () => { + t.mock.restoreAll(); + syncBuiltinESMExports(); + await rm(root, { recursive: true, force: true }); + }); + const path = join(root, 'settings.json'); + await writeFile(path, corrupt); + const events: CorruptSettingsRecovery[] = []; + const store = createSettingsStore( + root, + options ?? { + onCorruptRecovery: (event) => { + events.push(event); + }, + }, + ); + return { root, path, events, store }; +} + +for (const [name, bytes] of [ + ['empty', Buffer.alloc(0)], + ['truncated', corrupt], + ['invalid UTF-8 and truncated', Buffer.concat([corrupt, Buffer.from([0xff, 0xc3])])], +] as const) { + test(`recovers ${name} settings with an exact byte backup before reporting`, async (t) => { + const { root, path, store, events } = await fixture(t); + await writeFile(path, bytes); + assert.deepEqual(await store.get(), createDefaultSettings()); + assert.equal(events.length, 1); + assert.equal(events[0].settingsPath, path); + assert.equal(events[0].outcome, 'recovered'); + assert.match(events[0].backupPath, /settings\.json\.corrupt-\d+-[a-f0-9-]+$/u); + assert.deepEqual(await readFile(events[0].backupPath), bytes); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), createDefaultSettings()); + await store.get(); + assert.equal(events.length, 1); + assert.equal((await readdir(root)).length, 2); + }); +} + +test('concurrent reads recover once and later reads observe external changes', async (t) => { + const { path, store, events } = await fixture(t); + const values = await Promise.all(Array.from({ length: 16 }, () => store.get())); + assert.equal(events.length, 1); + for (const value of values) + assert.deepEqual(JSON.parse(JSON.stringify(value)), createDefaultSettings()); + const changed = createDefaultSettings(); + changed.appearance.theme = 'dark'; + await writeFile(path, JSON.stringify(changed)); + assert.equal((await store.get()).appearance.theme, 'dark'); + assert.equal(events.length, 1); +}); + +test('missing and valid JSON keep their normal behavior without a recovery report', async (t) => { + const { path, root, store, events } = await fixture(t); + await rm(path); + await store.get(); + for (const text of ['null', '{}', '{"appearance":{"theme":"dark"}}']) { + await writeFile(path, text); + await store.get(); + assert.equal(await readFile(path, 'utf8'), text); + } + assert.deepEqual(events, []); + assert.deepEqual(await readdir(root), ['settings.json']); +}); + +for (const callback of [ + undefined, + () => { + throw new Error('observer failed'); + }, + async () => { + throw new Error('observer rejected'); + }, +]) { + test('optional or failing observer cannot change a successful recovery', async (t) => { + const { store } = await fixture(t, { onCorruptRecovery: callback }); + assert.deepEqual(await store.get(), createDefaultSettings()); + await turn(); // Any unhandled observer rejection would fail this test. + }); +} + +test('an async observer may reenter the store without deadlocking its queue', { + timeout: 5_000, +}, async (t) => { + const { root } = await fixture(t); + let observed: Promise | undefined; + const store = createSettingsStore(root, { + onCorruptRecovery: () => { + observed = store.get(); + return observed.then(() => {}); + }, + }); + await store.get(); + assert.deepEqual(JSON.parse(JSON.stringify(await observed)), createDefaultSettings()); +}); + +test('mutations use the recovered defaults and execute their own work once', async (t) => { + const { path, store, events } = await fixture(t); + assert.equal((await store.update({ appearance: { theme: 'dark' } })).appearance.theme, 'dark'); + await writeFile(path, corrupt); + let predicates = 0; + let patches = 0; + const result = await store.updateIf( + (current) => { + predicates += 1; + assert.deepEqual(current, createDefaultSettings()); + return true; + }, + () => { + patches += 1; + return { appearance: { theme: 'light' } }; + }, + ); + assert.equal(result.applied, true); + assert.equal(result.settings.appearance.theme, 'light'); + assert.equal(predicates, 1); + assert.equal(patches, 1); + await writeFile(path, corrupt); + assert.equal((await store.upsertOnboardingMilestone('first_chat_sent', 'completed')).length, 1); + await writeFile(path, corrupt); + assert.deepEqual(await store.clearOnboardingMilestone('first_chat_sent'), []); + assert.equal(events.length, 4); + assert.equal(new Set(events.map((event) => event.backupPath)).size, 4); +}); + +test('private backups do not change the normal settings umask policy', { + skip: process.platform === 'win32', +}, async (t) => { + const { path, store, events } = await fixture(t); + await chmod(path, 0o644); + const previous = process.umask(0o027); + try { + await store.get(); + } finally { + process.umask(previous); + } + assert.equal((await stat(events[0].backupPath)).mode & 0o777, 0o600); + assert.equal((await stat(path)).mode & 0o777, 0o640); +}); + +for (const code of ['EACCES', 'EIO']) { + test(`read ${code} does not reset or create backups`, async (t) => { + const { path, root, store, events } = await fixture(t); + const failure = Object.assign(new Error('read failed'), { code }); + const read = fs.readFile; + t.mock.method(fs, 'readFile', async (...args: Parameters) => { + if (args[0] === path) throw failure; + return read(...args); + }); + syncBuiltinESMExports(); + await assert.rejects(store.get(), (error) => error === failure); + assert.deepEqual(await read(path), corrupt); + assert.deepEqual(await readdir(root), ['settings.json']); + assert.deepEqual(events, []); + }); +} + +for (const phase of ['open', 'writeFile', 'chmod', 'sync', 'close', 'directory'] as const) { + test(`backup ${phase} failure preserves source and reports the original cause`, { + skip: process.platform === 'win32' && (phase === 'chmod' || phase === 'directory'), + }, async (t) => { + const { path, root, store, events } = await fixture(t); + const failure = Object.assign(new Error(`backup ${phase} failed`), { code: 'EIO' }); + const originalOpen = fs.open; + t.mock.method(fs, 'open', async (...args: Parameters) => { + const backup = String(args[0]).startsWith(`${path}.corrupt-`); + if (backup && phase === 'open') throw failure; + const handle = await originalOpen(...args); + if (backup && phase !== 'open' && phase !== 'directory') { + t.mock.method( + handle, + phase, + async () => { + throw failure; + }, + { times: 1 }, + ); + } + if (args[0] === root && phase === 'directory') { + t.mock.method(handle, 'sync', async () => { + throw failure; + }); + } + return handle; + }); + syncBuiltinESMExports(); + await assert.rejects(store.get(), (error) => { + assert.ok(error instanceof SettingsRecoveryError); + assert.equal(error.phase, 'backup'); + assert.equal(error.cause, failure); + assert.equal(error.settingsPath, path); + assert.equal(error.backupPath, undefined); + assert.equal(error.incompleteBackupPath, undefined); + assert.equal(error.message.includes('do-not-log'), false); + return true; + }); + assert.deepEqual(await readFile(path), corrupt); + assert.deepEqual(await readdir(root), ['settings.json']); + assert.deepEqual(events, []); + }); +} + +test('backup cleanup failure retains the original cause and identifies an incomplete file', async (t) => { + const { path, store } = await fixture(t); + const failure = new Error('backup write failed'); + const originalOpen = fs.open; + t.mock.method(fs, 'open', async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (String(args[0]).startsWith(`${path}.corrupt-`)) { + t.mock.method(handle, 'writeFile', async () => { + throw failure; + }); + } + return handle; + }); + t.mock.method(fs, 'rm', async () => { + throw new Error('cleanup failed'); + }); + syncBuiltinESMExports(); + await assert.rejects(store.get(), (error) => { + assert.ok(error instanceof SettingsRecoveryError); + assert.equal(error.cause, failure); + assert.equal(error.backupPath, undefined); + assert.ok(error.incompleteBackupPath?.startsWith(`${path}.corrupt-`)); + return true; + }); + assert.deepEqual(await readFile(path), corrupt); +}); + +for (const planted of ['file', 'symlink'] as const) { + test(`refuses a preexisting backup ${planted} without deleting it`, { + skip: process.platform === 'win32' && planted === 'symlink', + }, async (t) => { + const { path, root, store } = await fixture(t); + const target = join(root, 'unrelated'); + await writeFile(target, 'keep me'); + const originalOpen = fs.open; + let collision = ''; + t.mock.method(fs, 'open', async (...args: Parameters) => { + if (String(args[0]).startsWith(`${path}.corrupt-`)) { + collision = String(args[0]); + if (planted === 'file') await writeFile(collision, 'keep me'); + else await symlink(target, collision); + } + return originalOpen(...args); + }); + syncBuiltinESMExports(); + await assert.rejects( + store.get(), + (error) => + error instanceof SettingsRecoveryError && + (error.cause as NodeJS.ErrnoException).code === 'EEXIST', + ); + assert.equal(await readFile(collision, 'utf8'), 'keep me'); + assert.equal(await readFile(target, 'utf8'), 'keep me'); + assert.deepEqual(await readFile(path), corrupt); + }); +} + +test('reset publication failure retains the complete backup and never reports success', async (t) => { + const { path, root, store, events } = await fixture(t); + const failure = Object.assign(new Error('rename failed'), { code: 'EACCES' }); + const rename = fs.rename; + t.mock.method(fs, 'rename', async (...args: Parameters) => { + if (args[1] === path) throw failure; + return rename(...args); + }); + syncBuiltinESMExports(); + let backupPath = ''; + await assert.rejects(store.get(), (error) => { + assert.ok(error instanceof SettingsRecoveryError); + assert.equal(error.phase, 'reset'); + assert.equal(error.cause, failure); + assert.ok(error.backupPath); + backupPath = error.backupPath; + return true; + }); + assert.deepEqual(await readFile(backupPath), corrupt); + assert.deepEqual(await readFile(path), corrupt); + assert.equal((await readdir(root)).length, 2); + assert.deepEqual(events, []); +}); + +for (const code of ['ENOENT', 'syntax']) { + test(`a migration write failure (${code}) cannot trigger creation or recovery`, async (t) => { + const { path, root, store, events } = await fixture(t); + const text = '{"network":{"proxy":{"password":"legacy"}},"appearance":{"theme":"dark"}}'; + await writeFile(path, text); + const failure = + code === 'syntax' + ? new SyntaxError('migration failed') + : Object.assign(new Error('migration failed'), { code }); + t.mock.method(fs, 'rename', async () => { + throw failure; + }); + syncBuiltinESMExports(); + await assert.rejects(store.get(), (error) => error === failure); + assert.equal(await readFile(path, 'utf8'), text); + assert.deepEqual(await readdir(root), ['settings.json']); + assert.deepEqual(events, []); + }); +} + +test('normalization errors are not interpreted as invalid JSON', async (t) => { + const { path, store, events } = await fixture(t); + await writeFile(path, '{}'); + const failure = new SyntaxError('normalizer failed'); + t.mock.method(JSON, 'parse', () => + Object.defineProperty({}, 'network', { + get() { + throw failure; + }, + }), + ); + await assert.rejects(store.get(), (error) => error === failure); + assert.deepEqual(events, []); + assert.equal(await readFile(path, 'utf8'), '{}'); +}); + +for (const mutation of ['get', 'update', 'updateIf', 'milestone'] as const) { + test(`post-publication failure remains commit-unknown through ${mutation}`, { + skip: process.platform === 'win32', + }, async (t) => { + const { root, path, store, events } = await fixture(t); + const failure = Object.assign(new Error('reset directory sync failed'), { code: 'EIO' }); + const originalOpen = fs.open; + let directories = 0; + let syncs = 0; + t.mock.method(fs, 'open', async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === root && ++directories === 2) { + t.mock.method(handle, 'sync', async () => { + syncs += 1; + throw failure; + }); + } + return handle; + }); + syncBuiltinESMExports(); + let predicateCalls = 0; + const operation = + mutation === 'get' + ? store.get() + : mutation === 'update' + ? store.update({ appearance: { theme: 'dark' } }) + : mutation === 'milestone' + ? store.upsertOnboardingMilestone('first_chat_sent', 'completed') + : store.updateIf( + () => { + predicateCalls += 1; + return true; + }, + { appearance: { theme: 'dark' } }, + ); + await assert.rejects(operation, (error) => { + assert.ok(error instanceof SettingsRecoveryCommitUnknownError); + assert.ok(error instanceof AtomicFileWriteCommitUnknownError); + assert.equal(error.published, true); + assert.ok(error.cause instanceof AtomicFileWriteCommitUnknownError); + assert.equal(error.cause.cause, failure); + assert.equal(error.settingsPath, path); + assert.equal(error.backupPath, events[0]?.backupPath); + assert.equal(error.message.includes('do-not-log'), false); + return true; + }); + assert.equal(syncs, 1); + assert.equal(directories, 2); + assert.equal(predicateCalls, 0); + assert.equal(events.length, 1); + assert.equal(events[0].outcome, 'commit-unknown'); + assert.deepEqual(await readFile(events[0].backupPath), corrupt); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), createDefaultSettings()); + assert.deepEqual(JSON.parse(JSON.stringify(await store.get())), createDefaultSettings()); + assert.equal(events.length, 1); + }); +} diff --git a/packages/storage/src/settings-store.ts b/packages/storage/src/settings-store.ts index 94f549e310..90f0e4aa29 100644 --- a/packages/storage/src/settings-store.ts +++ b/packages/storage/src/settings-store.ts @@ -17,13 +17,76 @@ * under the License. */ -import { mkdir, readFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import type { OnboardingMilestone, OnboardingMilestoneId } from '@maka/core/onboarding'; import { createDefaultSettings, mergeSettings, normalizeSettings } from '@maka/core/settings'; import { sanitizeOnboardingMilestones } from '@maka/core/onboarding'; -import { writeAtomicFile } from './atomic-file-write.js'; +import { AtomicFileWriteCommitUnknownError, writeAtomicFile } from './atomic-file-write.js'; +import { syncDirectory } from './stable-storage.js'; + +export interface CorruptSettingsRecovery { + readonly settingsPath: string; + readonly backupPath: string; + readonly outcome: 'recovered' | 'commit-unknown'; +} + +export interface SettingsStoreOptions { + /** Observes publication, not delivery of a notification. Never awaited while + * holding the store queue; callback failures cannot change the write result. */ + onCorruptRecovery?: (recovery: CorruptSettingsRecovery) => void | Promise; +} + +export class SettingsRecoveryError extends Error { + readonly settingsPath: string; + readonly phase: 'backup' | 'reset'; + /** Present only when a complete backup passed its synchronization steps. */ + readonly backupPath?: string; + /** A file this attempt created but could not finish or remove. */ + readonly incompleteBackupPath?: string; + + constructor(options: { + settingsPath: string; + phase: 'backup' | 'reset'; + backupPath?: string; + incompleteBackupPath?: string; + cause: unknown; + }) { + const detail = options.backupPath + ? `Original bytes are backed up at ${options.backupPath}.` + : 'No complete backup was confirmed.'; + super( + `Cannot recover invalid JSON settings at ${options.settingsPath}: ${options.phase} failed. ` + + `The original settings file was not replaced. ${detail}` + + (options.incompleteBackupPath + ? ` An incomplete backup may remain at ${options.incompleteBackupPath}.` + : '') + + ' Close the app and check file access and disk space before retrying.', + { cause: options.cause }, + ); + this.name = 'SettingsRecoveryError'; + this.settingsPath = options.settingsPath; + this.phase = options.phase; + this.backupPath = options.backupPath; + this.incompleteBackupPath = options.incompleteBackupPath; + } +} + +export class SettingsRecoveryCommitUnknownError extends AtomicFileWriteCommitUnknownError { + constructor( + readonly settingsPath: string, + readonly backupPath: string, + cause: AtomicFileWriteCommitUnknownError, + ) { + super({ cause }); + this.name = 'SettingsRecoveryCommitUnknownError'; + this.message = + `Default settings were published at ${settingsPath}, but durability is unconfirmed. ` + + `Original bytes are backed up at ${backupPath}. Reload before retrying; do not replay the update automatically.`; + } +} /** * A conditional write's patch, either fixed or derived from the state the @@ -68,15 +131,21 @@ export interface SettingsStore { clearOnboardingMilestone(id: OnboardingMilestoneId): Promise; } -export function createSettingsStore(workspaceRoot: string): SettingsStore { - return new FileSettingsStore(workspaceRoot); +export function createSettingsStore( + workspaceRoot: string, + options: SettingsStoreOptions = {}, +): SettingsStore { + return new FileSettingsStore(workspaceRoot, options); } class FileSettingsStore implements SettingsStore { private readonly settingsPath: string; private queue: Promise = Promise.resolve(); - constructor(workspaceRoot: string) { + constructor( + workspaceRoot: string, + private readonly options: SettingsStoreOptions, + ) { this.settingsPath = join(workspaceRoot, 'settings.json'); } @@ -90,20 +159,100 @@ class FileSettingsStore implements SettingsStore { } private async readOrCreate(): Promise { + let bytes: Buffer; try { - const text = await readFile(this.settingsPath, 'utf8'); - const persisted: unknown = JSON.parse(text); - const settings = normalizeSettings(persisted); - if (hasLegacyProxyCredentialFields(persisted)) { - await this.write(settings); - } - return settings; + bytes = await readFile(this.settingsPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; const settings = createDefaultSettings(); await this.write(settings); return settings; } + + let persisted: unknown; + try { + persisted = JSON.parse(bytes.toString('utf8')); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + // Never attach the parse error: its message can quote stored secrets. + return this.recoverCorruptSettings(bytes); + } + const settings = normalizeSettings(persisted); + if (hasLegacyProxyCredentialFields(persisted)) { + await this.write(settings); + } + return settings; + } + + private async recoverCorruptSettings(bytes: Buffer): Promise { + const backupPath = await this.backupCorruptSettings(bytes); + const settings = createDefaultSettings(); + try { + await this.write(settings); + } catch (error) { + if (error instanceof AtomicFileWriteCommitUnknownError) { + this.reportRecovery(backupPath, 'commit-unknown'); + throw new SettingsRecoveryCommitUnknownError(this.settingsPath, backupPath, error); + } + throw new SettingsRecoveryError({ + settingsPath: this.settingsPath, + phase: 'reset', + backupPath, + cause: error, + }); + } + this.reportRecovery(backupPath, 'recovered'); + return settings; + } + + /** An exclusive byte-for-byte backup, completed before replacing settings. + * Keeping the source in place avoids turning a failed recovery into ENOENT. + * This has the same platform fsync limits as the shared atomic writer. */ + private async backupCorruptSettings(bytes: Buffer): Promise { + const backupPath = `${this.settingsPath}.corrupt-${Date.now()}-${randomUUID()}`; + let created = false; + try { + const handle = await open(backupPath, 'wx', 0o600); + created = true; + try { + await handle.writeFile(bytes); + if (process.platform !== 'win32') await handle.chmod(0o600); + await handle.sync(); + await handle.close(); + } catch (error) { + await handle.close().catch(() => {}); + throw error; + } + await syncDirectory(dirname(this.settingsPath)); + return backupPath; + } catch (error) { + let incompleteBackupPath: string | undefined; + if (created) { + await rm(backupPath, { force: true }).catch(() => { + incompleteBackupPath = backupPath; + }); + } + throw new SettingsRecoveryError({ + settingsPath: this.settingsPath, + phase: 'backup', + incompleteBackupPath, + cause: error, + }); + } + } + + private reportRecovery(backupPath: string, outcome: CorruptSettingsRecovery['outcome']): void { + try { + void Promise.resolve( + this.options.onCorruptRecovery?.({ + settingsPath: this.settingsPath, + backupPath, + outcome, + }), + ).catch(() => {}); + } catch { + // Notification failure must not undo or misclassify a published reset. + } } async update(patch: UpdateAppSettingsInput): Promise { From 374389d5663fea5a35d0f7e1ae7631fe16c67e5d Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 14 Sep 2026 14:30:30 +0800 Subject: [PATCH 2/2] fix(mcp): surface repair guidance for corrupt config files Return a typed invalid-JSON error naming the persisted file while preserving its bytes and omitting parser messages that may contain credentials. Show localized repair guidance in Desktop and TUI, including mutations after a successful TUI startup. Keep live MCP state intact and make error details scrollable in small terminals, with Escape returning to the server list. Cover read and mutation refusal, localized rendering, scrolling, existing connections and explicit operations after an external file repair. Refs #4285 Generated-by: OpenAI Codex --- .../__tests__/mcp-ipc-commit-unknown.test.ts | 34 ++++- .../src/main/__tests__/mcp-page-model.test.ts | 10 +- apps/desktop/src/renderer/locales/mcp-copy.ts | 5 +- apps/desktop/src/renderer/mcp-page-model.ts | 8 +- apps/desktop/src/renderer/mcp-page.tsx | 18 +-- .../src/__tests__/pi-tui-mcp-status.test.ts | 120 ++++++++++++++++++ .../src/__tests__/tui-copy-catalog.test.ts | 1 + .../cli/src/__tests__/tui-mcp-control.test.ts | 111 +++++++++++++++- packages/cli/src/pi-tui-mcp-status.ts | 63 ++++++++- packages/cli/src/tui-copy-catalog.ts | 9 ++ packages/cli/src/tui-mcp-control.ts | 24 +++- .../src/__tests__/mcp-config-store.test.ts | 44 ++++++- packages/storage/src/mcp-config-store.ts | 18 ++- 13 files changed, 433 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts b/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts index c9f12ae74c..f38ef00907 100644 --- a/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import fs, { mkdtemp, readFile, rm } from 'node:fs/promises'; +import fs, { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -29,11 +29,12 @@ import { McpClientManager } from '@maka/mcp'; import { AtomicFileWriteCommitUnknownError, createMcpConfigStore, + McpConfigSourceError, type McpConfigStore, } from '@maka/storage/mcp-config-store'; import { registerMcpIpcMain, type McpIpcMainDeps } from '../mcp-ipc-main.js'; import { getMcpCopy } from '../../renderer/locales/mcp-copy.js'; -import { mcpWriteFailureMessage } from '../../renderer/mcp-page-model.js'; +import { mcpConfigFailureMessage } from '../../renderer/mcp-page-model.js'; test('MCP remove reconciles a live manager after the real store publishes then fails directory sync', { skip: process.platform === 'win32', @@ -66,7 +67,7 @@ test('MCP remove reconciles a live manager after the real store publishes then f assert.ok(error instanceof AtomicFileWriteCommitUnknownError); assert.equal(error.published, true); assert.equal(error.cause, fault.error); - assert.equal(mcpWriteFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.writeDurabilityUnknown); + assert.equal(mcpConfigFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.writeDurabilityUnknown); return true; }); assert.deepEqual(await diskConfig(root), { version: MCP_CONFIG_VERSION, mcpServers: {} }); @@ -125,7 +126,7 @@ for (const phase of ['read', 'sync', 'emit'] as const) { assert.match(error.message, /out of sync/u); assert.equal(error.cause, tracked.error()); assert.deepEqual(error.errors, [tracked.error(), reconciliationError]); - assert.equal(mcpWriteFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.writeOutOfSync); + assert.equal(mcpConfigFailureMessage(error, getMcpCopy('en')), getMcpCopy('en').errors.writeOutOfSync); return true; }); assert.ok((await diskConfig(root)).mcpServers.fixture); @@ -272,3 +273,28 @@ function mutationHarness(store: McpConfigStore, overrides: Partial { + const { root, store } = await fixtureStore(t); + const path = join(root, 'mcp.json'); + const source = '{"secret":"must-not-appear"'; + await writeFile(path, source); + const ipc = mutationHarness(store); + for (const call of [() => ipc.invoke('mcp:getConfig'), () => ipc.invoke('mcp:importConfig', '{"new":{"command":"unused"}}')]) { + await assert.rejects(call(), (error) => { + assert.ok(error instanceof McpConfigSourceError); + assert.equal(error.path, path); + assert.ok(error.message.includes(path)); + assert.match(error.message, /back up and repair/u); + for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) { + const copy = getMcpCopy(locale); + const ipcError: Error = new Error(`Error invoking remote method 'mcp:getConfig': ${error.name}: ${error.message}`); + assert.equal(mcpConfigFailureMessage(ipcError, copy), copy.errors.invalidConfigFile(path)); + } + assert.equal(error.message.includes('must-not-appear'), false); + return true; + }); + } + assert.deepEqual(ipc.synced, []); + assert.equal(await readFile(path, 'utf8'), source); +}); diff --git a/apps/desktop/src/main/__tests__/mcp-page-model.test.ts b/apps/desktop/src/main/__tests__/mcp-page-model.test.ts index 1da68a9ef8..a7653edb01 100644 --- a/apps/desktop/src/main/__tests__/mcp-page-model.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-page-model.test.ts @@ -29,7 +29,7 @@ import { mcpDraftProtocolPreference, mcpDraftFromConfig, presentMcpNegotiatedProtocol, - mcpWriteFailureMessage, + mcpConfigFailureMessage, } from '../../renderer/mcp-page-model.js'; const copy = getMcpCopy('en'); @@ -43,14 +43,14 @@ test('MCP write errors retain actionable localized meaning across Electron seria [durabilityError.message, localized.errors.writeDurabilityUnknown], [outOfSync, localized.errors.writeOutOfSync], ]) { - assert.equal(mcpWriteFailureMessage(message, localized), expected); + assert.equal(mcpConfigFailureMessage(message, localized), expected); assert.equal( - mcpWriteFailureMessage(new Error(`Error invoking remote method 'mcp:remove': Error: ${message}`), localized), + mcpConfigFailureMessage(new Error(`Error invoking remote method 'mcp:remove': Error: ${message}`), localized), expected, ); } - assert.equal(mcpWriteFailureMessage(new Error('unrelated private details'), localized), undefined); - assert.equal(mcpWriteFailureMessage(undefined, localized), undefined); + assert.equal(mcpConfigFailureMessage(new Error('unrelated private details'), localized), undefined); + assert.equal(mcpConfigFailureMessage(undefined, localized), undefined); } }); diff --git a/apps/desktop/src/renderer/locales/mcp-copy.ts b/apps/desktop/src/renderer/locales/mcp-copy.ts index 12f67ae260..6578475dda 100644 --- a/apps/desktop/src/renderer/locales/mcp-copy.ts +++ b/apps/desktop/src/renderer/locales/mcp-copy.ts @@ -24,7 +24,7 @@ export type McpCopy = { load: string; install(name: string): string; cancelInstall(name: string): string; save: string; import: string; update: string; test: string; remove: string; unavailableStatus: string; mapLine(line: number): string; importJson: string; importObject: string; importVersion(version: string): string; importServersObject: string; importProtocolVersion: string; - writeDurabilityUnknown: string; writeOutOfSync: string; + writeDurabilityUnknown: string; invalidConfigFile: (path: string) => string; writeOutOfSync: string; }; toast: { templateInstalled(name: string): string; templateInstalledDetail: string; installed(name: string): string; @@ -75,6 +75,7 @@ const MCP_COPY = { 'zh-CN': { errors: { load: '载入 MCP 失败', install: (name) => `安装 ${name} 失败`, cancelInstall: (name) => `取消安装 ${name} 失败`, save: '保存 MCP 失败', + invalidConfigFile: (path) => `${path} 中的 JSON 无效,文件未被修改。请关闭应用,备份并修复此文件后重试。`, writeDurabilityUnknown: '写入已发布,但无法确认断电后是否保留。请检查刷新后的配置再决定是否重试。', writeOutOfSync: '写入的持久性尚未确认,MCP 运行状态也未能与配置同步。请检查配置并重新同步后再重试。', import: '导入 MCP 失败', update: '更新 MCP 失败', test: 'MCP 测试失败', remove: '删除 MCP 失败', unavailableStatus: 'Server 没有返回可用状态。', @@ -136,6 +137,7 @@ const MCP_COPY = { 'zh-TW': { errors: { load: '載入 MCP 失敗', install: (name) => `安裝 ${name} 失敗`, cancelInstall: (name) => `取消安裝 ${name} 失敗`, save: '儲存 MCP 失敗', + invalidConfigFile: (path) => `${path} 中的 JSON 無效,檔案未被修改。請關閉應用程式,備份並修復此檔案後重試。`, writeDurabilityUnknown: '寫入已發布,但無法確認斷電後是否保留。請檢查重新整理後的設定再決定是否重試。', writeOutOfSync: '寫入的持久性尚未確認,MCP 執行狀態也未能與設定同步。請檢查設定並重新同步後再重試。', import: '匯入 MCP 失敗', update: '更新 MCP 失敗', test: 'MCP 測試失敗', remove: '刪除 MCP 失敗', unavailableStatus: 'Server 沒有返回可用狀態。', @@ -197,6 +199,7 @@ const MCP_COPY = { en: { errors: { load: 'Failed to load MCP', install: (name) => `Failed to install ${name}`, cancelInstall: (name) => `Failed to cancel installation of ${name}`, save: 'Failed to save MCP', + invalidConfigFile: (path) => `Invalid JSON in ${path}. The file is unchanged. Close the app, back up and repair this file before retrying.`, writeDurabilityUnknown: 'The write was published, but survival after power loss could not be confirmed. Check the refreshed configuration before retrying.', writeOutOfSync: 'Write durability could not be confirmed, and MCP runtime state is out of sync with the configuration. Check the configuration and resynchronize before retrying.', import: 'Failed to import MCP', update: 'Failed to update MCP', test: 'MCP test failed', remove: 'Failed to delete MCP', unavailableStatus: 'The server did not return an available status.', diff --git a/apps/desktop/src/renderer/mcp-page-model.ts b/apps/desktop/src/renderer/mcp-page-model.ts index 5ac7688628..18fc2b9e05 100644 --- a/apps/desktop/src/renderer/mcp-page-model.ts +++ b/apps/desktop/src/renderer/mcp-page-model.ts @@ -28,9 +28,13 @@ import type { McpCopy } from './locales/mcp-copy.js'; import { formatCommandLine, parseCommandLine } from './mcp-command-line.js'; /** Electron preserves error messages, but not custom error fields. Map only - * the fixed publication-error messages to safe, localized presentation. */ -export function mcpWriteFailureMessage(error: unknown, copy: McpCopy): string | undefined { + * the fixed config-error messages to safe, localized presentation. */ +export function mcpConfigFailureMessage(error: unknown, copy: McpCopy): string | undefined { const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + const invalidFile = /MCP config at ([^\r\n]+) contains invalid JSON\. The file was not modified\. Close the app, back up and repair this file before retrying\.$/u.exec(message); + if (invalidFile) { + return copy.errors.invalidConfigFile(invalidFile[1].replace(/[\u0000-\u001f\u007f-\u009f]/gu, '')); + } if (message.includes('MCP write durability is uncertain and runtime state is out of sync')) { return copy.errors.writeOutOfSync; } diff --git a/apps/desktop/src/renderer/mcp-page.tsx b/apps/desktop/src/renderer/mcp-page.tsx index 96aac359a2..93c0e6259b 100644 --- a/apps/desktop/src/renderer/mcp-page.tsx +++ b/apps/desktop/src/renderer/mcp-page.tsx @@ -107,7 +107,7 @@ import { mcpDraftProtocolPreference, mcpDraftFromConfig, presentMcpNegotiatedProtocol, - mcpWriteFailureMessage, + mcpConfigFailureMessage, type McpEditorDraft, } from './mcp-page-model'; import { settingsActionErrorMessage } from './settings/settings-error-copy'; @@ -189,7 +189,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (mounted.current) { reportRuntimeHostError( copy.errors.load, - settingsActionErrorMessage(error, locale), + mcpConfigFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), defaultRuntimeHostDiagnosticTarget(error), ); } @@ -297,7 +297,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (mounted.current && !cancelledInstalls.current.has(entry.id)) { reportRuntimeHostError( copy.errors.install(entry.name), - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), + mcpConfigFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), defaultRuntimeHostDiagnosticTarget(error), ); await reload(); @@ -327,7 +327,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (mounted.current) { reportRuntimeHostError( copy.errors.cancelInstall(entry.name), - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), + mcpConfigFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), defaultRuntimeHostDiagnosticTarget(error), ); await reload(); @@ -364,7 +364,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (mounted.current) { reportRuntimeHostError( copy.errors.save, - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), + mcpConfigFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), defaultRuntimeHostDiagnosticTarget(error), ); // A rejected mutation may already have replaced mcp.json. Refresh @@ -397,7 +397,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (mounted.current) { reportRuntimeHostError( copy.errors.import, - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), + mcpConfigFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), defaultRuntimeHostDiagnosticTarget(error), ); await reload(); @@ -418,7 +418,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (mounted.current) { reportRuntimeHostError( copy.errors.update, - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), + mcpConfigFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), defaultRuntimeHostDiagnosticTarget(error), ); await reload(); @@ -448,7 +448,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (mounted.current) { reportRuntimeHostError( copy.errors.test, - settingsActionErrorMessage(error, locale), + mcpConfigFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), defaultRuntimeHostDiagnosticTarget(error), ); } @@ -484,7 +484,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { if (mounted.current) { reportRuntimeHostError( copy.errors.remove, - mcpWriteFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), + mcpConfigFailureMessage(error, copy) ?? settingsActionErrorMessage(error, locale), defaultRuntimeHostDiagnosticTarget(error), ); await reload(); diff --git a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts index 73b8936200..54a78d70b0 100644 --- a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts +++ b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts @@ -553,3 +553,123 @@ function surface( execute: async () => ({ status: 'failed', reason: 'manager-failed' }), }; } + +test('invalid persisted MCP JSON renders its location and repair guidance in every locale', () => { + for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) { + const overlay = new McpManagementOverlay({ + locale, + surface: surface({ + initialization: 'error', + invalidConfigPath: '/profile/mcp.json', + configuration: 'synchronizing', + publication: 'not_published', + toolCount: 0, + servers: [], + }), + viewportRows: () => 20, + onClose: () => {}, + onChange: () => {}, + }); + const text = overlay.render(160).map(stripAnsi).join('\n'); + assert.match(text, /\/profile\/mcp\.json/u); + assert.match(text, /back up and repair|备份并修复|備份並修復/u); + assert.match(text, /unchanged|未被修改/u); + } +}); + +for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) { + test(`runtime MCP file errors show ${locale} repair guidance and return to the live server list`, async () => { + const snapshot = listSnapshot(); + const mcp = surface(snapshot); + mcp.execute = async () => ({ + status: 'failed', + reason: 'invalid-config-file', + path: '/profile/\u0000mcp.json', + }); + let closed = false; + const overlay = new McpManagementOverlay({ + locale, + surface: mcp, + viewportRows: () => 20, + onClose: () => { + closed = true; + }, + onChange: () => {}, + }); + const render = () => overlay.render(160).map(stripAnsi).join('\n'); + render(); + overlay.handleInput(' '); + await new Promise((resolve) => setImmediate(resolve)); + const text = render(); + assert.ok(text.includes('/profile/mcp.json')); + assert.match(text, /back up and repair|备份并修复|備份並修復/u); + assert.ok(text.includes(TUI_COPY_RESOURCES['mcp-status'][locale].footer.diagnostic)); + assert.doesNotMatch(text, /\u0000/u); + assert.equal(mcp.snapshot().initialization, 'ready'); + overlay.handleInput('\u001b'); + assert.equal(closed, false); + assert.ok(render().includes('filesystem')); + assert.equal(render().includes('/profile/mcp.json'), false); + mcp.execute = async () => ({ status: 'applied', effect: 'published' }); + overlay.handleInput(' '); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(render().includes(TUI_COPY_RESOURCES['mcp-status'][locale].editor.results.published)); + }); +} + +for (const phase of ['initialization', 'mutation'] as const) { + test(`MCP ${phase} repair details scroll in a small terminal and clamp after resizing`, async () => { + const path = '/Users/example/Library/Application Support/Maka/workspaces/default/mcp.json'; + const mcp = surface( + phase === 'mutation' + ? listSnapshot() + : { + initialization: 'error', + configuration: 'synchronizing', + publication: 'not_published', + invalidConfigPath: path, + toolCount: 0, + servers: [], + }, + ); + mcp.execute = async () => ({ status: 'failed', reason: 'invalid-config-file', path }); + let rows = 4; + const overlay = new McpManagementOverlay({ + locale: 'en', + surface: mcp, + viewportRows: () => rows, + onClose: () => {}, + onChange: () => {}, + }); + const render = () => overlay.render(50).map(stripAnsi).join('\n'); + render(); + if (phase === 'mutation') { + overlay.handleInput(' '); + await new Promise((resolve) => setImmediate(resolve)); + } + const first = render(); + assert.equal(first.includes('retrying.'), false); + overlay.handleInput('\u001b[B'); + assert.notEqual(render(), first); + overlay.handleInput('\u001b[A'); + assert.equal(render(), first); + overlay.handleInput('\u001b[6~'); + assert.notEqual(render(), first); + overlay.handleInput('\u001b[5~'); + assert.equal(render(), first); + overlay.handleInput('\u001b[F'); + const last = render(); + assert.ok(last.includes('retrying.')); + overlay.handleInput('\u001b[6~'); + assert.equal(render(), last); + overlay.handleInput('\u001b[H'); + assert.equal(render(), first); + overlay.handleInput('\u001b[F'); + render(); + rows = 30; + const expanded = render(); + assert.match(expanded, /1-\d+ \/ \d+/u); + assert.ok(expanded.includes('mcp.json')); + assert.ok(expanded.includes('retrying.')); + }); +} diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index 7cc2b7a0ba..a328810b39 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -37,6 +37,7 @@ const MESSAGE_VALUES = { state: 'ready', count: 2, detail: 'HTTP 401', + path: '/profile/mcp.json', hasDetail: true, bytes: 40_000, serverId: 'filesystem', diff --git a/packages/cli/src/__tests__/tui-mcp-control.test.ts b/packages/cli/src/__tests__/tui-mcp-control.test.ts index 2c6977babf..f33a7ca922 100644 --- a/packages/cli/src/__tests__/tui-mcp-control.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-control.test.ts @@ -19,7 +19,7 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; -import fs, { mkdtemp, readFile, rm } from 'node:fs/promises'; +import fs, { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -34,7 +34,11 @@ import { AtomicFileWriteCommitUnknownError, createMcpConfigStore, } from '@maka/storage/mcp-config-store'; -import { createTuiMcpController, type TuiMcpPublicationAvailability } from '../tui-mcp-control.js'; +import { + createTuiMcpController, + type TuiMcpAction, + type TuiMcpPublicationAvailability, +} from '../tui-mcp-control.js'; import { waitFor } from './tui-terminal-mock.js'; test('TUI MCP startup stays backgrounded and publishes the discovered snapshot', async () => { @@ -1302,3 +1306,106 @@ function deferredValue() { }); return { promise, resolve }; } + +test('TUI MCP retains only the invalid persisted config path for repair guidance', async () => { + const root = await mkdtemp(join(tmpdir(), 'tui-mcp-invalid-json-')); + const manager = managerHarness(0, []); + const connection = connectionHarness(); + const path = join(root, 'mcp.json'); + const bytes = '{"secret":"do-not-display-this"'; + await writeFile(path, bytes); + const controller = createTuiMcpController( + { workspaceRoot: root, connection: connection.connection }, + { + configStore: createMcpConfigStore(root), + manager: manager.manager, + createProvider: () => provider('unused'), + }, + ); + try { + await waitFor( + () => controller.snapshot().initialization === 'error', + 'invalid MCP file to fail initialization', + ); + assert.equal(controller.snapshot().invalidConfigPath, path); + assert.equal(JSON.stringify(controller.snapshot()).includes('do-not-display-this'), false); + assert.equal(connection.replacements.length, 0); + assert.equal(await readFile(path, 'utf8'), bytes); + } finally { + await controller.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +for (const kind of ['add', 'edit', 'set_enabled', 'remove', 'commit_import'] as const) { + test(`TUI MCP ${kind} retains a corrupt file diagnostic without disturbing live state`, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'tui-mcp-runtime-corrupt-')); + const path = join(root, 'mcp.json'); + const store = createMcpConfigStore(root); + const initial: McpConfigFile = { + version: 3, + mcpServers: { docs: { enabled: false, url: 'https://docs.example/mcp' } }, + }; + await store.transform(() => initial); + const order: string[] = []; + const connection = connectionHarness(); + const controller = createTuiMcpController( + { workspaceRoot: root, connection: connection.connection }, + { + configStore: store, + manager: managementManager(order).manager, + createProvider: () => undefined, + }, + ); + t.after(async () => { + await controller.close(); + await rm(root, { recursive: true, force: true }); + }); + await waitFor( + () => + controller.snapshot().initialization === 'ready' && + controller.snapshot().publication === 'not_published', + 'MCP initialization without a capability provider', + ); + const edit = controller.configForEdit('docs'); + assert.ok(edit); + const preview = controller.previewImport('{"new":{"command":"unused"}}'); + assert.equal(preview.status, 'ready'); + const actions: Record = { + add: { kind: 'add', serverId: 'new', config: { command: 'unused' } }, + edit: { + kind: 'edit', + serverId: 'docs', + expectedRevision: edit.revision, + config: { command: 'unused' }, + }, + set_enabled: { kind: 'set_enabled', serverId: 'docs', enabled: true }, + remove: { kind: 'remove', serverId: 'docs' }, + commit_import: { kind: 'commit_import', previewId: preview.preview.previewId }, + }; + const before = controller.snapshot(); + order.length = 0; + const bytes = '{"secret":"never-display-this"'; + await writeFile(path, bytes); + const result = await controller.execute(actions[kind]); + assert.deepEqual(result, { status: 'failed', reason: 'invalid-config-file', path }); + assert.deepEqual(controller.snapshot(), before); + assert.deepEqual(controller.configForEdit('docs'), edit); + assert.deepEqual(order, []); + assert.equal(connection.unregisters, 0); + assert.equal(await readFile(path, 'utf8'), bytes); + assert.equal(JSON.stringify(result).includes('never-display-this'), false); + + // An external repair makes the next explicit operation usable without a + // controller restart or a stale initialization-error flag. + await writeFile(path, JSON.stringify(initial)); + const repaired = await controller.execute({ + kind: 'add', + serverId: 'repaired', + config: { command: 'unused' }, + }); + assert.equal(repaired.status, 'applied'); + assert.equal(controller.snapshot().initialization, 'ready'); + assert.ok((await store.get()).mcpServers.repaired); + }); +} diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts index 9ee6f6808f..4101343e95 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -24,6 +24,7 @@ import { matchesKey, truncateToWidth, visibleWidth, + wrapTextWithAnsi, type Component, type TUI, } from '@earendil-works/pi-tui'; @@ -52,6 +53,7 @@ interface TuiMcpStatusCopy { readonly title: string; readonly footer: { readonly back: string; + readonly diagnostic: string; readonly readOnly: string; readonly manage: string; readonly managePublication: string; @@ -60,6 +62,7 @@ interface TuiMcpStatusCopy { readonly unavailableDetail: string; readonly loading: string; readonly loadError: string; + readonly invalidConfigFile: string; readonly noServers: string; readonly publication: Readonly< Record['publication'], string> @@ -88,8 +91,10 @@ interface TuiMcpStatusCopy { }; } +type TuiMcpNoticeResult = Exclude; + type TuiMcpResultCode = - | Extract['reason'] + | Extract['reason'] | Extract['effect'] | 'turn_active' | 'invalid' @@ -136,6 +141,7 @@ type InputKind = type McpOverlayPhase = | { kind: 'list' } + | { kind: 'config_error'; path: string } | { kind: 'add_choice' } | { kind: 'transport'; draft: GuidedDraft } | { kind: 'protocol'; draft: GuidedDraft } @@ -210,7 +216,8 @@ export class McpManagementOverlay implements Component { else this.backToList(); return; } - if (this.phase.kind === 'list') this.handleListInput(data); + if (this.phase.kind === 'config_error') this.handleTextScroll(data); + else if (this.phase.kind === 'list') this.handleListInput(data); else if (this.phase.kind === 'add_choice') this.handleAddChoice(data); else if (this.phase.kind === 'transport') this.handleTransport(data); else if (this.phase.kind === 'protocol') this.handleProtocol(data); @@ -270,6 +277,12 @@ export class McpManagementOverlay implements Component { private handleListInput(data: string): void { const snapshot = this.input.surface?.snapshot(); const servers = snapshot?.servers ?? []; + if ( + (servers.length === 0 || snapshot?.initialization === 'error') && + this.handleTextScroll(data) + ) { + return; + } if (matchesKey(data, Key.up)) { this.selected = clamp(this.selected - 1, 0, servers.length - 1); } else if (matchesKey(data, Key.down)) { @@ -314,6 +327,19 @@ export class McpManagementOverlay implements Component { this.input.onChange(); } + private handleTextScroll(data: string): boolean { + if (matchesKey(data, Key.up)) this.top -= 1; + else if (matchesKey(data, Key.down)) this.top += 1; + else if (matchesKey(data, Key.pageUp)) this.top -= Math.max(1, this.bodyRows); + else if (matchesKey(data, Key.pageDown)) this.top += Math.max(1, this.bodyRows); + else if (matchesKey(data, Key.home)) this.top = 0; + else if (matchesKey(data, Key.end)) this.top = this.maxTop(); + else return false; + this.top = clamp(this.top, 0, this.maxTop()); + this.input.onChange(); + return true; + } + private handleAddChoice(data: string): void { if (matchesKey(data, 'g')) this.startInput('server_id', { serverId: '' }); else if (matchesKey(data, 'j')) this.startInput('import'); @@ -466,6 +492,13 @@ export class McpManagementOverlay implements Component { result = { status: 'failed', reason: 'manager-failed' }; } if (this.closed || attempt !== this.actionAttempt) return; + if (result.status === 'failed' && result.reason === 'invalid-config-file') { + this.phase = { kind: 'config_error', path: result.path }; + this.notice = undefined; + this.top = 0; + this.input.onChange(); + return; + } this.phase = { kind: 'list' }; this.notice = actionNotice(result, this.input.locale); this.input.onChange(); @@ -475,6 +508,9 @@ export class McpManagementOverlay implements Component { this.serverRows = []; const snapshot = this.input.surface?.snapshot(); if (!snapshot) return unavailableDocument(this.input.locale); + if (this.phase.kind === 'config_error') { + return wrapTextWithAnsi(invalidConfigFileCopy(this.input.locale, this.phase.path), width); + } if (this.phase.kind === 'input') return this.inputDocument(width); const editor = MCP_STATUS_COPY[this.input.locale].editor; if (this.phase.kind === 'add_choice') { @@ -528,7 +564,16 @@ export class McpManagementOverlay implements Component { } if (snapshot.initialization === 'loading') return [...lines, loadingCopy(this.input.locale)]; if (snapshot.initialization === 'error') { - return [...lines, ansi.red(loadErrorCopy(this.input.locale))]; + lines.push(ansi.red(loadErrorCopy(this.input.locale))); + if (snapshot.invalidConfigPath) { + lines.push( + ...wrapTextWithAnsi( + invalidConfigFileCopy(this.input.locale, snapshot.invalidConfigPath), + width, + ), + ); + } + return lines; } if (snapshot.servers.length === 0) return [...lines, '', emptyCopy(this.input.locale)]; lines.push(''); @@ -557,7 +602,9 @@ export class McpManagementOverlay implements Component { private footer(): string { const copy = MCP_STATUS_COPY[this.input.locale].footer; + if (this.phase.kind === 'config_error') return copy.diagnostic; if (this.phase.kind !== 'list') return copy.back; + if (this.input.surface?.snapshot().initialization === 'error') return copy.readOnly; if (!this.management()) return copy.readOnly; return this.input.surface?.snapshot().canManagePublicationCredential ? copy.managePublication @@ -749,7 +796,7 @@ function serverLines(server: TuiMcpServerSnapshot, locale: UiLocale, selected: b } function actionNotice( - result: TuiMcpActionResult, + result: TuiMcpNoticeResult, locale: UiLocale, ): { level: 'info' | 'error'; text: string } { if (result.status === 'conflict' || result.status === 'failed') { @@ -778,6 +825,14 @@ function resultCopy(locale: UiLocale, code: TuiMcpResultCode): string { return MCP_STATUS_COPY[locale].editor.results[code] ?? code; } +function invalidConfigFileCopy(locale: UiLocale, path: string): string { + return formatUiMessage( + MCP_STATUS_COPY[locale].invalidConfigFile, + { path: path.replace(/[\u0000-\u001f\u007f-\u009f]/gu, '') }, + locale, + ); +} + function confirmAddDocument(draft: GuidedDraft, locale: UiLocale): string[] { const editor = MCP_STATUS_COPY[locale].editor; return [ diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 881dc7421b..3d5ab30736 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -267,6 +267,7 @@ export const TUI_COPY_RESOURCES = { title: 'MCP SERVERS', footer: { back: 'Esc back', + diagnostic: '↑/↓ scroll · Esc back', readOnly: '↑/↓ scroll · q/Esc close', manage: 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · Esc Close', @@ -279,6 +280,8 @@ export const TUI_COPY_RESOURCES = { loading: 'Loading mcp.json and discovering tools…', loadError: 'MCP configuration could not be loaded; no tools were published to the Runtime Host.', + invalidConfigFile: + 'Invalid JSON in {path}. The file is unchanged. Close the app, back up and repair this file before retrying.', noServers: 'No MCP servers are configured. Press a to add one.', publication: { waiting: 'waiting to publish', @@ -375,6 +378,7 @@ export const TUI_COPY_RESOURCES = { title: 'MCP 服务器', footer: { back: 'Esc 返回', + diagnostic: '↑/↓ 滚动 · Esc 返回', readOnly: '↑/↓ 滚动 · q/Esc 关闭', manage: 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · Esc 关闭', managePublication: @@ -384,6 +388,8 @@ export const TUI_COPY_RESOURCES = { unavailableDetail: '远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。', loading: '正在读取 mcp.json 并发现工具…', loadError: '无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。', + invalidConfigFile: + '{path} 中的 JSON 无效,文件未被修改。请关闭应用,备份并修复此文件后重试。', noServers: '尚未配置 MCP 服务器。按 a 添加。', publication: { waiting: '等待发布', @@ -475,6 +481,7 @@ export const TUI_COPY_RESOURCES = { title: 'MCP 伺服器', footer: { back: 'Esc 返回', + diagnostic: '↑/↓ 捲動 · Esc 返回', readOnly: '↑/↓ 捲動 · q/Esc 關閉', manage: 'a 新增 · Enter 編輯 · Space 啟用/停用 · t 測試 · r 重新連線 · d 刪除 · Esc 關閉', managePublication: @@ -484,6 +491,8 @@ export const TUI_COPY_RESOURCES = { unavailableDetail: '遠端 Runtime Host 的用戶端 MCP 工具關聯將於後續版本提供。', loading: '正在讀取 mcp.json 並探索工具…', loadError: '無法讀取或套用 MCP 設定;未向 Runtime Host 發佈任何工具。', + invalidConfigFile: + '{path} 中的 JSON 無效,檔案未被修改。請關閉應用程式,備份並修復此檔案後重試。', noServers: '尚未設定 MCP 伺服器。按 a 新增。', publication: { waiting: '等待發佈', diff --git a/packages/cli/src/tui-mcp-control.ts b/packages/cli/src/tui-mcp-control.ts index 31dac5580f..e7f690f6cd 100644 --- a/packages/cli/src/tui-mcp-control.ts +++ b/packages/cli/src/tui-mcp-control.ts @@ -77,6 +77,8 @@ export interface TuiMcpServerSnapshot { export interface TuiMcpSnapshot { readonly initialization: 'loading' | 'ready' | 'error'; + /** Safe source location for invalid persisted JSON, never the parser message. */ + readonly invalidConfigPath?: string; readonly configuration: 'ready' | 'synchronizing' | 'out_of_sync'; readonly publication: TuiMcpPublicationState; readonly canManagePublicationCredential?: boolean; @@ -138,6 +140,11 @@ export type TuiMcpActionEffect = export type TuiMcpActionResult = | { readonly status: 'applied'; readonly effect: TuiMcpActionEffect } | { readonly status: 'tested'; readonly test: McpTestResult; readonly effect: TuiMcpActionEffect } + | { + readonly status: 'failed'; + readonly reason: 'invalid-config-file'; + readonly path: string; + } | { readonly status: 'failed'; readonly reason: 'commit-unknown'; @@ -401,9 +408,15 @@ class TuiMcpControllerImpl implements TuiMcpController { this.#config = cloneConfig(config); this.#refreshManagerSnapshot('ready', 'ready'); this.#requestPublication(); - } catch { + } catch (error) { if (this.#closed) return; - this.#updateSnapshot({ initialization: 'error', publication: 'not_published' }); + this.#updateSnapshot({ + initialization: 'error', + publication: 'not_published', + ...(error instanceof McpConfigSourceError && error.reason === 'invalid-json' && error.path + ? { invalidConfigPath: error.path } + : {}), + }); } } @@ -502,6 +515,9 @@ class TuiMcpControllerImpl implements TuiMcpController { }); } catch (error) { if (error instanceof TuiMcpMutationError) return error.result; + if (error instanceof McpConfigSourceError && error.reason === 'invalid-json' && error.path) { + return { status: 'failed', reason: 'invalid-config-file', path: error.path }; + } if (error instanceof AtomicFileWriteCommitUnknownError) { // The transform has already published, including any credential // retirement. Reload its authority; never replay those effects. @@ -740,7 +756,9 @@ class TuiMcpControllerImpl implements TuiMcpController { } #updateSnapshot( - update: Partial>, + update: Partial< + Pick + >, ): void { this.#snapshot = freezeSnapshot({ ...this.#snapshot, ...update }); this.#notify(); diff --git a/packages/storage/src/__tests__/mcp-config-store.test.ts b/packages/storage/src/__tests__/mcp-config-store.test.ts index cf9fa323a1..dc57b1042b 100644 --- a/packages/storage/src/__tests__/mcp-config-store.test.ts +++ b/packages/storage/src/__tests__/mcp-config-store.test.ts @@ -26,6 +26,7 @@ import { afterEach, test } from 'node:test'; import { MCP_CONFIG_VERSION, resolveMcpProtocolPreference } from '@maka/core/mcp'; import { createMcpConfigStore, + McpConfigSourceError, normalizeMcpConfig, normalizeMcpImport, } from '../mcp-config-store.js'; @@ -548,7 +549,7 @@ test('normalizes and bounds the remote oauth block', async () => { ['read', 'a\tb'], ['read"admin'], ['read\\admin'], - ['readadmin'], + ['read\u0001admin'], ['café'], ]) { assert.throws( @@ -601,3 +602,44 @@ async function tempRoot(): Promise { roots.push(root); return root; } + +test('corrupt persisted MCP JSON has a safe actionable error and mutations cannot overwrite it', async () => { + const root = await tempRoot(); + const path = join(root, 'mcp.json'); + const bytes = Buffer.from('{"secret":"never-include-this"'); + await writeFile(path, bytes); + const store = createMcpConfigStore(root); + let transformed = false; + for (const operation of [ + () => store.get(), + () => + store.transform((config) => { + transformed = true; + return config; + }), + () => store.upsert('new', { command: 'unused' }), + () => store.remove('old'), + ]) { + await assert.rejects(operation(), (error) => { + assert.ok(error instanceof McpConfigSourceError); + assert.equal(error.reason, 'invalid-json'); + assert.equal(error.path, path); + assert.ok(error.message.includes(path)); + assert.match(error.message, /not modified.*back up and repair/u); + assert.equal(error.message.includes('never-include-this'), false); + assert.equal(error.cause, undefined); + return true; + }); + assert.deepEqual(await readFile(path), bytes); + } + assert.equal(transformed, false); + assert.throws( + () => normalizeMcpImport('{bad'), + (error) => { + assert.ok(error instanceof McpConfigSourceError); + assert.equal(error.reason, 'invalid-json'); + assert.equal(error.path, undefined); + return true; + }, + ); +}); diff --git a/packages/storage/src/mcp-config-store.ts b/packages/storage/src/mcp-config-store.ts index afadeb316c..52904a4cfe 100644 --- a/packages/storage/src/mcp-config-store.ts +++ b/packages/storage/src/mcp-config-store.ts @@ -66,6 +66,7 @@ export class McpConfigSourceError extends Error { readonly reason: McpConfigSourceFailureReason, readonly version?: string, message: string = reason, + readonly path?: string, ) { super(message); this.name = 'McpConfigSourceError'; @@ -183,7 +184,22 @@ class FileMcpConfigStore implements McpConfigStore { if (Buffer.byteLength(text, 'utf8') > MAX_CONFIG_BYTES) { throw new Error('MCP config exceeds 1 MiB'); } - return normalizeMcpConfig(JSON.parse(text)); + let persisted: unknown; + try { + persisted = JSON.parse(text); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + // JSON.parse can quote credentials in its message. Report the location + // and recovery action without retaining those source bytes in an error. + throw new McpConfigSourceError( + 'invalid-json', + undefined, + `MCP config at ${this.path} contains invalid JSON. The file was not modified. ` + + 'Close the app, back up and repair this file before retrying.', + this.path, + ); + } + return normalizeMcpConfig(persisted); } private async readOrCreate(): Promise {