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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 18:04:02 +08:00
parent 188214918b
commit e070c4a0d0
17 changed files with 853 additions and 36 deletions

View File

@ -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')
})
})

View File

@ -8,7 +8,96 @@
<style>
/* Prevent a warm flash before the theme is hydrated. */
html { background: #FFFFFF; }
html, body, #root { min-height: 100%; margin: 0; }
</style>
<script>
(function () {
var bootDeadlineMs = 8000
function summarize(value) {
if (!value) return 'Unknown startup error'
if (typeof value === 'string') return value
if (value.message) return value.message
try {
return JSON.stringify(value)
} catch (_) {
return String(value)
}
}
function renderStartupError(reason) {
if (window.__CC_HAHA_BOOTSTRAPPED__) return
var root = document.getElementById('root')
if (!root) return
var summary = summarize(reason)
var details = [
summary,
'',
'URL: ' + window.location.href,
'User Agent: ' + navigator.userAgent,
'Time: ' + new Date().toISOString()
].join('\n')
root.innerHTML = ''
var shell = document.createElement('main')
shell.style.cssText = [
'box-sizing:border-box',
'min-height:100vh',
'padding:32px',
'background:#fff',
'color:#151515',
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'display:flex',
'align-items:center',
'justify-content:center'
].join(';')
var panel = document.createElement('section')
panel.style.cssText = 'width:min(760px,100%);border:1px solid #d8d8d8;border-radius:8px;padding:24px;background:#fff'
var title = document.createElement('h1')
title.textContent = 'Desktop startup failed'
title.style.cssText = 'margin:0 0 8px;font-size:20px;line-height:1.3;font-weight:700;color:#111'
var body = document.createElement('p')
body.textContent = 'The app could not finish booting. Keep this text when reporting the issue.'
body.style.cssText = 'margin:0 0 16px;font-size:14px;line-height:1.6;color:#555'
var pre = document.createElement('pre')
pre.textContent = details
pre.style.cssText = 'box-sizing:border-box;max-height:360px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:0;padding:14px;border-radius:6px;background:#f6f6f6;color:#222;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace'
panel.appendChild(title)
panel.appendChild(body)
panel.appendChild(pre)
shell.appendChild(panel)
root.appendChild(shell)
}
window.__CC_HAHA_SHOW_STARTUP_ERROR__ = renderStartupError
window.addEventListener('error', function (event) {
var target = event.target
if (target && target !== window) {
var tagName = target.tagName
if (tagName === 'SCRIPT' || tagName === 'LINK') {
renderStartupError('Startup resource failed to load: ' + (target.src || target.href || tagName))
}
return
}
renderStartupError(event.message || event.error || 'Startup script error')
}, true)
window.addEventListener('unhandledrejection', function (event) {
renderStartupError(event.reason || 'Unhandled startup promise rejection')
})
window.setTimeout(function () {
renderStartupError('Desktop app did not finish bootstrapping within ' + bootDeadlineMs + 'ms. This usually means the WebView could not execute the app bundle or startup code stopped before React mounted.')
}, bootDeadlineMs)
})()
</script>
</head>
<body>
<div id="root"></div>

83
desktop/src/main.test.tsx Normal file
View File

@ -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: () => <div>Auto boot app</div>,
}))
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 = '<div id="root"></div>'
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))
})
})

View File

@ -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<DesktopBootstrapModules> = 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(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
)
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(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
)
void bootstrapDesktopApp()

View File

@ -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 */

View File

@ -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(')
})
})

View File

@ -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')
})
})

View File

@ -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) {

View File

@ -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、插件、技能或会话相关配置
## 安装说明

View File

@ -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([
{

View File

@ -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<string, FileLineCoverage>()
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)

View File

@ -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<string, string>
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<string, string>
}
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'),

View File

@ -11,6 +11,19 @@ type MigrationReport = {
}
type JsonObject = Record<string, unknown>
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<MigrationReport> | 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<void> {
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<MigrationReport> {
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',

View File

@ -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()
})
})

View File

@ -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}`)

View File

@ -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')
})
})

View File

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