From 3a39cb74c02b2a40a6f1051ce5a47eab2ebf6aa2 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 14:36:22 +0800 Subject: [PATCH 1/4] fix(#340): migrate legacy credentials from homedir when globalRoot differs When CAT_CAFE_GLOBAL_CONFIG_ROOT is unset and projectRoot != homedir, the legacy migration only searched projectRoot for provider-profiles secrets, missing API keys written to ~/.cat-cafe/ by the pre-F340 installer (which defaulted to homedir without --project-dir). Add migrateHomedirLegacyProviderProfiles() as a third migration source in ensureMigrated(), and migrateAllLegacySources() in the installer. Both are best-effort: corrupt homedir files log warnings but don't block startup. Co-Authored-By: Claude Opus 4.6 --- packages/api/src/config/catalog-accounts.ts | 25 ++++++++++ packages/api/test/account-startup.test.js | 5 ++ packages/api/test/catalog-accounts.test.js | 50 +++++++++++++++++++ .../__tests__/project-setup-card-ime.test.tsx | 8 +-- scripts/install-auth-config.mjs | 24 ++++++--- 5 files changed, 99 insertions(+), 13 deletions(-) diff --git a/packages/api/src/config/catalog-accounts.ts b/packages/api/src/config/catalog-accounts.ts index 73f53f9a0..d6e9df467 100644 --- a/packages/api/src/config/catalog-accounts.ts +++ b/packages/api/src/config/catalog-accounts.ts @@ -329,15 +329,40 @@ function migrateProjectAccountsToGlobal(projectRoot: string): void { } } +// ── Homedir legacy migration (picks up secrets written by pre-F340 installer without --project-dir) ── + +let homedirLegacyDone = false; + +function migrateHomedirLegacyProviderProfiles(projectRoot?: string): void { + if (homedirLegacyDone) return; + const globalRoot = resolveGlobalRoot(projectRoot); + const home = homedir(); + if (resolve(globalRoot) === resolve(home)) { + // Global root IS homedir — already covered by migrateLegacyProviderProfiles. + homedirLegacyDone = true; + return; + } + try { + migrateLegacyFrom(home, projectRoot); + homedirLegacyDone = true; + } catch (err) { + // Best-effort: don't block startup if homedir legacy files are corrupt. + console.error('[catalog-accounts] homedir legacy→global migration failed:', err); + homedirLegacyDone = true; + } +} + function ensureMigrated(projectRoot: string): void { migrateLegacyProviderProfiles(projectRoot); migrateProjectLegacyProviderProfiles(projectRoot); + migrateHomedirLegacyProviderProfiles(projectRoot); migrateProjectAccountsToGlobal(projectRoot); } /** Reset migration state (for tests). */ export function resetMigrationState(): void { legacyMigrationDone = false; + homedirLegacyDone = false; migratedProjects.clear(); migratedProjectLegacy.clear(); } diff --git a/packages/api/test/account-startup.test.js b/packages/api/test/account-startup.test.js index ed18ef3d4..106029cb6 100644 --- a/packages/api/test/account-startup.test.js +++ b/packages/api/test/account-startup.test.js @@ -8,12 +8,16 @@ describe('accountStartupHook (F340 fail-fast)', () => { let globalRoot; let projectRoot; let previousGlobalRoot; + let previousHome; beforeEach(async () => { globalRoot = await mkdtemp(join(tmpdir(), 'acct-startup-')); projectRoot = await mkdtemp(join(tmpdir(), 'acct-startup-proj-')); previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + previousHome = process.env.HOME; process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot; + // Isolate homedir so the homedir migration doesn't pick up real ~/.cat-cafe/ files + process.env.HOME = globalRoot; await mkdir(join(globalRoot, '.cat-cafe'), { recursive: true }); await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true }); }); @@ -21,6 +25,7 @@ describe('accountStartupHook (F340 fail-fast)', () => { afterEach(async () => { if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; + process.env.HOME = previousHome; await rm(globalRoot, { recursive: true, force: true }); await rm(projectRoot, { recursive: true, force: true }); }); diff --git a/packages/api/test/catalog-accounts.test.js b/packages/api/test/catalog-accounts.test.js index 18913849b..414f2d74e 100644 --- a/packages/api/test/catalog-accounts.test.js +++ b/packages/api/test/catalog-accounts.test.js @@ -9,12 +9,16 @@ describe('global accounts (F340)', () => { let globalRoot; let projectRoot; let previousGlobalRoot; + let previousHome; beforeEach(async () => { globalRoot = await mkdtemp(join(tmpdir(), 'global-accounts-')); projectRoot = await mkdtemp(join(tmpdir(), 'project-accounts-')); previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + previousHome = process.env.HOME; process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot; + // Isolate homedir so the homedir migration doesn't pick up real ~/.cat-cafe/ files + process.env.HOME = globalRoot; await mkdir(join(globalRoot, '.cat-cafe'), { recursive: true }); await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true }); }); @@ -22,6 +26,7 @@ describe('global accounts (F340)', () => { afterEach(async () => { if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; + process.env.HOME = previousHome; await rm(globalRoot, { recursive: true, force: true }); await rm(projectRoot, { recursive: true, force: true }); }); @@ -506,4 +511,49 @@ describe('global accounts (F340)', () => { assert.equal(creds.shared, undefined, 'legacy secret must NOT be attached to different-source api_key account'); } }); + + it('migrates legacy credentials from homedir when globalRoot differs from homedir', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); + + // Simulate: CAT_CAFE_GLOBAL_CONFIG_ROOT is unset, projectRoot != homedir. + // Old installer (pre-F340) wrote secrets to homedir when --project-dir was not given. + delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + + const fakeHome = await mkdtemp(join(tmpdir(), 'fake-home-')); + await mkdir(join(fakeHome, '.cat-cafe'), { recursive: true }); + const savedHome = process.env.HOME; + process.env.HOME = fakeHome; + + try { + // Legacy provider-profiles + secrets in homedir (old installer output) + await writeFile( + join(fakeHome, '.cat-cafe', 'provider-profiles.json'), + JSON.stringify({ + version: 2, + providers: [{ id: 'homedir-account', authType: 'api_key', baseUrl: 'https://home.api/v1' }], + }), + 'utf-8', + ); + await writeFile( + join(fakeHome, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify({ profiles: { 'homedir-account': { apiKey: 'sk-from-homedir' } } }), + 'utf-8', + ); + + // projectRoot is a separate directory — no legacy files there + const result = readCatalogAccounts(projectRoot); + assert.equal(result['homedir-account']?.baseUrl, 'https://home.api/v1', 'account from homedir must be migrated'); + + // Credentials should be migrated to globalRoot (= projectRoot when env unset) + const credRaw = await readFile(join(projectRoot, '.cat-cafe', 'credentials.json'), 'utf-8'); + const creds = JSON.parse(credRaw); + assert.equal(creds['homedir-account']?.apiKey, 'sk-from-homedir', 'API key from homedir must be migrated'); + } finally { + process.env.HOME = savedHome; + // Restore env for subsequent tests + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot; + await rm(fakeHome, { recursive: true, force: true }); + } + }); }); diff --git a/packages/web/src/components/__tests__/project-setup-card-ime.test.tsx b/packages/web/src/components/__tests__/project-setup-card-ime.test.tsx index 6a8bd8ac2..f27f03332 100644 --- a/packages/web/src/components/__tests__/project-setup-card-ime.test.tsx +++ b/packages/web/src/components/__tests__/project-setup-card-ime.test.tsx @@ -40,13 +40,7 @@ describe('ProjectSetupCard IME guard', () => { await act(async () => { root.render( - , + , ); }); diff --git a/scripts/install-auth-config.mjs b/scripts/install-auth-config.mjs index 10bbcae7e..862acf040 100644 --- a/scripts/install-auth-config.mjs +++ b/scripts/install-auth-config.mjs @@ -336,6 +336,21 @@ function migrateLegacyProfiles(projectDir) { } } +/** Run all legacy migration sources: projectDir, globalRoot, and homedir (if different). */ +function migrateAllLegacySources(projectDir) { + if (projectDir) migrateLegacyProfiles(projectDir); + migrateLegacyProfiles(null); // reads from globalDir() = resolveGlobalRoot() + // Also try homedir: pre-F340 installer without --project-dir wrote there. + const home = homedir(); + if (path.resolve(resolveGlobalRoot()) !== path.resolve(home)) { + try { + migrateLegacyProfiles(home); + } catch { + // Best-effort: don't block operation if homedir legacy files are corrupt. + } + } +} + // ── Commands ── function setClientAuth(client, mode, options) { @@ -453,8 +468,7 @@ try { // Migrate legacy files before applying const projDir = getOptional(values, 'project-dir', ''); _activeProjectDir = projDir; - if (projDir) migrateLegacyProfiles(projDir); - migrateLegacyProfiles(null); + migrateAllLegacySources(projDir); const mode = getRequired(values, 'mode'); if (mode === 'oauth') { setClientAuth(client, 'oauth', {}); @@ -486,8 +500,7 @@ try { const projectDir = getRequired(values, 'project-dir'); _activeProjectDir = projectDir; // Migrate legacy files before removal so accounts/credentials are in global store - if (projectDir) migrateLegacyProfiles(projectDir); - migrateLegacyProfiles(null); + migrateAllLegacySources(projectDir); const force = values.get('force')?.[0] === 'true'; removeClientAuth(client, `installer-${client}`, projectDir, { force }); process.exit(0); @@ -497,8 +510,7 @@ try { const projectDir = getOptional(values, 'project-dir', ''); _activeProjectDir = projectDir; // Migrate legacy files before applying new setting - if (projectDir) migrateLegacyProfiles(projectDir); - migrateLegacyProfiles(null); + migrateAllLegacySources(projectDir); const apiKey = getOptional(values, 'api-key', '') || process.env._INSTALLER_API_KEY || ''; if (!apiKey) { console.error('Error: API key required via --api-key or _INSTALLER_API_KEY env var'); From d1b863adff6312328f82238f31c74d4b2427485a Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 14:42:18 +0800 Subject: [PATCH 2/4] fix: use per-target Set for homedir migration cache (multi-project support) homedirLegacyDone was a single boolean but the migration target changes with projectRoot. In a multi-project process, only the first project got homedir credentials. Changed to Set keyed by resolved globalRoot, matching migratedProjectLegacy pattern. Added multi-project regression test. Also fixed HOME isolation in account-resolver and account-startup tests to prevent real ~/.cat-cafe/ files from interfering. Co-Authored-By: Claude Opus 4.6 --- packages/api/src/config/catalog-accounts.ts | 15 +++--- packages/api/test/account-resolver.test.js | 10 +++- packages/api/test/catalog-accounts.test.js | 51 +++++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/api/src/config/catalog-accounts.ts b/packages/api/src/config/catalog-accounts.ts index d6e9df467..923822993 100644 --- a/packages/api/src/config/catalog-accounts.ts +++ b/packages/api/src/config/catalog-accounts.ts @@ -331,24 +331,25 @@ function migrateProjectAccountsToGlobal(projectRoot: string): void { // ── Homedir legacy migration (picks up secrets written by pre-F340 installer without --project-dir) ── -let homedirLegacyDone = false; +const migratedHomedirLegacy = new Set(); function migrateHomedirLegacyProviderProfiles(projectRoot?: string): void { - if (homedirLegacyDone) return; const globalRoot = resolveGlobalRoot(projectRoot); + const resolvedTarget = resolve(globalRoot); + if (migratedHomedirLegacy.has(resolvedTarget)) return; const home = homedir(); - if (resolve(globalRoot) === resolve(home)) { + if (resolvedTarget === resolve(home)) { // Global root IS homedir — already covered by migrateLegacyProviderProfiles. - homedirLegacyDone = true; + migratedHomedirLegacy.add(resolvedTarget); return; } try { migrateLegacyFrom(home, projectRoot); - homedirLegacyDone = true; + migratedHomedirLegacy.add(resolvedTarget); } catch (err) { // Best-effort: don't block startup if homedir legacy files are corrupt. console.error('[catalog-accounts] homedir legacy→global migration failed:', err); - homedirLegacyDone = true; + migratedHomedirLegacy.add(resolvedTarget); } } @@ -362,7 +363,7 @@ function ensureMigrated(projectRoot: string): void { /** Reset migration state (for tests). */ export function resetMigrationState(): void { legacyMigrationDone = false; - homedirLegacyDone = false; + migratedHomedirLegacy.clear(); migratedProjects.clear(); migratedProjectLegacy.clear(); } diff --git a/packages/api/test/account-resolver.test.js b/packages/api/test/account-resolver.test.js index ed0c84abe..0d0c4b544 100644 --- a/packages/api/test/account-resolver.test.js +++ b/packages/api/test/account-resolver.test.js @@ -7,7 +7,13 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; describe('account-resolver (4b unified runtime resolution)', () => { let projectRoot; let previousGlobalRoot; - const ENV_KEYS_TO_ISOLATE = ['CAT_CAFE_GLOBAL_CONFIG_ROOT', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; + const ENV_KEYS_TO_ISOLATE = [ + 'CAT_CAFE_GLOBAL_CONFIG_ROOT', + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', + 'GOOGLE_API_KEY', + 'HOME', + ]; const savedEnv = {}; beforeEach(async () => { @@ -18,6 +24,8 @@ describe('account-resolver (4b unified runtime resolution)', () => { delete process.env[key]; } process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = projectRoot; + // Isolate homedir so the homedir migration doesn't pick up real ~/.cat-cafe/ files + process.env.HOME = projectRoot; await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true }); }); diff --git a/packages/api/test/catalog-accounts.test.js b/packages/api/test/catalog-accounts.test.js index 414f2d74e..fca317bcd 100644 --- a/packages/api/test/catalog-accounts.test.js +++ b/packages/api/test/catalog-accounts.test.js @@ -556,4 +556,55 @@ describe('global accounts (F340)', () => { await rm(fakeHome, { recursive: true, force: true }); } }); + + it('migrates homedir credentials to multiple projects in the same process', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); + + delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + + const fakeHome = await mkdtemp(join(tmpdir(), 'fake-home-')); + await mkdir(join(fakeHome, '.cat-cafe'), { recursive: true }); + const projectB = await mkdtemp(join(tmpdir(), 'project-b-')); + await mkdir(join(projectB, '.cat-cafe'), { recursive: true }); + const savedHome = process.env.HOME; + process.env.HOME = fakeHome; + + try { + await writeFile( + join(fakeHome, '.cat-cafe', 'provider-profiles.json'), + JSON.stringify({ + version: 2, + providers: [{ id: 'homedir-account', authType: 'api_key', baseUrl: 'https://home.api/v1' }], + }), + 'utf-8', + ); + await writeFile( + join(fakeHome, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify({ profiles: { 'homedir-account': { apiKey: 'sk-from-homedir' } } }), + 'utf-8', + ); + + // First project migrates successfully + const resultA = readCatalogAccounts(projectRoot); + assert.equal(resultA['homedir-account']?.baseUrl, 'https://home.api/v1', 'projectA must get homedir account'); + + // Second project must ALSO get the homedir credentials (not skipped by boolean cache) + const resultB = readCatalogAccounts(projectB); + assert.equal( + resultB['homedir-account']?.baseUrl, + 'https://home.api/v1', + 'projectB must also get homedir account', + ); + + const credRawB = await readFile(join(projectB, '.cat-cafe', 'credentials.json'), 'utf-8'); + const credsB = JSON.parse(credRawB); + assert.equal(credsB['homedir-account']?.apiKey, 'sk-from-homedir', 'projectB must also get homedir API key'); + } finally { + process.env.HOME = savedHome; + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot; + await rm(fakeHome, { recursive: true, force: true }); + await rm(projectB, { recursive: true, force: true }); + } + }); }); From af955a286330bbbb314b2c72a794151bf58cea99 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 15:02:40 +0800 Subject: [PATCH 3/4] fix: re-throw non-corrupt homedir migration failures Only swallow SyntaxError/ENOENT (corrupt/missing source files) in the homedir migration catch block. Account conflicts and other migration errors now propagate for fail-fast behavior. Co-Authored-By: Claude Opus 4.6 --- packages/api/src/config/catalog-accounts.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/api/src/config/catalog-accounts.ts b/packages/api/src/config/catalog-accounts.ts index 923822993..fbe7d16fb 100644 --- a/packages/api/src/config/catalog-accounts.ts +++ b/packages/api/src/config/catalog-accounts.ts @@ -347,9 +347,14 @@ function migrateHomedirLegacyProviderProfiles(projectRoot?: string): void { migrateLegacyFrom(home, projectRoot); migratedHomedirLegacy.add(resolvedTarget); } catch (err) { - // Best-effort: don't block startup if homedir legacy files are corrupt. - console.error('[catalog-accounts] homedir legacy→global migration failed:', err); - migratedHomedirLegacy.add(resolvedTarget); + // Only swallow parse/read errors (corrupt homedir files). Re-throw account + // conflicts and other migration errors so callers get a fail-fast signal. + if (err instanceof SyntaxError || (err instanceof Error && err.message.includes('ENOENT'))) { + console.error('[catalog-accounts] homedir legacy→global migration failed (corrupt source, skipped):', err); + migratedHomedirLegacy.add(resolvedTarget); + } else { + throw err; + } } } From 34f3c624854fc1149da9f7f3e3d6e8129d355b21 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 15:09:19 +0800 Subject: [PATCH 4/4] fix(test): use delete for HOME env restore when originally unset Co-Authored-By: Claude Opus 4.6 --- packages/api/test/account-startup.test.js | 3 ++- packages/api/test/catalog-accounts.test.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/api/test/account-startup.test.js b/packages/api/test/account-startup.test.js index 106029cb6..c3b3dff95 100644 --- a/packages/api/test/account-startup.test.js +++ b/packages/api/test/account-startup.test.js @@ -25,7 +25,8 @@ describe('accountStartupHook (F340 fail-fast)', () => { afterEach(async () => { if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; - process.env.HOME = previousHome; + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; await rm(globalRoot, { recursive: true, force: true }); await rm(projectRoot, { recursive: true, force: true }); }); diff --git a/packages/api/test/catalog-accounts.test.js b/packages/api/test/catalog-accounts.test.js index fca317bcd..dbe383b69 100644 --- a/packages/api/test/catalog-accounts.test.js +++ b/packages/api/test/catalog-accounts.test.js @@ -26,7 +26,8 @@ describe('global accounts (F340)', () => { afterEach(async () => { if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; - process.env.HOME = previousHome; + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; await rm(globalRoot, { recursive: true, force: true }); await rm(projectRoot, { recursive: true, force: true }); });