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}. ` +