diff --git a/.ai-run/guides/usage/project-config.md b/.ai-run/guides/usage/project-config.md index 2c4a0dc5..bf69507d 100644 --- a/.ai-run/guides/usage/project-config.md +++ b/.ai-run/guides/usage/project-config.md @@ -137,6 +137,8 @@ codemie-kimi --profile kimi # uses global kimi profile + local project f codemie-claude --profile anthropic # uses global anthropic profile + local project fields ``` +> **URL precondition.** Project-context preservation (`codeMieProject`, `codeMieIntegration`, `codeMieUrl`) applies only when the selected global profile and the local team profile target the same `codeMieUrl` (compared after stripping trailing slashes and lower-casing). When the URLs differ, the user is switching CodeMie environments and the team's project/integration IDs would reference the wrong env's records — so the local project context is dropped and the selected global profile supplies everything. This is enforced in `ConfigLoader.load()` and `ConfigLoader.loadWithSources()`. + ### CI/CD overrides ```bash diff --git a/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/decisions.jsonl b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/decisions.jsonl new file mode 100644 index 00000000..43132103 --- /dev/null +++ b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/decisions.jsonl @@ -0,0 +1 @@ +{"ts":"2026-06-29T13:50:00Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved via HITL prompt: clean gates, 30/30 tests, 4 conformant commits, gate logic + tests + doc note match the plan.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"question":"Code-review verdict for fix/cross-env-project-fields-leak?","options":["Approve","Request changes","Abort"],"phase":5,"risk_flags":[],"artifact_refs":["docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/plan.md","git diff main...HEAD"]}} diff --git a/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/events.jsonl b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/events.jsonl new file mode 100644 index 00000000..02465223 --- /dev/null +++ b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/events.jsonl @@ -0,0 +1 @@ +{"schema":1,"ts":"2026-06-29T13:50:00Z","event":"decision.recorded","run_id":"cross-env-project-fields-leak","phase":5,"actor":"decision-router","summary":"Decision recorded for code-review.final: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} diff --git a/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/gate-plan.json b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/gate-plan.json new file mode 100644 index 00000000..8b83f8e3 --- /dev/null +++ b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/gate-plan.json @@ -0,0 +1,18 @@ +{ + "schema": 1, + "runner": "npm", + "source": "guide", + "guide_path": ".ai-run/guides/quality-gates.md", + "gates": [ + { "id": "license-check", "command": "npm run license-check", "available": true }, + { "id": "lint", "command": "npm run lint", "available": true }, + { "id": "typecheck", "command": "npm run typecheck", "available": true }, + { "id": "build", "command": "npm run build", "available": true }, + { "id": "unit", "command": "npm run test:unit", "available": true }, + { "id": "integration", "command": "npm run test:integration", "available": true }, + { "id": "commitlint", "command": "npm run commitlint:last", "available": true }, + { "id": "ui", "command": "(n/a — no UI surface)", "available": false } + ], + "ui_globs": ["\\.(tsx|jsx|css|html|vue|svelte)$", "src/(ui|frontend|components)/"], + "detected_at": "2026-06-29T13:55:00Z" +} diff --git a/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/plan.md b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/plan.md new file mode 100644 index 00000000..622e258c --- /dev/null +++ b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/plan.md @@ -0,0 +1,587 @@ +# Cross-env Profile Leak in ConfigLoader Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop `ConfigLoader` from preserving the local team profile's `codeMieProject`, `codeMieIntegration`, and `codeMieUrl` when `--profile ` targets a different CodeMie environment than the team's local `activeProfile`. + +**Architecture:** Add a private static URL-equality gate on top of the existing `applyProjectOnly` branch in `ConfigLoader.load()`. Apply the same gate at the parallel call site in `ConfigLoader.loadWithSources()`, hoisting `loadGlobalConfigProfile` so the comparison value is in scope before the filter decision. `PROJECT_FIELDS` and `filterProjectFields` are unchanged. + +**Tech Stack:** TypeScript (ES modules, `.js` import extension required), Vitest, Node ≥ 20, `valtio` not used here. Existing test patterns: real temp dirs under `process.cwd()/tmp-test-config/`, `vi.spyOn(paths, 'getCodemieHome')` for global-config redirection. + +## Global Constraints + +- Inline URL normalization: `url.replace(/\/+$/, '').toLowerCase()`. Do NOT import `ensureApiBase` from `src/providers/core/codemie-auth-helpers.ts` — it appends `/code-assistant-api` and would create a cross-layer dependency. +- Gate triggers ONLY when both URLs are non-empty AND normalized-differ. All other combinations (either side empty, both empty, both equal) preserve project context as today. +- All three `PROJECT_FIELDS` (`codeMieProject`, `codeMieIntegration`, `codeMieUrl`) drop atomically as a bundle — never partially. +- `load()` and `loadWithSources()` must produce parity for the same inputs (the final merged config is identical). +- Test framework: Vitest. New tests must follow the existing `tmp-test-config` / `vi.spyOn` pattern in `src/utils/__tests__/config-project-override.test.ts`. +- ES-module imports require the `.js` extension (TypeScript convention in this repo). +- No new public API on `ConfigLoader`. The gate is a `private static` helper. +- Commit message format: Conventional Commits (`fix(config): ...`). No ticket prefix on the type branch (per repo `git-workflow.md`). +- Tests only on explicit request — but this task IS an explicit test request (TDD is mandated by sdlc-light). + +--- + +## File Structure + +| Path | Action | Responsibility | +|---|---|---| +| `src/utils/config.ts` | Modify | Add `shouldPreserveProjectContext` helper; wire gate into `load()` and `loadWithSources()`. | +| `src/utils/__tests__/config-project-override.test.ts` | Modify | Append a new `describe('ConfigLoader - cross-env URL gate', ...)` block with helper-unit tests and integration tests. | +| `.ai-run/guides/usage/project-config.md` | Modify | Add a URL-equality note under the "Team profile with personal provider" pattern. | + +No new files. No file deletions. + +--- + +## Task 1: Add `shouldPreserveProjectContext` private helper + +**Files:** +- Modify: `src/utils/config.ts` — add helper near existing `filterProjectFields` (~line 363) and the `PROJECT_FIELDS` constant (~line 353). +- Test: `src/utils/__tests__/config-project-override.test.ts` — append new describe block. + +**Interfaces:** +- Produces: `private static shouldPreserveProjectContext(localUrl: string | undefined, globalUrl: string | undefined): boolean` — true iff URL pair does NOT indicate a cross-env conflict. Tasks 2 and 3 consume this exact signature. + +**Test-first: yes — six failing tests for the helper covering same URL, different URL, trailing slash, case difference, both undefined, one undefined.** + +- [ ] **Step 1: Open the test file and locate the closing `});` of the existing root `describe`** + +Locate the final `});` that closes `describe('ConfigLoader - Project-Level Configuration', ...)`. The new describe block sits AFTER that closing — as a sibling, not nested. (Sibling avoids re-running the `beforeEach`/`afterEach` filesystem setup for pure-function tests.) + +- [ ] **Step 2: Write the failing helper unit tests** + +Append to `src/utils/__tests__/config-project-override.test.ts`: + +```typescript +describe('ConfigLoader - cross-env URL gate', () => { + describe('shouldPreserveProjectContext', () => { + // Helper is private — access via index signature cast for unit testing. + // This is the established pattern when a Vitest suite needs to reach a + // class-private static. No production code reads it this way. + const gate = (l: string | undefined, g: string | undefined): boolean => + (ConfigLoader as unknown as { + shouldPreserveProjectContext: (l?: string, g?: string) => boolean; + }).shouldPreserveProjectContext(l, g); + + it('preserves when both URLs are equal', () => { + expect(gate('https://prod.example.com', 'https://prod.example.com')).toBe(true); + }); + + it('preserves when URLs differ only by trailing slash', () => { + expect(gate('https://prod.example.com/', 'https://prod.example.com')).toBe(true); + }); + + it('preserves when URLs differ only by case', () => { + expect(gate('https://PROD.example.com', 'https://prod.example.com')).toBe(true); + }); + + it('drops when URLs differ on host', () => { + expect(gate('https://prod.example.com', 'https://preview.example.com')).toBe(false); + }); + + it('preserves when local URL is undefined', () => { + expect(gate(undefined, 'https://preview.example.com')).toBe(true); + }); + + it('preserves when global URL is undefined', () => { + expect(gate('https://prod.example.com', undefined)).toBe(true); + }); + + it('preserves when both URLs are undefined', () => { + expect(gate(undefined, undefined)).toBe(true); + }); + + it('preserves when local URL is empty string', () => { + expect(gate('', 'https://preview.example.com')).toBe(true); + }); + + it('preserves when global URL is empty string', () => { + expect(gate('https://prod.example.com', '')).toBe(true); + }); + }); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts -t "shouldPreserveProjectContext"` +Expected: All 9 tests fail with `TypeError: ConfigLoader.shouldPreserveProjectContext is not a function` (helper doesn't exist yet). + +- [ ] **Step 4: Implement the helper in `src/utils/config.ts`** + +Locate `PROJECT_FIELDS` at line 353. Add the helper RIGHT BEFORE `private static filterProjectFields(...)` at line 363 (so the two project-context helpers sit together): + +```typescript + /** + * Returns true when the local team profile's project context (codeMieProject, + * codeMieIntegration, codeMieUrl) is safe to compose with the selected global + * profile. The composition is only safe when both profiles target the same + * CodeMie environment — otherwise the local project/integration IDs reference + * the wrong env's database rows and the URL is outright wrong. + * + * The gate is conservative: it only blocks composition when both URLs are + * explicitly set and normalized-differ. A missing URL on either side is + * treated as "no signal of conflict" and composition proceeds. This matches + * the common case where a local profile sets only `codeMieProject` and relies + * on the global profile for the URL. + */ + private static shouldPreserveProjectContext( + localUrl: string | undefined, + globalUrl: string | undefined + ): boolean { + if (!localUrl || !globalUrl) return true; + const normalize = (u: string): string => u.replace(/\/+$/, '').toLowerCase(); + return normalize(localUrl) === normalize(globalUrl); + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts -t "shouldPreserveProjectContext"` +Expected: All 9 tests PASS. + +- [ ] **Step 6: Run the full file to verify no regressions** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts` +Expected: All existing tests still pass, plus the 9 new ones. + +- [ ] **Step 7: Commit** + +```bash +git add src/utils/config.ts src/utils/__tests__/config-project-override.test.ts +git commit -m "fix(config): add shouldPreserveProjectContext URL-equality helper" +``` + +--- + +## Task 2: Wire the gate into `ConfigLoader.load()` + +**Files:** +- Modify: `src/utils/config.ts` lines 100-106 (the `applyProjectOnly` branch in `load()`). `globalConfig` is already in scope from line 91. +- Test: `src/utils/__tests__/config-project-override.test.ts` — extend the `cross-env URL gate` describe block with three integration tests under a nested `describe('load with --profile')`. + +**Interfaces:** +- Consumes: `shouldPreserveProjectContext(localUrl, globalUrl)` from Task 1. +- Produces: `ConfigLoader.load()` returns a config with `codeMieUrl`/`codeMieProject`/`codeMieIntegration` from the selected global profile (and NOT the local team profile) whenever `applyProjectOnly` is true AND URLs differ. Otherwise behavior is unchanged. + +**Test-first: yes — three failing tests showing `load()` currently leaks team URL/project/integration when global profile points at a different env.** + +- [ ] **Step 1: Write the three failing integration tests** + +Append inside the existing `describe('ConfigLoader - cross-env URL gate', ...)` block from Task 1 (as a sibling of the helper describe): + +```typescript + describe('load with --profile cross-env', () => { + /** Build a global config with two profiles and write it under the mocked GLOBAL_CONFIG_DIR. */ + async function writeGlobal(activeProfile: string, profiles: Record>) { + const config: MultiProviderConfig = { + version: 2, + activeProfile, + profiles: profiles as MultiProviderConfig['profiles'] + }; + await fs.writeFile(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2)); + } + + /** Build a local project config and write it. */ + async function writeLocal(activeProfile: string, profiles: Record>) { + const config: MultiProviderConfig = { + version: 2, + activeProfile, + profiles: profiles as MultiProviderConfig['profiles'] + }; + await fs.writeFile(LOCAL_CONFIG_PATH, JSON.stringify(config, null, 2)); + } + + it('drops project context when --profile targets a different CodeMie URL', async () => { + await writeGlobal('preview', { + preview: { + provider: 'ai-run-sso', + codeMieUrl: 'https://preview.example.com', + baseUrl: 'https://preview.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'preview' + } + }); + await writeLocal('team-prod', { + 'team-prod': { + provider: 'ai-run-sso', + codeMieUrl: 'https://prod.example.com', + codeMieProject: 'prod-proj', + codeMieIntegration: 'prod-int' as unknown as CodeMieIntegrationInfo, + baseUrl: 'https://prod.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'team-prod' + } + }); + + const cfg = await ConfigLoader.load(path.join(TEST_DIR, 'project'), { name: 'preview' }); + + expect(cfg.codeMieUrl).toBe('https://preview.example.com'); + expect(cfg.codeMieProject).toBeUndefined(); + expect(cfg.codeMieIntegration).toBeUndefined(); + }); + + it('preserves project context when --profile targets the same CodeMie URL', async () => { + await writeGlobal('personal-anthropic', { + 'personal-anthropic': { + provider: 'anthropic-subscription', + codeMieUrl: 'https://prod.example.com', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-6', + name: 'personal-anthropic' + } + }); + await writeLocal('team-prod', { + 'team-prod': { + provider: 'ai-run-sso', + codeMieUrl: 'https://prod.example.com', + codeMieProject: 'prod-proj', + codeMieIntegration: 'prod-int' as unknown as CodeMieIntegrationInfo, + baseUrl: 'https://prod.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'team-prod' + } + }); + + const cfg = await ConfigLoader.load(path.join(TEST_DIR, 'project'), { name: 'personal-anthropic' }); + + expect(cfg.codeMieUrl).toBe('https://prod.example.com'); + expect(cfg.codeMieProject).toBe('prod-proj'); + expect(cfg.codeMieIntegration).toBe('prod-int'); + }); + + it('preserves local codeMieProject when local profile has no codeMieUrl', async () => { + await writeGlobal('preview', { + preview: { + provider: 'ai-run-sso', + codeMieUrl: 'https://preview.example.com', + baseUrl: 'https://preview.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'preview' + } + }); + await writeLocal('team-default', { + 'team-default': { + provider: 'ai-run-sso', + codeMieProject: 'shared-proj', + model: 'claude-sonnet-4-6', + name: 'team-default' + // no codeMieUrl on the local side + } + }); + + const cfg = await ConfigLoader.load(path.join(TEST_DIR, 'project'), { name: 'preview' }); + + expect(cfg.codeMieUrl).toBe('https://preview.example.com'); + expect(cfg.codeMieProject).toBe('shared-proj'); + }); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts -t "load with --profile cross-env"` +Expected: +- Test 1 FAILS — `cfg.codeMieUrl` is `'https://prod.example.com'` (leaked from local) instead of preview. +- Test 2 PASSES (same-env preservation already works today). Keeping it as a green-on-green guard against regression. +- Test 3 FAILS — depending on field-undefined handling, may pass; expected fail mode is `cfg.codeMieUrl` is wrong (similar leak path). + +If test 2 already passes, that's fine — it stays green through the fix and acts as a regression guard. + +- [ ] **Step 3: Apply the gate in `ConfigLoader.load()`** + +Modify lines 100-106 of `src/utils/config.ts`. Before: + +```typescript + const applyProjectOnly = + cliOverrides?.name && localProfileName && cliOverrides.name !== localProfileName; + const effectiveLocalConfig = applyProjectOnly + ? this.filterProjectFields(localConfig) + : localConfig; + + Object.assign(config, this.removeUndefined(effectiveLocalConfig)); +``` + +After: + +```typescript + const applyProjectOnly = + cliOverrides?.name && localProfileName && cliOverrides.name !== localProfileName; + // When applying project-only composition, gate it on URL equality. If the + // selected global profile targets a different CodeMie env than the local + // team profile, the team's project/integration/URL all reference the wrong + // env's records — drop the project-context bundle and let the global + // profile supply everything. + const preserveProjectContext = + applyProjectOnly && + this.shouldPreserveProjectContext(localConfig.codeMieUrl, globalConfig.codeMieUrl); + const effectiveLocalConfig = preserveProjectContext + ? this.filterProjectFields(localConfig) + : applyProjectOnly + ? {} + : localConfig; + + Object.assign(config, this.removeUndefined(effectiveLocalConfig)); +``` + +Note: `applyProjectOnly && !preserveProjectContext` yields `{}` — explicit empty object, so `removeUndefined` returns `{}` and `Object.assign` is a no-op. The earlier `Object.assign(config, removeUndefined(globalConfig))` at line 92 has already populated globalConfig's fields; this branch leaves them untouched. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts -t "load with --profile cross-env"` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Run the whole file to confirm no regressions** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts` +Expected: All tests pass (existing + helper + integration). + +- [ ] **Step 6: Run lint and typecheck** + +Run: `npm run lint && npm run typecheck` +Expected: Clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/utils/config.ts src/utils/__tests__/config-project-override.test.ts +git commit -m "fix(config): gate project-context preservation on codeMieUrl equality in load()" +``` + +--- + +## Task 3: Wire the gate into `ConfigLoader.loadWithSources()` + +**Files:** +- Modify: `src/utils/config.ts` — `loadWithSources()` at lines 1154-1174. The fix requires hoisting `loadGlobalConfigProfile(selectedProfileName)` from inside the `configs` array (line 1173) to BEFORE the `applyProjectOnly` block (line 1157). +- Test: `src/utils/__tests__/config-project-override.test.ts` — append one test to the existing `cross-env URL gate` describe block. + +**Interfaces:** +- Consumes: `shouldPreserveProjectContext` from Task 1. +- Produces: parity with `load()` — `loadWithSources()` returns the same merged `config` and a `sources` map where `sources['codeMieUrl']` is `'global'` (not `'project'`) when URLs differ. The `effectiveLocalConfig` used for source attribution must reflect the same gate. + +**Test-first: yes — one failing test asserting `sources['codeMieUrl'].source === 'global'` for the cross-env scenario.** + +- [ ] **Step 1: Write the failing source-attribution test** + +Append inside `describe('ConfigLoader - cross-env URL gate', ...)` after the `load with --profile cross-env` block: + +```typescript + describe('loadWithSources with --profile cross-env', () => { + it('reports codeMieUrl source as "global" when URLs differ', async () => { + const config: MultiProviderConfig = { + version: 2, + activeProfile: 'preview', + profiles: { + preview: { + provider: 'ai-run-sso', + codeMieUrl: 'https://preview.example.com', + baseUrl: 'https://preview.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'preview' + } as MultiProviderConfig['profiles'][string] + } + }; + await fs.writeFile(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2)); + + const localConfig: MultiProviderConfig = { + version: 2, + activeProfile: 'team-prod', + profiles: { + 'team-prod': { + provider: 'ai-run-sso', + codeMieUrl: 'https://prod.example.com', + codeMieProject: 'prod-proj', + baseUrl: 'https://prod.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'team-prod' + } as MultiProviderConfig['profiles'][string] + } + }; + await fs.writeFile(LOCAL_CONFIG_PATH, JSON.stringify(localConfig, null, 2)); + + const { config: merged, sources } = await ConfigLoader.loadWithSources( + path.join(TEST_DIR, 'project'), + { name: 'preview' } + ); + + expect(merged.codeMieUrl).toBe('https://preview.example.com'); + expect(sources['codeMieUrl']?.source).toBe('global'); + expect(sources['codeMieProject']).toBeUndefined(); + }); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts -t "loadWithSources with --profile cross-env"` +Expected: FAIL — `sources['codeMieUrl'].source` is `'project'` (the local team URL leaks into the source attribution). + +- [ ] **Step 3: Hoist global config load and apply gate in `loadWithSources()`** + +Modify `src/utils/config.ts` lines 1154-1184. Before: + +```typescript + const selectedProfileName = await this.resolveProfileName(workingDir, cliOverrides?.name); + const localProfileName = await this.resolveLocalProfileName(workingDir, selectedProfileName); + + const applyProjectOnly = + cliOverrides?.name && localProfileName && cliOverrides.name !== localProfileName; + const localConfig = await this.loadLocalConfigProfile(workingDir, localProfileName); + const effectiveLocalConfig = applyProjectOnly + ? this.filterProjectFields(localConfig) + : localConfig; + + const configs: ConfigLayer[] = [ + { + data: { + timeout: 0, // Unlimited timeout by default for long AI requests + debug: false + }, + source: 'default' + }, + { + data: await this.loadGlobalConfigProfile(selectedProfileName), + source: 'global' + }, + { + data: effectiveLocalConfig, + source: 'project' + }, +``` + +After: + +```typescript + const selectedProfileName = await this.resolveProfileName(workingDir, cliOverrides?.name); + const localProfileName = await this.resolveLocalProfileName(workingDir, selectedProfileName); + + // Hoisted: global config must be loaded BEFORE the URL-equality gate decision below. + const globalConfig = await this.loadGlobalConfigProfile(selectedProfileName); + const localConfig = await this.loadLocalConfigProfile(workingDir, localProfileName); + + const applyProjectOnly = + cliOverrides?.name && localProfileName && cliOverrides.name !== localProfileName; + const preserveProjectContext = + applyProjectOnly && + this.shouldPreserveProjectContext(localConfig.codeMieUrl, globalConfig.codeMieUrl); + const effectiveLocalConfig = preserveProjectContext + ? this.filterProjectFields(localConfig) + : applyProjectOnly + ? {} + : localConfig; + + const configs: ConfigLayer[] = [ + { + data: { + timeout: 0, // Unlimited timeout by default for long AI requests + debug: false + }, + source: 'default' + }, + { + data: globalConfig, + source: 'global' + }, + { + data: effectiveLocalConfig, + source: 'project' + }, +``` + +(The rest of the function — env layer, CLI layer, attribution loop, final `await this.load(...)` call — stays unchanged.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts -t "loadWithSources with --profile cross-env"` +Expected: PASS. + +- [ ] **Step 5: Run all existing `loadWithSources` tests for regression** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts -t "loadWithSources"` +Expected: All pass (both the new test and the existing 5+ tests for `loadWithSources` in the same file). + +- [ ] **Step 6: Run lint, typecheck, full test file** + +Run: `npm run lint && npm run typecheck && npx vitest run src/utils/__tests__/config-project-override.test.ts` +Expected: Clean and all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/utils/config.ts src/utils/__tests__/config-project-override.test.ts +git commit -m "fix(config): apply URL-equality gate in loadWithSources for source attribution parity" +``` + +--- + +## Task 4: Documentation update + +**Files:** +- Modify: `.ai-run/guides/usage/project-config.md` — add a URL-equality note under the "Team profile with personal provider" section (~line 131). + +**Interfaces:** +- Consumes: nothing (documentation only). +- Produces: A single paragraph explaining the URL-equality precondition for project-context composition. No code change. + +**Test-first: no — documentation. No failing test to author; verification is a manual read.** + +- [ ] **Step 1: Locate the "Team profile with personal provider" section** + +Run: `grep -n "Team profile with personal provider\|personal-anthropic\|PROJECT_FIELDS" .ai-run/guides/usage/project-config.md` +Expected: a single match around line 131. Read 30 lines around it to understand surrounding context. + +- [ ] **Step 2: Add the URL-equality note** + +Open the file, locate the paragraph describing the team-plus-personal-provider composition, and insert this paragraph immediately AFTER it: + +```markdown +> **URL precondition.** Project-context preservation (`codeMieProject`, `codeMieIntegration`, `codeMieUrl`) applies only when the selected global profile and the local team profile target the same `codeMieUrl` (compared after stripping trailing slashes and lower-casing). When the URLs differ, the user is switching CodeMie environments and the team's project/integration IDs would reference the wrong env's records — so the local project context is dropped and the selected global profile supplies everything. This is enforced in `ConfigLoader.load()` and `ConfigLoader.loadWithSources()`. +``` + +- [ ] **Step 3: Verify the markdown renders cleanly** + +Run: `head -200 .ai-run/guides/usage/project-config.md | grep -A1 "URL precondition"` +Expected: the paragraph appears, no orphan list markers nearby, no broken numbering. Eyeball the file once. + +- [ ] **Step 4: Commit** + +```bash +git add .ai-run/guides/usage/project-config.md +git commit -m "docs(config): note URL-equality precondition for project-context preservation" +``` + +--- + +## Final validation + +After all four tasks complete: + +- [ ] **Step 1: Full test suite** + +Run: `npx vitest run src/utils/__tests__/config-project-override.test.ts` +Expected: All tests pass. New count: existing tests + 9 (helper) + 3 (load integration) + 1 (loadWithSources attribution) = 13 new tests. + +- [ ] **Step 2: Quality gates** + +Run: `npm run lint && npm run typecheck && npm run build` +Expected: All clean. + +- [ ] **Step 3: Diff sanity** + +Run: `git diff main --stat` +Expected: three files changed — `src/utils/config.ts`, `src/utils/__tests__/config-project-override.test.ts`, `.ai-run/guides/usage/project-config.md`. Roughly +120 / -8 lines. + +- [ ] **Step 4: Manual repro of EPMCDME-13167 symptom** + +Run: +```bash +codemie proxy stop +codemie proxy connect desktop --profile preview --force +cat ~/.codemie/proxy-daemon.json | jq '{targetUrl, syncCodeMieUrl}' +``` + +Expected (after fix): both `targetUrl` and `syncCodeMieUrl` point at `codemie-preview.lab.epam.com` even when run from inside this repo (which has local `activeProfile=epm-cdme` with `codeMieUrl=codemie.lab.epam.com`). + +This step is for the reviewer's confidence — not a unit-test requirement. The behavior is fully covered by the unit tests above. diff --git a/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/qa-report.md b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/qa-report.md new file mode 100644 index 00000000..2b1819b8 --- /dev/null +++ b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/qa-report.md @@ -0,0 +1,27 @@ +# QA Gate Report — cross-env-project-fields-leak + +**Branch**: fix/cross-env-project-fields-leak +**Runner**: npm (guide-first via `.ai-run/guides/quality-gates.md`) +**Started**: 2026-06-29T13:55:00Z +**Status**: PASSED + +## Gates + +| Gate | Status | Duration | Command | Notes | +|--------------|--------|----------|------------------------------------|-------| +| license-check | PASS | ~5s | `npm run license-check` | No missing/stale headers reported. Output is dependency license summary. | +| lint | PASS | ~6s | `npm run lint` | ESLint 9.x, `--max-warnings=0`, zero warnings. | +| typecheck | PASS | ~6s | `npm run typecheck` | `tsc --noEmit`, no diagnostics. | +| build | PASS | ~8s | `npm run build` | `tsc && tsc-alias && npm run copy-plugin` all clean. | +| unit | PASS | 10.28s | `npm run test:unit` | 2162 pass / 1 skipped / 142 files. New tests: 13 added in `src/utils/__tests__/config-project-override.test.ts`. | +| integration | PASS | 24.27s | `npm run test:integration` | 220 pass / 1 skipped / 27 files. | +| commitlint | PASS | <1s | `npm run commitlint:last` + `npx commitlint --from main --to HEAD` | All 4 commits on the branch conform to Conventional Commits. | +| ui | SKIPPED | — | (n/a) | No UI surface changed — diff is ConfigLoader internals + tests + one markdown line. `feature-verification` not required. | + +## Failure detail + +None. + +## Drift signal + +no — implementation, tests, and plan all describe the same private helper signature `shouldPreserveProjectContext(localUrl, globalUrl): boolean`, the same gate behavior at both call sites, and the same six PROJECT_FIELDS / `preserveProjectContext` shape. No type, signature, or method-name drift. diff --git a/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/technical-analysis.md b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/technical-analysis.md new file mode 100644 index 00000000..647bc61f --- /dev/null +++ b/docs/superpowers/tasks/2026-06-29-cross-env-project-fields-leak/technical-analysis.md @@ -0,0 +1,259 @@ +# Technical Research + +**Task**: ConfigLoader profile composition project-context url +**Generated**: 2026-06-29T00:00:00Z +**Research path**: filesystem + +--- + +## 1. Original Context + +Fix the cross-env profile leak in `ConfigLoader` (src/utils/config.ts). + +Background: +When the user runs `codemie ... --profile ` from a repository that has a local +`.codemie/codemie-cli.config.json` declaring a different `activeProfile` with a different +`codeMieUrl`, the loader currently preserves `codeMieProject`, `codeMieIntegration`, and +`codeMieUrl` from the LOCAL team profile (via `filterProjectFields(localConfig)` and +`PROJECT_FIELDS`). The intent of PROJECT_FIELDS (added in commit c35b54a84) is to support +the "personal provider on top of team's project context" composition, which only works +when both profiles share the same `codeMieUrl`. When URLs differ (cross-env switch, e.g. +--profile preview while team is on prod), preserving any of the three fields yields wrong +records — codeMieUrl loudly (oauth2-proxy HTML breaks JSON.parse in the managed-MCP catalog +fetch, EPMCDME-13167), codeMieProject and codeMieIntegration silently (wrong DB rows). + +Required fix: +Gate `filterProjectFields` on URL equality. When the local team profile's `codeMieUrl` +matches the selected global profile's `codeMieUrl`, preserve the project context as today. +When they differ, drop the project-context bundle entirely so the selected global profile +supplies everything. Normalize URLs (trailing slash, case) before comparing. + +Constraint: do NOT remove PROJECT_FIELDS entirely. The same-env composition use case is +legitimate and must keep working unchanged. + +Touch points already identified by manual inspection: +- src/utils/config.ts: `PROJECT_FIELDS` definition (line ~353), `filterProjectFields` + (line ~363), `ConfigLoader.load` (lines 68-171), and a parallel block at line ~1161 + that may also need the same gate. +- Tests: any existing tests for cross-profile composition in tests/unit/utils/ or + similar. + +--- + +## 2. Codebase Findings + +### Existing Implementations + +**Primary file: `src/utils/config.ts`** + +- **Line 353-357** — `PROJECT_FIELDS` constant: + ```typescript + private static readonly PROJECT_FIELDS: (keyof CodeMieConfigOptions)[] = [ + 'codeMieProject', + 'codeMieIntegration', + 'codeMieUrl' + ]; + ``` + No URL equality guard. All three fields are unconditionally preserved when `applyProjectOnly` is true. + +- **Lines 363-373** — `filterProjectFields(config)`: + Iterates `PROJECT_FIELDS` and copies any defined field from `config` into a new object. No parameters for comparison context. Returns up to all three fields regardless of URL match. + +- **Lines 68-171** — `ConfigLoader.load()` — first call site (primary path): + Priority stack (low to high): defaults → globalConfig (line 91-92) → localConfig filtered (lines 95-106) → env vars with profile protection (lines 109-163) → CLI overrides (lines 166-168). + - Line 82: `selectedProfileName = await this.resolveProfileName(workingDir, cliOverrides?.name)` + - Line 88: `localProfileName = await this.resolveLocalProfileName(workingDir, selectedProfileName)` + - Line 91: `globalConfig = await this.loadGlobalConfigProfile(selectedProfileName)` — **global config is available here** + - Line 92: `Object.assign(config, removeUndefined(globalConfig))` — global URL written to config + - Line 95: `localConfig = await this.loadLocalConfigProfile(workingDir, localProfileName)` + - Lines 100-101: `applyProjectOnly = cliOverrides?.name && localProfileName && cliOverrides.name !== localProfileName` + - Lines 102-104: `effectiveLocalConfig = applyProjectOnly ? this.filterProjectFields(localConfig) : localConfig` — **`globalConfig` is in scope but not passed to `filterProjectFields`** + - Line 106: `Object.assign(config, removeUndefined(effectiveLocalConfig))` — overwrites previously set globalConfig.codeMieUrl with the local team's codeMieUrl + +- **Lines 120-157** — "Profile protection" env-vars block: + When `cliOverrides.name` is set, strips `codeMieUrl`, `baseUrl`, `apiKey`, `model`, `provider`, `authMethod`, `codeMieIntegration` from env before applying. This is the existing mechanism that prevents `CODEMIE_URL` in the shell environment from overriding the selected profile. An analogous guard is needed for the local-config path but operates differently: instead of dropping env vars, it should drop the local config's codeMieUrl when URLs disagree. + +- **Lines 1139-1210** — `ConfigLoader.loadWithSources()` — second call site: + - Line 1154: `selectedProfileName` resolved + - Line 1155: `localProfileName` resolved + - Lines 1157-1162: `applyProjectOnly` computed, local config loaded, `filterProjectFields` applied — **global config has NOT been loaded yet** + - Line 1173 (inside `configs` array literal): `await this.loadGlobalConfigProfile(selectedProfileName)` — global config loaded after the filter decision is already made + - This ordering issue means the fix in `loadWithSources` must hoist global config loading to before line 1157, or pass the global URL via a separate await before the filter call. + +**URL normalization helper: `src/providers/core/codemie-auth-helpers.ts`** + +- **Lines 22-28** — `ensureApiBase(rawUrl: string): string`: + ```typescript + let base = rawUrl.replace(/\/$/, ''); + if (!/\/code-assistant-api(\/|$)/i.test(base)) { + base = `${base}/code-assistant-api`; + } + return base; + ``` + Strips trailing slash AND appends `/code-assistant-api` path. This is an API-base builder, not a bare URL normalizer. Using it for equality comparison would require both URLs to already have the `/code-assistant-api` suffix, which `codeMieUrl` stored in profiles does not have. Importing this into `config.ts` also creates a cross-layer dependency (utils -> providers). The fix should use inline normalization: `url.replace(/\/+$/, '').toLowerCase()`. + +**Commit c35b54a84 (Jun 16 2026)** — the introducing commit: +Added `resolveLocalProfileName()`, `PROJECT_FIELDS`, `filterProjectFields()`, and the `applyProjectOnly` branch in both `load()` and `loadWithSources()`. Also added a Use Case 4 section to `.codemie/guides/usage/project-config.md` (a file path that no longer exists on disk — the live guides are in `.ai-run/guides/`). + +### Architecture and Layers Affected + +- **Config / Utilities layer** (`src/utils/config.ts`): primary change site — `filterProjectFields`, both call sites, URL normalization inline +- **Provider / SSO layer** (`src/providers/plugins/sso/sso.auth.ts`): consumer of `codeMieUrl` as credential storage key — affected by fix (correct key restored after fix) +- **CLI / Proxy layer** (`src/cli/commands/proxy/index.ts`, `src/cli/commands/proxy/connectors/managed-mcp-remote.ts`): consumers of `config.codeMieUrl` for MCP catalog fetch and daemon startup +- **Agent layer** (`src/agents/core/AgentCLI.ts`): derives `config.baseUrl` from `config.codeMieUrl` — most dangerous silent failure path +- **Documentation** (`.ai-run/guides/usage/project-config.md`): needs one-paragraph addition describing the URL-gate constraint + +### Integration Points + +**Internal module dependencies (codeMieUrl consumers):** + +| File | How codeMieUrl is used | Failure mode if wrong | +|---|---|---| +| `src/agents/core/AgentCLI.ts:205` | `config.baseUrl = ensureApiBase(config.codeMieUrl)` — rewrites all agent API routing | Silent: all agent calls route to wrong env | +| `src/cli/commands/proxy/index.ts:179,344` | `syncCodeMieUrl: config.codeMieUrl` passed to `spawnDaemon` | Silent: daemon registered with wrong URL | +| `src/cli/commands/proxy/index.ts:302` | `if (!config.codeMieUrl) throw ConfigurationError` — non-empty wrong URL passes this guard | Guard bypassed; wrong URL propagates | +| `src/cli/commands/proxy/connectors/managed-mcp-remote.ts:63` | `sso.getStoredCredentials(codeMieUrl)` — lookup key for SSO creds | Silent null return → MCP catalog not fetched; EPMCDME-13167 failure | +| `src/providers/plugins/sso/sso.auth.ts:78,127` | Stored as credential storage key; `storeSSOCredentials(creds, this.codeMieUrl)` | Credentials stored under wrong key; auth fails on next call | +| `src/providers/plugins/sso/sso.health.ts:62` | `getStoredCredentials(config.codeMieUrl)` for health check | Silent null → health check skips auth verification | +| `src/providers/plugins/sso/sso.models.ts:82,113` | SSO integration lookup and `fetchIntegrations(codeMieUrl)` | Wrong DB rows returned for project integrations | +| `src/cli/commands/skills/lib/skills-search-client.ts:114,132` | SSO lookup key for skills search | Auth failure or wrong-env skills returned | +| `src/cli/commands/skills/lib/skills-metrics.ts:359` | SSO lookup key for analytics sync | Analytics silently lost or sent to wrong env | +| `src/cli/commands/skills/lib/require-auth.ts:27` | SSO lookup key for auth check | Auth check skipped or fails against wrong env | +| `src/utils/sdk-client.ts:49` | `credentialLookupUrl = config.codeMieUrl \|\| config.baseUrl` | Wrong env credentials used for SDK calls | +| `src/agents/plugins/claude/statusline-installer.ts` | Credential lookup key | Statusline not installed, no error shown | + +**External dependencies:** +- No external service client changes needed — the fix is entirely within `ConfigLoader` + +### Patterns and Conventions + +- **Priority stack pattern**: `ConfigLoader.load` uses a sequential `Object.assign` cascade (defaults → global → local → env → CLI). The URL gate must be inserted at the local-config merge step (lines 102-104). +- **`applyProjectOnly` gate pattern**: already established at the same lines. The URL gate is an additional condition layered on top. +- **Inline URL normalization**: project convention in `config.ts` uses `replace()` directly (e.g. at line 23 of `codemie-auth-helpers.ts`). No dedicated utility function exists in `src/utils/` for base URL normalization — a one-liner `url.replace(/\/+$/, '').toLowerCase()` is consistent with the codebase style. +- **Parallel `load`/`loadWithSources` maintenance**: both methods duplicate the profile-resolution logic. Any change to one must be mirrored in the other. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +- **`.ai-run/guides/usage/project-config.md`** (primary): covers ConfigLoader, config priority, profile resolution, and common patterns. Contains "Team profile with personal provider" (lines 131-139) — the condensed echo of the original Use Case 4 from commit c35b54a84. This is the file that needs a one-paragraph update. +- **`.ai-run/guides/architecture/architecture.md`**: covers the 5-layer architecture. ConfigLoader sits in the Utilities layer and is referenced generically; no profile-composition details. +- **`.ai-run/guides/development/development-practices.md`**: covers error handling and async patterns. No config-specific content relevant to this fix. +- **`.codemie/guides/` directory**: does not exist on disk. Commit c35b54a84 added Use Case 4 to `.codemie/guides/usage/project-config.md`, but that path was never migrated into `.ai-run/guides/`. The richer documentation from the original commit was lost. + +### Architectural Decisions + +- **Commit c35b54a84 (Jun 16 2026)**: introduced `PROJECT_FIELDS` = `['codeMieProject', 'codeMieIntegration', 'codeMieUrl']` and the `filterProjectFields`/`applyProjectOnly` mechanism. Intent: "personal provider on top of team project context" composition. The decision to include `codeMieUrl` in `PROJECT_FIELDS` implicitly assumed same-env usage; the URL-gate constraint was not implemented at that time. +- **"Profile protection" env block (lines 120-157)**: an earlier decision establishing the principle that `--profile` should insulate the selected profile's credentials from ambient env vars. The URL-gate fix extends this principle to local-config merging. + +### Derived Conventions + +- `codeMieUrl` is the primary SSO credential storage key across the entire codebase — every consumer uses it as a lookup key into the credential store. A wrong `codeMieUrl` silently routes all auth operations to the wrong environment. +- The `codeMieUrl` vs `baseUrl` distinction: `codeMieUrl` is the human-facing CodeMie instance URL (e.g. `https://company.codemie.ai`); `baseUrl` is the provider's AI API endpoint. They are different fields serving different consumers. +- Both `load()` and `loadWithSources()` must be kept in sync — they duplicate the priority logic intentionally (the latter adds source tracking). Changes to the merge logic in `load()` must always be reflected in `loadWithSources()`. + +--- + +## 4. Testing Landscape + +### Existing Coverage + +**File: `src/utils/__tests__/config-project-override.test.ts`** — 390 lines, Vitest + +Covered scenarios: +- `initProjectConfig` — creates local config file, applies `codeMieProject`/`codeMieIntegration`/`profileName` overrides +- `hasLocalConfig` / `hasProjectConfig` — true/false detection +- `loadWithSources` — returns `ConfigWithSources` structure, tracks source labels, detects local config existence, tracks project-level overrides, prioritizes CLI over project +- Priority system — CLI > env > project > global > default (using model field as the test axis) +- Field override behavior — `codeMieProject` from project overrides global, `codeMieIntegration` from project overrides global, partial overrides (only some fields set locally) + +**Note**: all tests assume the SAME profile name is active in both global and local config. No test exercises the `applyProjectOnly` branch (`cliOverrides.name !== localProfileName`). + +### Testing Framework and Patterns + +- Framework: **Vitest** (`describe`, `it`, `expect`, `beforeEach`, `afterEach`, `vi`) +- Pattern: creates real temp directories under `process.cwd()/tmp-test-config/`, writes real JSON config files, calls `ConfigLoader` methods directly +- Mocking: `vi.spyOn(paths, 'getCodemieHome')` and `vi.spyOn(paths, 'getCodemiePath')` redirect global config reads to the temp dir +- Cleanup: `fs.rm(TEST_DIR, { recursive: true, force: true })` and `vi.restoreAllMocks()` in `afterEach` +- Env pollution prevention: manually `delete process.env.CODEMIE_*` in `beforeEach` + +### Coverage Gaps + +The following scenarios have no test coverage and will be needed for this fix: + +1. **Cross-env URL mismatch with `--profile`**: global profile has `codeMieUrl: 'https://preview.codemie.ai'`, local team profile has `codeMieUrl: 'https://prod.codemie.ai'`, user runs with `cliOverrides.name = 'preview-profile'`. Expected: `codeMieProject`, `codeMieIntegration`, and `codeMieUrl` are all absent from the final merged config (supplied entirely by the global profile). + +2. **Same-env URL match with `--profile`**: global profile and local team profile both have `codeMieUrl: 'https://prod.codemie.ai'` (possibly with trailing slash variant). Expected: `codeMieProject` and `codeMieIntegration` from local profile ARE preserved (same-env composition still works). + +3. **URL present in global but absent in local**: global profile has `codeMieUrl`, local profile has none (typical when local config only sets `codeMieProject`). Expected: global's `codeMieUrl` wins; local's `codeMieProject` is preserved (URL gate should treat missing local URL as "not conflicting"). + +4. **`filterProjectFields` unit test**: directly tests the function with/without URL parameter to verify correct field inclusion/exclusion. + +5. **`loadWithSources` with `applyProjectOnly`**: verifies that the `sources` record for `codeMieUrl` shows `'global'` (not `'project'`) when URLs differ. + +--- + +## 5. Configuration and Environment + +### Environment Variables + +Relevant env vars in `ConfigLoader.loadFromEnv()`: + +| Env Var | Config Field | Profile-protection behavior | +|---|---|---| +| `CODEMIE_URL` | `codeMieUrl` | Stripped from env when `--profile` explicit (line 142-144) | +| `CODEMIE_PROJECT` | `codeMieProject` | No stripping | +| `CODEMIE_INTEGRATION_ID` | `codeMieIntegration` | Stripped when `--profile` explicit (line 150-153) | +| `CODEMIE_PROVIDER` | `provider` | Stripped when `--profile` explicit | +| `CODEMIE_BASE_URL` | `baseUrl` | Stripped when `--profile` explicit | +| `CODEMIE_API_KEY` | `apiKey` | Stripped when `--profile` explicit | +| `CODEMIE_MODEL` | `model` | Stripped when `--profile` explicit | + +### Configuration Files + +- **`~/.codemie/codemie-cli.config.json`** (global): `MultiProviderConfig` schema `version: 2`, `activeProfile`, `profiles` map. Read by `ConfigLoader.loadGlobalConfigProfile()`. +- **`.codemie/codemie-cli.config.json`** (local, per-repo): same schema. Read by `ConfigLoader.loadLocalConfigProfile()`. This file's `activeProfile` determines `localProfileName` via `resolveLocalProfileName()`. +- **`.codemie/credentials.json`**: SSO credential store keyed by normalized `codeMieUrl`. Not read by `ConfigLoader` directly — read by `sso.auth.ts` using the `codeMieUrl` that `ConfigLoader` emits. + +### Feature Flags and Deployment Concerns + +- No feature flags involved. +- No deployment manifest changes needed. +- The fix is purely a runtime merge-logic change in `ConfigLoader` — no schema changes, no migration needed. +- The `codeMieUrl` stored in `.codemie/credentials.json` is not affected by this fix; credentials remain associated with their original URLs. + +--- + +## 6. Risk Indicators + +- **Both call sites must be patched**: `ConfigLoader.load()` line 103 AND `ConfigLoader.loadWithSources()` line 1161 contain identical `filterProjectFields` calls. Missing either leaves a residual leak path. The `loadWithSources` fix requires hoisting `loadGlobalConfigProfile` from inside the `configs` array (line 1173) to before the `applyProjectOnly` block (before line 1157) — a refactoring risk if not done carefully. + +- **`loadWithSources` ordering dependency**: in the current code, `loadGlobalConfigProfile` is called lazily as part of the `configs` array initializer (line 1173). The URL-gate fix requires it to be called eagerly BEFORE computing `effectiveLocalConfig`. This changes evaluation order; the async operation is independent and safe, but the hoisting must be explicit. + +- **No existing test for `applyProjectOnly` branch**: `src/utils/__tests__/config-project-override.test.ts` never sets `cliOverrides.name` to a value that differs from `localProfileName`. The bug lived undetected because no test covered the cross-profile activation path. + +- **`AgentCLI.ts` line 205 — most impactful silent failure**: `config.baseUrl = ensureApiBase(config.codeMieUrl)` rewrites the AI API routing URL from the leaked `codeMieUrl`. All agent API calls (completions, tool calls, streaming) silently route to the wrong environment. This is worse than the MCP catalog failure (EPMCDME-13167) because it affects every agent interaction, not just the catalog fetch. + +- **SSO credential key corruption is not self-healing**: if a user has run with the leaked URL and had credentials stored under the wrong key, they need to re-authenticate after the fix. The fix corrects future loads but does not repair existing credential store entries. + +- **URL normalization must not use `ensureApiBase`**: that function appends `/code-assistant-api` and is not appropriate for equality comparison of base URLs. Using it would require both sides to be API-base URLs, which they are not. The comparison must use a simple `url.replace(/\/+$/, '').toLowerCase()` normalization or similar. Importing `ensureApiBase` from `src/providers/core/` into `src/utils/config.ts` would also introduce a cross-layer dependency (utils importing providers). + +- **`codeMieUrl` absent in local profile is not a URL mismatch**: if the local team profile does not set `codeMieUrl` (common for configs that only set `codeMieProject`), the URL gate must treat a missing/undefined local URL as compatible (not a conflict). Failing to handle this case would break the majority of existing team configurations that rely on the global profile's `codeMieUrl`. + +- **`codeMieUrl` absent in global profile edge case**: if the selected global profile has no `codeMieUrl` (e.g. a pure-Bedrock profile), the URL gate behavior needs to be defined. The task requires the global profile to supply everything when URLs differ; if neither profile has a URL, the local project fields (`codeMieProject`, `codeMieIntegration`) are still valid to preserve. The safest behavior: only trigger the URL gate when both sides have non-empty URLs and they differ. + +- **Guide documentation gap**: the `.ai-run/guides/usage/project-config.md` file does not mention `codeMieUrl` at all (despite it being a `PROJECT_FIELD`), and does not document the URL-gate constraint. The "Team profile with personal provider" pattern section (line 131) needs a note that the composition requires both profiles to share the same CodeMie URL. + +- **No codegraph results**: codegraph MCP tools were described in the environment but returned tool-not-found errors. All research was conducted via filesystem tools. If the codegraph index becomes available, a caller/callee graph for `filterProjectFields` would verify no additional call sites exist beyond lines 103 and 1161. + +--- + +## 7. Summary for Complexity Assessment + +The fix touches a single file (`src/utils/config.ts`) at two symmetrical call sites (lines 103 and 1161), plus a one-paragraph documentation update in `.ai-run/guides/usage/project-config.md`. The code change surface is small — approximately 15-20 new lines across both call sites — but the `loadWithSources` site has an ordering dependency that requires hoisting one async call, making it more than a one-liner insertion. The fix introduces a private URL-normalization expression (trailing slash strip + lowercase) inline rather than importing an existing helper, to avoid a cross-layer dependency. All three `PROJECT_FIELDS` are dropped as a bundle when URLs differ, which matches the task requirement. + +The affected area follows an established pattern (`applyProjectOnly` is already present and tested by the existing priority-system tests), but the critical `filterProjectFields` branch itself has zero test coverage. Two new test scenarios are essential: cross-env URL mismatch (all three PROJECT_FIELDS dropped) and same-env URL match (PROJECT_FIELDS preserved as today). A third edge case — missing local `codeMieUrl` — must also be handled to avoid breaking the majority of team configurations that only set `codeMieProject` in their local profile without a `codeMieUrl`. + +The blast radius of the bug, while fixed in one file, propagates through 15+ downstream consumers across the SSO, proxy, agent, and skills layers — all using `codeMieUrl` as a credential-store lookup key. The most dangerous silent failure is `AgentCLI.ts` line 205 rewriting `config.baseUrl` to the wrong environment's API endpoint. The fix unblocks EPMCDME-13167 (managed-MCP catalog `JSON.parse` failure) as the reported loud symptom, but also silently fixes wrong DB row lookups, wrong credential key associations, and wrong agent API routing. Risk is low given the change is isolated to `ConfigLoader`, but the missing test coverage for the `applyProjectOnly` branch is the main confidence gap and must be addressed as part of this fix. diff --git a/src/utils/__tests__/config-project-override.test.ts b/src/utils/__tests__/config-project-override.test.ts index 8292bb64..cd134746 100644 --- a/src/utils/__tests__/config-project-override.test.ts +++ b/src/utils/__tests__/config-project-override.test.ts @@ -387,3 +387,228 @@ describe('ConfigLoader - Project-Level Configuration', () => { }); }); }); + +describe('ConfigLoader - cross-env URL gate', () => { + describe('shouldPreserveProjectContext', () => { + // Helper is private — access via index signature cast for unit testing. + // This is the established pattern when a Vitest suite needs to reach a + // class-private static. No production code reads it this way. + const gate = (l: string | undefined, g: string | undefined): boolean => + (ConfigLoader as unknown as { + shouldPreserveProjectContext: (l?: string, g?: string) => boolean; + }).shouldPreserveProjectContext(l, g); + + it('preserves when both URLs are equal', () => { + expect(gate('https://prod.example.com', 'https://prod.example.com')).toBe(true); + }); + + it('preserves when URLs differ only by trailing slash', () => { + expect(gate('https://prod.example.com/', 'https://prod.example.com')).toBe(true); + }); + + it('preserves when URLs differ only by case', () => { + expect(gate('https://PROD.example.com', 'https://prod.example.com')).toBe(true); + }); + + it('drops when URLs differ on host', () => { + expect(gate('https://prod.example.com', 'https://preview.example.com')).toBe(false); + }); + + it('preserves when local URL is undefined', () => { + expect(gate(undefined, 'https://preview.example.com')).toBe(true); + }); + + it('preserves when global URL is undefined', () => { + expect(gate('https://prod.example.com', undefined)).toBe(true); + }); + + it('preserves when both URLs are undefined', () => { + expect(gate(undefined, undefined)).toBe(true); + }); + + it('preserves when local URL is empty string', () => { + expect(gate('', 'https://preview.example.com')).toBe(true); + }); + + it('preserves when global URL is empty string', () => { + expect(gate('https://prod.example.com', '')).toBe(true); + }); + }); + + describe('load with --profile cross-env', () => { + // ConfigLoader.GLOBAL_CONFIG and .GLOBAL_CONFIG_DIR are static class fields + // evaluated at module load time, so vi.spyOn(paths, ...) in beforeEach is + // too late to redirect them. Save the originals and override the statics + // directly per-test to point at the temp dir. + const ORIGINAL_GLOBAL_CONFIG = (ConfigLoader as unknown as { GLOBAL_CONFIG: string }).GLOBAL_CONFIG; + const ORIGINAL_GLOBAL_CONFIG_DIR = (ConfigLoader as unknown as { GLOBAL_CONFIG_DIR: string }).GLOBAL_CONFIG_DIR; + + beforeEach(async () => { + await fs.mkdir(path.join(TEST_DIR, '.codemie'), { recursive: true }); + await fs.mkdir(path.join(TEST_DIR, 'project', '.codemie'), { recursive: true }); + + vi.spyOn(paths, 'getCodemieHome').mockReturnValue(GLOBAL_CONFIG_DIR); + vi.spyOn(paths, 'getCodemiePath').mockImplementation((subpath: string) => { + return path.join(GLOBAL_CONFIG_DIR, subpath); + }); + (ConfigLoader as unknown as { GLOBAL_CONFIG: string }).GLOBAL_CONFIG = GLOBAL_CONFIG_PATH; + (ConfigLoader as unknown as { GLOBAL_CONFIG_DIR: string }).GLOBAL_CONFIG_DIR = GLOBAL_CONFIG_DIR; + + delete process.env.CODEMIE_PROVIDER; + delete process.env.CODEMIE_MODEL; + delete process.env.CODEMIE_BASE_URL; + delete process.env.CODEMIE_API_KEY; + delete process.env.CODEMIE_TIMEOUT; + delete process.env.CODEMIE_DEBUG; + delete process.env.CODEMIE_PROFILE_CONFIG; + delete process.env.CODEMIE_INTEGRATION_ID; + delete process.env.CODEMIE_PROJECT; + delete process.env.CODEMIE_URL; + }); + + afterEach(async () => { + await fs.rm(TEST_DIR, { recursive: true, force: true }); + vi.restoreAllMocks(); + (ConfigLoader as unknown as { GLOBAL_CONFIG: string }).GLOBAL_CONFIG = ORIGINAL_GLOBAL_CONFIG; + (ConfigLoader as unknown as { GLOBAL_CONFIG_DIR: string }).GLOBAL_CONFIG_DIR = ORIGINAL_GLOBAL_CONFIG_DIR; + }); + + async function writeGlobal( + activeProfile: string, + profiles: Record> + ) { + const config: MultiProviderConfig = { + version: 2, + activeProfile, + profiles: profiles as MultiProviderConfig['profiles'] + }; + await fs.writeFile(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2)); + } + + async function writeLocal( + activeProfile: string, + profiles: Record> + ) { + const config: MultiProviderConfig = { + version: 2, + activeProfile, + profiles: profiles as MultiProviderConfig['profiles'] + }; + await fs.writeFile(LOCAL_CONFIG_PATH, JSON.stringify(config, null, 2)); + } + + it('drops project context when --profile targets a different CodeMie URL', async () => { + await writeGlobal('preview', { + preview: { + provider: 'ai-run-sso', + codeMieUrl: 'https://preview.example.com', + baseUrl: 'https://preview.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'preview' + } + }); + await writeLocal('team-prod', { + 'team-prod': { + provider: 'ai-run-sso', + codeMieUrl: 'https://prod.example.com', + codeMieProject: 'prod-proj', + codeMieIntegration: 'prod-int' as unknown as CodeMieIntegrationInfo, + baseUrl: 'https://prod.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'team-prod' + } + }); + + const cfg = await ConfigLoader.load(path.join(TEST_DIR, 'project'), { name: 'preview' }); + + expect(cfg.codeMieUrl).toBe('https://preview.example.com'); + expect(cfg.codeMieProject).toBeUndefined(); + expect(cfg.codeMieIntegration).toBeUndefined(); + }); + + it('preserves project context when --profile targets the same CodeMie URL', async () => { + await writeGlobal('personal-anthropic', { + 'personal-anthropic': { + provider: 'anthropic-subscription', + codeMieUrl: 'https://prod.example.com', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-6', + name: 'personal-anthropic' + } + }); + await writeLocal('team-prod', { + 'team-prod': { + provider: 'ai-run-sso', + codeMieUrl: 'https://prod.example.com', + codeMieProject: 'prod-proj', + codeMieIntegration: 'prod-int' as unknown as CodeMieIntegrationInfo, + baseUrl: 'https://prod.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'team-prod' + } + }); + + const cfg = await ConfigLoader.load(path.join(TEST_DIR, 'project'), { name: 'personal-anthropic' }); + + expect(cfg.codeMieUrl).toBe('https://prod.example.com'); + expect(cfg.codeMieProject).toBe('prod-proj'); + expect(cfg.codeMieIntegration).toBe('prod-int'); + }); + + it('preserves local codeMieProject when local profile has no codeMieUrl', async () => { + await writeGlobal('preview', { + preview: { + provider: 'ai-run-sso', + codeMieUrl: 'https://preview.example.com', + baseUrl: 'https://preview.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'preview' + } + }); + await writeLocal('team-default', { + 'team-default': { + provider: 'ai-run-sso', + codeMieProject: 'shared-proj', + model: 'claude-sonnet-4-6', + name: 'team-default' + } + }); + + const cfg = await ConfigLoader.load(path.join(TEST_DIR, 'project'), { name: 'preview' }); + + expect(cfg.codeMieUrl).toBe('https://preview.example.com'); + expect(cfg.codeMieProject).toBe('shared-proj'); + }); + + it('loadWithSources reports codeMieUrl source as "global" when URLs differ', async () => { + await writeGlobal('preview', { + preview: { + provider: 'ai-run-sso', + codeMieUrl: 'https://preview.example.com', + baseUrl: 'https://preview.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'preview' + } + }); + await writeLocal('team-prod', { + 'team-prod': { + provider: 'ai-run-sso', + codeMieUrl: 'https://prod.example.com', + codeMieProject: 'prod-proj', + baseUrl: 'https://prod.example.com/code-assistant-api', + model: 'claude-sonnet-4-6', + name: 'team-prod' + } + }); + + const { config: merged, sources } = await ConfigLoader.loadWithSources( + path.join(TEST_DIR, 'project'), + { name: 'preview' } + ); + + expect(merged.codeMieUrl).toBe('https://preview.example.com'); + expect(sources['codeMieUrl']?.source).toBe('global'); + expect(sources['codeMieProject']).toBeUndefined(); + }); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index 268823c5..f086b8bc 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -99,9 +99,19 @@ export class ConfigLoader { // model, and credentials from being silently replaced by the local team's defaults. const applyProjectOnly = cliOverrides?.name && localProfileName && cliOverrides.name !== localProfileName; - const effectiveLocalConfig = applyProjectOnly + // When applying project-only composition, gate it on URL equality. If the + // selected global profile targets a different CodeMie env than the local + // team profile, the team's project/integration/URL all reference the wrong + // env's records — drop the project-context bundle and let the global + // profile supply everything. + const preserveProjectContext = + applyProjectOnly && + this.shouldPreserveProjectContext(localConfig.codeMieUrl, globalConfig.codeMieUrl); + const effectiveLocalConfig = preserveProjectContext ? this.filterProjectFields(localConfig) - : localConfig; + : applyProjectOnly + ? {} + : localConfig; Object.assign(config, this.removeUndefined(effectiveLocalConfig)); @@ -356,6 +366,28 @@ export class ConfigLoader { 'codeMieUrl' ]; + /** + * Returns true when the local team profile's project context (codeMieProject, + * codeMieIntegration, codeMieUrl) is safe to compose with the selected global + * profile. The composition is only safe when both profiles target the same + * CodeMie environment — otherwise the local project/integration IDs reference + * the wrong env's database rows and the URL is outright wrong. + * + * The gate is conservative: it only blocks composition when both URLs are + * explicitly set and normalized-differ. A missing URL on either side is + * treated as "no signal of conflict" and composition proceeds. This matches + * the common case where a local profile sets only `codeMieProject` and relies + * on the global profile for the URL. + */ + private static shouldPreserveProjectContext( + localUrl: string | undefined, + globalUrl: string | undefined + ): boolean { + if (!localUrl || !globalUrl) return true; + const normalize = (u: string): string => u.replace(/\/+$/, '').toLowerCase(); + return normalize(localUrl) === normalize(globalUrl); + } + /** * Keep only project-level fields from a local profile. Used when the selected global * profile differs from the team's local default profile. @@ -1154,12 +1186,21 @@ export class ConfigLoader { const selectedProfileName = await this.resolveProfileName(workingDir, cliOverrides?.name); const localProfileName = await this.resolveLocalProfileName(workingDir, selectedProfileName); + // Hoisted: global config must be loaded BEFORE the URL-equality gate + // decision below. + const globalConfig = await this.loadGlobalConfigProfile(selectedProfileName); + const localConfig = await this.loadLocalConfigProfile(workingDir, localProfileName); + const applyProjectOnly = cliOverrides?.name && localProfileName && cliOverrides.name !== localProfileName; - const localConfig = await this.loadLocalConfigProfile(workingDir, localProfileName); - const effectiveLocalConfig = applyProjectOnly + const preserveProjectContext = + applyProjectOnly && + this.shouldPreserveProjectContext(localConfig.codeMieUrl, globalConfig.codeMieUrl); + const effectiveLocalConfig = preserveProjectContext ? this.filterProjectFields(localConfig) - : localConfig; + : applyProjectOnly + ? {} + : localConfig; const configs: ConfigLayer[] = [ { @@ -1170,7 +1211,7 @@ export class ConfigLoader { source: 'default' }, { - data: await this.loadGlobalConfigProfile(selectedProfileName), + data: globalConfig, source: 'global' }, {