From fc1eff7823dc056b85eed7969dd695ae7c1a9e6d Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Wed, 15 Jul 2026 20:13:20 +0500 Subject: [PATCH 01/20] docs(agents): add brainstorming design spec for settings conflict detection Design for detecting ANTHROPIC_BASE_URL in ~/.claude/settings.json that would silently override the active profile URL. Proposes a new settings-conflict.ts helper called from claude.plugin.ts beforeRun with chalk-formatted warning. Co-Authored-By: Claude Sonnet 4.6 --- ...0988-settings-conflict-detection-design.md | 215 ++++++++++++++++++ docs/superpowers/work-items/EPMCDME-10988.md | 34 +++ .../work-items/work-item-events.jsonl | 3 + 3 files changed, 252 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-epmcdme-10988-settings-conflict-detection-design.md create mode 100644 docs/superpowers/work-items/EPMCDME-10988.md 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..8a8a7eea --- /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'` + +### 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` (or append to `claude.plugin.statusline.test.ts`): + +- `beforeRun` calls `displayWarningMessage` when `detectSettingsConflict` returns non-null +- `beforeRun` does NOT call `displayWarningMessage` for conflict when result is `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: displayWarningMessage(title, details) → 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 | `displayWarningMessage` 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..9a151ac2 --- /dev/null +++ b/docs/superpowers/work-items/EPMCDME-10988.md @@ -0,0 +1,34 @@ +# Work Item: EPMCDME-10988 + +**Type**: Bug +**Status**: Ready for dev +**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..0b7bf283 100644 --- a/docs/superpowers/work-items/work-item-events.jsonl +++ b/docs/superpowers/work-items/work-item-events.jsonl @@ -7,3 +7,6 @@ {"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":{}} From 10e5dbf5527dc3bea49313f5ba1b975423dd1a31 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Wed, 15 Jul 2026 20:36:12 +0500 Subject: [PATCH 02/20] docs(agents): add implementation plan for settings conflict detection 3-task TDD plan: settings-conflict.ts helper (6 unit tests), beforeRun wiring (3 integration tests), and project-config.md documentation update. Co-Authored-By: Claude Sonnet 4.6 --- ...mcdme-10988-settings-conflict-detection.md | 525 ++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-epmcdme-10988-settings-conflict-detection.md 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. ✓ From 86435ee2ccf8eb373fb8752a8f7ec72483fe22df Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Wed, 15 Jul 2026 21:08:39 +0500 Subject: [PATCH 03/20] feat(agents): add detectSettingsConflict helper Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/settings-conflict.test.ts | 94 +++++++++++++++++++ .../plugins/claude/settings-conflict.ts | 33 +++++++ 2 files changed, 127 insertions(+) create mode 100644 src/agents/plugins/claude/__tests__/settings-conflict.test.ts create mode 100644 src/agents/plugins/claude/settings-conflict.ts 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..b900f39a --- /dev/null +++ b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts @@ -0,0 +1,94 @@ +/** + * 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(); + }); +}); diff --git a/src/agents/plugins/claude/settings-conflict.ts b/src/agents/plugins/claude/settings-conflict.ts new file mode 100644 index 00000000..8819455a --- /dev/null +++ b/src/agents/plugins/claude/settings-conflict.ts @@ -0,0 +1,33 @@ +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 }; +} From 826af222c07d788a72bf579cde4df5ad45b73988 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 10:04:39 +0500 Subject: [PATCH 04/20] feat(agents): warn on ANTHROPIC_BASE_URL override in settings.json Wire detectSettingsConflict into lifecycle.beforeRun: emits a chalk.yellow multi-line warning to stderr when ~/.claude/settings.json contains an ANTHROPIC_BASE_URL that differs from (or is absent from) the profile env. Uses a dynamic import of settings-conflict.js (matching the existing statusline-installer pattern) to avoid auto-mock initialisation slowness in the WSL2 test environment. hookTimeout is raised from 10 s to 30 s in vitest.config.ts to accommodate the Windows-filesystem cold-start latency; this also resolves pre-existing statusline test timeouts. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/claude.plugin.conflict.test.ts | 114 ++++++++++++++++++ src/agents/plugins/claude/claude.plugin.ts | 20 +++ vitest.config.ts | 4 +- 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts 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..518d44da --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts @@ -0,0 +1,114 @@ +/** + * 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); + }); +}); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 967aae0d..627ab322 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -221,6 +221,26 @@ 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. + const { detectSettingsConflict } = await import('./settings-conflict.js'); + 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; }, 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', From ff958a521a3bed5efb5fe7ce644f601dc2837b7f Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 11:01:01 +0500 Subject: [PATCH 05/20] docs(agents): document ANTHROPIC_BASE_URL override behavior --- .ai-run/guides/usage/project-config.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.ai-run/guides/usage/project-config.md b/.ai-run/guides/usage/project-config.md index ed819af6..5f6a0350 100644 --- a/.ai-run/guides/usage/project-config.md +++ b/.ai-run/guides/usage/project-config.md @@ -193,6 +193,22 @@ 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, 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`. + +--- + ## Troubleshooting | Symptom | Cause | Fix | From 1736d31a3b6bd7ea8a8345b86794639992583935 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 11:13:04 +0500 Subject: [PATCH 06/20] fix(agents): add path assertion, fix doc arrow and labels Co-Authored-By: Claude Sonnet 4.6 --- .ai-run/guides/usage/project-config.md | 6 +++--- .../plugins/claude/__tests__/settings-conflict.test.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.ai-run/guides/usage/project-config.md b/.ai-run/guides/usage/project-config.md index 5f6a0350..7b583367 100644 --- a/.ai-run/guides/usage/project-config.md +++ b/.ai-run/guides/usage/project-config.md @@ -199,11 +199,11 @@ 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 +- **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) +`~/.claude/settings.json` > `env.ANTHROPIC_BASE_URL` (codemie-code profile) To avoid silent overrides: remove `ANTHROPIC_BASE_URL` from `~/.claude/settings.json`. diff --git a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts index b900f39a..3d1e235a 100644 --- a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts @@ -44,6 +44,7 @@ describe('detectSettingsConflict', () => { const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); + expect(vi.mocked(fsMod.existsSync)).toHaveBeenCalledWith(SETTINGS_PATH); expect(result).toBeNull(); }); @@ -71,6 +72,7 @@ describe('detectSettingsConflict', () => { 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 }); }); From 9922962c24ede0e22af9d44eb9ad399fc62f1ff8 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 11:34:39 +0500 Subject: [PATCH 07/20] fix(agents): harden settings-conflict detection (CR-001/002/003) - Wrap detectSettingsConflict import+call in try/catch so a thrown exception (e.g. os.homedir() failing in containers) degrades gracefully with logger.warn instead of aborting session startup - Strip C0/C1 control characters and ANSI CSI sequences from URL values before chalk interpolation to prevent terminal injection via a crafted ~/.claude/settings.json - Replace ?? with || for profileUrl fallback so an explicit empty-string profile URL also shows "(not set)" in the warning Extends the integration test suite from 3 to 6 tests to cover: - try/catch degradation path (logger.warn, no throw) - empty-string profileUrl fallback - C0/ANSI stripping in URL values Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/claude.plugin.conflict.test.ts | 45 +++++++++++++++++++ src/agents/plugins/claude/claude.plugin.ts | 44 +++++++++++------- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts index 518d44da..938dca80 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts @@ -111,4 +111,49 @@ describe('Claude Plugin – settings conflict detection in beforeRun', () => { ); 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'); + }); }); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 627ab322..7b4c36a7 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -224,21 +224,35 @@ 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. - const { detectSettingsConflict } = await import('./settings-conflict.js'); - 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(''); + try { + const { detectSettingsConflict } = await import('./settings-conflict.js'); + const conflict = await detectSettingsConflict(env); + if (conflict) { + // Strip C0/C1 control characters and ANSI CSI sequences before displaying + // URL values from settings.json — prevents terminal injection via crafted URLs. + const safeUrl = (s: string): string => + s.replace(/[\x00-\x1f\x7f]|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, ''); + const profileDisplay = safeUrl(conflict.profileUrl || '(not set — direct Anthropic API)'); + const activeDisplay = safeUrl(conflict.settingsUrl); + 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 │ ${activeDisplay} ← 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(''); + } + } 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; From c8de6eb41e218f185e8fe114f10aee6bc55003e8 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 11:37:22 +0500 Subject: [PATCH 08/20] docs(agents): correct design spec to match actual implementation Update spec to reflect the implementation decisions made during SDD: - displayWarningMessage replaced by console.error(chalk.yellow(...)) - Integration tests go in dedicated conflict.test.ts file - Clarify resolveHomeDir import path pattern Co-Authored-By: Claude Sonnet 4.6 --- ...mcdme-10988-settings-conflict-detection-design.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 8a8a7eea..72bfc79b 100644 --- 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 @@ -82,7 +82,7 @@ export async function detectSettingsConflict( - `existsSync` from `'fs'` - `readFile` from `'fs/promises'` - `join` from `'path'` -- `resolveHomeDir` from `'../../../utils/paths.js'` +- `resolveHomeDir` from `'../../../utils/paths.js'` (deep-relative, matching the pattern in `claude.plugin.ts` line 21) ### Modified: `src/agents/plugins/claude/claude.plugin.ts` @@ -136,10 +136,10 @@ Test cases: | 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` (or append to `claude.plugin.statusline.test.ts`): +Integration test addition in `claude.plugin.conflict.test.ts` (dedicated file — do not mix into `claude.plugin.statusline.test.ts`): -- `beforeRun` calls `displayWarningMessage` when `detectSettingsConflict` returns non-null -- `beforeRun` does NOT call `displayWarningMessage` for conflict when result is `null` +- `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` @@ -176,7 +176,7 @@ BaseAgentAdapter.run() return { settingsUrl, profileUrl } else return null - → if ConflictInfo: displayWarningMessage(title, details) → stderr + → if ConflictInfo: console.error(chalk.yellow(...)) → stderr → return env (env unchanged; warning is informational) 4. Claude Code starts → uses settings.json URL if present ``` @@ -200,7 +200,7 @@ BaseAgentAdapter.run() | AC | Implementation | |---|---| | Detect `ANTHROPIC_BASE_URL` override at startup | `detectSettingsConflict` called from `beforeRun` | -| Clear warning printed before session starts | `displayWarningMessage` in `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` | From 78c6a4feafb1d963515de48ccc47ef79ff88594d Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 11:37:47 +0500 Subject: [PATCH 09/20] docs(agents): mark EPMCDME-10988 ready for review All phases complete: implementation, code review (with fix-up), QA gates passed. Work item status updated from 'Ready for dev' to 'Ready for review'. Branch: EPMCDME-10988 Commits: ffcbbc8..282c66a (8 commits) Co-Authored-By: Claude Sonnet 4.6 --- docs/superpowers/work-items/EPMCDME-10988.md | 2 +- docs/superpowers/work-items/work-item-events.jsonl | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/work-items/EPMCDME-10988.md b/docs/superpowers/work-items/EPMCDME-10988.md index 9a151ac2..625beead 100644 --- a/docs/superpowers/work-items/EPMCDME-10988.md +++ b/docs/superpowers/work-items/EPMCDME-10988.md @@ -1,7 +1,7 @@ # Work Item: EPMCDME-10988 **Type**: Bug -**Status**: Ready for dev +**Status**: Ready for review **Assignee**: Aleksandr Budanov **External Ticket**: EPMCDME-10988 (Jira EPM-CDME) **External Sync**: synced diff --git a/docs/superpowers/work-items/work-item-events.jsonl b/docs/superpowers/work-items/work-item-events.jsonl index 0b7bf283..b7c141b6 100644 --- a/docs/superpowers/work-items/work-item-events.jsonl +++ b/docs/superpowers/work-items/work-item-events.jsonl @@ -10,3 +10,4 @@ {"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"} From 66d9a5563c8427d9c5996df898cc842f67d813a0 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 15:11:43 +0500 Subject: [PATCH 10/20] test(agents): add missing edge-case coverage for detectSettingsConflict Cover empty-string ANTHROPIC_BASE_URL (CR-004) and readFile EACCES rejection (CR-005) identified in tech lead code review. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/settings-conflict.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts index 3d1e235a..8ca60bae 100644 --- a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts @@ -93,4 +93,24 @@ describe('detectSettingsConflict', () => { 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({ 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(); + }); }); From dca39da3a55c7723fb3f98af8ebc6cb089ba0de0 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 15:18:37 +0500 Subject: [PATCH 11/20] docs(agents): fix manual-verification guide for fresh-clone users - Add git clone from fork as the primary path for users without the repo - Replace hardcoded WSL path in smoke.mjs with $REPO_DIR shell variable - Clarify that origin/fork remote layout is for existing clones only Co-Authored-By: Claude Sonnet 4.6 --- .../manual-verification.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/runs/20260715-1402-EPMCDME-10988/manual-verification.md diff --git a/docs/superpowers/runs/20260715-1402-EPMCDME-10988/manual-verification.md b/docs/superpowers/runs/20260715-1402-EPMCDME-10988/manual-verification.md new file mode 100644 index 00000000..4b20b551 --- /dev/null +++ b/docs/superpowers/runs/20260715-1402-EPMCDME-10988/manual-verification.md @@ -0,0 +1,253 @@ +# Manual Verification — EPMCDME-10988 + +How to confirm the fix works locally before raising a PR. + +## Repository setup + +The upstream repo is `codemie-ai/codemie-code` (read-only for contributors). +The fix lives on branch `EPMCDME-10988` in the personal fork and the PR is open: +**https://github.com/codemie-ai/codemie-code/pull/425** + +**If you don't have the repo locally, clone directly from the fork:** + +```bash +git clone https://github.com/alex-budanov/codemie-code.git +``` + +The branch `EPMCDME-10988` is already in that fork — `git checkout EPMCDME-10988` after cloning. + +**If you already have the repo cloned** (e.g. from upstream), the recommended remote layout is: + +| Remote | URL | Purpose | +|---|---|---| +| `origin` | `https://github.com/codemie-ai/codemie-code.git` | Upstream — fetch/pull only | +| `fork` | `https://github.com/alex-budanov/codemie-code.git` | Personal fork — push here | + +### Working with the fork + +```bash +# Fetch latest upstream changes +git fetch origin + +# Push a new branch to your fork +git push fork + +# Create a PR from your fork to upstream +gh pr create \ + --repo codemie-ai/codemie-code \ + --head alex-budanov: \ + --base main + +# If git push asks for credentials, use your gh token: +GH_TOKEN=$(gh auth token) +git remote set-url fork "https://alex-budanov:${GH_TOKEN}@github.com/alex-budanov/codemie-code.git" +git push fork +git remote set-url fork https://github.com/alex-budanov/codemie-code.git # reset to safe URL after push +``` + +--- + +## Safety first — create a restore script + +Run this **before touching anything**. It saves your current `~/.claude/settings.json` +so you can get back to a clean state in one command if anything goes wrong. + +```bash +cp ~/.claude/settings.json /tmp/claude-settings-backup.json +``` + +Then create the restore script: + +```bash +cat > /tmp/restore-claude-settings.sh << SCRIPT +#!/usr/bin/env bash +cp /tmp/claude-settings-backup.json ~/.claude/settings.json +echo "Restored ~/.claude/settings.json" +cat ~/.claude/settings.json +SCRIPT +chmod +x /tmp/restore-claude-settings.sh +``` + +If anything breaks at any point, run: + +```bash +bash /tmp/restore-claude-settings.sh +``` + +--- + +## Step 1 — Get the branch and build + +**If you do not have the repo cloned yet**, clone from the fork: + +```bash +git clone https://github.com/alex-budanov/codemie-code.git +cd codemie-code +git checkout EPMCDME-10988 +``` + +**If you already have the repo cloned**, fetch the branch from the fork: + +```bash +cd + +# Make sure the fork is a registered remote (add it once if missing) +git remote get-url fork 2>/dev/null || \ + git remote add fork https://github.com/alex-budanov/codemie-code.git + +git fetch fork +git checkout EPMCDME-10988 +``` + +Then build, and capture the repo path for later steps: + +```bash +npm run build +REPO_DIR=$(pwd) +echo "REPO_DIR=$REPO_DIR" # keep this shell open — Steps 3c/3d need it +``` + +Expected: build completes with no TypeScript errors. + +--- + +## Step 2 — Run the automated tests + +```bash +npx vitest run \ + src/agents/plugins/claude/__tests__/settings-conflict.test.ts \ + src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts \ + --reporter=verbose +``` + +Expected: **12 passed** (6 unit + 6 integration). + +This is the primary verification — the tests exercise every code path +including the warning, the empty-string fallback, the try/catch degradation, +and the ANSI stripping. If all 12 pass, the fix is correct. + +--- + +## Step 3 — Manual E2E: see the warning in a real terminal + +This shows the warning as an end-user would see it. +Do this in a **plain terminal** (not inside a Claude Code session) to avoid +any self-disruption risk. + +### 3a — Find your active profile's base URL + +```bash +cat ~/.codemie/codemie-cli.config.json | grep baseUrl +``` + +Note the value — you'll use it below. Example: `https://codemie.lab.epam.com/code-assistant-api` + +### 3b — Inject a conflicting URL into `~/.claude/settings.json` + +Open `~/.claude/settings.json` in any editor and add one key: + +```json +{ + "ANTHROPIC_BASE_URL": "https://a-different-url.example.com", + ... existing keys stay as-is ... +} +``` + +Or with Python (no editor needed): + +```bash +python3 - << 'EOF' +import json, pathlib +p = pathlib.Path.home() / '.claude' / 'settings.json' +s = json.loads(p.read_text()) +s['ANTHROPIC_BASE_URL'] = 'https://a-different-url.example.com' +p.write_text(json.dumps(s, indent=2)) +print("Injected. Current file:") +print(p.read_text()) +EOF +``` + +### 3c — Write a one-file smoke test + +Use `$REPO_DIR` set in Step 1 — run this in the **same shell**: + +```bash +# Replace the URL below with the one you found in Step 3a +PROFILE_URL='https://codemie.lab.epam.com/code-assistant-api' + +cat > /tmp/smoke.mjs << EOF +import { ClaudePluginMetadata } from '${REPO_DIR}/dist/agents/plugins/claude/claude.plugin.js'; + +const beforeRun = ClaudePluginMetadata.lifecycle?.beforeRun; +if (!beforeRun) { console.error('beforeRun not found'); process.exit(1); } + +const env = { ...process.env, ANTHROPIC_BASE_URL: '${PROFILE_URL}' }; + +console.error('[smoke] Calling beforeRun — the ⚠️ warning should appear below:\n'); +await beforeRun(env, {}); +console.error('\n[smoke] Done.'); +EOF +``` + +### 3d — Run the smoke test + +```bash +node /tmp/smoke.mjs 2>&1 +``` + +### Expected output + +``` +[smoke] Calling beforeRun — the ⚠️ warning should appear below: + +⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json +──────────────────────────────────────────────────────────── + Profile URL │ https://codemie.lab.epam.com/code-assistant-api + Active URL │ https://a-different-url.example.com ← settings.json wins + + ~/.claude/settings.json ANTHROPIC_BASE_URL takes precedence + over the profile value. Session will use the settings.json URL. + + To fix: remove ANTHROPIC_BASE_URL from ~/.claude/settings.json +──────────────────────────────────────────────────────────── + +[smoke] Done. +``` + +The warning text is printed to **stderr** (yellow), so you may need to redirect +stderr to stdout to see it in some terminals: `node /tmp/smoke.mjs 2>&1` + +### 3e — Verify the no-conflict case + +Change the injected URL in `~/.claude/settings.json` to **match** the profile URL +(same value as `ANTHROPIC_BASE_URL` in the env). Re-run the smoke test. +Expected: no warning, just the `[smoke] Done.` line. + +--- + +## Step 4 — Restore `~/.claude/settings.json` + +**Do this immediately after Step 3**, before opening any Claude session. + +```bash +bash /tmp/restore-claude-settings.sh +``` + +Verify the file no longer contains `ANTHROPIC_BASE_URL`: + +```bash +grep ANTHROPIC_BASE_URL ~/.claude/settings.json && echo "NOT CLEAN" || echo "Clean ✓" +``` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `Cannot find module '.../claude.plugin.js'` | Run `npm run build` first; use the absolute path in the import | +| Warning not shown | Check that `ANTHROPIC_BASE_URL` in `settings.json` differs from the env value | +| Session broken / Claude not responding | Run `bash /tmp/restore-claude-settings.sh` immediately | +| `[smoke] beforeRun not found` | The build is stale; re-run `npm run build` | +| `git push fork` asks for credentials | Use the token approach: `GH_TOKEN=$(gh auth token)` then set the URL as shown in the fork setup section above | +| PR not visible on `codemie-ai/codemie-code` | Confirm you used `--repo codemie-ai/codemie-code` and `--head alex-budanov:` in the `gh pr create` command | From 6d85a6cbddef6aaa1e00a75c467333969c633d25 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 16 Jul 2026 15:19:48 +0500 Subject: [PATCH 12/20] chore: untrack manual-verification.md (local sdlc artifact) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/superpowers/runs/ is gitignored — this file was accidentally force-added. It is a local pipeline artifact, not part of the PR. Co-Authored-By: Claude Sonnet 4.6 --- .../manual-verification.md | 253 ------------------ 1 file changed, 253 deletions(-) delete mode 100644 docs/superpowers/runs/20260715-1402-EPMCDME-10988/manual-verification.md diff --git a/docs/superpowers/runs/20260715-1402-EPMCDME-10988/manual-verification.md b/docs/superpowers/runs/20260715-1402-EPMCDME-10988/manual-verification.md deleted file mode 100644 index 4b20b551..00000000 --- a/docs/superpowers/runs/20260715-1402-EPMCDME-10988/manual-verification.md +++ /dev/null @@ -1,253 +0,0 @@ -# Manual Verification — EPMCDME-10988 - -How to confirm the fix works locally before raising a PR. - -## Repository setup - -The upstream repo is `codemie-ai/codemie-code` (read-only for contributors). -The fix lives on branch `EPMCDME-10988` in the personal fork and the PR is open: -**https://github.com/codemie-ai/codemie-code/pull/425** - -**If you don't have the repo locally, clone directly from the fork:** - -```bash -git clone https://github.com/alex-budanov/codemie-code.git -``` - -The branch `EPMCDME-10988` is already in that fork — `git checkout EPMCDME-10988` after cloning. - -**If you already have the repo cloned** (e.g. from upstream), the recommended remote layout is: - -| Remote | URL | Purpose | -|---|---|---| -| `origin` | `https://github.com/codemie-ai/codemie-code.git` | Upstream — fetch/pull only | -| `fork` | `https://github.com/alex-budanov/codemie-code.git` | Personal fork — push here | - -### Working with the fork - -```bash -# Fetch latest upstream changes -git fetch origin - -# Push a new branch to your fork -git push fork - -# Create a PR from your fork to upstream -gh pr create \ - --repo codemie-ai/codemie-code \ - --head alex-budanov: \ - --base main - -# If git push asks for credentials, use your gh token: -GH_TOKEN=$(gh auth token) -git remote set-url fork "https://alex-budanov:${GH_TOKEN}@github.com/alex-budanov/codemie-code.git" -git push fork -git remote set-url fork https://github.com/alex-budanov/codemie-code.git # reset to safe URL after push -``` - ---- - -## Safety first — create a restore script - -Run this **before touching anything**. It saves your current `~/.claude/settings.json` -so you can get back to a clean state in one command if anything goes wrong. - -```bash -cp ~/.claude/settings.json /tmp/claude-settings-backup.json -``` - -Then create the restore script: - -```bash -cat > /tmp/restore-claude-settings.sh << SCRIPT -#!/usr/bin/env bash -cp /tmp/claude-settings-backup.json ~/.claude/settings.json -echo "Restored ~/.claude/settings.json" -cat ~/.claude/settings.json -SCRIPT -chmod +x /tmp/restore-claude-settings.sh -``` - -If anything breaks at any point, run: - -```bash -bash /tmp/restore-claude-settings.sh -``` - ---- - -## Step 1 — Get the branch and build - -**If you do not have the repo cloned yet**, clone from the fork: - -```bash -git clone https://github.com/alex-budanov/codemie-code.git -cd codemie-code -git checkout EPMCDME-10988 -``` - -**If you already have the repo cloned**, fetch the branch from the fork: - -```bash -cd - -# Make sure the fork is a registered remote (add it once if missing) -git remote get-url fork 2>/dev/null || \ - git remote add fork https://github.com/alex-budanov/codemie-code.git - -git fetch fork -git checkout EPMCDME-10988 -``` - -Then build, and capture the repo path for later steps: - -```bash -npm run build -REPO_DIR=$(pwd) -echo "REPO_DIR=$REPO_DIR" # keep this shell open — Steps 3c/3d need it -``` - -Expected: build completes with no TypeScript errors. - ---- - -## Step 2 — Run the automated tests - -```bash -npx vitest run \ - src/agents/plugins/claude/__tests__/settings-conflict.test.ts \ - src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts \ - --reporter=verbose -``` - -Expected: **12 passed** (6 unit + 6 integration). - -This is the primary verification — the tests exercise every code path -including the warning, the empty-string fallback, the try/catch degradation, -and the ANSI stripping. If all 12 pass, the fix is correct. - ---- - -## Step 3 — Manual E2E: see the warning in a real terminal - -This shows the warning as an end-user would see it. -Do this in a **plain terminal** (not inside a Claude Code session) to avoid -any self-disruption risk. - -### 3a — Find your active profile's base URL - -```bash -cat ~/.codemie/codemie-cli.config.json | grep baseUrl -``` - -Note the value — you'll use it below. Example: `https://codemie.lab.epam.com/code-assistant-api` - -### 3b — Inject a conflicting URL into `~/.claude/settings.json` - -Open `~/.claude/settings.json` in any editor and add one key: - -```json -{ - "ANTHROPIC_BASE_URL": "https://a-different-url.example.com", - ... existing keys stay as-is ... -} -``` - -Or with Python (no editor needed): - -```bash -python3 - << 'EOF' -import json, pathlib -p = pathlib.Path.home() / '.claude' / 'settings.json' -s = json.loads(p.read_text()) -s['ANTHROPIC_BASE_URL'] = 'https://a-different-url.example.com' -p.write_text(json.dumps(s, indent=2)) -print("Injected. Current file:") -print(p.read_text()) -EOF -``` - -### 3c — Write a one-file smoke test - -Use `$REPO_DIR` set in Step 1 — run this in the **same shell**: - -```bash -# Replace the URL below with the one you found in Step 3a -PROFILE_URL='https://codemie.lab.epam.com/code-assistant-api' - -cat > /tmp/smoke.mjs << EOF -import { ClaudePluginMetadata } from '${REPO_DIR}/dist/agents/plugins/claude/claude.plugin.js'; - -const beforeRun = ClaudePluginMetadata.lifecycle?.beforeRun; -if (!beforeRun) { console.error('beforeRun not found'); process.exit(1); } - -const env = { ...process.env, ANTHROPIC_BASE_URL: '${PROFILE_URL}' }; - -console.error('[smoke] Calling beforeRun — the ⚠️ warning should appear below:\n'); -await beforeRun(env, {}); -console.error('\n[smoke] Done.'); -EOF -``` - -### 3d — Run the smoke test - -```bash -node /tmp/smoke.mjs 2>&1 -``` - -### Expected output - -``` -[smoke] Calling beforeRun — the ⚠️ warning should appear below: - -⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json -──────────────────────────────────────────────────────────── - Profile URL │ https://codemie.lab.epam.com/code-assistant-api - Active URL │ https://a-different-url.example.com ← settings.json wins - - ~/.claude/settings.json ANTHROPIC_BASE_URL takes precedence - over the profile value. Session will use the settings.json URL. - - To fix: remove ANTHROPIC_BASE_URL from ~/.claude/settings.json -──────────────────────────────────────────────────────────── - -[smoke] Done. -``` - -The warning text is printed to **stderr** (yellow), so you may need to redirect -stderr to stdout to see it in some terminals: `node /tmp/smoke.mjs 2>&1` - -### 3e — Verify the no-conflict case - -Change the injected URL in `~/.claude/settings.json` to **match** the profile URL -(same value as `ANTHROPIC_BASE_URL` in the env). Re-run the smoke test. -Expected: no warning, just the `[smoke] Done.` line. - ---- - -## Step 4 — Restore `~/.claude/settings.json` - -**Do this immediately after Step 3**, before opening any Claude session. - -```bash -bash /tmp/restore-claude-settings.sh -``` - -Verify the file no longer contains `ANTHROPIC_BASE_URL`: - -```bash -grep ANTHROPIC_BASE_URL ~/.claude/settings.json && echo "NOT CLEAN" || echo "Clean ✓" -``` - ---- - -## Troubleshooting - -| Symptom | Fix | -|---|---| -| `Cannot find module '.../claude.plugin.js'` | Run `npm run build` first; use the absolute path in the import | -| Warning not shown | Check that `ANTHROPIC_BASE_URL` in `settings.json` differs from the env value | -| Session broken / Claude not responding | Run `bash /tmp/restore-claude-settings.sh` immediately | -| `[smoke] beforeRun not found` | The build is stale; re-run `npm run build` | -| `git push fork` asks for credentials | Use the token approach: `GH_TOKEN=$(gh auth token)` then set the URL as shown in the fork setup section above | -| PR not visible on `codemie-ai/codemie-code` | Confirm you used `--repo codemie-ai/codemie-code` and `--head alex-budanov:` in the `gh pr create` command | From e71d40f9920b6dc340662172b277cc2c52f8decc Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Wed, 22 Jul 2026 18:45:29 +0500 Subject: [PATCH 13/20] fix(agents): address CR-001 and CR-002 from code review CR-001 [CRITICAL]: read ANTHROPIC_BASE_URL from settings.env block Claude Code stores env vars under a nested `env` object in settings.json, not at the root. `settings.ANTHROPIC_BASE_URL` was always undefined so detection never fired. Fixed to read `settings.env.ANTHROPIC_BASE_URL`. Updated all test mocks to use `{ env: { ... } }` schema so they validate the spec rather than the broken implementation. CR-002 [MAJOR]: fix CSI regex alternation order to prevent bracket residue The C0 catch-all `[\x00-\x1f]` was consuming `\x1b` before the full CSI pattern could match, leaving `[31mFORGED[0m` visible in the terminal. Moved the CSI alternative first so the entire escape sequence is consumed atomically. Added a failing test for the injected-residue attack vector. Also: add `vi.mock('../../../core/BaseAgentAdapter.js')` to the conflict integration test to prevent SSO provider auto-registration from triggering network I/O during test setup (caused 30s timeouts in WSL/CI environments). --- .../__tests__/claude.plugin.conflict.test.ts | 29 +++++++++++++++++++ .../__tests__/settings-conflict.test.ts | 8 ++--- src/agents/plugins/claude/claude.plugin.ts | 2 +- .../plugins/claude/settings-conflict.ts | 3 +- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts index 938dca80..6683bbbe 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts @@ -42,6 +42,17 @@ 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; @@ -156,4 +167,22 @@ describe('Claude Plugin – settings conflict detection in beforeRun', () => { expect(allOutput).not.toContain('\r'); expect(allOutput).not.toContain('\x1b[2K'); }); + + 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'); + }); }); diff --git a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts index 8ca60bae..2069302e 100644 --- a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts @@ -59,7 +59,7 @@ describe('detectSettingsConflict', () => { 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); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ env: { ANTHROPIC_BASE_URL: PROFILE_URL } }) as any); const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); @@ -68,7 +68,7 @@ describe('detectSettingsConflict', () => { 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); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ env: { ANTHROPIC_BASE_URL: SETTINGS_URL } }) as any); const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); @@ -78,7 +78,7 @@ describe('detectSettingsConflict', () => { 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); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ env: { ANTHROPIC_BASE_URL: SETTINGS_URL } }) as any); const result = await detectSettingsConflict({}); @@ -96,7 +96,7 @@ describe('detectSettingsConflict', () => { 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({ ANTHROPIC_BASE_URL: '' }) as any); + vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({ env: { ANTHROPIC_BASE_URL: '' } }) as any); const result = await detectSettingsConflict({ ANTHROPIC_BASE_URL: PROFILE_URL }); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 7b4c36a7..a80ce94a 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -231,7 +231,7 @@ export const ClaudePluginMetadata: AgentMetadata = { // Strip C0/C1 control characters and ANSI CSI sequences before displaying // URL values from settings.json — prevents terminal injection via crafted URLs. const safeUrl = (s: string): string => - s.replace(/[\x00-\x1f\x7f]|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, ''); + s.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|[\x00-\x1f\x7f]/g, ''); // eslint-disable-line no-control-regex const profileDisplay = safeUrl(conflict.profileUrl || '(not set — direct Anthropic API)'); const activeDisplay = safeUrl(conflict.settingsUrl); console.error(chalk.yellow('\n⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json')); diff --git a/src/agents/plugins/claude/settings-conflict.ts b/src/agents/plugins/claude/settings-conflict.ts index 8819455a..ec694e52 100644 --- a/src/agents/plugins/claude/settings-conflict.ts +++ b/src/agents/plugins/claude/settings-conflict.ts @@ -23,7 +23,8 @@ export async function detectSettingsConflict( return null; } - const settingsUrl = settings.ANTHROPIC_BASE_URL; + const envBlock = settings.env as Record | undefined; + const settingsUrl = envBlock?.ANTHROPIC_BASE_URL; if (typeof settingsUrl !== 'string' || !settingsUrl) return null; const profileUrl = env.ANTHROPIC_BASE_URL; From 5ae7ac89b6b751e4602b56f0f4af83092d4c5b1a Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Wed, 22 Jul 2026 20:04:10 +0500 Subject: [PATCH 14/20] fix(agents): address CR-001 CR-002 CR-003 from tech lead review CR-001: update project-config.md to document env block schema for ANTHROPIC_BASE_URL; add regression test asserting root-level key returns null. CR-002: replace hand-rolled CSI regex in safeUrl with strip-ansi + C1 control-range blocklist ([\x00-\x1f\x7f-\x9f]); add test for single-byte C1 CSI form (\x9b) that the old regex missed. CR-003: guard against JSON.parse returning null or a non-object value in detectSettingsConflict; add tests for JSON null and JSON array inputs. Co-Authored-By: Claude Sonnet 4.6 --- .ai-run/guides/usage/project-config.md | 11 +++++++- .../__tests__/claude.plugin.conflict.test.ts | 16 +++++++++++ .../__tests__/settings-conflict.test.ts | 28 +++++++++++++++++++ src/agents/plugins/claude/claude.plugin.ts | 3 +- .../plugins/claude/settings-conflict.ts | 1 + 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/.ai-run/guides/usage/project-config.md b/.ai-run/guides/usage/project-config.md index 7b583367..c411e5d1 100644 --- a/.ai-run/guides/usage/project-config.md +++ b/.ai-run/guides/usage/project-config.md @@ -196,7 +196,16 @@ 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, Claude Code uses it instead of any environment variable. +`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 diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts index 6683bbbe..935d57e1 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts @@ -168,6 +168,22 @@ describe('Claude Plugin – settings conflict detection in beforeRun', () => { 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 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 diff --git a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts index 2069302e..2b5c3d16 100644 --- a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts @@ -94,6 +94,34 @@ describe('detectSettingsConflict', () => { 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); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index a80ce94a..4cf35c97 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, @@ -231,7 +232,7 @@ export const ClaudePluginMetadata: AgentMetadata = { // Strip C0/C1 control characters and ANSI CSI sequences before displaying // URL values from settings.json — prevents terminal injection via crafted URLs. const safeUrl = (s: string): string => - s.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|[\x00-\x1f\x7f]/g, ''); // eslint-disable-line no-control-regex + stripAnsi(s).replace(/[\x00-\x1f\x7f-\x9f]/gu, ''); // eslint-disable-line no-control-regex const profileDisplay = safeUrl(conflict.profileUrl || '(not set — direct Anthropic API)'); const activeDisplay = safeUrl(conflict.settingsUrl); console.error(chalk.yellow('\n⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json')); diff --git a/src/agents/plugins/claude/settings-conflict.ts b/src/agents/plugins/claude/settings-conflict.ts index ec694e52..09897125 100644 --- a/src/agents/plugins/claude/settings-conflict.ts +++ b/src/agents/plugins/claude/settings-conflict.ts @@ -19,6 +19,7 @@ export async function detectSettingsConflict( 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; } From 4bf0fbe491f4c737c52ccd505ab67b088a2e89d4 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 23 Jul 2026 08:47:04 +0500 Subject: [PATCH 15/20] fix(agents): harden safeUrl against DCS injection and URL userinfo spoofing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from a blocklist to a layered defence: 1. DCS pre-strip — remove full \x1bP/\x1bX/\x1b^/\x1b_ sequences including payload before strip-ansi; ansi-regex only strips the 2-byte introducer, leaving any ASCII payload intact. 2. ASCII allowlist [^\x20-\x7e] — replaces the old C0/C1 blocklist; also covers Bidi override chars, soft hyphen (U+00AD), zero-width chars, combining marks, and all other non-ASCII Unicode vectors in one rule. 3. URL userinfo guard — new URL() parse detects credentials in the URL (the @-trick: https://trusted@evil.com routes to evil.com); userinfo is stripped and [credentials removed] is prepended so the real host is visible. Two new TDD tests added (10 total in the conflict test suite). Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/claude.plugin.conflict.test.ts | 32 +++++++++++++++++++ src/agents/plugins/claude/claude.plugin.ts | 30 ++++++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts index 935d57e1..2c82b26e 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts @@ -184,6 +184,38 @@ describe('Claude Plugin – settings conflict detection in beforeRun', () => { 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('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 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 diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 4cf35c97..9c67860b 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -229,10 +229,32 @@ export const ClaudePluginMetadata: AgentMetadata = { const { detectSettingsConflict } = await import('./settings-conflict.js'); const conflict = await detectSettingsConflict(env); if (conflict) { - // Strip C0/C1 control characters and ANSI CSI sequences before displaying - // URL values from settings.json — prevents terminal injection via crafted URLs. - const safeUrl = (s: string): string => - stripAnsi(s).replace(/[\x00-\x1f\x7f-\x9f]/gu, ''); // eslint-disable-line no-control-regex + // 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 => { + const noStringCmds = s.replace(/(?:\x1b[PX^_]|[\x90\x98\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.username = ''; + url.password = ''; + return `[credentials removed] ${url.toString()}`; + } + } catch { + // Not a parseable URL — return stripped string as-is + } + return stripped; + }; const profileDisplay = safeUrl(conflict.profileUrl || '(not set — direct Anthropic API)'); const activeDisplay = safeUrl(conflict.settingsUrl); console.error(chalk.yellow('\n⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json')); From 950b3e77644091d4f2ad57376183601908669335 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Thu, 23 Jul 2026 09:13:39 +0500 Subject: [PATCH 16/20] fix(agents): fix em dash corruption in safeUrl fallback; add missing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR-NEW-001: the hardcoded '(not set — direct Anthropic API)' fallback was passed through safeUrl, whose ASCII allowlist strips U+2014 (em dash) and collapses the surrounding spaces to a double space. Fixed by bypassing safeUrl for the known-safe constant — only user-controlled values need sanitising. Four tests added (14 total): - em dash preserved in hardcoded fallback (regression guard for CR-NEW-001) - DCS with no terminator — validates the |$ end-of-string fallback path - C1 DCS form (\x90...\x07) — single-byte introducer path in pre-strip regex - URL userinfo with password field (user:pass@host) — covers url.password branch Also adds an inline comment on the DCS regex identifying the ESC-form and C1-form character groups (\x90 \x98 \x9e \x9f) for future maintainers. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/claude.plugin.conflict.test.ts | 61 +++++++++++++++++++ src/agents/plugins/claude/claude.plugin.ts | 7 ++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts index 2c82b26e..d627e6ae 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts @@ -199,6 +199,51 @@ describe('Claude Plugin – settings conflict detection in beforeRun', () => { 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. @@ -216,6 +261,22 @@ describe('Claude Plugin – settings conflict detection in beforeRun', () => { 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 diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 9c67860b..37c3c732 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -241,6 +241,7 @@ export const ClaudePluginMetadata: AgentMetadata = { // 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 \x9e \x9f const noStringCmds = s.replace(/(?:\x1b[PX^_]|[\x90\x98\x9e\x9f])[\s\S]*?(?:\x07|\x1b\\|\x9c|$)/g, ''); // eslint-disable-line no-control-regex const stripped = stripAnsi(noStringCmds).replace(/[^\x20-\x7e]/gu, ''); try { @@ -255,7 +256,11 @@ export const ClaudePluginMetadata: AgentMetadata = { } return stripped; }; - const profileDisplay = safeUrl(conflict.profileUrl || '(not set — direct Anthropic API)'); + // 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. + const profileDisplay = conflict.profileUrl + ? safeUrl(conflict.profileUrl) + : '(not set — direct Anthropic API)'; const activeDisplay = safeUrl(conflict.settingsUrl); console.error(chalk.yellow('\n⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json')); console.error(chalk.yellow('─'.repeat(60))); From 5b78112a94133e2483d242eb4f80fb447bca13f7 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Mon, 27 Jul 2026 13:02:10 +0500 Subject: [PATCH 17/20] fix(agents): strip query-string credentials from safeUrl output Expand credentials-removal branch to also clear url.search and trigger it when query params are present. Prevents api_key= style secrets leaking into console.error and CI log aggregators. Co-Authored-By: Claude Sonnet 4.6 --- src/agents/plugins/claude/claude.plugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 37c3c732..b5dcff8c 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -246,9 +246,10 @@ export const ClaudePluginMetadata: AgentMetadata = { const stripped = stripAnsi(noStringCmds).replace(/[^\x20-\x7e]/gu, ''); try { const url = new URL(stripped); - if (url.username || url.password) { + if (url.username || url.password || url.search) { url.username = ''; url.password = ''; + url.search = ''; return `[credentials removed] ${url.toString()}`; } } catch { From 5601c84ae1a27766200c5512cd2026b3a1af1782 Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Mon, 27 Jul 2026 14:39:14 +0500 Subject: [PATCH 18/20] feat(agents): detect ANTHROPIC_MODEL override in settings.json; display model in conflict warning Extends ConflictInfo with optional settingsModel/profileModel fields. detectSettingsConflict now checks settings.env.ANTHROPIC_MODEL alongside ANTHROPIC_BASE_URL. The CLI warning block conditionally shows a model row when a model conflict is present, satisfying AC-3 ('display actual endpoint/model being used'). 16 unit tests passing (5 new for model-conflict paths). Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/settings-conflict.test.ts | 63 +++++++++++++++++++ src/agents/plugins/claude/claude.plugin.ts | 31 +++++---- .../plugins/claude/settings-conflict.ts | 28 ++++++--- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts index 2b5c3d16..9d6cbe76 100644 --- a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts @@ -141,4 +141,67 @@ describe('detectSettingsConflict', () => { 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 b5dcff8c..a4d29012 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -259,19 +259,28 @@ export const ClaudePluginMetadata: AgentMetadata = { }; // 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. - const profileDisplay = conflict.profileUrl - ? safeUrl(conflict.profileUrl) - : '(not set — direct Anthropic API)'; - const activeDisplay = safeUrl(conflict.settingsUrl); - console.error(chalk.yellow('\n⚠️ ANTHROPIC_BASE_URL override detected in ~/.claude/settings.json')); + console.error(chalk.yellow('\n⚠️ ~/.claude/settings.json overrides detected')); console.error(chalk.yellow('─'.repeat(60))); - console.error(chalk.yellow(` Profile URL │ ${profileDisplay}`)); - console.error(chalk.yellow(` Active URL │ ${activeDisplay} ← 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.')); + 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 ?? '(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 ANTHROPIC_BASE_URL from ~/.claude/settings.json')); + console.error(chalk.yellow(' To fix: remove overriding keys from ~/.claude/settings.json')); console.error(chalk.yellow('─'.repeat(60))); console.error(''); } diff --git a/src/agents/plugins/claude/settings-conflict.ts b/src/agents/plugins/claude/settings-conflict.ts index 09897125..3d46ddbe 100644 --- a/src/agents/plugins/claude/settings-conflict.ts +++ b/src/agents/plugins/claude/settings-conflict.ts @@ -4,8 +4,10 @@ import { join } from 'path'; import { resolveHomeDir } from '../../../utils/paths.js'; export interface ConflictInfo { - settingsUrl: string; - profileUrl: string | undefined; + settingsUrl?: string; + profileUrl?: string; + settingsModel?: string; + profileModel?: string; } export async function detectSettingsConflict( @@ -25,11 +27,23 @@ export async function detectSettingsConflict( } const envBlock = settings.env as Record | undefined; - const settingsUrl = envBlock?.ANTHROPIC_BASE_URL; - if (typeof settingsUrl !== 'string' || !settingsUrl) return null; + const rawUrl = envBlock?.ANTHROPIC_BASE_URL; + const rawModel = envBlock?.ANTHROPIC_MODEL; - const profileUrl = env.ANTHROPIC_BASE_URL; - if (profileUrl !== undefined && settingsUrl === profileUrl) return null; + const hasUrlConflict = + typeof rawUrl === 'string' && + rawUrl !== '' && + (env.ANTHROPIC_BASE_URL === undefined || rawUrl !== env.ANTHROPIC_BASE_URL); - return { settingsUrl, profileUrl }; + 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 } : {}), + }; } From bd3655e8eb609e64e6b535b4724594af074d358f Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Mon, 27 Jul 2026 19:49:04 +0500 Subject: [PATCH 19/20] fix(agents): patch OSC C1-form, url.hash, and profileModel injection vectors - CR-001: add \x9d to DCS pre-strip regex to cover C1-form OSC introducer - CR-002: clear url.hash in safeUrl to prevent fragment-payload leakage - CR-003: wrap conflict.profileModel in safeUrl() before terminal output - Add three regression tests covering each injection vector All 17 tests in claude.plugin.conflict.test.ts pass. --- .../__tests__/claude.plugin.conflict.test.ts | 55 +++++++++++++++++++ src/agents/plugins/claude/claude.plugin.ts | 11 ++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts index d627e6ae..e9b11c00 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.conflict.test.ts @@ -294,4 +294,59 @@ describe('Claude Plugin – settings conflict detection in beforeRun', () => { 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/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index a4d29012..8da76779 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -241,15 +241,16 @@ export const ClaudePluginMetadata: AgentMetadata = { // 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 \x9e \x9f - const noStringCmds = s.replace(/(?:\x1b[PX^_]|[\x90\x98\x9e\x9f])[\s\S]*?(?:\x07|\x1b\\|\x9c|$)/g, ''); // eslint-disable-line no-control-regex + // 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) { + if (url.username || url.password || url.search || url.hash) { url.username = ''; url.password = ''; url.search = ''; + url.hash = ''; return `[credentials removed] ${url.toString()}`; } } catch { @@ -271,7 +272,9 @@ export const ClaudePluginMetadata: AgentMetadata = { console.error(chalk.yellow('')); } if (conflict.settingsModel) { - const profileModelDisplay = conflict.profileModel ?? '(not set — profile default)'; + 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`)); From e7346fe5c96f0a98fecc763709a52fd662bf2a3b Mon Sep 17 00:00:00 2001 From: Aleksandr Budanov Date: Tue, 28 Jul 2026 17:29:14 +0500 Subject: [PATCH 20/20] fix(tests): use path.join for SETTINGS_PATH to fix Windows CI Hardcoded POSIX string caused Test (Windows) to fail: path.join on Windows converts '/home/testuser/claude' to '\home\testuser\claude', so the assertion never matched. Computing SETTINGS_PATH with join() makes the expected value platform-correct on both Ubuntu and Windows. Co-Authored-By: Claude Sonnet 4.6 --- src/agents/plugins/claude/__tests__/settings-conflict.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts index 9d6cbe76..ecb1acdc 100644 --- a/src/agents/plugins/claude/__tests__/settings-conflict.test.ts +++ b/src/agents/plugins/claude/__tests__/settings-conflict.test.ts @@ -6,6 +6,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; vi.mock('fs/promises'); vi.mock('fs'); @@ -20,7 +21,7 @@ describe('detectSettingsConflict', () => { let fsMod: typeof import('fs'); let fsp: typeof import('fs/promises'); - const SETTINGS_PATH = '/home/testuser/claude/settings.json'; + 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';