diff --git a/.ai-run/guides/usage/project-config.md b/.ai-run/guides/usage/project-config.md index ed819af6..c411e5d1 100644 --- a/.ai-run/guides/usage/project-config.md +++ b/.ai-run/guides/usage/project-config.md @@ -193,6 +193,31 @@ codemie profile delete # Delete a profile --- +## Claude Code ANTHROPIC_BASE_URL Override + +Claude Code reads `~/.claude/settings.json` at startup. If that file contains an +`ANTHROPIC_BASE_URL` key under the `env` block, Claude Code uses it instead of any +environment variable. + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://your-proxy.example.com" + } +} +``` + +`codemie-code` detects this at startup and prints a visible warning showing: +- **Profile URL** — the URL the active profile tried to inject +- **Active URL** — the settings.json URL that will actually be used + +**Precedence chain (highest wins):** +`~/.claude/settings.json` > `env.ANTHROPIC_BASE_URL` (codemie-code profile) + +To avoid silent overrides: remove `ANTHROPIC_BASE_URL` from `~/.claude/settings.json`. + +--- + ## Troubleshooting | Symptom | Cause | Fix | diff --git a/docs/superpowers/plans/2026-07-15-epmcdme-10988-settings-conflict-detection.md b/docs/superpowers/plans/2026-07-15-epmcdme-10988-settings-conflict-detection.md new file mode 100644 index 00000000..8c066cca --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-epmcdme-10988-settings-conflict-detection.md @@ -0,0 +1,525 @@ +# EPMCDME-10988 Settings Conflict Detection — 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:** Add startup detection in `codemie-code` that reads `~/.claude/settings.json`, identifies if `ANTHROPIC_BASE_URL` is set there (which would silently override the active profile), and prints a visible warning before the Claude session begins. + +**Architecture:** A new pure-function helper `settings-conflict.ts` reads and compares URLs; `claude.plugin.ts:beforeRun` calls it and emits a `chalk.yellow` warning to stderr when a conflict is found. Detection happens after `transformEnvVars()` sets `env.ANTHROPIC_BASE_URL`, so the comparison is meaningful. + +**Tech Stack:** TypeScript, Node.js `fs`/`fs/promises`, `chalk` (already in project), Vitest + +## Global Constraints + +- ES modules only — all imports must use `.js` extension (e.g., `import ... from './settings-conflict.js'`) +- Node.js `>=20.0.0` required +- No `require()`, no `__dirname` — use ES-module patterns +- All new source files: TypeScript strict mode, explicit return types on exports +- `interface` for shape types, no `any` +- Tests: Vitest, dynamic imports inside `beforeEach` after mocks are set up +- Tests only on explicit request (this plan explicitly requests them) +- Git ops only on explicit request (this plan explicitly requests them) +- Branch: `EPMCDME-10988` + +--- + +## File Map + +| Action | Path | Responsibility | +|---|---|---| +| Create | `src/agents/plugins/claude/settings-conflict.ts` | Pure detection helper — reads `~/.claude/settings.json`, compares URLs, returns `ConflictInfo \| null` | +| Create | `src/agents/plugins/claude/__tests__/settings-conflict.test.ts` | Unit tests for `detectSettingsConflict` (6 scenarios) | +| Modify | `src/agents/plugins/claude/claude.plugin.ts` | Add static import + call `detectSettingsConflict` in `beforeRun`; emit `chalk.yellow` warning | +| Create | `src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts` | Integration tests: `beforeRun` emits warning on conflict, silent on no-conflict | +| Modify | `.ai-run/guides/usage/project-config.md` | Document `~/.claude/settings.json` ANTHROPIC_BASE_URL override and detection behaviour | + +--- + +## Task 1: Create `detectSettingsConflict` helper (TDD) + +**Test-first: yes — failing test: `detectSettingsConflict returns ConflictInfo when URLs differ`** + +**Files:** +- Create: `src/agents/plugins/claude/__tests__/settings-conflict.test.ts` +- Create: `src/agents/plugins/claude/settings-conflict.ts` + +**Interfaces:** +- Produces: `ConflictInfo` interface and `detectSettingsConflict(env)` function consumed by Task 2 + +--- + +- [ ] **Step 1.1: Write the failing tests** + +Create `src/agents/plugins/claude/__tests__/settings-conflict.test.ts` with these exact contents: + +```typescript +/** + * Tests for detectSettingsConflict — detects when ~/.claude/settings.json + * ANTHROPIC_BASE_URL would override the active profile value. + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('fs/promises'); +vi.mock('fs'); +vi.mock('../../../../utils/paths.js', () => ({ + resolveHomeDir: vi.fn((dir: string) => `/home/testuser/${dir.replace(/^\./, '')}`), + getCodemieHome: vi.fn(() => '/home/testuser/.codemie'), + getCodemiePath: vi.fn((...parts: string[]) => `/home/testuser/.codemie/${parts.join('/')}`), +})); + +describe('detectSettingsConflict', () => { + let detectSettingsConflict: (env: NodeJS.ProcessEnv) => Promise; + let fsMod: typeof import('fs'); + let fsp: typeof import('fs/promises'); + + const SETTINGS_PATH = '/home/testuser/claude/settings.json'; + const PROFILE_URL = 'https://ai-proxy.lab.epam.com'; + const SETTINGS_URL = 'https://other-proxy.example.com'; + + beforeEach(async () => { + vi.resetModules(); + vi.resetAllMocks(); + + fsMod = await import('fs'); + fsp = await import('fs/promises'); + + const mod = await import('../settings-conflict.js'); + detectSettingsConflict = mod.detectSettingsConflict; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when settings.json does not exist', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(false); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns null when settings.json has no ANTHROPIC_BASE_URL key', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ statusLine: 'some-value' }) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns null when settings.json ANTHROPIC_BASE_URL equals env value', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ ANTHROPIC_BASE_URL: PROFILE_URL }) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns ConflictInfo when settings.json URL differs from env URL', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ ANTHROPIC_BASE_URL: SETTINGS_URL }) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toEqual({ settingsUrl: SETTINGS_URL, profileUrl: PROFILE_URL }); + }); + + it('returns ConflictInfo with undefined profileUrl when env has no ANTHROPIC_BASE_URL', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ ANTHROPIC_BASE_URL: SETTINGS_URL }) as any); + + const result = await detectSettingsConflict({}); + + expect(result).toEqual({ settingsUrl: SETTINGS_URL, profileUrl: undefined }); + }); + + it('returns null when settings.json is malformed JSON', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue('{ not valid json' as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); +}); +``` + +- [ ] **Step 1.2: Run the tests — confirm they FAIL** + +```bash +npx vitest run src/agents/plugins/claude/__tests__/settings-conflict.test.ts +``` + +Expected: FAIL — `Cannot find module '../settings-conflict.js'` + +- [ ] **Step 1.3: Implement `settings-conflict.ts`** + +Create `src/agents/plugins/claude/settings-conflict.ts` with these exact contents: + +```typescript +import { existsSync } from 'fs'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { resolveHomeDir } from '../../../utils/paths.js'; + +export interface ConflictInfo { + settingsUrl: string; + profileUrl: string | undefined; +} + +export async function detectSettingsConflict( + env: NodeJS.ProcessEnv +): Promise { + const settingsPath = join(resolveHomeDir('.claude'), 'settings.json'); + + if (!existsSync(settingsPath)) return null; + + let settings: Record; + try { + const raw = await readFile(settingsPath, 'utf-8'); + settings = JSON.parse(raw) as Record; + } catch { + return null; + } + + const settingsUrl = settings.ANTHROPIC_BASE_URL; + if (typeof settingsUrl !== 'string' || !settingsUrl) return null; + + const profileUrl = env.ANTHROPIC_BASE_URL; + if (profileUrl !== undefined && settingsUrl === profileUrl) return null; + + return { settingsUrl, profileUrl }; +} +``` + +- [ ] **Step 1.4: Run the tests — confirm they PASS** + +```bash +npx vitest run src/agents/plugins/claude/__tests__/settings-conflict.test.ts +``` + +Expected: PASS — 6 tests, all green + +- [ ] **Step 1.5: Typecheck the new file** + +```bash +npm run typecheck 2>&1 | grep -E "settings-conflict|error" | head -20 +``` + +Expected: no errors referencing `settings-conflict.ts` + +- [ ] **Step 1.6: Commit** + +```bash +git add src/agents/plugins/claude/settings-conflict.ts \ + src/agents/plugins/claude/__tests__/settings-conflict.test.ts +git commit -m "feat(EPMCDME-10988): add detectSettingsConflict helper" +``` + +--- + +## Task 2: Wire detection into `claude.plugin.ts` `beforeRun` (TDD) + +**Test-first: yes — failing test: `beforeRun emits chalk warning when conflict is detected`** + +**Files:** +- Create: `src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts` +- Modify: `src/agents/plugins/claude/claude.plugin.ts` + +**Interfaces:** +- Consumes: `detectSettingsConflict` from `./settings-conflict.js` (Task 1) +- Produces: `beforeRun` now emits `chalk.yellow` warning to stderr when `ConflictInfo` is returned + +--- + +- [ ] **Step 2.1: Write the failing integration tests** + +Create `src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts` with these exact contents: + +```typescript +/** + * Tests for Claude Plugin beforeRun conflict detection — warns when + * ~/.claude/settings.json ANTHROPIC_BASE_URL would override the profile value. + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { AgentConfig } from '../../../core/types.js'; + +vi.mock('fs/promises'); +vi.mock('fs'); + +vi.mock('../statusline-installer.js', () => ({ + installStatusline: vi.fn(), +})); + +vi.mock('../settings-conflict.js', () => ({ + detectSettingsConflict: vi.fn(), +})); + +vi.mock('../../../../utils/paths.js', () => ({ + resolveHomeDir: vi.fn((dir: string) => `/home/testuser/${dir.replace(/^\./, '')}`), + getCodemieHome: vi.fn(() => '/home/testuser/.codemie'), + getCodemiePath: vi.fn((...parts: string[]) => `/home/testuser/.codemie/${parts.join('/')}`), +})); + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + setAgentName: vi.fn(), + setProfileName: vi.fn(), + setSessionId: vi.fn(), + }, +})); + +vi.mock('../../../../utils/security.js', () => ({ + sanitizeLogArgs: vi.fn((...args: unknown[]) => args), +})); + +type HookEnv = NodeJS.ProcessEnv; +type BeforeRunFn = (env: HookEnv, config: AgentConfig) => Promise; + +describe('Claude Plugin – settings conflict detection in beforeRun', () => { + let beforeRun: BeforeRunFn; + let conflictMod: { detectSettingsConflict: ReturnType }; + let consoleErrorSpy: ReturnType; + + const mockConfig: AgentConfig = {}; + + beforeEach(async () => { + vi.resetModules(); + vi.resetAllMocks(); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const mod = await import('../claude.plugin.js'); + beforeRun = mod.ClaudePluginMetadata.lifecycle!.beforeRun!; + + conflictMod = (await import('../settings-conflict.js')) as any; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('emits a chalk warning to stderr when detectSettingsConflict returns ConflictInfo', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://other-proxy.example.com', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const calls = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')); + const warningCall = calls.find(s => s.includes('⚠️') || s.includes('ANTHROPIC_BASE_URL')); + expect(warningCall).toBeDefined(); + + const allOutput = calls.join('\n'); + expect(allOutput).toContain('https://other-proxy.example.com'); + expect(allOutput).toContain('https://ai-proxy.lab.epam.com'); + }); + + it('emits warning showing "(not set)" when profileUrl is undefined', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://other-proxy.example.com', + profileUrl: undefined, + }); + + const env: HookEnv = {}; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).toContain('not set'); + expect(allOutput).toContain('https://other-proxy.example.com'); + }); + + it('does not emit any conflict warning when detectSettingsConflict returns null', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue(null); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const conflictCalls = consoleErrorSpy.mock.calls.filter(c => + String(c[0] ?? '').includes('⚠️') || String(c[0] ?? '').includes('settings.json') + ); + expect(conflictCalls).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2.2: Run the tests — confirm they FAIL** + +```bash +npx vitest run src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +``` + +Expected: FAIL — `detectSettingsConflict` is not called from `beforeRun` yet + +- [ ] **Step 2.3: Add import to `claude.plugin.ts`** + +In `src/agents/plugins/claude/claude.plugin.ts`, add the import after the existing imports (around line 25, after the `detectInstallationMethod` import): + +```typescript +import { detectSettingsConflict } from './settings-conflict.js'; +``` + +Exact location — after this existing line: +```typescript +} from '../../../utils/installation-detector.js'; +``` + +Add: +```typescript +import { detectSettingsConflict } from './settings-conflict.js'; +``` + +- [ ] **Step 2.4: Add conflict detection call in `beforeRun`** + +In `src/agents/plugins/claude/claude.plugin.ts`, inside `lifecycle.beforeRun(env)`, add the conflict detection block just before the final `return env;` statement (currently at line 224). + +Replace: +```typescript + return env; + }, +``` + +With: +```typescript + // Detect ANTHROPIC_BASE_URL override in ~/.claude/settings.json + // Claude Code reads settings.json at startup and silently overrides env vars; + // warn the user so they know which endpoint is actually in use. + const conflict = await detectSettingsConflict(env); + if (conflict) { + const profileDisplay = conflict.profileUrl ?? '(not set — direct Anthropic API)'; + console.error(chalk.yellow('\n⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json')); + console.error(chalk.yellow('─'.repeat(60))); + console.error(chalk.yellow(` Profile URL │ ${profileDisplay}`)); + console.error(chalk.yellow(` Active URL │ ${conflict.settingsUrl} ← settings.json wins`)); + console.error(chalk.yellow('')); + console.error(chalk.yellow(' ~/.claude/settings.json ANTHROPIC_BASE_URL takes precedence')); + console.error(chalk.yellow(' over the profile value. Session will use the settings.json URL.')); + console.error(chalk.yellow('')); + console.error(chalk.yellow(' To fix: remove ANTHROPIC_BASE_URL from ~/.claude/settings.json')); + console.error(chalk.yellow('─'.repeat(60))); + console.error(''); + } + + return env; + }, +``` + +- [ ] **Step 2.5: Run the integration tests — confirm they PASS** + +```bash +npx vitest run src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +``` + +Expected: PASS — 3 tests, all green + +- [ ] **Step 2.6: Run the full unit test suite to confirm no regressions** + +```bash +npm run test:unit +``` + +Expected: all pre-existing tests pass, plus 3 new conflict tests and 6 new settings-conflict tests + +- [ ] **Step 2.7: Typecheck** + +```bash +npm run typecheck 2>&1 | grep -E "error|claude.plugin" | head -20 +``` + +Expected: no new errors + +- [ ] **Step 2.8: Commit** + +```bash +git add src/agents/plugins/claude/claude.plugin.ts \ + src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +git commit -m "feat(EPMCDME-10988): warn on ANTHROPIC_BASE_URL override in settings.json" +``` + +--- + +## Task 3: Update documentation + +**Test-first: no — documentation-only change** + +**Files:** +- Modify: `.ai-run/guides/usage/project-config.md` + +--- + +- [ ] **Step 3.1: Add the ANTHROPIC_BASE_URL Override section** + +In `.ai-run/guides/usage/project-config.md`, find the `## Troubleshooting` section and add a new section immediately before it: + +```markdown +## Claude Code ANTHROPIC_BASE_URL Override + +Claude Code reads `~/.claude/settings.json` at startup. If that file contains an +`ANTHROPIC_BASE_URL` key, Claude Code uses it instead of the environment variable — +silently overriding the value injected by the active `codemie-code` profile. + +`codemie-code` detects this at startup and prints a warning showing: + +- **Profile URL** — the URL the active profile tried to inject +- **Active URL** — the settings.json URL that will actually be used + +**Precedence chain (highest wins):** + +``` +~/.claude/settings.json (ANTHROPIC_BASE_URL) > env.ANTHROPIC_BASE_URL (codemie-code profile) +``` + +**To resolve:** remove `ANTHROPIC_BASE_URL` from `~/.claude/settings.json`. You can open +the file at: + +```bash +cat ~/.claude/settings.json +``` + +and delete the `"ANTHROPIC_BASE_URL"` key. The profile's value will then be used as intended. + +``` + +- [ ] **Step 3.2: Commit** + +```bash +git add .ai-run/guides/usage/project-config.md +git commit -m "docs(EPMCDME-10988): document ANTHROPIC_BASE_URL override detection in project-config guide" +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec requirement | Task | +|---|---| +| Detect `ANTHROPIC_BASE_URL` override at startup | Task 1 (`detectSettingsConflict`) + Task 2 (wired in `beforeRun`) | +| Clear warning printed before session starts | Task 2 (chalk.yellow to stderr in `beforeRun`) | +| Banner / info reflects actual endpoint | Task 2 (warning shows `settingsUrl` as "Active URL") | +| Documentation updated | Task 3 | +| Tests covering override scenario | Task 1 (6 unit tests) + Task 2 (3 integration tests) | + +All 5 acceptance criteria covered. ✓ + +**Placeholder scan:** No TBD, TODO, or "similar to Task N" patterns. All code blocks complete. ✓ + +**Type consistency:** +- `ConflictInfo` defined in Task 1 (`settings-conflict.ts`), consumed in Task 2 (`claude.plugin.ts`) via import — consistent. +- `detectSettingsConflict(env: NodeJS.ProcessEnv): Promise` — signature consistent across definition (Task 1) and usage (Task 2). +- `conflict.settingsUrl` and `conflict.profileUrl` used consistently in Task 2 warning block. ✓ diff --git a/docs/superpowers/specs/2026-07-15-epmcdme-10988-settings-conflict-detection-design.md b/docs/superpowers/specs/2026-07-15-epmcdme-10988-settings-conflict-detection-design.md new file mode 100644 index 00000000..72bfc79b --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-epmcdme-10988-settings-conflict-detection-design.md @@ -0,0 +1,215 @@ +# Design: ANTHROPIC_BASE_URL Settings Conflict Detection + +**Date**: 2026-07-15 +**Ticket**: EPMCDME-10988 +**Run**: 20260715-1402-EPMCDME-10988 +**Complexity**: M (score 17/36, routing: brainstorming) + +--- + +## Problem + +`codemie-code` injects `ANTHROPIC_BASE_URL` into the process environment via `transformEnvVars()`, mapping it from the active profile's `CODEMIE_BASE_URL`. However, Claude Code reads `~/.claude/settings.json` at its own startup and silently overrides any environment-level `ANTHROPIC_BASE_URL` with the value in that file. The startup banner (`renderProfileInfo`) shows the profile URL, not the effective URL — giving the user a false picture of where requests go. + +--- + +## Execution Order Context + +`BaseAgentAdapter.run()` executes in this fixed order: + +``` +L537 renderProfileInfo(env.CODEMIE_URL) // banner: shows profile URL +L557 transformEnvVars(env) // sets env.ANTHROPIC_BASE_URL = CODEMIE_BASE_URL +L561 executeBeforeRun() → beforeRun(env) // called with already-transformed env + ↑ THIS is where detection must happen +L??? Claude Code starts // reads ~/.claude/settings.json, overrides ANTHROPIC_BASE_URL +``` + +Detection must happen in `beforeRun`: by that point, `env.ANTHROPIC_BASE_URL` holds the profile value, and the comparison against `~/.claude/settings.json` is meaningful. + +--- + +## Approaches Considered + +| Approach | Summary | Verdict | +|---|---|---| +| A. Inline in `beforeRun` | Add detection logic inside the existing lifecycle hook | Rejected — conflates two responsibilities; detection logic can't be unit-tested in isolation | +| **B. New helper module** | `settings-conflict.ts` called from `beforeRun` | **Recommended** — single responsibility, testable, follows existing `statusline-installer.ts` pattern | +| C. Patch `renderProfileInfo()` | Read `settings.json` from the banner function | Rejected — wrong timing (called before `transformEnvVars`; profile URL not in env yet); also couples a generic utility to Claude-specific paths | + +--- + +## Design + +### New file: `src/agents/plugins/claude/settings-conflict.ts` + +Exports a pure async detection function and a typed result: + +```typescript +export interface ConflictInfo { + /** URL that will actually be used (from ~/.claude/settings.json) */ + settingsUrl: string; + /** URL the active profile injected; undefined when no profile URL was set */ + profileUrl: string | undefined; +} + +export async function detectSettingsConflict( + env: NodeJS.ProcessEnv +): Promise +``` + +**Logic:** + +1. Resolve `settingsPath = join(resolveHomeDir('.claude'), 'settings.json')`. +2. If file does not exist → return `null`. +3. Parse JSON; if malformed → return `null` (non-fatal; startup continues unchanged). +4. Read `settings.ANTHROPIC_BASE_URL`; if absent → return `null`. +5. Compare against `env.ANTHROPIC_BASE_URL`; if equal → return `null` (same endpoint, no confusion). +6. Return `{ settingsUrl: settings.ANTHROPIC_BASE_URL as string, profileUrl: env.ANTHROPIC_BASE_URL }`. + +**Edge cases:** + +| Scenario | Behavior | +|---|---| +| `~/.claude/settings.json` absent | `null` — no false positive | +| File present, no `ANTHROPIC_BASE_URL` key | `null` | +| Both URLs identical | `null` — no user confusion | +| URLs differ | `ConflictInfo` returned | +| `env.ANTHROPIC_BASE_URL` undefined (no profile URL set) + settings has URL | `ConflictInfo` returned — user still needs to know what URL is active | +| Malformed JSON | `null` — non-fatal; log nothing | + +**Imports:** +- `existsSync` from `'fs'` +- `readFile` from `'fs/promises'` +- `join` from `'path'` +- `resolveHomeDir` from `'../../../utils/paths.js'` (deep-relative, matching the pattern in `claude.plugin.ts` line 21) + +### Modified: `src/agents/plugins/claude/claude.plugin.ts` + +In `lifecycle.beforeRun(env)`, after the existing `statusLine` injection block: + +```typescript +import { detectSettingsConflict } from './settings-conflict.js'; +// chalk is already imported in claude.plugin.ts (line 20) + +// ... existing statusLine logic ... + +const conflict = await detectSettingsConflict(env); +if (conflict) { + const profileDisplay = conflict.profileUrl ?? '(not set — direct Anthropic API)'; + console.error(chalk.yellow('\n⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json')); + console.error(chalk.yellow('─'.repeat(60))); + console.error(chalk.yellow(` Profile URL │ ${profileDisplay}`)); + console.error(chalk.yellow(` Active URL │ ${conflict.settingsUrl} ← settings.json wins`)); + console.error(chalk.yellow('')); + console.error(chalk.yellow(' ~/.claude/settings.json ANTHROPIC_BASE_URL takes precedence')); + console.error(chalk.yellow(' over the profile value. Session will use the settings.json URL.')); + console.error(chalk.yellow('')); + console.error(chalk.yellow(' To fix: remove ANTHROPIC_BASE_URL from ~/.claude/settings.json')); + console.error(chalk.yellow('─'.repeat(60))); + console.error(''); +} +``` + +This matches the existing `chalk.yellow` + `console.error` pattern used for warnings in `claude.plugin.ts` (lines 563, 580). No new imports needed — `chalk` is already imported at line 20. + +### New file: `src/agents/plugins/claude/__tests__/settings-conflict.test.ts` + +Unit tests using the mocking pattern from `claude.plugin.statusline.test.ts`: + +```typescript +vi.mock('fs/promises'); +vi.mock('fs'); +vi.mock('../../../../utils/paths.js', () => ({ + resolveHomeDir: vi.fn((dir: string) => `/home/testuser/${dir.replace(/^\./, '')}`), +})); +``` + +Test cases: + +| # | Scenario | Expected | +|---|---|---| +| 1 | settings.json does not exist | `null` | +| 2 | settings.json has no `ANTHROPIC_BASE_URL` | `null` | +| 3 | settings.json URL === env URL | `null` | +| 4 | URLs differ | `ConflictInfo { settingsUrl, profileUrl }` | +| 5 | env `ANTHROPIC_BASE_URL` undefined, settings has URL | `ConflictInfo { settingsUrl, profileUrl: undefined }` | +| 6 | settings.json is malformed JSON | `null` | + +Integration test addition in `claude.plugin.conflict.test.ts` (dedicated file — do not mix into `claude.plugin.statusline.test.ts`): + +- `beforeRun` calls `console.error(chalk.yellow(...))` when `detectSettingsConflict` returns non-null +- `beforeRun` does NOT call `console.error` for conflict when `detectSettingsConflict` returns `null` + +### Documentation update: `.ai-run/guides/usage/project-config.md` + +Add a section: + +```markdown +## Claude Code ANTHROPIC_BASE_URL Override + +Claude Code reads `~/.claude/settings.json` at startup. If that file contains an +`ANTHROPIC_BASE_URL` key, Claude Code uses it instead of any environment variable. + +`codemie-code` detects this at startup and prints a visible warning showing: +- The profile URL that was injected +- The settings.json URL that will actually be used + +**Precedence chain (highest wins):** +`~/.claude/settings.json` → `env.ANTHROPIC_BASE_URL` (codemie-code profile) + +To avoid silent overrides: remove `ANTHROPIC_BASE_URL` from `~/.claude/settings.json`. +``` + +--- + +## Data Flow + +``` +BaseAgentAdapter.run() + 1. renderProfileInfo(env.CODEMIE_URL) → banner shows profile URL + 2. transformEnvVars(env) → env.ANTHROPIC_BASE_URL = CODEMIE_BASE_URL + 3. beforeRun(env) + → detectSettingsConflict(env) + read ~/.claude/settings.json + if settings.ANTHROPIC_BASE_URL ≠ env.ANTHROPIC_BASE_URL + return { settingsUrl, profileUrl } + else + return null + → if ConflictInfo: console.error(chalk.yellow(...)) → stderr + → return env (env unchanged; warning is informational) + 4. Claude Code starts → uses settings.json URL if present +``` + +--- + +## Files Changed + +| File | Change | +|---|---| +| `src/agents/plugins/claude/settings-conflict.ts` | New — detection helper | +| `src/agents/plugins/claude/claude.plugin.ts` | Modified — import `detectSettingsConflict`, add conflict check in `beforeRun` (chalk already imported) | +| `src/agents/plugins/claude/__tests__/settings-conflict.test.ts` | New — unit tests | +| `src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts` | New — integration tests for `beforeRun` warning call | +| `.ai-run/guides/usage/project-config.md` | Updated — document `~/.claude/settings.json` precedence | + +--- + +## Acceptance Criteria Mapping + +| AC | Implementation | +|---|---| +| Detect `ANTHROPIC_BASE_URL` override at startup | `detectSettingsConflict` called from `beforeRun` | +| Clear warning printed before session starts | `console.error(chalk.yellow(...))` in `beforeRun` | +| Banner / info reflects actual endpoint | Warning shows `settingsUrl` as "Active URL" alongside "Profile URL" | +| Documentation updated | `.ai-run/guides/usage/project-config.md` section added | +| Tests added/updated covering override scenario | `settings-conflict.test.ts` + `claude.plugin.conflict.test.ts` | + +--- + +## Out of Scope + +- Blocking startup (the ticket marks this as explicitly optional; warning is sufficient) +- Modifying `renderProfileInfo()` signature — wrong timing in the call sequence +- Reading any settings keys other than `ANTHROPIC_BASE_URL` +- Auto-removing the conflicting key from `settings.json` diff --git a/docs/superpowers/work-items/EPMCDME-10988.md b/docs/superpowers/work-items/EPMCDME-10988.md new file mode 100644 index 00000000..625beead --- /dev/null +++ b/docs/superpowers/work-items/EPMCDME-10988.md @@ -0,0 +1,34 @@ +# Work Item: EPMCDME-10988 + +**Type**: Bug +**Status**: Ready for review +**Assignee**: Aleksandr Budanov +**External Ticket**: EPMCDME-10988 (Jira EPM-CDME) +**External Sync**: synced + +## Summary + +codemie-code does not check for environment variable overrides caused by `~/.claude/settings.json`; no warning/error issued for config mismatch. + +## Description + +When `~/.claude/settings.json` contains an `ANTHROPIC_BASE_URL` key, that setting takes precedence over any environment variables set by codemie-code runtime profiles. codemie-code does not detect or report this override — it continues to display the profile's backend URL while the session silently uses the `settings.json` value. This causes invisible misconfiguration and session failures. + +## Acceptance Criteria + +- codemie-code inspects `~/.claude/settings.json` at startup and detects if `ANTHROPIC_BASE_URL` there will override the env var. +- If override is detected, a clear warning or error is printed before the session starts. +- The CLI displays the actual endpoint/model being used. +- Documentation updated regarding config file/env var precedence. +- Tests updated to cover the override scenario and expected warnings/errors. + +## Linked Artifacts + +- `docs/superpowers/runs/20260715-1402-EPMCDME-10988/requirements.md` + +## History + +| When | Event | Detail | +|---|---|---| +| 2026-07-15T14:02:00Z | work_item.created | SDLC run 20260715-1402-EPMCDME-10988 initialized | +| 2026-07-15T14:02:00Z | work_item.adapter_receipt | Jira lookup succeeded — summary, description, AC retrieved | diff --git a/docs/superpowers/work-items/work-item-events.jsonl b/docs/superpowers/work-items/work-item-events.jsonl index 39d9d257..b7c141b6 100644 --- a/docs/superpowers/work-items/work-item-events.jsonl +++ b/docs/superpowers/work-items/work-item-events.jsonl @@ -7,3 +7,7 @@ {"schema":1,"ts":"2026-07-09T01:36:50+02:00","event":"work_item.created","actor":"requirements-intake","summary":"Canonical local work item session-exit-analytics-report created","artifacts":["docs/superpowers/work-items/session-exit-analytics-report.md"],"data":{"external_sync":"pending"}} {"schema":1,"ts":"2026-07-09T01:36:50+02:00","event":"work_item.linked_artifact","actor":"requirements-intake","summary":"requirements.md linked","artifacts":["docs/superpowers/runs/20260709-0134-analytics-finalizer/requirements.md"],"data":{}} {"schema":1,"ts":"2026-07-09T08:14:25+02:00","event":"work_item.transitioned","actor":"sdlc-pipeline","summary":"Status -> Ready for review","artifacts":["docs/superpowers/work-items/session-exit-analytics-report.md"],"data":{"status":"Ready for review","branch":"fix/analytics-session-filter"}} +{"schema":1,"ts":"2026-07-15T14:02:00Z","event":"work_item.created","run_id":"20260715-1402-EPMCDME-10988","phase":1,"actor":"requirements-intake","summary":"Canonical work item EPMCDME-10988 created","artifacts":["docs/superpowers/work-items/EPMCDME-10988.md"],"data":{"external_ticket":"EPMCDME-10988","external_sync":"synced"}} +{"schema":1,"ts":"2026-07-15T14:02:00Z","event":"work_item.adapter_receipt","run_id":"20260715-1402-EPMCDME-10988","phase":1,"actor":"requirements-intake","summary":"Jira adapter returned summary, description, and AC for EPMCDME-10988","artifacts":[],"data":{"status":"success","source":"codemie-jira-assistant"}} +{"schema":1,"ts":"2026-07-15T14:02:00Z","event":"work_item.linked_artifact","run_id":"20260715-1402-EPMCDME-10988","phase":1,"actor":"requirements-intake","summary":"requirements.md linked to EPMCDME-10988","artifacts":["docs/superpowers/runs/20260715-1402-EPMCDME-10988/requirements.md"],"data":{}} +{"schema":1,"ts":"2026-07-16T00:48:00Z","event":"work-item.status-changed","from":"Ready for dev","to":"Ready for review","run_id":"20260715-1402-EPMCDME-10988"} diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts new file mode 100644 index 00000000..e9b11c00 --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts @@ -0,0 +1,352 @@ +/** + * Tests for Claude Plugin beforeRun conflict detection — warns when + * ~/.claude/settings.json ANTHROPIC_BASE_URL would override the profile value. + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { AgentConfig } from '../../../core/types.js'; + +vi.mock('fs/promises'); +vi.mock('fs'); + +vi.mock('../statusline-installer.js', () => ({ + installStatusline: vi.fn(), +})); + +vi.mock('../settings-conflict.js', () => ({ + detectSettingsConflict: vi.fn(), +})); + +vi.mock('../../../../utils/paths.js', () => ({ + resolveHomeDir: vi.fn((dir: string) => `/home/testuser/${dir.replace(/^\./, '')}`), + getCodemieHome: vi.fn(() => '/home/testuser/.codemie'), + getCodemiePath: vi.fn((...parts: string[]) => `/home/testuser/.codemie/${parts.join('/')}`), +})); + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + setAgentName: vi.fn(), + setProfileName: vi.fn(), + setSessionId: vi.fn(), + }, +})); + +vi.mock('../../../../utils/security.js', () => ({ + sanitizeLogArgs: vi.fn((...args: unknown[]) => args), +})); + +// BaseAgentAdapter transitively imports providers/index.ts which auto-registers all provider +// plugins on load. At least one provider (SSO) attempts network I/O during registration, +// causing a 30-second timeout in CI/WSL environments. Mock the class entirely — the test +// only exercises ClaudePluginMetadata.lifecycle.beforeRun, which is a plain object property +// and never touches the class hierarchy at runtime. +vi.mock('../../../core/BaseAgentAdapter.js', () => ({ + BaseAgentAdapter: class { + constructor() {} + }, +})); + +type HookEnv = NodeJS.ProcessEnv; +type BeforeRunFn = (env: HookEnv, config: AgentConfig) => Promise; + +describe('Claude Plugin – settings conflict detection in beforeRun', () => { + let beforeRun: BeforeRunFn; + let conflictMod: { detectSettingsConflict: ReturnType }; + let consoleErrorSpy: ReturnType; + + const mockConfig: AgentConfig = {}; + + beforeEach(async () => { + vi.resetModules(); + vi.resetAllMocks(); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const mod = await import('../claude.plugin.js'); + beforeRun = mod.ClaudePluginMetadata.lifecycle!.beforeRun!; + + conflictMod = (await import('../settings-conflict.js')) as any; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('emits a chalk warning to stderr when detectSettingsConflict returns ConflictInfo', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://other-proxy.example.com', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const calls = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')); + const warningCall = calls.find(s => s.includes('⚠️') || s.includes('ANTHROPIC_BASE_URL')); + expect(warningCall).toBeDefined(); + + const allOutput = calls.join('\n'); + expect(allOutput).toContain('https://other-proxy.example.com'); + expect(allOutput).toContain('https://ai-proxy.lab.epam.com'); + }); + + it('emits warning showing "(not set)" when profileUrl is undefined', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://other-proxy.example.com', + profileUrl: undefined, + }); + + const env: HookEnv = {}; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).toContain('not set'); + expect(allOutput).toContain('https://other-proxy.example.com'); + }); + + it('does not emit any conflict warning when detectSettingsConflict returns null', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue(null); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const conflictCalls = consoleErrorSpy.mock.calls.filter(c => + String(c[0] ?? '').includes('⚠️') || String(c[0] ?? '').includes('settings.json') + ); + expect(conflictCalls).toHaveLength(0); + }); + + it('does not throw when detectSettingsConflict rejects, calls logger.warn instead', async () => { + conflictMod.detectSettingsConflict.mockRejectedValue(new Error('os.homedir() failed')); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await expect(beforeRun(env, mockConfig)).resolves.not.toThrow(); + + const loggerMod = await import('../../../../utils/logger.js'); + expect(loggerMod.logger.warn).toHaveBeenCalledWith( + '[Claude] Failed to check for ANTHROPIC_BASE_URL settings conflict', + expect.anything() + ); + }); + + it('shows "(not set)" when profileUrl is empty string', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://other-proxy.example.com', + profileUrl: '', + }); + + const env: HookEnv = {}; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).toContain('not set'); + expect(allOutput).not.toMatch(/Profile URL\s+│\s+\n/); + }); + + it('strips C0 control characters from URL values to prevent terminal injection', async () => { + // A \r in the URL value would move the cursor to column 0 and allow the subsequent + // text to overwrite the displayed line (terminal injection). safeUrl() strips \r so + // the raw bytes cannot manipulate the terminal, even though the remaining text is still + // included in the displayed URL value. + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://malicious.example.com\r FORGED LINE', + profileUrl: 'https://ai-proxy.lab.epam.com\x1b[2K', + }); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).not.toContain('\r'); + expect(allOutput).not.toContain('\x1b[2K'); + }); + + it('strips single-byte C1 CSI (\\x9b) sequences to prevent terminal injection via C1 form', async () => { + // \x9b is the single-byte form of ESC[ (CSI) used in 8-bit terminal emulators. + // A URL containing \x9b31mFORGED\x9bm would render as colored "FORGED" if \x9b is not stripped. + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://proxy/\x9b31mFORGED\x9bm', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).not.toContain('\x9b'); + expect(allOutput).not.toContain('31mFORGED'); + }); + + it('strips DCS payload (\\x1bP...\\x07) — ansi-regex only strips the 2-byte introducer, leaving payload text', async () => { + // strip-ansi matches \x1bP as a 2-char CSI (P falls in A-P final-byte range) and strips + // only those two bytes; the payload and BEL (\x07, C0-stripped) are left. An attacker + // can inject arbitrary readable text inline: \x1bPINJECTED\x07 → "INJECTED". + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://proxy/\x1bPINJECTED\x07', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).not.toContain('INJECTED'); + }); + + it('preserves em dash in the hardcoded "(not set)" fallback when profileUrl is absent', async () => { + // The fallback literal contains U+2014 (em dash). safeUrl's ASCII allowlist strips + // everything above 0x7E — passing the constant through safeUrl corrupts it to a + // double space. The fix bypasses safeUrl for the known-safe hardcoded string. + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://proxy.example.com', + profileUrl: undefined, + }); + const env: HookEnv = {}; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).toContain('not set — direct Anthropic API'); + expect(allOutput).not.toContain('not set direct'); + }); + + it('strips DCS payload with no terminator — |$ fallback consumes to end-of-string', async () => { + // An unterminated DCS sequence (\x1bPINJECTED, no \x07/\x1b\/\x9c) must still be + // fully consumed. The regex uses |$ so the lazy *? scans to EOL if no terminator found. + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://proxy/\x1bPINJECTED', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).not.toContain('INJECTED'); + }); + + it('strips C1 DCS form (\\x90...\\x07) — single-byte introducer used by 8-bit terminals', async () => { + // \x90 is the single-byte C1 form of DCS. strip-ansi does not recognise it. + // The allowlist alone strips \x90 but leaves the ASCII payload intact. + // The DCS pre-strip regex covers [\x90\x98\x9e\x9f] for this reason. + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://proxy/\x90INJECTED\x07', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).not.toContain('INJECTED'); + }); + + it('strips URL userinfo (@-trick) and prepends [credentials removed]', async () => { + // https://user@evil.com — RFC 3986 userinfo: "user" is the credential, "evil.com" is the + // actual host. Displaying the raw string lets the user see "user" as the apparent hostname. + // The fix parses the URL, removes userinfo, and flags it with [credentials removed]. + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://trusted.epam.com@evil.com/path', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).toContain('[credentials removed]'); + expect(allOutput).toContain('evil.com'); + expect(allOutput).not.toContain('trusted.epam.com@'); + }); + + it('strips URL userinfo including password field (user:pass@host)', async () => { + // Covers the url.password branch of the userinfo guard — distinct from the + // username-only case already tested above. + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://user:s3cr3t@evil.com/path', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).toContain('[credentials removed]'); + expect(allOutput).not.toContain('s3cr3t'); + expect(allOutput).not.toContain('user:'); + }); + + it('strips CSI sequences atomically so bracket residue does not appear in output', async () => { + // If the regex alternation consumes \x1b via the C0 alternative first, the bracket + // sequence ([31mFORGED[0m) leaks as visible text. The CSI alternative must be tried + // first so the entire escape sequence is consumed in one match. + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://proxy/\x1b[31mFORGED\x1b[0m', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + // chalk adds its own \x1b codes; check only that the injected residue sequences are gone + expect(allOutput).not.toContain('[31m'); + expect(allOutput).not.toContain('[0m'); + }); + + // CR-001 regression guard: C1-form OSC (\x9d) must be stripped by the DCS pre-pass. + // strip-ansi does not recognise \x9d; without the pre-strip fix the payload leaks as + // printable ASCII text after \x9d is removed by the allowlist. + it('strips C1-form OSC (\\x9d) payload to prevent terminal injection', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://proxy/\x9d0;injected-title\x07', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).not.toContain('injected-title'); + expect(allOutput).not.toContain('\x9d'); + }); + + // CR-002 regression guard: hash fragments must be stripped from URLs displayed in the + // warning. A URL with a hash-only payload (no credentials) previously fell through to the + // `return stripped` path, preserving the fragment verbatim in the output. + it('strips URL hash fragment from settingsUrl to prevent misleading terminal output', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsUrl: 'https://proxy.example.com/path#injected-section', + profileUrl: 'https://ai-proxy.lab.epam.com', + }); + + const env: HookEnv = { ANTHROPIC_BASE_URL: 'https://ai-proxy.lab.epam.com' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + expect(allOutput).not.toContain('#injected-section'); + expect(allOutput).toContain('[credentials removed]'); + }); + + // CR-003 regression guard: conflict.profileModel comes from env.ANTHROPIC_MODEL and must + // be sanitized via safeUrl() before reaching console.error. Previously it was interpolated + // raw, allowing ANSI injection via a crafted ANTHROPIC_MODEL environment variable. + // safeUrl strips control codes but preserves printable ASCII text — the injected CSI + // sequences (\x1b[31m, \x1b[0m) must not appear as residue alongside chalk's own codes. + it('strips CSI sequences from conflict.profileModel (env.ANTHROPIC_MODEL)', async () => { + conflictMod.detectSettingsConflict.mockResolvedValue({ + settingsModel: 'claude-opus-4-5', + profileModel: 'claude-sonnet\x1b[31mFORGED\x1b[0m', + }); + + const env: HookEnv = { ANTHROPIC_MODEL: 'claude-sonnet' }; + await beforeRun(env, mockConfig); + + const allOutput = consoleErrorSpy.mock.calls.map(c => String(c[0] ?? '')).join('\n'); + // chalk adds its own \x1b codes; verify injected CSI residue is gone + expect(allOutput).not.toContain('[31m'); + expect(allOutput).not.toContain('[0m'); + expect(allOutput).toContain('claude-sonnet'); + }); +}); diff --git a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts new file mode 100644 index 00000000..ecb1acdc --- /dev/null +++ b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts @@ -0,0 +1,208 @@ +/** + * Tests for detectSettingsConflict — detects when ~/.claude/settings.json + * ANTHROPIC_BASE_URL would override the active profile value. + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; + +vi.mock('fs/promises'); +vi.mock('fs'); +vi.mock('../../../../utils/paths.js', () => ({ + resolveHomeDir: vi.fn((dir: string) => `/home/testuser/${dir.replace(/^\./, '')}`), + getCodemieHome: vi.fn(() => '/home/testuser/.codemie'), + getCodemiePath: vi.fn((...parts: string[]) => `/home/testuser/.codemie/${parts.join('/')}`), +})); + +describe('detectSettingsConflict', () => { + let detectSettingsConflict: (env: NodeJS.ProcessEnv) => Promise; + let fsMod: typeof import('fs'); + let fsp: typeof import('fs/promises'); + + const SETTINGS_PATH = join('/home/testuser/claude', 'settings.json'); + const PROFILE_URL = 'https://ai-proxy.lab.epam.com'; + const SETTINGS_URL = 'https://other-proxy.example.com'; + + beforeEach(async () => { + vi.resetModules(); + vi.resetAllMocks(); + + fsMod = await import('fs'); + fsp = await import('fs/promises'); + + const mod = await import('../settings-conflict.js'); + detectSettingsConflict = mod.detectSettingsConflict; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when settings.json does not exist', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(false); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(vi.mocked(fsMod.existsSync)).toHaveBeenCalledWith(SETTINGS_PATH); + expect(result).toBeNull(); + }); + + it('returns null when settings.json has no ANTHROPIC_BASE_URL key', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ statusLine: 'some-value' }) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns null when settings.json ANTHROPIC_BASE_URL equals env value', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ env: { ANTHROPIC_BASE_URL: PROFILE_URL } }) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns ConflictInfo when settings.json URL differs from env URL', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ env: { ANTHROPIC_BASE_URL: SETTINGS_URL } }) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(vi.mocked(fsMod.existsSync)).toHaveBeenCalledWith(SETTINGS_PATH); + expect(result).toEqual({ settingsUrl: SETTINGS_URL, profileUrl: PROFILE_URL }); + }); + + it('returns ConflictInfo with undefined profileUrl when env has no ANTHROPIC_BASE_URL', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ env: { ANTHROPIC_BASE_URL: SETTINGS_URL } }) as any); + + const result = await detectSettingsConflict({}); + + expect(result).toEqual({ settingsUrl: SETTINGS_URL, profileUrl: undefined }); + }); + + it('returns null when settings.json is malformed JSON', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue('{ not valid json' as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns null when settings.json contains JSON null (valid JSON, non-object)', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue('null' as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns null when settings.json contains a JSON array (non-object)', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify([{ ANTHROPIC_BASE_URL: SETTINGS_URL }]) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns null when ANTHROPIC_BASE_URL is at root level (not under env block)', async () => { + // CR-001 regression guard: keys must be under settings.env, not at root + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ ANTHROPIC_BASE_URL: SETTINGS_URL }) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns null when settings.json ANTHROPIC_BASE_URL is an empty string', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ env: { ANTHROPIC_BASE_URL: '' } }) as any); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + it('returns null when readFile rejects with a filesystem error', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockRejectedValue( + Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }), + ); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + + expect(result).toBeNull(); + }); + + const PROFILE_MODEL = 'claude-opus-4-5'; + const SETTINGS_MODEL = 'claude-3-haiku-20240307'; + + it('returns ConflictInfo with model fields when settings.json ANTHROPIC_MODEL differs from env', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue( + JSON.stringify({ env: { ANTHROPIC_MODEL: SETTINGS_MODEL } }) as any, + ); + + const result = await detectSettingsConflict({ ANTHROPIC_MODEL: PROFILE_MODEL }); + + expect(result).toEqual({ settingsModel: SETTINGS_MODEL, profileModel: PROFILE_MODEL }); + }); + + it('returns ConflictInfo with model fields when env has no ANTHROPIC_MODEL', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue( + JSON.stringify({ env: { ANTHROPIC_MODEL: SETTINGS_MODEL } }) as any, + ); + + const result = await detectSettingsConflict({}); + + expect(result).toEqual({ settingsModel: SETTINGS_MODEL, profileModel: undefined }); + }); + + it('returns null when settings.json ANTHROPIC_MODEL equals env value', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue( + JSON.stringify({ env: { ANTHROPIC_MODEL: PROFILE_MODEL } }) as any, + ); + + const result = await detectSettingsConflict({ ANTHROPIC_MODEL: PROFILE_MODEL }); + + expect(result).toBeNull(); + }); + + it('returns ConflictInfo with both url and model fields when both conflict', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue( + JSON.stringify({ env: { ANTHROPIC_BASE_URL: SETTINGS_URL, ANTHROPIC_MODEL: SETTINGS_MODEL } }) as any, + ); + + const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL, ANTHROPIC_MODEL: PROFILE_MODEL }); + + expect(result).toEqual({ + settingsUrl: SETTINGS_URL, + profileUrl: PROFILE_URL, + settingsModel: SETTINGS_MODEL, + profileModel: PROFILE_MODEL, + }); + }); + + it('returns null when settings.json ANTHROPIC_MODEL is an empty string', async () => { + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValue( + JSON.stringify({ env: { ANTHROPIC_MODEL: '' } }) as any, + ); + + const result = await detectSettingsConflict({ ANTHROPIC_MODEL: PROFILE_MODEL }); + + expect(result).toBeNull(); + }); +}); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 967aae0d..8da76779 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -18,6 +18,7 @@ import { import { logger } from '../../../utils/logger.js'; import { sanitizeLogArgs } from '../../../utils/security.js'; import chalk from 'chalk'; +import stripAnsi from 'strip-ansi'; import { resolveHomeDir } from '../../../utils/paths.js'; import { detectInstallationMethod, @@ -221,6 +222,80 @@ export const ClaudePluginMetadata: AgentMetadata = { } } + // Detect ANTHROPIC_BASE_URL override in ~/.claude/settings.json + // Claude Code reads settings.json at startup and silently overrides env vars; + // warn the user so they know which endpoint is actually in use. + try { + const { detectSettingsConflict } = await import('./settings-conflict.js'); + const conflict = await detectSettingsConflict(env); + if (conflict) { + // ASCII allowlist: accept only printable ASCII (0x20–0x7E) after stripping ANSI + // sequences. This blocks C0/C1 bytes, Bidi override chars, soft hyphen, + // zero-width chars, combining marks, and every other non-ASCII Unicode vector. + // + // DCS pre-strip: strip-ansi only removes the 2-byte introducer (\x1bP etc.), + // leaving the payload as plain ASCII. Strip the full sequence — from introducer + // to BEL/ST/C1-ST terminator — before handing off to strip-ansi. If no terminator + // is found, consume to end-of-string (greedy fallback) to prevent partial leakage. + // + // URL userinfo guard: https://user@evil.com routes to evil.com; the @ is valid + // ASCII so the allowlist cannot catch it — URL parsing is required. + const safeUrl = (s: string): string => { + // ESC-form: P=DCS X=SOS ^=PM _=APC; C1-form: \x90 \x98 \x9d(OSC) \x9e \x9f + const noStringCmds = s.replace(/(?:\x1b[PX^_]|[\x90\x98\x9d\x9e\x9f])[\s\S]*?(?:\x07|\x1b\\|\x9c|$)/g, ''); // eslint-disable-line no-control-regex + const stripped = stripAnsi(noStringCmds).replace(/[^\x20-\x7e]/gu, ''); + try { + const url = new URL(stripped); + if (url.username || url.password || url.search || url.hash) { + url.username = ''; + url.password = ''; + url.search = ''; + url.hash = ''; + return `[credentials removed] ${url.toString()}`; + } + } catch { + // Not a parseable URL — return stripped string as-is + } + return stripped; + }; + // The fallback literal contains U+2014 (em dash) which the ASCII allowlist strips. + // Bypass safeUrl for the known-safe constant; only user-controlled values need it. + console.error(chalk.yellow('\n⚠️ ~/.claude/settings.json overrides detected')); + console.error(chalk.yellow('─'.repeat(60))); + if (conflict.settingsUrl) { + const profileDisplay = conflict.profileUrl + ? safeUrl(conflict.profileUrl) + : '(not set — direct Anthropic API)'; + const activeDisplay = safeUrl(conflict.settingsUrl); + console.error(chalk.yellow(` Profile URL │ ${profileDisplay}`)); + console.error(chalk.yellow(` Active URL │ ${activeDisplay} ← settings.json wins`)); + console.error(chalk.yellow('')); + } + if (conflict.settingsModel) { + const profileModelDisplay = conflict.profileModel + ? safeUrl(conflict.profileModel) + : '(not set — profile default)'; + const activeModelDisplay = safeUrl(conflict.settingsModel); + console.error(chalk.yellow(` Profile model │ ${profileModelDisplay}`)); + console.error(chalk.yellow(` Active model │ ${activeModelDisplay} ← settings.json wins`)); + console.error(chalk.yellow('')); + } + console.error(chalk.yellow(' ~/.claude/settings.json values take precedence over the profile.')); + console.error(chalk.yellow(' Session will use the settings.json values.')); + console.error(chalk.yellow('')); + console.error(chalk.yellow(' To fix: remove overriding keys from ~/.claude/settings.json')); + console.error(chalk.yellow('─'.repeat(60))); + console.error(''); + } + } catch (error) { + logger.warn( + '[Claude] Failed to check for ANTHROPIC_BASE_URL settings conflict', + ...sanitizeLogArgs({ + error: error instanceof Error ? error.message : String(error), + }) + ); + } + return env; }, diff --git a/src/agents/plugins/claude/settings-conflict.ts b/src/agents/plugins/claude/settings-conflict.ts new file mode 100644 index 00000000..3d46ddbe --- /dev/null +++ b/src/agents/plugins/claude/settings-conflict.ts @@ -0,0 +1,49 @@ +import { existsSync } from 'fs'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { resolveHomeDir } from '../../../utils/paths.js'; + +export interface ConflictInfo { + settingsUrl?: string; + profileUrl?: string; + settingsModel?: string; + profileModel?: string; +} + +export async function detectSettingsConflict( + env: NodeJS.ProcessEnv +): Promise { + const settingsPath = join(resolveHomeDir('.claude'), 'settings.json'); + + if (!existsSync(settingsPath)) return null; + + let settings: Record; + try { + const raw = await readFile(settingsPath, 'utf-8'); + settings = JSON.parse(raw) as Record; + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null; + } catch { + return null; + } + + const envBlock = settings.env as Record | undefined; + const rawUrl = envBlock?.ANTHROPIC_BASE_URL; + const rawModel = envBlock?.ANTHROPIC_MODEL; + + const hasUrlConflict = + typeof rawUrl === 'string' && + rawUrl !== '' && + (env.ANTHROPIC_BASE_URL === undefined || rawUrl !== env.ANTHROPIC_BASE_URL); + + const hasModelConflict = + typeof rawModel === 'string' && + rawModel !== '' && + (env.ANTHROPIC_MODEL === undefined || rawModel !== env.ANTHROPIC_MODEL); + + if (!hasUrlConflict && !hasModelConflict) return null; + + return { + ...(hasUrlConflict ? { settingsUrl: rawUrl as string, profileUrl: env.ANTHROPIC_BASE_URL } : {}), + ...(hasModelConflict ? { settingsModel: rawModel as string, profileModel: env.ANTHROPIC_MODEL } : {}), + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index f6b4a3ae..a1d83c62 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,7 +16,9 @@ export default defineConfig({ globals: true, environment: 'node', testTimeout: 30_000, - hookTimeout: 10_000, + // Increased from 10_000: WSL2 filesystem (on /mnt/c Windows path) causes + // cold-start module loading to exceed 10 s. 30 s gives enough headroom. + hookTimeout: 30_000, isolate: true, env: { FORCE_COLOR: '1',