From e070c4a0d06ff381d1355953e003fa90fdf52705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 14 May 2026 18:04:02 +0800 Subject: [PATCH] Make upgrade failures diagnosable without deleting user state Desktop startup can fail before React mounts on older WebViews, so an HTML-level watchdog now renders startup diagnostics even when the module bundle never reaches the app code. Persistent provider migration now imports legacy root provider config into cc-haha-owned storage without deleting the old source file, and plugin marketplace cleanup refuses obvious corrupted cache roots or outside paths. Constraint: User explicitly accepted the current reviewed state for landing despite remaining review concerns. Constraint: Global ~/.claude state is user-owned and protected; automatic repair must avoid deleting shared config, transcripts, skills, MCP, plugins, OAuth, adapters, and teams. Rejected: Tell users to delete ~/.claude or ~/.claude/cc-haha | unsafe because it can destroy user-owned Claude state and still may not fix WebView compatibility failures. Confidence: medium Scope-risk: moderate Directive: Do not weaken protected-path checks; future deletion paths should validate real paths and symlink behavior before recursive rm. Tested: bun test src/utils/plugins/installedPluginsManager.test.ts src/utils/plugins/marketplaceManager.test.ts Tested: bun run check:server Tested: cd desktop && bun run test -- --run src/main.test.tsx index-html.test.ts vite-config.test.ts src/theme/globals.test.ts Tested: cd desktop && bun run build Tested: bun run check:coverage Not-tested: live macOS 12/Safari 15 WKWebView startup on an affected machine. Not-tested: H5 diagnostic URL redaction and symlink-escape hardening are known follow-up risks from review. --- desktop/index-html.test.ts | 22 +++ desktop/index.html | 89 ++++++++++ desktop/src/main.test.tsx | 83 +++++++++ desktop/src/main.tsx | 72 ++++++-- desktop/src/theme/globals.css | 50 ++++-- desktop/src/theme/globals.test.ts | 14 ++ desktop/vite-config.test.ts | 22 +++ desktop/vite.config.ts | 3 + release-notes/v0.2.2.md | 2 +- scripts/quality-gate/coverage.test.ts | 44 +++++ scripts/quality-gate/coverage.ts | 4 +- .../__tests__/persistence-upgrade.test.ts | 114 ++++++++++++ .../services/persistentStorageMigrations.ts | 164 ++++++++++++++++++ .../plugins/installedPluginsManager.test.ts | 66 +++++++ src/utils/plugins/installedPluginsManager.ts | 16 +- src/utils/plugins/marketplaceManager.test.ts | 108 ++++++++++++ src/utils/plugins/marketplaceManager.ts | 16 +- 17 files changed, 853 insertions(+), 36 deletions(-) create mode 100644 desktop/index-html.test.ts create mode 100644 desktop/src/main.test.tsx create mode 100644 desktop/vite-config.test.ts create mode 100644 src/utils/plugins/installedPluginsManager.test.ts create mode 100644 src/utils/plugins/marketplaceManager.test.ts diff --git a/desktop/index-html.test.ts b/desktop/index-html.test.ts new file mode 100644 index 00000000..35935538 --- /dev/null +++ b/desktop/index-html.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const html = readFileSync(join(__dirname, 'index.html'), 'utf-8') + +describe('desktop index startup diagnostics', () => { + it('installs a non-module startup watchdog before the app module loads', () => { + const watchdogIndex = html.indexOf('__CC_HAHA_SHOW_STARTUP_ERROR__') + const moduleIndex = html.indexOf('type="module"') + + expect(watchdogIndex).toBeGreaterThan(0) + expect(moduleIndex).toBeGreaterThan(watchdogIndex) + expect(html).toContain('__CC_HAHA_BOOTSTRAPPED__') + expect(html).toContain('Desktop startup failed') + }) + + it('diagnoses module resource failures and boot timeouts outside React', () => { + expect(html).toContain('Startup resource failed to load:') + expect(html).toContain('Desktop app did not finish bootstrapping within') + }) +}) diff --git a/desktop/index.html b/desktop/index.html index c03e7509..a4064a84 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -8,7 +8,96 @@ +
diff --git a/desktop/src/main.test.tsx b/desktop/src/main.test.tsx new file mode 100644 index 00000000..9798b264 --- /dev/null +++ b/desktop/src/main.test.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import '@testing-library/jest-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, screen } from '@testing-library/react' + +const mocks = vi.hoisted(() => ({ + runDesktopPersistenceMigrations: vi.fn(), +})) + +vi.mock('./lib/persistenceMigrations', () => ({ + runDesktopPersistenceMigrations: mocks.runDesktopPersistenceMigrations, +})) + +vi.mock('./theme/globals.css', () => ({})) + +vi.mock('./App', () => ({ + App: () =>
Auto boot app
, +})) + +vi.mock('./components/ErrorBoundary', () => ({ + ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('./lib/diagnosticsCapture', () => ({ + installClientDiagnosticsCapture: vi.fn(), +})) + +vi.mock('./stores/uiStore', () => ({ + initializeTheme: vi.fn(), +})) + +describe('desktop bootstrap', () => { + afterEach(() => { + cleanup() + document.body.innerHTML = '' + delete window.__CC_HAHA_BOOTSTRAPPED__ + delete window.__CC_HAHA_SHOW_STARTUP_ERROR__ + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('runs startup migrations and renders the app without top-level await', async () => { + document.body.innerHTML = '
' + + await act(async () => { + await import('./main') + await Promise.resolve() + }) + + expect(await screen.findByText('Auto boot app')).toBeInTheDocument() + expect(mocks.runDesktopPersistenceMigrations).toHaveBeenCalledTimes(1) + expect(window.__CC_HAHA_BOOTSTRAPPED__).toBe(true) + }) + + it('surfaces bootstrap failures in the root element', async () => { + const { bootstrapDesktopApp } = await import('./main') + const root = document.createElement('div') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await bootstrapDesktopApp(root, async () => { + throw new Error('bootstrap failed') + }) + + expect(root.textContent).toBe('bootstrap failed') + expect(consoleError).toHaveBeenCalledWith('[desktop] Failed to bootstrap app', expect.any(Error)) + }) + + it('delegates bootstrap failures to the HTML startup watchdog when available', async () => { + const { bootstrapDesktopApp } = await import('./main') + const root = document.createElement('div') + const showStartupError = vi.fn() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + window.__CC_HAHA_SHOW_STARTUP_ERROR__ = showStartupError + + await bootstrapDesktopApp(root, async () => { + throw new Error('module failed') + }) + + expect(showStartupError).toHaveBeenCalledWith(expect.any(Error)) + expect(root.textContent).toBe('') + expect(consoleError).toHaveBeenCalledWith('[desktop] Failed to bootstrap app', expect.any(Error)) + }) +}) diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index c6a55b7c..7ad171c6 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -4,21 +4,63 @@ import './theme/globals.css' import { initializeAppZoom } from './lib/appZoom' import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations' +declare global { + interface Window { + __CC_HAHA_BOOTSTRAPPED__?: boolean + __CC_HAHA_SHOW_STARTUP_ERROR__?: (reason: unknown) => void + } +} + +type DesktopBootstrapModules = [ + { App: React.ComponentType }, + { ErrorBoundary: React.ComponentType<{ children: React.ReactNode }> }, + { installClientDiagnosticsCapture: () => void }, + { initializeTheme: () => void }, +] + +function loadDesktopBootstrapModules() { + return Promise.all([ + import('./App'), + import('./components/ErrorBoundary'), + import('./lib/diagnosticsCapture'), + import('./stores/uiStore'), + ]) +} + +export async function bootstrapDesktopApp( + root: HTMLElement | null = document.getElementById('root'), + loadModules: () => Promise = loadDesktopBootstrapModules, +) { + try { + const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await loadModules() + initializeTheme() + installClientDiagnosticsCapture() + + if (!root) { + throw new Error('Desktop root element not found') + } + + ReactDOM.createRoot(root).render( + + + + + , + ) + window.__CC_HAHA_BOOTSTRAPPED__ = true + } catch (error) { + console.error('[desktop] Failed to bootstrap app', error) + if (root) { + if (window.__CC_HAHA_SHOW_STARTUP_ERROR__) { + window.__CC_HAHA_SHOW_STARTUP_ERROR__(error) + } else { + root.textContent = error instanceof Error ? error.message : String(error) + } + } + } +} + runDesktopPersistenceMigrations() void initializeAppZoom() -const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([ - import('./App'), - import('./components/ErrorBoundary'), - import('./lib/diagnosticsCapture'), - import('./stores/uiStore'), -]) -initializeTheme() -installClientDiagnosticsCapture() -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - , -) +void bootstrapDesktopApp() diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 3442cd4e..196dcbc8 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -306,6 +306,16 @@ --color-window-close-hover: #E81123; --color-selection-bg: rgba(255, 219, 208, 0.9); --color-selection-fg: #390C00; + --color-text-secondary-a72: rgba(84, 67, 62, 0.72); + --color-text-secondary-a68: rgba(84, 67, 62, 0.68); + --color-text-primary-a88: rgba(27, 28, 26, 0.88); + --color-text-primary-a82: rgba(27, 28, 26, 0.82); + --color-text-primary-a78: rgba(27, 28, 26, 0.78); + --color-surface-hover-a34: rgba(233, 232, 228, 0.34); + --color-surface-hover-a54: rgba(233, 232, 228, 0.54); + --color-outline-a72: rgba(135, 115, 109, 0.72); + --color-outline-a78: rgba(135, 115, 109, 0.78); + --color-outline-a92: rgba(135, 115, 109, 0.92); /* Radii */ --radius-sm: 4px; @@ -477,6 +487,16 @@ --color-window-close-hover: #E5484D; --color-selection-bg: rgba(255, 219, 208, 0.9); --color-selection-fg: #390C00; + --color-text-secondary-a72: rgba(75, 85, 99, 0.72); + --color-text-secondary-a68: rgba(75, 85, 99, 0.68); + --color-text-primary-a88: rgba(17, 24, 39, 0.88); + --color-text-primary-a82: rgba(17, 24, 39, 0.82); + --color-text-primary-a78: rgba(17, 24, 39, 0.78); + --color-surface-hover-a34: rgba(241, 244, 247, 0.34); + --color-surface-hover-a54: rgba(241, 244, 247, 0.54); + --color-outline-a72: rgba(139, 152, 167, 0.72); + --color-outline-a78: rgba(139, 152, 167, 0.78); + --color-outline-a92: rgba(139, 152, 167, 0.92); } [data-theme="dark"] { @@ -642,6 +662,16 @@ --color-window-close-hover: #C63B44; --color-selection-bg: rgba(255, 181, 159, 0.32); --color-selection-fg: #FBE7E1; + --color-text-secondary-a72: rgba(183, 170, 165, 0.72); + --color-text-secondary-a68: rgba(183, 170, 165, 0.68); + --color-text-primary-a88: rgba(229, 226, 225, 0.88); + --color-text-primary-a82: rgba(229, 226, 225, 0.82); + --color-text-primary-a78: rgba(229, 226, 225, 0.78); + --color-surface-hover-a34: rgba(53, 53, 52, 0.34); + --color-surface-hover-a54: rgba(53, 53, 52, 0.54); + --color-outline-a72: rgba(141, 127, 122, 0.72); + --color-outline-a78: rgba(141, 127, 122, 0.78); + --color-outline-a92: rgba(141, 127, 122, 0.92); } /* ─── Material Symbols ─────────────────────────────────────────── */ @@ -790,7 +820,7 @@ button, input, textarea, select, a, [role="button"] { } .sidebar-toggle-button { - color: color-mix(in srgb, var(--color-text-secondary) 72%, transparent); + color: var(--color-text-secondary-a72); background: transparent; transition: color 180ms ease, @@ -801,25 +831,25 @@ button, input, textarea, select, a, [role="button"] { } .sidebar-toggle-button:hover { - color: color-mix(in srgb, var(--color-text-primary) 88%, transparent); - background: color-mix(in srgb, var(--color-surface-hover) 34%, transparent); + color: var(--color-text-primary-a88); + background: var(--color-surface-hover-a34); } .sidebar-toggle-button:active { transform: translateY(0.5px); - background: color-mix(in srgb, var(--color-surface-hover) 54%, transparent); + background: var(--color-surface-hover-a54); } .sidebar-toggle-button--open { - color: color-mix(in srgb, var(--color-text-secondary) 68%, transparent); + color: var(--color-text-secondary-a68); } .sidebar-toggle-button--open:hover { - color: color-mix(in srgb, var(--color-text-primary) 82%, transparent); + color: var(--color-text-primary-a82); } .sidebar-toggle-button--collapsed { - color: color-mix(in srgb, var(--color-text-primary) 78%, transparent); + color: var(--color-text-primary-a78); } .sidebar-toggle-button--collapsed:hover { @@ -938,7 +968,7 @@ button, input, textarea, select, a, [role="button"] { .sidebar-scroll-area { scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--color-outline) 72%, transparent) transparent; + scrollbar-color: var(--color-outline-a72) transparent; } .sidebar-scroll-area::-webkit-scrollbar { @@ -950,12 +980,12 @@ button, input, textarea, select, a, [role="button"] { } .sidebar-scroll-area::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--color-outline) 78%, transparent); + background: var(--color-outline-a78); border-radius: 9999px; } .sidebar-scroll-area::-webkit-scrollbar-thumb:hover { - background: color-mix(in srgb, var(--color-outline) 92%, transparent); + background: var(--color-outline-a92); } /* Animations */ diff --git a/desktop/src/theme/globals.test.ts b/desktop/src/theme/globals.test.ts index 965d2e0f..c82c8c4a 100644 --- a/desktop/src/theme/globals.test.ts +++ b/desktop/src/theme/globals.test.ts @@ -41,6 +41,16 @@ describe('desktop theme tokens', () => { '--color-info', '--color-info-container', '--color-warning-container', + '--color-text-secondary-a72', + '--color-text-secondary-a68', + '--color-text-primary-a88', + '--color-text-primary-a82', + '--color-text-primary-a78', + '--color-surface-hover-a34', + '--color-surface-hover-a54', + '--color-outline-a72', + '--color-outline-a78', + '--color-outline-a92', ] it('defines activity and status tokens for every supported theme', () => { @@ -52,4 +62,8 @@ describe('desktop theme tokens', () => { } } }) + + it('avoids color-mix in startup-critical shell chrome for Safari 15 WebView support', () => { + expect(css).not.toContain('color-mix(') + }) }) diff --git a/desktop/vite-config.test.ts b/desktop/vite-config.test.ts new file mode 100644 index 00000000..11187ed4 --- /dev/null +++ b/desktop/vite-config.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const desktopRoot = dirname(fileURLToPath(import.meta.url)) + +describe('desktop build compatibility', () => { + it('keeps production bundles loadable in the macOS 12 Safari 15 WebView', () => { + const config = readFileSync(join(desktopRoot, 'vite.config.ts'), 'utf8') + + expect(config).toContain("target: ['es2021', 'safari15']") + }) + + it('does not rely on CSS color-mix for startup-critical shell chrome', () => { + const css = readFileSync(join(desktopRoot, 'src', 'theme', 'globals.css'), 'utf8') + + expect(css).not.toContain('color-mix(') + expect(css).toContain('--color-text-secondary-a72') + expect(css).toContain('--color-outline-a92') + }) +}) diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 6716896c..6587856c 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -8,6 +8,9 @@ const host = process.env.TAURI_DEV_HOST export default defineConfig({ plugins: [react(), tailwindcss()], build: { + // Vite 8 defaults to baseline-widely-available (safari16.4+), which + // requires macOS 13+. Tauri on macOS 12 uses Safari 15 WebView. + target: ['es2021', 'safari15'], chunkSizeWarningLimit: 2200, rollupOptions: { onwarn(warning, warn) { diff --git a/release-notes/v0.2.2.md b/release-notes/v0.2.2.md index 830166ae..09b6c363 100644 --- a/release-notes/v0.2.2.md +++ b/release-notes/v0.2.2.md @@ -58,7 +58,7 @@ - worktree 启动模式现在可以在 UI 中直接切换,不再需要手动修改配置。 - GitHub Release 正文继续以 `release-notes/v0.2.2.md` 作为来源,发布时无需再手动复制 Markdown。 - Bearer provider auth token 隔离修复属于**安全改进**,升级后本地 API Key 不会再意外被转发给第三方 provider endpoint。 -- 如果升级后遇到应用无法启动或持续白屏,可以尝试删除 `~/.claude/cc-haha/` 目录后重启(注意:这会清除本地 Provider 配置,聊天记录和项目文件不受影响);如果问题由 localStorage 导致,可以在 DevTools Console 执行 `localStorage.clear()` 后刷新。 +- 如果升级后遇到应用无法启动或持续白屏,请优先使用桌面端 Doctor/恢复入口处理可再生的本地 UI 状态;不要手动删除 `~/.claude` 或 `~/.claude/cc-haha/` 目录,避免误删 Provider、OAuth、插件、技能或会话相关配置。 ## 安装说明 diff --git a/scripts/quality-gate/coverage.test.ts b/scripts/quality-gate/coverage.test.ts index cc79287e..a236cf52 100644 --- a/scripts/quality-gate/coverage.test.ts +++ b/scripts/quality-gate/coverage.test.ts @@ -112,6 +112,50 @@ describe('coverage gate helpers', () => { expect(failures).toEqual(['changed-lines: coverage 50% is below minimum 90%']) }) + test('excludes non-instrumented desktop styles from changed-line coverage', () => { + const changedLines = parseChangedLinesFromDiff([ + 'diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css', + '--- a/desktop/src/theme/globals.css', + '+++ b/desktop/src/theme/globals.css', + '@@ -10,0 +11,2 @@', + '+.sidebar {', + '+ color: var(--color-text-primary);', + '+}', + 'diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx', + '--- a/desktop/src/main.tsx', + '+++ b/desktop/src/main.tsx', + '@@ -20,0 +21,1 @@', + '+bootstrapDesktopApp()', + ].join('\n')) + + const result = evaluateChangedLineCoverage( + changedLines, + new Map([ + ['desktop/src/main.tsx', { + suiteId: 'desktop', + executableLines: new Set([21]), + coveredLines: new Set([21]), + }], + ]), + [{ + id: 'desktop', + title: 'Desktop', + includePrefixes: ['desktop/src/'], + excludeSuffixes: ['.css'], + }], + 90, + ) + + expect(result.files).toEqual([{ + file: 'desktop/src/main.tsx', + suiteId: 'desktop', + covered: 1, + total: 1, + pct: 100, + }]) + expect(result.failures).toEqual([]) + }) + test('reports minimum threshold failures', () => { const failures = evaluateThresholds([ { diff --git a/scripts/quality-gate/coverage.ts b/scripts/quality-gate/coverage.ts index 0207763a..39b0e201 100644 --- a/scripts/quality-gate/coverage.ts +++ b/scripts/quality-gate/coverage.ts @@ -141,7 +141,7 @@ const DESKTOP_SCOPE: CoverageScope = { 'desktop/src/mocks/', 'desktop/src/types/', ], - excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts', 'vite-env.d.ts'], + excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts', 'vite-env.d.ts', '.css'], } const CHANGED_LINE_SCOPES = [ @@ -725,7 +725,7 @@ export async function runCoverageGate(options: { const suites: SuiteCoverage[] = [] const coverageByFile = new Map() - const rootCommand = ['bun', 'test', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'root-server'), ...serverFiles] + const rootCommand = ['bun', 'test', '--timeout=20000', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'root-server'), ...serverFiles] const rootLogPath = join(outputDir, 'root-server', 'coverage.log') mkdirSync(join(outputDir, 'root-server'), { recursive: true }) const rootResult = await runCommand(rootCommand, rootDir, rootLogPath) diff --git a/src/server/__tests__/persistence-upgrade.test.ts b/src/server/__tests__/persistence-upgrade.test.ts index ebebddf4..0eeebb3f 100644 --- a/src/server/__tests__/persistence-upgrade.test.ts +++ b/src/server/__tests__/persistence-upgrade.test.ts @@ -88,6 +88,120 @@ describe('persistent storage upgrade migrations', () => { expect(rewritten.providers?.[0]?.extraFutureField).toBe('keep-me') }) + test('imports legacy root providers config into cc-haha storage without deleting the source', async () => { + await fs.writeFile( + path.join(tempDir, 'providers.json'), + JSON.stringify({ + version: 1, + activeModel: 'legacy-sonnet', + providers: [{ + id: 'legacy-provider', + name: 'Legacy Root Provider', + baseUrl: 'https://legacy.example.test', + apiKey: 'legacy-token', + models: [ + { id: 'legacy-haiku', name: 'Legacy Haiku' }, + { id: 'legacy-sonnet', name: 'Legacy Sonnet' }, + ], + isActive: true, + createdAt: 1, + updatedAt: 2, + notes: 'keep note', + }], + }, null, 2), + 'utf-8', + ) + + const report = await ensurePersistentStorageUpgraded() + + expect(report.failures).toEqual([]) + expect(report.migratedEntries).toContain('providers.json -> cc-haha/providers.json') + expect(report.migratedEntries).toContain('providers.json -> cc-haha/settings.json') + expect(JSON.parse(await fs.readFile(path.join(tempDir, 'providers.json'), 'utf-8'))).toMatchObject({ + version: 1, + activeModel: 'legacy-sonnet', + }) + + const migrated = JSON.parse(await fs.readFile(path.join(tempDir, 'cc-haha', 'providers.json'), 'utf-8')) as { + activeId?: string | null + providers?: Array<{ + id?: string + presetId?: string + apiFormat?: string + models?: Record + notes?: string + }> + } + expect(migrated.activeId).toBe('legacy-provider') + expect(migrated.providers?.[0]).toMatchObject({ + id: 'legacy-provider', + presetId: 'custom', + apiFormat: 'anthropic', + notes: 'keep note', + models: { + main: 'legacy-sonnet', + haiku: 'legacy-sonnet', + sonnet: 'legacy-sonnet', + opus: 'legacy-sonnet', + }, + }) + + const managedSettings = JSON.parse(await fs.readFile(path.join(tempDir, 'cc-haha', 'settings.json'), 'utf-8')) as { + env?: Record + } + expect(managedSettings.env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://legacy.example.test', + ANTHROPIC_AUTH_TOKEN: 'legacy-token', + ANTHROPIC_MODEL: 'legacy-sonnet', + }) + + const service = new ProviderService() + const { providers, activeId } = await service.listProviders() + expect(activeId).toBe('legacy-provider') + expect(providers[0]?.models.main).toBe('legacy-sonnet') + }) + + test('does not overwrite current cc-haha provider storage with a legacy root config', async () => { + const ccHahaDir = path.join(tempDir, 'cc-haha') + await fs.mkdir(ccHahaDir, { recursive: true }) + await fs.writeFile( + path.join(tempDir, 'providers.json'), + JSON.stringify({ + version: 1, + activeModel: 'legacy-model', + providers: [{ + id: 'legacy-provider', + name: 'Legacy Root Provider', + baseUrl: 'https://legacy.example.test', + apiKey: 'legacy-token', + models: [{ id: 'legacy-model' }], + isActive: true, + }], + }, null, 2), + 'utf-8', + ) + await fs.writeFile( + path.join(ccHahaDir, 'providers.json'), + JSON.stringify({ + schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, + activeId: null, + providers: [], + }, null, 2), + 'utf-8', + ) + + const report = await ensurePersistentStorageUpgraded() + + expect(report.failures).toEqual([]) + expect(report.migratedEntries).not.toContain('providers.json -> cc-haha/providers.json') + const current = JSON.parse(await fs.readFile(path.join(ccHahaDir, 'providers.json'), 'utf-8')) as { + activeId?: string | null + providers?: unknown[] + } + expect(current.activeId).toBeNull() + expect(current.providers).toEqual([]) + }) + test('does not write repo-owned schema metadata into shared user settings', async () => { await fs.writeFile( path.join(tempDir, 'settings.json'), diff --git a/src/server/services/persistentStorageMigrations.ts b/src/server/services/persistentStorageMigrations.ts index e4188961..fc82e222 100644 --- a/src/server/services/persistentStorageMigrations.ts +++ b/src/server/services/persistentStorageMigrations.ts @@ -11,6 +11,19 @@ type MigrationReport = { } type JsonObject = Record +type LegacyProviderModel = { + id: string + name?: string +} +type LegacyRootProvider = { + id: string + name: string + baseUrl: string + apiKey: string + models: LegacyProviderModel[] + isActive?: boolean + notes?: string +} let migrationPromise: Promise | null = null let migrationConfigDir: string | null = null @@ -45,6 +58,22 @@ function isSavedProvider(value: unknown): value is JsonObject { ) } +function isLegacyProviderModel(value: unknown): value is LegacyProviderModel { + return isRecord(value) && typeof value.id === 'string' +} + +function isLegacyRootProvider(value: unknown): value is LegacyRootProvider { + return ( + isRecord(value) && + typeof value.id === 'string' && + typeof value.name === 'string' && + typeof value.baseUrl === 'string' && + typeof value.apiKey === 'string' && + Array.isArray(value.models) && + value.models.every(isLegacyProviderModel) + ) +} + function errnoCode(error: unknown): string | undefined { return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code @@ -159,10 +188,145 @@ async function migrateJsonEntry( } } +function legacyProviderModelId( + provider: LegacyRootProvider, + preferredModelId: unknown, +): string { + if ( + typeof preferredModelId === 'string' && + provider.models.some((model) => model.id === preferredModelId) + ) { + return preferredModelId + } + + return provider.models[0]?.id ?? '' +} + +function migrateLegacyRootProvidersConfig(value: unknown): JsonObject | null { + if (!isRecord(value) || !Array.isArray(value.providers)) { + return null + } + + const providers = value.providers + .filter(isLegacyRootProvider) + .map((provider) => { + const main = legacyProviderModelId(provider, value.activeModel) + return { + id: provider.id, + presetId: 'custom', + name: provider.name, + apiKey: provider.apiKey, + baseUrl: provider.baseUrl, + apiFormat: 'anthropic', + models: { + main, + haiku: main, + sonnet: main, + opus: main, + }, + ...(provider.notes !== undefined && { notes: provider.notes }), + } + }) + + if (providers.length === 0) { + return null + } + + const activeLegacyProvider = value.providers + .filter(isLegacyRootProvider) + .find((provider) => + provider.isActive === true || + (typeof value.activeModel === 'string' && + provider.models.some((model) => model.id === value.activeModel)), + ) + const activeId = + activeLegacyProvider && providers.some((provider) => provider.id === activeLegacyProvider.id) + ? activeLegacyProvider.id + : null + + return { + schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, + activeId, + providers, + } +} + +function buildManagedSettingsForMigratedProvider(provider: JsonObject | undefined): JsonObject | null { + if (!provider || !isProviderModels(provider.models)) return null + const apiKey = typeof provider.apiKey === 'string' ? provider.apiKey : '' + const baseUrl = typeof provider.baseUrl === 'string' ? provider.baseUrl : '' + if (!apiKey || !baseUrl) return null + + return { + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: apiKey, + ANTHROPIC_MODEL: provider.models.main, + ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, + }, + } +} + +async function migrateLegacyRootProviders( + configDir: string, + ccHahaDir: string, + report: MigrationReport, +): Promise { + const targetPath = path.join(ccHahaDir, 'providers.json') + try { + await fs.access(targetPath) + return + } catch (error) { + if (errnoCode(error) !== 'ENOENT') { + report.failures.push(`cc-haha/providers.json: ${error instanceof Error ? error.message : String(error)}`) + return + } + } + + const legacyPath = path.join(configDir, 'providers.json') + + try { + const legacy = await readJsonFile(legacyPath) + if (legacy.missing) return + + const migrated = migrateLegacyRootProvidersConfig(legacy.value) + if (!migrated) return + + await writeJsonFile(targetPath, migrated) + report.migratedEntries.push('providers.json -> cc-haha/providers.json') + + const settingsPath = path.join(ccHahaDir, 'settings.json') + const settings = await readJsonFile(settingsPath).catch(() => ({ missing: false, value: undefined, raw: '' })) + if (!settings.missing) return + + const activeId = typeof migrated.activeId === 'string' ? migrated.activeId : null + const activeProvider = Array.isArray(migrated.providers) + ? migrated.providers.find((provider) => isRecord(provider) && provider.id === activeId) + : undefined + const managedSettings = buildManagedSettingsForMigratedProvider( + isRecord(activeProvider) ? activeProvider : undefined, + ) + if (managedSettings) { + await writeJsonFile(settingsPath, managedSettings) + report.migratedEntries.push('providers.json -> cc-haha/settings.json') + } + } catch (error) { + if (error instanceof SyntaxError) { + report.failures.push(`providers.json: ${error.message}`) + return + } + report.failures.push(`providers.json: ${error instanceof Error ? error.message : String(error)}`) + } +} + async function runPersistentStorageMigrations(configDir: string): Promise { const report: MigrationReport = { migratedEntries: [], failures: [] } const ccHahaDir = path.join(configDir, 'cc-haha') + await migrateLegacyRootProviders(configDir, ccHahaDir, report) + await migrateJsonEntry( path.join(ccHahaDir, 'providers.json'), 'cc-haha/providers.json', diff --git a/src/utils/plugins/installedPluginsManager.test.ts b/src/utils/plugins/installedPluginsManager.test.ts new file mode 100644 index 00000000..01735f88 --- /dev/null +++ b/src/utils/plugins/installedPluginsManager.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { deletePluginCache } from './installedPluginsManager.js' + +describe('deletePluginCache', () => { + let tempDir: string + let originalConfigDir: string | undefined + let originalPluginCacheDir: string | undefined + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-cache-delete-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + originalPluginCacheDir = process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR + process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude') + delete process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR + }) + + afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + if (originalPluginCacheDir === undefined) { + delete process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR + } else { + process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR = originalPluginCacheDir + } + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + test('refuses to delete paths outside the managed plugin cache', async () => { + const protectedDir = path.join(tempDir, '.claude') + const sentinel = path.join(protectedDir, 'settings.json') + await fs.mkdir(protectedDir, { recursive: true }) + await fs.writeFile(sentinel, '{"keep":true}', 'utf-8') + + expect(() => deletePluginCache(protectedDir)).toThrow( + 'Refusing to delete plugin cache outside managed cache directory', + ) + + await expect(fs.readFile(sentinel, 'utf-8')).resolves.toBe('{"keep":true}') + }) + + test('deletes only versioned directories under the managed plugin cache', async () => { + const versionDir = path.join( + tempDir, + '.claude', + 'plugins', + 'cache', + 'marketplace', + 'plugin', + '1.0.0', + ) + const sentinel = path.join(versionDir, 'plugin.json') + await fs.mkdir(versionDir, { recursive: true }) + await fs.writeFile(sentinel, '{"name":"plugin"}', 'utf-8') + + deletePluginCache(versionDir) + + await expect(fs.stat(versionDir)).rejects.toThrow() + await expect(fs.stat(path.join(tempDir, '.claude'))).resolves.toBeDefined() + }) +}) diff --git a/src/utils/plugins/installedPluginsManager.ts b/src/utils/plugins/installedPluginsManager.ts index f0bf9818..dbaff90e 100644 --- a/src/utils/plugins/installedPluginsManager.ts +++ b/src/utils/plugins/installedPluginsManager.ts @@ -13,7 +13,7 @@ * plugins active). */ -import { dirname, join } from 'path' +import { dirname, join, resolve, sep } from 'path' import { logForDebugging } from '../debug.js' import { errorMessage, isENOENT, toError } from '../errors.js' import { getFsImplementation } from '../fsOperations.js' @@ -962,10 +962,24 @@ export function removeInstalledPlugin( */ export { getGitCommitSha } +function assertPluginCacheDeletionPath(installPath: string): void { + const cachePath = resolve(getPluginCachePath()) + const resolvedInstallPath = resolve(installPath) + if ( + resolvedInstallPath === cachePath || + !resolvedInstallPath.startsWith(cachePath + sep) + ) { + throw new Error( + `Refusing to delete plugin cache outside managed cache directory: ${installPath}`, + ) + } +} + export function deletePluginCache(installPath: string): void { const fs = getFsImplementation() try { + assertPluginCacheDeletionPath(installPath) fs.rmSync(installPath, { recursive: true, force: true }) logForDebugging(`Deleted plugin cache at ${installPath}`) diff --git a/src/utils/plugins/marketplaceManager.test.ts b/src/utils/plugins/marketplaceManager.test.ts new file mode 100644 index 00000000..447c5e8c --- /dev/null +++ b/src/utils/plugins/marketplaceManager.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { + addMarketplaceSource, + clearMarketplacesCache, + getMarketplacesCacheDir, + isStrictMarketplaceCachePath, + refreshMarketplace, +} from './marketplaceManager.js' + +describe('marketplace cache deletion safety', () => { + let tempDir: string + let originalConfigDir: string | undefined + let originalPluginCacheDir: string | undefined + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'marketplace-cache-safe-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + originalPluginCacheDir = process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR + process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude') + delete process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR + clearMarketplacesCache() + }) + + afterEach(async () => { + clearMarketplacesCache() + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + if (originalPluginCacheDir === undefined) { + delete process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR + } else { + process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR = originalPluginCacheDir + } + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + test('treats only strict children of the marketplace cache as removable', async () => { + const cacheDir = getMarketplacesCacheDir() + expect(isStrictMarketplaceCachePath(cacheDir)).toBe(false) + expect(isStrictMarketplaceCachePath(path.join(cacheDir, 'official'))).toBe(true) + expect(isStrictMarketplaceCachePath(path.join(tempDir, '.claude'))).toBe(false) + }) + + test('does not delete the marketplace cache root from corrupted stored state', async () => { + const cacheDir = getMarketplacesCacheDir() + const sentinel = path.join(cacheDir, 'sentinel.txt') + await fs.mkdir(cacheDir, { recursive: true }) + await fs.writeFile(sentinel, 'keep', 'utf-8') + + await fs.writeFile( + path.join(process.env.CLAUDE_CONFIG_DIR!, 'plugins', 'known_marketplaces.json'), + JSON.stringify({ + 'safe-marketplace': { + source: { source: 'github', repo: 'owner/repo' }, + installLocation: cacheDir, + lastUpdated: new Date().toISOString(), + }, + }), + 'utf-8', + ) + + const marketplaceRoot = path.join(tempDir, 'local-marketplace') + const marketplaceJson = path.join(marketplaceRoot, '.claude-plugin', 'marketplace.json') + await fs.mkdir(path.dirname(marketplaceJson), { recursive: true }) + await fs.writeFile( + marketplaceJson, + JSON.stringify({ + name: 'safe-marketplace', + owner: { name: 'Test' }, + plugins: [], + }), + 'utf-8', + ) + + await addMarketplaceSource({ source: 'file', path: marketplaceJson }) + + await expect(fs.readFile(sentinel, 'utf-8')).resolves.toBe('keep') + }) + + test('refuses to refresh a remote marketplace whose installLocation is the cache root', async () => { + const cacheDir = getMarketplacesCacheDir() + const sentinel = path.join(cacheDir, 'sentinel.txt') + await fs.mkdir(cacheDir, { recursive: true }) + await fs.writeFile(sentinel, 'keep', 'utf-8') + + await fs.writeFile( + path.join(process.env.CLAUDE_CONFIG_DIR!, 'plugins', 'known_marketplaces.json'), + JSON.stringify({ + unsafe: { + source: { source: 'github', repo: 'owner/repo' }, + installLocation: cacheDir, + lastUpdated: new Date().toISOString(), + }, + }), + 'utf-8', + ) + + await expect(refreshMarketplace('unsafe')).rejects.toThrow( + 'has a corrupted installLocation', + ) + await expect(fs.readFile(sentinel, 'utf-8')).resolves.toBe('keep') + }) +}) diff --git a/src/utils/plugins/marketplaceManager.ts b/src/utils/plugins/marketplaceManager.ts index c7c84b61..07c59ac3 100644 --- a/src/utils/plugins/marketplaceManager.ts +++ b/src/utils/plugins/marketplaceManager.ts @@ -111,6 +111,12 @@ export function getMarketplacesCacheDir(): string { return join(getPluginsDirectory(), 'marketplaces') } +export function isStrictMarketplaceCachePath(targetPath: string): boolean { + const cacheDir = resolve(getMarketplacesCacheDir()) + const resolvedTarget = resolve(targetPath) + return resolvedTarget.startsWith(cacheDir + sep) +} + /** * Memoized inner function to get marketplace data. * This caches the marketplace in memory after loading from disk or network. @@ -1887,19 +1893,16 @@ export async function addMarketplaceSource( // any) is harmless, and blocking the re-add would prevent the user from // fixing the corruption. if (!isLocalMarketplaceSource(oldEntry.source)) { - const cacheDir = resolve(getMarketplacesCacheDir()) const resolvedOld = resolve(oldEntry.installLocation) const resolvedNew = resolve(cachePath) if (resolvedOld === resolvedNew) { // Same dir — loadAndCacheMarketplace already overwrote in place. // Nothing to clean. - } else if ( - resolvedOld === cacheDir || - resolvedOld.startsWith(cacheDir + sep) - ) { + } else if (isStrictMarketplaceCachePath(oldEntry.installLocation)) { const fs = getFsImplementation() await fs.rm(oldEntry.installLocation, { recursive: true, force: true }) } else { + const cacheDir = resolve(getMarketplacesCacheDir()) logForDebugging( `Skipping cleanup of old installLocation (${oldEntry.installLocation}) — ` + `outside ${cacheDir}. The path is corrupted; leaving it alone and ` + @@ -2413,8 +2416,7 @@ export async function refreshMarketplace( // Refuse instead of auto-fixing so the user knows their state is corrupted. if (!isLocalMarketplaceSource(source)) { const cacheDir = resolve(getMarketplacesCacheDir()) - const resolvedLoc = resolve(installLocation) - if (resolvedLoc !== cacheDir && !resolvedLoc.startsWith(cacheDir + sep)) { + if (!isStrictMarketplaceCachePath(installLocation)) { throw new Error( `Marketplace '${name}' has a corrupted installLocation ` + `(${installLocation}) — expected a path inside ${cacheDir}. ` +