codeloop-mcp-server 0.1.63 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/auth/critical_floors.d.ts.map +1 -1
  2. package/dist/auth/critical_floors.js +4 -0
  3. package/dist/auth/critical_floors.js.map +1 -1
  4. package/dist/auth/key_resolver.d.ts +55 -0
  5. package/dist/auth/key_resolver.d.ts.map +1 -0
  6. package/dist/auth/key_resolver.js +41 -0
  7. package/dist/auth/key_resolver.js.map +1 -0
  8. package/dist/auth/key_source.d.ts +60 -4
  9. package/dist/auth/key_source.d.ts.map +1 -1
  10. package/dist/auth/key_source.js +281 -48
  11. package/dist/auth/key_source.js.map +1 -1
  12. package/dist/evidence/observability.d.ts +67 -0
  13. package/dist/evidence/observability.d.ts.map +1 -0
  14. package/dist/evidence/observability.js +57 -0
  15. package/dist/evidence/observability.js.map +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +52 -4
  18. package/dist/index.js.map +1 -1
  19. package/dist/runners/app_logger.d.ts.map +1 -1
  20. package/dist/runners/app_logger.js +31 -3
  21. package/dist/runners/app_logger.js.map +1 -1
  22. package/dist/runners/logging_readiness.d.ts +72 -0
  23. package/dist/runners/logging_readiness.d.ts.map +1 -0
  24. package/dist/runners/logging_readiness.js +419 -0
  25. package/dist/runners/logging_readiness.js.map +1 -0
  26. package/dist/runners/remote_logs.d.ts +105 -0
  27. package/dist/runners/remote_logs.d.ts.map +1 -0
  28. package/dist/runners/remote_logs.js +336 -0
  29. package/dist/runners/remote_logs.js.map +1 -0
  30. package/dist/tools/gate_check.d.ts.map +1 -1
  31. package/dist/tools/gate_check.js +25 -0
  32. package/dist/tools/gate_check.js.map +1 -1
  33. package/dist/tools/interaction_replay.d.ts +18 -0
  34. package/dist/tools/interaction_replay.d.ts.map +1 -1
  35. package/dist/tools/interaction_replay.js +102 -1
  36. package/dist/tools/interaction_replay.js.map +1 -1
  37. package/dist/tools/verify.d.ts.map +1 -1
  38. package/dist/tools/verify.js +61 -5
  39. package/dist/tools/verify.js.map +1 -1
  40. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"critical_floors.d.ts","sourceRoot":"","sources":["../../src/auth/critical_floors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,WAAW,aAAa;IAC5B,4DAA4D;IAC5D,WAAW,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,EAAE,aAAa,EA+E1C,CAAC"}
1
+ {"version":3,"file":"critical_floors.d.ts","sourceRoot":"","sources":["../../src/auth/critical_floors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,WAAW,aAAa;IAC5B,4DAA4D;IAC5D,WAAW,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,EAAE,aAAa,EAoF1C,CAAC"}
@@ -100,6 +100,10 @@ export const CRITICAL_FLOORS = [
100
100
  min_version: "0.1.61",
101
101
  reason: "0.1.60 could CRASH THE ENTIRE MCP SERVER on a warm npx cache — backend_runtime.js (new in 0.1.60) statically imported @codelooptech/shared/init/detect-backend, a submodule first shipped in shared@0.1.30, but both codeloop-mcp-server and codeloop only pinned `@codelooptech/shared: ^0.1.22`. So npx could pair mcp-server 0.1.60 with an OLDER cached shared (0.1.22–0.1.29) that lacks detect-backend.js, and the static import threw ERR_MODULE_NOT_FOUND at module-load time, taking down index -> verify -> backend_runtime before any tool could run (the same class of stale-cache skew the 0.1.53 floor first patched for rules-version.js, reintroduced when the dep floor wasn't raised alongside the new submodule). 0.1.61 fixes it durably two ways: (1) raises the @codelooptech/shared dependency floor to ^0.1.30 in BOTH codeloop-mcp-server and codeloop so a fresh resolve can never pick a shared missing the file, and (2) makes detect-backend a LAZY, GUARDED dynamic import — if the submodule can't be loaded for any reason, backend auto-detection degrades to n/a (backend gates skip) instead of crashing the server, so no future shared-submodule addition can ever hard-crash CodeLoop again. Users on a broken 0.1.60 install must clear the npx cache (delete %LocalAppData%/npm-cache/_npx on Windows or ~/.npm/_npx on macOS/Linux, or run `npx clear-npx-cache`) and relaunch so npx fetches 0.1.61.",
102
102
  },
103
+ {
104
+ min_version: "0.1.64",
105
+ reason: "Observability blind spot — pre-0.1.64 CodeLoop could only SCAN whatever logs an app happened to emit (runtime_log_clean_evidence over backend.log + app log + browser console); it never checked whether the app HAD a logging system, and it never pulled production / hosting / third-party logs. So an app with no structured logger, no request logging, and no global error handler swallowed exceptions invisibly and the runtime-log gate passed as n/a (nothing to scan) — root causes stayed unfindable. 0.1.64 ships an observability-enforcement layer: (1) logging_observability_evidence — a per-stack (node/web/python/dotnet/flutter/go/rust) applicable-or-n/a BLOCKER gate that detects whether the project has a structured logger, request/HTTP logging (servers), and a global error handler / React error boundary, classifies the project (frontend/backend/fullstack/cli/library) to decide which dimensions are required, and BLOCKS ready_for_review with stack-specific 'build this' instructions when a required dimension is missing (severity + per-dimension requirements tunable via config.observability); (2) remote-log collection — auto-detects deploy targets + SaaS providers from project markers (Vercel/Netlify/Heroku/Fly/Railway/Render/Cloudflare/Firebase/Google Cloud/AWS/Supabase/Stripe/Convex + a config-extensible long tail) and, when the provider CLI is installed, pulls read-only logs into the run's logs/ dir so production / 3rd-party errors flow through the same runtime_log_clean_evidence blocker as local logs — when a detected provider's CLI is missing or unauthenticated, CodeLoop pushes the agent to install / log in; (3) per-interaction log correlation — codeloop_interaction_replay now ties each recorded codeloop_interact to the backend/app/remote log lines emitted in a window around it (errorsNear), so a click that triggers a server-side error is bound directly to the offending log line. ALSO in 0.1.64: revoked-key diagnostics no longer mislead users with an active key. Cursor/Claude inject CODELOOP_API_KEY from the mcp.json `env` block, which OVERRIDES the shell — so a stale hardcoded key in .cursor/mcp.json kept losing to the dashboard-rotated key in ~/.zshrc, yet the old diagnostic scanned ONLY rc files and (finding the live key there) pointed users at ~/.zshrc, making the revoked-key error look like a CodeLoop bug ('but my key is active!'). identifyKeySource now scans the mcp.json env blocks (project + global Cursor, .mcp.json, .vscode, Claude) FIRST, reports the exact hardcoded file+line, and detects SHADOWING (a different active key sitting in the shell/config that the hardcoded key is silently overriding) so the fix instructions name the real file to edit and tell the user to reload the MCP server.",
106
+ },
103
107
  {
104
108
  min_version: "0.1.63",
105
109
  reason: "Out-of-app confirm/alert modal was NEVER detected (recurrence) — 0.1.54/0.1.55/0.1.57 only hardened FILE dialogs; a plain Yes/No confirmation MessageBox raised by clicking a button (e.g. Photometry-DB 'Delete TEMP PCB Test Range') stayed invisible to the detector and blocked the journey. detectWindowsModal only flagged a window when UIA reported IsModal=true AND the dialog TITLE contained the app name, OR the title matched a file-dialog pattern — but a WPF/WinForms MessageBox is a SEPARATE top-level #32770 window whose caption is 'Confirm'/'Delete'/the app name and whose UIA IsModal flag is frequently FALSE, so it slipped through every pass (is_modal_present:false). No F4 directive fired and the agent kept clicking the now-blocked button beneath it, then mis-read 'click missed' and looped. A second bug compounded it: win_ui_inspect / win_ui_automate located the app window with an EXACT NameProperty match, so app_name 'Photometry DB' never matched the live title 'PhotometryDB - Professional…' (no space) and every inspect returned 'App window not found', forcing coordinate guessing. 0.1.63 ships H12: (1) the Windows modal probe now collects each window's ProcessId and resolves the app's own pid(s) + main-window handle(s), and flags any app-OWNED secondary top-level window carrying a dialog signal (modal flag / #32770 or dialog class / confirm-or-alert title) as the modal regardless of its caption — the title- and IsModal-independent ownership signal that finally catches in-process MessageBox confirms; (2) classifyModalKind now treats an ambiguous #32770 as `confirm` (routes to codeloop_handle_modal for an agent decision) instead of `file_dialog` (auto-Escape), while a file-TITLED #32770 stays file_dialog; (3) codeloop_handle_modal with decision confirm/cancel now INVOKES the matching Yes/OK/Save/Delete or No/Cancel button on the dialog directly via UIA and re-detects to confirm it cleared, so a destructive Yes/No box no longer depends on which button is the Enter/Escape default; (4) buildUiaAppElementLookup + a whitespace-insensitive process-lookup fallback make win_ui_inspect / win_ui_automate / screenshot / window-bounds resolve the app window even when app_name differs from the live title by spaces or a suffix, or doesn't embed it at all (owning-process match).",
@@ -1 +1 @@
1
- {"version":3,"file":"critical_floors.js","sourceRoot":"","sources":["../../src/auth/critical_floors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AASH;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,eAAe,GAAoB;IAC9C;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,ufAAuf;KAC1f;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EAAE,4hBAA4hB;KACriB;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EAAE,yvBAAyvB;KAClwB;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,kxBAAkxB;KACrxB;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,0/BAA0/B;KAC7/B;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,0iCAA0iC;KAC7iC;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,gqDAAgqD;KACnqD;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,uqDAAuqD;KAC1qD;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,w+EAAw+E;KAC3+E;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,88EAA88E;KACj9E;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,uiEAAuiE;KAC1iE;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,u/DAAu/D;KAC1/D;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,k3DAAk3D;KACr3D;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,oiDAAoiD;KACviD;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,g3CAAg3C;KACn3C;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,gwEAAgwE;KACnwE;CACF,CAAC"}
1
+ {"version":3,"file":"critical_floors.js","sourceRoot":"","sources":["../../src/auth/critical_floors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AASH;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,eAAe,GAAoB;IAC9C;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,ufAAuf;KAC1f;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EAAE,4hBAA4hB;KACriB;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EAAE,yvBAAyvB;KAClwB;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,kxBAAkxB;KACrxB;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,0/BAA0/B;KAC7/B;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,0iCAA0iC;KAC7iC;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,gqDAAgqD;KACnqD;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,uqDAAuqD;KAC1qD;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,w+EAAw+E;KAC3+E;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,88EAA88E;KACj9E;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,uiEAAuiE;KAC1iE;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,u/DAAu/D;KAC1/D;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,k3DAAk3D;KACr3D;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,oiDAAoiD;KACviD;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,g3CAAg3C;KACn3C;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,srFAAsrF;KACzrF;IACD;QACE,WAAW,EAAE,QAAQ;QACrB,MAAM,EACJ,gwEAAgwE;KACnwE;CACF,CAAC"}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Self-healing API-key resolver.
3
+ *
4
+ * The failure mode we're closing: Cursor / Claude Code launch the MCP
5
+ * server with a `CODELOOP_API_KEY` injected from an mcp.json `env` block.
6
+ * That value OVERRIDES the user's shell. When the user rotates their key
7
+ * on the dashboard and updates `~/.zshrc` (or the dashboard rotates it for
8
+ * them) but forgets the hardcoded mcp.json copy, the server keeps
9
+ * presenting a now-REVOKED key and every tool call hard-blocks — even
10
+ * though the user has a perfectly active key sitting in another file.
11
+ *
12
+ * `resolveActiveApiKey` makes that impossible to get stuck on: if the
13
+ * configured key validates, we use it (zero extra work, no disk scan). If
14
+ * it's revoked/invalid, we scan the other on-disk keys (shell, PowerShell,
15
+ * every mcp.json, config.json) and use the FIRST one that validates,
16
+ * reporting which stale file the user should clean up. As long as ANY
17
+ * active key exists on the machine, CodeLoop keeps working.
18
+ *
19
+ * We only ever fall back when the primary key is genuinely invalid — never
20
+ * on `activation_required` (no key at all) and never on a transient
21
+ * backend/offline result (validateApiKey already returns `valid: true` in
22
+ * offline mode). Full key values are read only to attempt validation and
23
+ * are never logged.
24
+ */
25
+ import type { ApiKeyValidation, ActivationResponse } from "@codelooptech/shared";
26
+ import { type OnDiskKey } from "./key_source.js";
27
+ export interface ResolvedApiKey {
28
+ /** The key that should actually be used for this run (may differ from the input). */
29
+ key: string | undefined;
30
+ /** Validation result for `key`. */
31
+ result: ApiKeyValidation | ActivationResponse;
32
+ /**
33
+ * Present only when we fell back from a revoked configured key to an
34
+ * active on-disk key. Drives the user-facing recovery notice.
35
+ */
36
+ recovered?: {
37
+ /** Prefix of the revoked key that was configured (e.g. injected by mcp.json). */
38
+ stale_prefix: string;
39
+ /** Prefix of the active key we recovered to. */
40
+ active_prefix: string;
41
+ /** Where the active key was found. */
42
+ origin: OnDiskKey["origin"];
43
+ file: string;
44
+ line: number | null;
45
+ };
46
+ /** How many distinct on-disk keys we tried (diagnostics only). */
47
+ tried_count?: number;
48
+ }
49
+ /**
50
+ * Validate `configuredKey`; if it's revoked/invalid, attempt to recover by
51
+ * trying every other distinct key found on disk. Returns the first key that
52
+ * validates (or the original failing result if none do).
53
+ */
54
+ export declare function resolveActiveApiKey(configuredKey: string | undefined, projectDir: string): Promise<ResolvedApiKey>;
55
+ //# sourceMappingURL=key_resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"key_resolver.d.ts","sourceRoot":"","sources":["../../src/auth/key_resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAEjF,OAAO,EAAqB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEpE,MAAM,WAAW,cAAc;IAC7B,qFAAqF;IACrF,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,mCAAmC;IACnC,MAAM,EAAE,gBAAgB,GAAG,kBAAkB,CAAC;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE;QACV,iFAAiF;QACjF,YAAY,EAAE,MAAM,CAAC;QACrB,gDAAgD;QAChD,aAAa,EAAE,MAAM,CAAC;QACtB,sCAAsC;QACtC,MAAM,EAAE,SAAS,CAAC,QAAQ,CAAC,CAAC;QAC5B,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;KACrB,CAAC;IACF,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAMD;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,cAAc,CAAC,CAmCzB"}
@@ -0,0 +1,41 @@
1
+ import { validateApiKey, isActivationRequired } from "./api_key.js";
2
+ import { collectOnDiskKeys } from "./key_source.js";
3
+ function isValid(r) {
4
+ return !isActivationRequired(r) && r.valid === true;
5
+ }
6
+ /**
7
+ * Validate `configuredKey`; if it's revoked/invalid, attempt to recover by
8
+ * trying every other distinct key found on disk. Returns the first key that
9
+ * validates (or the original failing result if none do).
10
+ */
11
+ export async function resolveActiveApiKey(configuredKey, projectDir) {
12
+ const primary = await validateApiKey(configuredKey);
13
+ // No key configured at all, or a valid key, or an offline/transient
14
+ // success — use as-is. (validateApiKey returns valid:true when the
15
+ // backend is unreachable, so we don't scan in that case.)
16
+ if (isActivationRequired(primary) || isValid(primary)) {
17
+ return { key: configuredKey, result: primary };
18
+ }
19
+ // Primary key is genuinely invalid/revoked. Try the other on-disk keys.
20
+ const candidates = collectOnDiskKeys(projectDir).filter((c) => c.value && c.value !== configuredKey);
21
+ for (const cand of candidates) {
22
+ const r = await validateApiKey(cand.value);
23
+ if (isValid(r)) {
24
+ return {
25
+ key: cand.value,
26
+ result: r,
27
+ recovered: {
28
+ stale_prefix: (configuredKey ?? "").slice(0, 12),
29
+ active_prefix: cand.key_prefix,
30
+ origin: cand.origin,
31
+ file: cand.file,
32
+ line: cand.line,
33
+ },
34
+ tried_count: candidates.length,
35
+ };
36
+ }
37
+ }
38
+ // Nothing on disk validated — surface the original failure.
39
+ return { key: configuredKey, result: primary, tried_count: candidates.length };
40
+ }
41
+ //# sourceMappingURL=key_resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"key_resolver.js","sourceRoot":"","sources":["../../src/auth/key_resolver.ts"],"names":[],"mappings":"AAyBA,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpE,OAAO,EAAE,iBAAiB,EAAkB,MAAM,iBAAiB,CAAC;AAyBpE,SAAS,OAAO,CAAC,CAAwC;IACvD,OAAO,CAAC,oBAAoB,CAAC,CAAC,CAAC,IAAK,CAAsB,CAAC,KAAK,KAAK,IAAI,CAAC;AAC5E,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,aAAiC,EACjC,UAAkB;IAElB,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,aAAa,CAAC,CAAC;IAEpD,oEAAoE;IACpE,mEAAmE;IACnE,0DAA0D;IAC1D,IAAI,oBAAoB,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACtD,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IACjD,CAAC;IAED,wEAAwE;IACxE,MAAM,UAAU,GAAG,iBAAiB,CAAC,UAAU,CAAC,CAAC,MAAM,CACrD,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,KAAK,aAAa,CAC5C,CAAC;IAEF,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;QAC9B,MAAM,CAAC,GAAG,MAAM,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC3C,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;YACf,OAAO;gBACL,GAAG,EAAE,IAAI,CAAC,KAAK;gBACf,MAAM,EAAE,CAAC;gBACT,SAAS,EAAE;oBACT,YAAY,EAAE,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC;oBAChD,aAAa,EAAE,IAAI,CAAC,UAAU;oBAC9B,MAAM,EAAE,IAAI,CAAC,MAAM;oBACnB,IAAI,EAAE,IAAI,CAAC,IAAI;oBACf,IAAI,EAAE,IAAI,CAAC,IAAI;iBAChB;gBACD,WAAW,EAAE,UAAU,CAAC,MAAM;aAC/B,CAAC;QACJ,CAAC;IACH,CAAC;IAED,4DAA4D;IAC5D,OAAO,EAAE,GAAG,EAAE,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC;AACjF,CAAC"}
@@ -1,3 +1,9 @@
1
+ export interface ConfigHit {
2
+ /** Absolute path of the file that owns the value. */
3
+ file: string;
4
+ /** 1-based line number of the match, when locatable. */
5
+ line: number | null;
6
+ }
1
7
  export type KeySource = {
2
8
  kind: "env";
3
9
  env_var: "CODELOOP_API_KEY";
@@ -7,22 +13,72 @@ export type KeySource = {
7
13
  line: number | null;
8
14
  /** First 12 chars of the key (safe to log). */
9
15
  key_prefix: string;
16
+ /**
17
+ * mcp.json `env` blocks that hardcode THIS exact key. When present
18
+ * these are the EFFECTIVE source — the IDE injects them and they
19
+ * override the shell — so the diagnostic points here first.
20
+ */
21
+ mcp_config_files?: ConfigHit[];
22
+ /**
23
+ * A DIFFERENT key found in the shell rc / config.json that the
24
+ * effective (hardcoded) key is silently overriding. Explains the
25
+ * "but my key is active!" case: the active key is there, just
26
+ * shadowed.
27
+ */
28
+ shadow?: {
29
+ key_prefix: string;
30
+ file: string;
31
+ line: number | null;
32
+ };
10
33
  } | {
11
34
  kind: "config";
12
35
  file: string;
13
36
  key_prefix: string;
37
+ mcp_config_files?: ConfigHit[];
38
+ shadow?: {
39
+ key_prefix: string;
40
+ file: string;
41
+ line: number | null;
42
+ };
14
43
  } | {
15
44
  kind: "none";
16
45
  };
17
46
  /**
18
47
  * Resolve the source of the API key currently being presented to the
19
- * MCP server. Pass the same `apiKey` value the server uses (`process.env.CODELOOP_API_KEY || config.api_key`)
20
- * and the `projectDir` it discovered.
48
+ * MCP server. Pass the same `apiKey` value the server uses
49
+ * (`process.env.CODELOOP_API_KEY || config.api_key`) and the
50
+ * `projectDir` it discovered.
21
51
  *
22
- * Safe to call on every revoked-key error: scanning a handful of rc
23
- * files is microseconds and we cache nothing.
52
+ * Safe to call on every revoked-key error: scanning a handful of files
53
+ * is microseconds and we cache nothing.
24
54
  */
25
55
  export declare function identifyKeySource(apiKey: string | undefined, projectDir: string): KeySource;
56
+ /** An API key value discovered on disk, with where it came from. */
57
+ export interface OnDiskKey {
58
+ /** Full key value (used only to attempt validation; never logged). */
59
+ value: string;
60
+ /** First 12 chars — safe to surface. */
61
+ key_prefix: string;
62
+ /** Human-readable origin: "shell", "powershell", "mcp_config", or "config_json". */
63
+ origin: "shell" | "powershell" | "mcp_config" | "config_json";
64
+ /** Absolute path of the owning file. */
65
+ file: string;
66
+ /** 1-based line number, when locatable. */
67
+ line: number | null;
68
+ }
69
+ /**
70
+ * Gather EVERY CodeLoop API key written anywhere on disk that could plausibly
71
+ * be the user's real key — shell rc files, PowerShell profiles, every
72
+ * mcp.json `env` block (all clients × OS), and the project's
73
+ * `.codeloop/config.json`. De-duplicated by full value, preserving the
74
+ * priority order (mcp.json first — it's what the IDE injects — then shell,
75
+ * then config).
76
+ *
77
+ * Used by the self-heal path: when the configured key is REVOKED we try each
78
+ * of these in turn so that as long as the user has ANY active key on disk,
79
+ * CodeLoop keeps working instead of hard-blocking.
80
+ */
81
+ export declare function collectOnDiskKeys(projectDir: string): OnDiskKey[];
26
82
  /**
27
83
  * Build the user-facing fix instructions for a revoked-key situation.
28
84
  * Returned as a structured object so callers can embed it in the JSON
@@ -1 +1 @@
1
- {"version":3,"file":"key_source.d.ts","sourceRoot":"","sources":["../../src/auth/key_source.ts"],"names":[],"mappings":"AA+BA,MAAM,MAAM,SAAS,GACjB;IACE,IAAI,EAAE,KAAK,CAAC;IACZ,OAAO,EAAE,kBAAkB,CAAC;IAC5B,0EAA0E;IAC1E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,4DAA4D;IAC5D,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;CACpB,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;CACpB,GACD;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAsErB;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,UAAU,EAAE,MAAM,GACjB,SAAS,CAyBX;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,SAAS,GAAG;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB,CA6CA"}
1
+ {"version":3,"file":"key_source.d.ts","sourceRoot":"","sources":["../../src/auth/key_source.ts"],"names":[],"mappings":"AAwCA,MAAM,WAAW,SAAS;IACxB,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,MAAM,MAAM,SAAS,GACjB;IACE,IAAI,EAAE,KAAK,CAAC;IACZ,OAAO,EAAE,kBAAkB,CAAC;IAC5B,0EAA0E;IAC1E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,4DAA4D;IAC5D,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,SAAS,EAAE,CAAC;IAC/B;;;;;OAKG;IACH,MAAM,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACpE,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE,SAAS,EAAE,CAAC;IAC/B,MAAM,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACpE,GACD;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAqMrB;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,UAAU,EAAE,MAAM,GACjB,SAAS,CA8CX;AAED,oEAAoE;AACpE,MAAM,WAAW,SAAS;IACxB,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,MAAM,EAAE,OAAO,GAAG,YAAY,GAAG,YAAY,GAAG,aAAa,CAAC;IAC9D,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,CAwBjE;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,SAAS,GAAG;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB,CAqFA"}
@@ -7,23 +7,32 @@
7
7
  * We never log or return the key value itself, only its prefix and the
8
8
  * file/line that owns it.
9
9
  *
10
- * Source ranking (matches the read order in `index.ts:57` —
11
- * `process.env.CODELOOP_API_KEY || config.api_key`):
10
+ * Why this is more than an rc-file scan
11
+ * -------------------------------------
12
+ * Cursor / Claude Code launch the MCP server with an `env` block from
13
+ * `.cursor/mcp.json` (project) and `~/.cursor/mcp.json` (global). That
14
+ * block is injected straight into the child process's environment and
15
+ * therefore OVERRIDES anything the user exported in `~/.zshrc`. So the
16
+ * value the server reads from `process.env.CODELOOP_API_KEY` is, in the
17
+ * common Cursor setup, the hardcoded mcp.json key — NOT the shell one.
12
18
  *
13
- * 1. `process.env.CODELOOP_API_KEY` (wins) scan common shell rc
14
- * files for the `export CODELOOP_API_KEY=...` line so we can tell
15
- * the user *which* file owns the value their shell currently
16
- * exports. We match by prefix (first 12 chars of the key) so we
17
- * don't accidentally point them at an OLD line that happens to
18
- * mention the var.
19
+ * The original implementation only scanned shell rc files. When the
20
+ * effective key was a stale mcp.json hardcode, the scan found nothing
21
+ * matching there and fell back to "likely ~/.zshrc" — pointing the user
22
+ * at the very file where their ACTIVE (rotated) key lives, which made
23
+ * the revoked-key error look like a CodeLoop bug ("but my key is
24
+ * active!"). We now scan the mcp.json `env` blocks FIRST (they win at
25
+ * runtime) and also detect SHADOWING — a different key sitting in the
26
+ * shell / config that the hardcoded mcp.json key is silently overriding
27
+ * — so the message explains exactly what happened.
19
28
  *
20
- * 2. `config.api_key` from the project's `.codeloop/config.json` —
21
- * simple file-path callout.
29
+ * Source ranking (matches the read order in `index.ts` —
30
+ * `process.env.CODELOOP_API_KEY || config.api_key`):
22
31
  *
32
+ * 1. `process.env.CODELOOP_API_KEY` (wins) — scan mcp.json env blocks
33
+ * AND shell rc files for the line that owns the live value.
34
+ * 2. `config.api_key` from the project's `.codeloop/config.json`.
23
35
  * 3. neither — `{ source: "none" }` (activation required).
24
- *
25
- * The returned `fix_steps` are deliberately copy-paste shell commands
26
- * the user can run to rotate, so the agent can present them verbatim.
27
36
  */
28
37
  import { existsSync, readFileSync } from "node:fs";
29
38
  import { join } from "node:path";
@@ -37,28 +46,72 @@ const SHELL_RC_CANDIDATES = [
37
46
  ".bashrc",
38
47
  ".bash_profile",
39
48
  ".profile",
40
- // Fish and PowerShell aren't covered yet env vars set there don't
41
- // typically leak into Cursor's MCP child process anyway. Add them if
42
- // a user reports a real-world miss.
49
+ // Fish: env vars set here don't usually leak into Cursor's MCP child,
50
+ // but they're cheap to scan and help the diagnostic point at the right
51
+ // file when a user does export from there.
52
+ join(".config", "fish", "config.fish"),
43
53
  ];
44
54
  /**
45
- * Scan well-known shell rc files for an `export CODELOOP_API_KEY=...`
46
- * line whose value's first 12 chars equal `keyPrefix`. Returns the
47
- * first match (rc files are read in the order above; the user's shell
48
- * applies them in roughly the same order so the first hit is usually
49
- * the winning one). Returns `null` if nothing matched.
50
- *
51
- * We deliberately match by prefix — not by full string — so:
52
- * - We never store the full key in this process's memory longer
53
- * than the comparison.
54
- * - Stale `export` lines for an older key don't masquerade as the
55
- * current source.
55
+ * PowerShell profile scripts the Windows equivalent of the shell rc
56
+ * files. A user who runs `$env:CODELOOP_API_KEY = "…"` (or `setx`) here
57
+ * is the Windows analogue of an `export` line in `~/.zshrc`, so we scan
58
+ * them for parity. Covers Windows PowerShell 5.1, PowerShell 7 (Windows
59
+ * + macOS/Linux), and the OneDrive-redirected Documents folder Windows
60
+ * often uses.
56
61
  */
57
- function findExportLine(keyPrefix) {
58
- // Walk a fairly generic pattern: `export CODELOOP_API_KEY="cl_live_…"`
59
- // or `setenv CODELOOP_API_KEY "cl_live_…"` (csh-style). We accept
60
- // single or double quotes and optional whitespace.
62
+ function powerShellProfileCandidates() {
63
+ const home = userHome();
64
+ const out = [
65
+ join(home, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"),
66
+ join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1"),
67
+ join(home, "Documents", "WindowsPowerShell", "profile.ps1"),
68
+ join(home, "Documents", "PowerShell", "profile.ps1"),
69
+ // OneDrive-redirected Documents (very common on managed Windows).
70
+ join(home, "OneDrive", "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"),
71
+ join(home, "OneDrive", "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1"),
72
+ // PowerShell 7 on macOS / Linux.
73
+ join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
74
+ ];
75
+ return out;
76
+ }
77
+ /**
78
+ * mcp.json-style config files whose `env` block can inject
79
+ * CODELOOP_API_KEY into the MCP server process. This is the list that
80
+ * matters most: Cursor / Claude Code launch the server with this `env`
81
+ * block, which OVERRIDES the shell. Covers every client × OS combination
82
+ * we support so a stale hardcoded key is always locatable.
83
+ */
84
+ function mcpConfigCandidates(projectDir) {
85
+ const home = userHome();
86
+ const appData = process.env.APPDATA; // Windows roaming app data
87
+ const out = [
88
+ // Cursor — project + global (same path on macOS/Linux/Windows via homedir).
89
+ join(projectDir, ".cursor", "mcp.json"),
90
+ join(home, ".cursor", "mcp.json"),
91
+ // Generic / VS Code project conventions.
92
+ join(projectDir, ".mcp.json"),
93
+ join(projectDir, ".vscode", "mcp.json"),
94
+ // VS Code user-level mcp.json (per OS).
95
+ join(home, "Library", "Application Support", "Code", "User", "mcp.json"),
96
+ join(home, ".config", "Code", "User", "mcp.json"),
97
+ // Claude Code (CLI) — global + nested project configs live in ~/.claude.json.
98
+ join(home, ".claude.json"),
99
+ // Claude Desktop — macOS, Linux, and Windows (%APPDATA%) locations.
100
+ join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
101
+ join(home, ".config", "claude", "claude_desktop_config.json"),
102
+ // Windsurf / Codeium.
103
+ join(home, ".codeium", "windsurf", "mcp_config.json"),
104
+ ];
105
+ if (appData) {
106
+ out.push(join(appData, "Claude", "claude_desktop_config.json"));
107
+ out.push(join(appData, "Code", "User", "mcp.json"));
108
+ }
109
+ return out;
110
+ }
111
+ /** Scan shell rc files for `export CODELOOP_API_KEY=…` lines. */
112
+ function scanRcFiles() {
61
113
  const home = userHome();
114
+ const out = [];
62
115
  for (const name of SHELL_RC_CANDIDATES) {
63
116
  const path = join(home, name);
64
117
  if (!existsSync(path))
@@ -73,45 +126,148 @@ function findExportLine(keyPrefix) {
73
126
  const lines = body.split("\n");
74
127
  for (let i = 0; i < lines.length; i += 1) {
75
128
  const line = lines[i] ?? "";
76
- // Cheap pre-filter — most rc files never mention us.
77
129
  if (!line.includes("CODELOOP_API_KEY"))
78
130
  continue;
79
- // Match anything that looks like an export of our env var. We
80
- // tolerate `export FOO=bar` with or without quotes, and the
81
- // `setenv FOO bar` csh form for completeness.
82
- const m = /(?:export\s+|setenv\s+)CODELOOP_API_KEY\s*[= ]\s*["']?([^"'\s]+)["']?/.exec(line);
131
+ // bash/zsh `export FOO=…` / csh `setenv FOO …` / fish `set -x FOO …`.
132
+ const m = /(?:export\s+|setenv\s+|set\s+(?:-x\s+|--export\s+)?)CODELOOP_API_KEY\s*[= ]\s*["']?([^"'\s]+)["']?/.exec(line);
83
133
  if (!m)
84
134
  continue;
85
- const raw = m[1] ?? "";
86
- if (raw.slice(0, keyPrefix.length) !== keyPrefix)
135
+ out.push({ file: path, line: i + 1, value: m[1] ?? "" });
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ /** Scan PowerShell profiles for `$env:CODELOOP_API_KEY = "…"` / `setx` lines. */
141
+ function scanPowerShellProfiles() {
142
+ const out = [];
143
+ const seen = new Set();
144
+ for (const path of powerShellProfileCandidates()) {
145
+ if (seen.has(path))
146
+ continue;
147
+ seen.add(path);
148
+ if (!existsSync(path))
149
+ continue;
150
+ let body;
151
+ try {
152
+ body = readFileSync(path, "utf-8");
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ const lines = body.split("\n");
158
+ for (let i = 0; i < lines.length; i += 1) {
159
+ const line = lines[i] ?? "";
160
+ if (!line.includes("CODELOOP_API_KEY"))
161
+ continue;
162
+ // `$env:CODELOOP_API_KEY = "…"` or `setx CODELOOP_API_KEY "…"`.
163
+ const m = /(?:\$env:CODELOOP_API_KEY\s*=\s*|setx\s+CODELOOP_API_KEY\s+)["']?([^"'\s]+)["']?/.exec(line);
164
+ if (!m)
87
165
  continue;
88
- return { file: path, line: i + 1 };
166
+ out.push({ file: path, line: i + 1, value: m[1] ?? "" });
89
167
  }
90
168
  }
91
- return null;
169
+ return out;
170
+ }
171
+ /** Combined shell + PowerShell scan — the cross-platform rc-file equivalent. */
172
+ function scanShellKeyFiles() {
173
+ return [...scanRcFiles(), ...scanPowerShellProfiles()];
174
+ }
175
+ /** Read the `api_key` value out of a project's `.codeloop/config.json`. */
176
+ function scanProjectConfig(projectDir) {
177
+ const path = join(projectDir, ".codeloop", "config.json");
178
+ if (!existsSync(path))
179
+ return [];
180
+ let body;
181
+ try {
182
+ body = readFileSync(path, "utf-8");
183
+ }
184
+ catch {
185
+ return [];
186
+ }
187
+ const lines = body.split("\n");
188
+ for (let i = 0; i < lines.length; i += 1) {
189
+ const line = lines[i] ?? "";
190
+ if (!line.includes("api_key"))
191
+ continue;
192
+ const m = /"api_key"\s*:\s*"([^"]+)"/.exec(line);
193
+ if (!m)
194
+ continue;
195
+ return [{ file: path, line: i + 1, value: m[1] ?? "" }];
196
+ }
197
+ return [];
198
+ }
199
+ /** Scan mcp.json-style config files for a hardcoded `"CODELOOP_API_KEY": "…"`. */
200
+ function scanMcpConfigs(projectDir) {
201
+ const out = [];
202
+ const seen = new Set();
203
+ for (const path of mcpConfigCandidates(projectDir)) {
204
+ if (seen.has(path))
205
+ continue;
206
+ seen.add(path);
207
+ if (!existsSync(path))
208
+ continue;
209
+ let body;
210
+ try {
211
+ body = readFileSync(path, "utf-8");
212
+ }
213
+ catch {
214
+ continue;
215
+ }
216
+ const lines = body.split("\n");
217
+ for (let i = 0; i < lines.length; i += 1) {
218
+ const line = lines[i] ?? "";
219
+ if (!line.includes("CODELOOP_API_KEY"))
220
+ continue;
221
+ // JSON form: "CODELOOP_API_KEY": "cl_live_…"
222
+ const m = /"CODELOOP_API_KEY"\s*:\s*"([^"]+)"/.exec(line);
223
+ if (!m)
224
+ continue;
225
+ out.push({ file: path, line: i + 1, value: m[1] ?? "" });
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+ function prefixOf(value) {
231
+ return value.slice(0, 12);
92
232
  }
93
233
  /**
94
234
  * Resolve the source of the API key currently being presented to the
95
- * MCP server. Pass the same `apiKey` value the server uses (`process.env.CODELOOP_API_KEY || config.api_key`)
96
- * and the `projectDir` it discovered.
235
+ * MCP server. Pass the same `apiKey` value the server uses
236
+ * (`process.env.CODELOOP_API_KEY || config.api_key`) and the
237
+ * `projectDir` it discovered.
97
238
  *
98
- * Safe to call on every revoked-key error: scanning a handful of rc
99
- * files is microseconds and we cache nothing.
239
+ * Safe to call on every revoked-key error: scanning a handful of files
240
+ * is microseconds and we cache nothing.
100
241
  */
101
242
  export function identifyKeySource(apiKey, projectDir) {
102
243
  if (!apiKey || apiKey.trim() === "") {
103
244
  return { kind: "none" };
104
245
  }
105
- const prefix = apiKey.slice(0, 12);
246
+ const prefix = prefixOf(apiKey);
247
+ const rcLines = scanShellKeyFiles();
248
+ const mcpLines = scanMcpConfigs(projectDir);
249
+ // mcp.json env blocks that hardcode THIS exact key — the effective
250
+ // override at runtime under Cursor / Claude Code.
251
+ const mcpHits = mcpLines
252
+ .filter((l) => l.value.slice(0, prefix.length) === prefix)
253
+ .map((l) => ({ file: l.file, line: l.line }));
254
+ // A DIFFERENT key sitting anywhere on disk — the one the user likely
255
+ // thinks is active but which the effective key is shadowing.
256
+ const shadowLine = [...mcpLines, ...rcLines].find((l) => l.value && l.value.slice(0, prefix.length) !== prefix);
257
+ const shadow = shadowLine
258
+ ? { key_prefix: prefixOf(shadowLine.value), file: shadowLine.file, line: shadowLine.line }
259
+ : undefined;
106
260
  // Branch 1: env var wins per the read order in index.ts.
107
261
  if (process.env.CODELOOP_API_KEY && process.env.CODELOOP_API_KEY === apiKey) {
108
- const hit = findExportLine(prefix);
262
+ const rcHit = rcLines.find((l) => l.value.slice(0, prefix.length) === prefix);
109
263
  return {
110
264
  kind: "env",
111
265
  env_var: "CODELOOP_API_KEY",
112
- file: hit?.file ?? null,
113
- line: hit?.line ?? null,
266
+ file: rcHit?.file ?? null,
267
+ line: rcHit?.line ?? null,
114
268
  key_prefix: prefix,
269
+ mcp_config_files: mcpHits.length > 0 ? mcpHits : undefined,
270
+ shadow,
115
271
  };
116
272
  }
117
273
  // Branch 2: came from the project config.
@@ -119,14 +275,91 @@ export function identifyKeySource(apiKey, projectDir) {
119
275
  kind: "config",
120
276
  file: join(projectDir, ".codeloop", "config.json"),
121
277
  key_prefix: prefix,
278
+ mcp_config_files: mcpHits.length > 0 ? mcpHits : undefined,
279
+ shadow,
122
280
  };
123
281
  }
282
+ /**
283
+ * Gather EVERY CodeLoop API key written anywhere on disk that could plausibly
284
+ * be the user's real key — shell rc files, PowerShell profiles, every
285
+ * mcp.json `env` block (all clients × OS), and the project's
286
+ * `.codeloop/config.json`. De-duplicated by full value, preserving the
287
+ * priority order (mcp.json first — it's what the IDE injects — then shell,
288
+ * then config).
289
+ *
290
+ * Used by the self-heal path: when the configured key is REVOKED we try each
291
+ * of these in turn so that as long as the user has ANY active key on disk,
292
+ * CodeLoop keeps working instead of hard-blocking.
293
+ */
294
+ export function collectOnDiskKeys(projectDir) {
295
+ const groups = [
296
+ { origin: "mcp_config", lines: scanMcpConfigs(projectDir) },
297
+ { origin: "shell", lines: scanRcFiles() },
298
+ { origin: "powershell", lines: scanPowerShellProfiles() },
299
+ { origin: "config_json", lines: scanProjectConfig(projectDir) },
300
+ ];
301
+ const out = [];
302
+ const seenValues = new Set();
303
+ for (const g of groups) {
304
+ for (const l of g.lines) {
305
+ const value = (l.value ?? "").trim();
306
+ if (!value || seenValues.has(value))
307
+ continue;
308
+ seenValues.add(value);
309
+ out.push({
310
+ value,
311
+ key_prefix: prefixOf(value),
312
+ origin: g.origin,
313
+ file: l.file,
314
+ line: l.line,
315
+ });
316
+ }
317
+ }
318
+ return out;
319
+ }
124
320
  /**
125
321
  * Build the user-facing fix instructions for a revoked-key situation.
126
322
  * Returned as a structured object so callers can embed it in the JSON
127
323
  * error envelope; rendering is up to the agent.
128
324
  */
129
325
  export function buildRevokedKeyDiagnostic(source) {
326
+ // The single highest-value case: the revoked key is hardcoded in one
327
+ // or more mcp.json env blocks. Those override the shell, so this is
328
+ // the file the user must edit — even though their shell may hold a
329
+ // perfectly active key.
330
+ const mcpFiles = source.mcp_config_files;
331
+ const shadow = source.shadow;
332
+ if ((source.kind === "env" || source.kind === "config") && mcpFiles && mcpFiles.length > 0) {
333
+ const fileList = mcpFiles
334
+ .map((h) => (h.line ? `${h.file} (line ${h.line})` : h.file))
335
+ .join(" and ");
336
+ const shadowNote = shadow
337
+ ? ` Your shell/config has a DIFFERENT key (${shadow.key_prefix}…) in ${shadow.file}, ` +
338
+ `but it is being IGNORED because the hardcoded mcp.json key takes precedence — ` +
339
+ `this is why your key looks active but isn't the one CodeLoop is using.`
340
+ : "";
341
+ const prefix = source.key_prefix ?? "your key";
342
+ return {
343
+ message: `Your API key (${prefix}…) is revoked. It is HARDCODED in the \`env\` block of ${fileList}, ` +
344
+ `which your IDE injects into the CodeLoop MCP server and which OVERRIDES any key exported in your shell ` +
345
+ `(~/.zshrc) or stored in .codeloop/config.json.${shadowNote} ` +
346
+ `Fix: replace the CODELOOP_API_KEY value in ${fileList} with an active key from ` +
347
+ `https://codeloop.tech/dashboard/keys (or DELETE that line to fall back to your shell/config key), ` +
348
+ `then fully reload the CodeLoop MCP server — toggle it off/on in your IDE's MCP settings, or restart the IDE.`,
349
+ source,
350
+ fix_steps: [
351
+ `# 1. Edit the mcp.json file(s) that hardcode the revoked key:`,
352
+ ...mcpFiles.map((h) => `open -e "${h.file}"`),
353
+ `# 2. In each "mcpServers" -> "codeloop" -> "env" block, set CODELOOP_API_KEY to an`,
354
+ `# active key from https://codeloop.tech/dashboard/keys — OR delete the`,
355
+ `# CODELOOP_API_KEY line entirely so the server uses your shell / .codeloop/config.json key.`,
356
+ ...(shadow
357
+ ? [`# (An active-looking key ${shadow.key_prefix}… already lives in ${shadow.file}.)`]
358
+ : []),
359
+ `# 3. Reload the MCP server: toggle CodeLoop off/on in your IDE's MCP settings, or restart the IDE.`,
360
+ ],
361
+ };
362
+ }
130
363
  if (source.kind === "env") {
131
364
  const location = source.file && source.line
132
365
  ? `${source.file} line ${source.line}`