fraim 2.0.329 → 2.0.332

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.
@@ -16,6 +16,7 @@ const ide_config_checks_1 = require("../doctor/checks/ide-config-checks");
16
16
  const mcp_connectivity_checks_1 = require("../doctor/checks/mcp-connectivity-checks");
17
17
  const scripts_checks_1 = require("../doctor/checks/scripts-checks");
18
18
  const agent_cli_health_checks_1 = require("../doctor/checks/agent-cli-health-checks");
19
+ const llm_auth_route_checks_1 = require("../doctor/checks/llm-auth-route-checks");
19
20
  const add_ide_1 = require("./add-ide");
20
21
  // Read version from package.json
21
22
  const getFramVersion = () => {
@@ -60,7 +61,11 @@ function getAllChecks() {
60
61
  ...(0, ide_config_checks_1.getIDEConfigChecks)(),
61
62
  ...(0, mcp_connectivity_checks_1.getMCPConnectivityChecks)(),
62
63
  ...(0, scripts_checks_1.getScriptsChecks)(),
63
- ...(0, agent_cli_health_checks_1.getAgentCliHealthChecks)()
64
+ ...(0, agent_cli_health_checks_1.getAgentCliHealthChecks)(),
65
+ // Issue #1798: an ambient ANTHROPIC_* variable silently reroutes every Hub-launched
66
+ // agent's billing, so it belongs in the standing health check rather than only in the
67
+ // on-demand llm-spend-detection skill.
68
+ ...(0, llm_auth_route_checks_1.getLlmAuthRouteChecks)()
64
69
  ];
65
70
  }
66
71
  async function runFixMcpRepair(options, repair = add_ide_1.runAddIDE) {
@@ -141,7 +141,8 @@ async function runChecks(checks, options, version) {
141
141
  ideConfiguration: { checks: [] },
142
142
  mcpConnectivity: { checks: [] },
143
143
  scripts: { checks: [] },
144
- agentCliHealth: { checks: [] }
144
+ agentCliHealth: { checks: [] },
145
+ llmAuthRoute: { checks: [] }
145
146
  };
146
147
  let passed = 0;
147
148
  let warnings = 0;
@@ -13,6 +13,7 @@ exports.getAgentCliHealthChecks = getAgentCliHealthChecks;
13
13
  exports.checkAgentCliHealthByCommand = checkAgentCliHealthByCommand;
14
14
  const child_process_1 = require("child_process");
15
15
  const path_1 = __importDefault(require("path"));
16
+ const redact_paths_1 = require("../redact-paths");
16
17
  const managed_agent_paths_1 = require("../../utils/managed-agent-paths");
17
18
  const machine_path_1 = require("../../utils/machine-path");
18
19
  const command_resolution_1 = require("../../mcp/command-resolution");
@@ -67,7 +68,17 @@ function probeVersion(commandPath) {
67
68
  function readPersistedUserPath() {
68
69
  return (0, machine_path_1.resolveMachinePathCached)();
69
70
  }
71
+ // Issue #1482 (Privacy PRIV05 / GDPR Art. 5(1)(c)): the single exit point for every branch
72
+ // below. `message` and `suggestion` are forwarded off this process — to the browser as
73
+ // `driftWarning`/`driftSuggestion` by `POST /api/ai-hub/check-agent`, and to the Hub's server
74
+ // log by `selfHealBrokenManagedAgents()` — and a resolved CLI path embeds the OS username.
75
+ // Redacting here rather than at each branch that happens to build a path into its message is
76
+ // deliberate: a branch added to `computeAgentCliHealth()` later is covered whether or not its
77
+ // author remembers this constraint. `details` is not redacted; see `redact-paths.ts` for why.
70
78
  async function runAgentCliHealthCheck(cli) {
79
+ return (0, redact_paths_1.redactCheckResultPaths)(await computeAgentCliHealth(cli));
80
+ }
81
+ async function computeAgentCliHealth(cli) {
71
82
  // "Ambient" is this process's own raw, unmodified PATH — exactly what a
72
83
  // shell already open before a PATH fix landed still sees (issue #1285's
73
84
  // "any shell the user already has open keeps its own stale in-memory copy
@@ -110,7 +121,8 @@ async function runAgentCliHealthCheck(cli) {
110
121
  if (!recoveredVersion) {
111
122
  return {
112
123
  status: 'error',
113
- message: `${cli.label} is installed at ${recoveredPath} but failed to run (\`${cli.command} --version\` produced no output).`,
124
+ // Issue #1482: basename only. The full path stays in `details` for local/self-heal use.
125
+ message: `${cli.label} is installed (launcher \`${(0, redact_paths_1.redactPathForDisplay)(recoveredPath)}\`) but failed to run (\`${cli.command} --version\` produced no output).`,
114
126
  suggestion: `Reinstall ${cli.label}, then run "${cli.command} --version" again to confirm it's fixed.`,
115
127
  details: { recoveredPath },
116
128
  };
@@ -143,7 +155,8 @@ async function runAgentCliHealthCheck(cli) {
143
155
  if (brokenPath) {
144
156
  return {
145
157
  status: 'error',
146
- message: `${cli.label} is installed at ${brokenPath} but failed to run (\`${cli.command} --version\` produced no output). An interrupted or partial update may have left a non-functional launcher.`,
158
+ // Issue #1482: basename only, same as the recovery branch above.
159
+ message: `${cli.label} is installed (launcher \`${(0, redact_paths_1.redactPathForDisplay)(brokenPath)}\`) but failed to run (\`${cli.command} --version\` produced no output). An interrupted or partial update may have left a non-functional launcher.`,
147
160
  suggestion: `Reinstall ${cli.label}, then run "${cli.command} --version" again to confirm it's fixed.`,
148
161
  details: { ambientPath, ambientVersion, managedPath, managedVersion },
149
162
  };
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ /**
3
+ * LLM billing-route health checks for the FRAIM doctor command.
4
+ *
5
+ * Issue #1798: a leftover `ANTHROPIC_API_KEY` silently reroutes every Hub-launched
6
+ * Claude Code agent onto a direct-API key the user may have already declined, and the
7
+ * only symptom is an opaque `Credit balance is too low` inside Hub while the same
8
+ * user's terminal keeps working. Nothing in FRAIM surfaced the cause, so the failure
9
+ * read as "Hub is broken".
10
+ *
11
+ * This is the on-demand env-var inspection from `skills/dev-ops/llm-spend-detection.md`
12
+ * (step 4) promoted into a standing health check, so the conflict is reported before
13
+ * the user hits it rather than only when they think to go looking.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.runAnthropicAuthRouteCheck = runAnthropicAuthRouteCheck;
17
+ exports.getLlmAuthRouteChecks = getLlmAuthRouteChecks;
18
+ const anthropic_auth_route_1 = require("../../../core/utils/anthropic-auth-route");
19
+ const claude_subscription_login_1 = require("../../../core/utils/claude-subscription-login");
20
+ const machine_path_1 = require("../../utils/machine-path");
21
+ function unsetInstruction(variable) {
22
+ return process.platform === 'win32'
23
+ ? `Remove ${variable} from Windows Settings → Environment Variables (or run: reg delete "HKCU\\Environment" /v ${variable} /f), then restart FRAIM Hub from the tray's Restart item so it picks up the change.`
24
+ : `Remove the ${variable} export from your shell profile (~/.zshrc, ~/.bashrc, or ~/.profile), then restart FRAIM Hub from the tray's Restart item so it picks up the change.`;
25
+ }
26
+ function formatFinding(finding, scope) {
27
+ return `${finding.variable} — ${finding.valueHint}; ${scope}. ${finding.effect}`;
28
+ }
29
+ function runAnthropicAuthRouteCheck(deps = {}) {
30
+ const env = deps.env ?? process.env;
31
+ const apiKeyResponses = deps.apiKeyResponses !== undefined ? deps.apiKeyResponses : (0, anthropic_auth_route_1.readClaudeApiKeyResponses)();
32
+ const describeScope = deps.describeScope ?? machine_path_1.describeEnvVarScope;
33
+ const subscriptionFallbackAvailable = deps.subscriptionFallbackAvailable !== undefined
34
+ ? deps.subscriptionFallbackAvailable
35
+ : (0, claude_subscription_login_1.hasUsableSubscriptionLogin)();
36
+ const { findings, hasConflict } = (0, anthropic_auth_route_1.assessAnthropicAuthRoute)({ env, apiKeyResponses, subscriptionFallbackAvailable });
37
+ if (findings.length === 0) {
38
+ return {
39
+ status: 'passed',
40
+ message: 'No ambient Anthropic auth variable is overriding the Claude Code billing route.',
41
+ };
42
+ }
43
+ const details = {};
44
+ for (const finding of findings) {
45
+ details[finding.variable] = formatFinding(finding, describeScope(finding.variable, env));
46
+ }
47
+ return verdictFor(findings, hasConflict, details);
48
+ }
49
+ /**
50
+ * Three outcomes, each with its own advice.
51
+ *
52
+ * A key the user already declined, with a subscription to fall back to, is the reported
53
+ * defect: an error, fixed by removing the variable. The same key with nothing to fall
54
+ * back to gets the opposite advice — removing it would leave the machine with no
55
+ * credential at all, so the fix is to settle which route the user wants. Anything else is
56
+ * a warning: it may be exactly what a direct-API user intends, and doctor has no basis to
57
+ * call that wrong.
58
+ */
59
+ function verdictFor(findings, hasConflict, details) {
60
+ const primary = findings.find((finding) => finding.severity === 'conflict') ?? findings[0];
61
+ const names = findings.map((finding) => finding.variable).join(', ');
62
+ if (hasConflict) {
63
+ return {
64
+ status: 'error',
65
+ message: `${primary.variable} overrides the claude.ai subscription login you already chose, so FRAIM Hub agents bill it instead and typically fail with "Credit balance is too low". Your interactive terminal is unaffected, which is why this looks like a Hub fault.`,
66
+ suggestion: unsetInstruction(primary.variable),
67
+ details,
68
+ };
69
+ }
70
+ const stranded = findings.some((finding) => finding.variable === 'ANTHROPIC_API_KEY' && finding.decision === 'rejected');
71
+ if (stranded) {
72
+ return {
73
+ status: 'warning',
74
+ message: 'ANTHROPIC_API_KEY is a key you declined in an interactive Claude Code session, but it is the only credential on this machine — there is no signed-in claude.ai subscription to fall back to. FRAIM leaves it in place rather than leaving you with nothing to authenticate with, so Hub and your terminal both use it.',
75
+ suggestion: 'Decide which route you want: run "claude" and sign in with /login to use your subscription, or accept the key at the prompt to keep using direct-API billing. Until then this variable stays in effect.',
76
+ details,
77
+ };
78
+ }
79
+ return {
80
+ status: 'warning',
81
+ message: `${names} ${findings.length === 1 ? 'is' : 'are'} set and take${findings.length === 1 ? 's' : ''} precedence over a claude.ai subscription login for every headless launch, including every FRAIM Hub agent. This is correct if you intend direct-API billing; if you do not, it is the cause of any "Credit balance is too low" or "Not logged in" failure you see only inside Hub.`,
82
+ suggestion: `Confirm you meant to set ${names}. ${unsetInstruction(primary.variable)}`,
83
+ details,
84
+ };
85
+ }
86
+ function getLlmAuthRouteChecks(deps = {}) {
87
+ return [
88
+ {
89
+ name: `Anthropic billing route (${anthropic_auth_route_1.ANTHROPIC_DIRECT_AUTH_VARIABLES.join(', ')})`,
90
+ category: 'llmAuthRoute',
91
+ critical: false,
92
+ run: async () => runAnthropicAuthRouteCheck(deps),
93
+ },
94
+ ];
95
+ }
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /**
3
+ * Path redaction for doctor check results that leave this machine.
4
+ *
5
+ * Issue #1482 (Privacy PRIV05, GDPR Art. 5(1)(c) data minimization), found during #1256's
6
+ * `implement-security-review`: a resolved agent-CLI path embeds the OS username on every
7
+ * mainstream platform (`/Users/camille/.local/bin/claude`,
8
+ * `C:\Users\sidma\AppData\Roaming\npm\claude.cmd`). `POST /api/ai-hub/check-agent`
9
+ * (`src/ai-hub/server.ts`) forwards a failing check's `message` and `suggestion` to the browser
10
+ * verbatim as `driftWarning` / `driftSuggestion`, and `selfHealBrokenManagedAgents()` writes
11
+ * `message` into the Hub's server log. The basename carries all the diagnostic value those
12
+ * messages need, so the prefix is dropped.
13
+ *
14
+ * Scope of the redaction, deliberately:
15
+ * - `message` / `suggestion`: redacted. These cross a process boundary.
16
+ * - `details`: left raw. It is never forwarded to any client — its only readers are
17
+ * `selfHealBrokenManagedAgents()` (which reads `details.managedPath` to decide whether to
18
+ * auto-reinstall a broken FRAIM-managed CLI) and `fraim doctor`'s own `--verbose` / `--json`
19
+ * output, which prints the operator their own paths in their own terminal.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.redactPathForDisplay = redactPathForDisplay;
23
+ exports.redactPathsInText = redactPathsInText;
24
+ exports.redactCheckResultPaths = redactCheckResultPaths;
25
+ /**
26
+ * Matches an absolute filesystem path: POSIX (`/a/b`), tilde-rooted (`~/a/b`), Windows
27
+ * drive-rooted (`C:\a\b` or `C:/a/b`), and UNC (`\\server\share`).
28
+ *
29
+ * The lookbehind keeps it off text that only looks path-shaped: a URL's `//` is preceded by
30
+ * `:`, `and/or` is preceded by a letter, and a relative `./bin/x` is preceded by `.`. The body
31
+ * stops at whitespace and quoting so trailing prose or a quoted command survives — except that
32
+ * a space is consumed when a path separator still follows before the next space, which is what
33
+ * keeps `C:\Users\John Smith\bin\claude.cmd` from redacting to `John` and leaving the surname
34
+ * behind. That lookahead cannot cross whitespace, so ordinary prose after a path (`... but
35
+ * failed to run`) still terminates the match.
36
+ */
37
+ const ABSOLUTE_PATH_PATTERN = /(?<![A-Za-z0-9_.:\\/~-])(?:[A-Za-z]:[\\/]|\\\\|~?\/)(?:[^\s"'`<>|]|[ \t](?=[^\s"'`<>|]*[\\/]))*/g;
38
+ /** Trailing sentence punctuation is part of the surrounding prose, not part of the path. */
39
+ const TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/;
40
+ /**
41
+ * The basename of a resolved path, with no directory prefix and therefore no username.
42
+ *
43
+ * Platform-independent on purpose: `path.basename()` does not treat `\` as a separator on
44
+ * POSIX, so a Windows path read from a config or a cross-platform fixture would come through
45
+ * whole. This repo's primary dev machine is win32 and its checks also run on macOS/Linux, so
46
+ * both separators are always honoured.
47
+ */
48
+ function redactPathForDisplay(filePath) {
49
+ const trimmed = filePath.replace(/[\\/]+$/, '');
50
+ const lastSeparator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
51
+ return lastSeparator === -1 ? trimmed : trimmed.slice(lastSeparator + 1);
52
+ }
53
+ /** Replaces every absolute filesystem path in `text` with its basename. */
54
+ function redactPathsInText(text) {
55
+ return text.replace(ABSOLUTE_PATH_PATTERN, (match) => {
56
+ const trailing = TRAILING_PUNCTUATION.exec(match)?.[0] ?? '';
57
+ const candidate = trailing ? match.slice(0, -trailing.length) : match;
58
+ // A bare drive root (`C:\`) or a lone `/` has no basename to keep; leave it as written.
59
+ const afterRoot = candidate.replace(/^[A-Za-z]:/, '');
60
+ if (!/[\\/].+/.test(afterRoot))
61
+ return match;
62
+ return redactPathForDisplay(candidate) + trailing;
63
+ });
64
+ }
65
+ /**
66
+ * Sanitizes the two fields that leave this machine, leaving `details` untouched.
67
+ *
68
+ * Applied at the single exit point of `runAgentCliHealthCheck()` rather than at each branch
69
+ * that happens to build a path into its message, so a branch added later is covered whether or
70
+ * not its author remembers this constraint — the failure mode issue #1482 was filed about.
71
+ */
72
+ function redactCheckResultPaths(result) {
73
+ const message = redactPathsInText(result.message);
74
+ const suggestion = result.suggestion === undefined ? undefined : redactPathsInText(result.suggestion);
75
+ if (message === result.message && suggestion === result.suggestion)
76
+ return result;
77
+ return { ...result, message, ...(suggestion === undefined ? {} : { suggestion }) };
78
+ }
@@ -17,7 +17,8 @@ const CATEGORY_NAMES = {
17
17
  ideConfiguration: 'IDE Configuration',
18
18
  mcpConnectivity: 'MCP Server Connectivity',
19
19
  scripts: 'Scripts',
20
- agentCliHealth: 'Agent CLI Health'
20
+ agentCliHealth: 'Agent CLI Health',
21
+ llmAuthRoute: 'LLM Billing Route'
21
22
  };
22
23
  function getStatusIcon(status) {
23
24
  switch (status) {
@@ -56,7 +56,7 @@ const isMacAppRunning = (appName) => {
56
56
  // "app not installed" via a temp HOME set this to keep results deterministic.
57
57
  if (process.env.FRAIM_DETECT_DISABLE_PROCESS_CHECK === '1')
58
58
  return false;
59
- const result = (0, child_process_1.spawnSync)('pgrep', ['-f', `${appName}.app/Contents`], { encoding: 'utf8', timeout: 1500 });
59
+ const result = (0, child_process_1.spawnSync)('pgrep', ['-f', `${appName}.app/Contents`], { encoding: 'utf8', windowsHide: true, timeout: 1500 });
60
60
  return result.status === 0 && Boolean((result.stdout || '').trim());
61
61
  };
62
62
  const hasAppEvidence = (appName) => {
@@ -82,8 +82,8 @@ const availableByVersionProbe = (command) => {
82
82
  const recoveredPath = (0, managed_agent_paths_1.buildRecoveredAgentPath)(process.env.PATH ?? process.env.Path);
83
83
  const env = { ...process.env, PATH: recoveredPath, Path: recoveredPath };
84
84
  const result = process.platform === 'win32'
85
- ? (0, child_process_1.spawnSync)('cmd.exe', ['/d', '/s', '/c', `${command} --version`], { encoding: 'utf8', timeout: 1500, env })
86
- : (0, child_process_1.spawnSync)(command, ['--version'], { encoding: 'utf8', timeout: 1500, env });
85
+ ? (0, child_process_1.spawnSync)('cmd.exe', ['/d', '/s', '/c', `${command} --version`], { encoding: 'utf8', windowsHide: true, timeout: 1500, env })
86
+ : (0, child_process_1.spawnSync)(command, ['--version'], { encoding: 'utf8', windowsHide: true, timeout: 1500, env });
87
87
  return result.status === 0 && !result.error && (0, managed_agent_paths_1.versionFromProbeOutput)(result.stdout, result.stderr) !== null;
88
88
  };
89
89
  const detectClaude = () => {
@@ -180,7 +180,7 @@ exports.CODEX_DESKTOP_DOWNLOAD_URL = 'https://chatgpt.com/download/';
180
180
  const detectCodexDesktopMac = () => {
181
181
  if (process.env.FRAIM_DETECT_DISABLE_PROCESS_CHECK === '1')
182
182
  return false;
183
- const result = (0, child_process_1.spawnSync)('mdfind', [`kMDItemCFBundleIdentifier == '${exports.CODEX_DESKTOP_BUNDLE_ID}'`], { encoding: 'utf8', timeout: 2000 });
183
+ const result = (0, child_process_1.spawnSync)('mdfind', [`kMDItemCFBundleIdentifier == '${exports.CODEX_DESKTOP_BUNDLE_ID}'`], { encoding: 'utf8', windowsHide: true, timeout: 2000 });
184
184
  return result.status === 0 && Boolean((result.stdout || '').trim());
185
185
  };
186
186
  const detectCodexDesktopWindows = () => {
@@ -192,7 +192,7 @@ const detectCodexDesktopWindows = () => {
192
192
  // publisher-assigned identifier, distinct from the mutable Start-menu
193
193
  // display name) is queried by a name pattern as the best-available proxy.
194
194
  const psCmd = `(Get-AppxPackage | Where-Object { $_.Name -like '*ChatGPT*' } | Select-Object -First 1).PackageFamilyName`;
195
- const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8', timeout: 3000 });
195
+ const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8', windowsHide: true, timeout: 3000 });
196
196
  return result.status === 0 && Boolean((result.stdout || '').trim());
197
197
  };
198
198
  const detectCodexDesktopOverride = () => {
@@ -9,8 +9,12 @@ exports.composeMachinePath = composeMachinePath;
9
9
  exports.resolveMachinePathCached = resolveMachinePathCached;
10
10
  exports.clearMachinePathCache = clearMachinePathCache;
11
11
  exports.setMachinePathResolver = setMachinePathResolver;
12
+ exports.readWindowsEnvVarScopes = readWindowsEnvVarScopes;
13
+ exports.setWindowsEnvScopeResolver = setWindowsEnvScopeResolver;
14
+ exports.describeEnvVarScope = describeEnvVarScope;
12
15
  const path_1 = __importDefault(require("path"));
13
16
  const child_process_1 = require("child_process");
17
+ const env_var_names_1 = require("../../core/utils/env-var-names");
14
18
  // ─── Issue #1748: Windows machine-PATH recovery ─────────────────────────────
15
19
  //
16
20
  // A Windows process inherits its environment at creation and never observes later
@@ -49,12 +53,14 @@ const MACHINE_PATH_CACHE_TTL_MS = 60_000;
49
53
  let machinePathResolverOverride = null;
50
54
  let cachedMachinePath;
51
55
  let cachedMachinePathAtMs = 0;
52
- // The two registry values Windows itself composes a process PATH from, in the order it
53
- // composes them: Machine first, then User.
54
- const WINDOWS_PATH_REGISTRY_KEYS = [
55
- [String.raw `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, 'Path'],
56
- [String.raw `HKCU\Environment`, 'Path'],
56
+ // The two registry hives Windows itself composes a process environment from, in the
57
+ // order it composes them: Machine first, then User.
58
+ const WINDOWS_ENVIRONMENT_REGISTRY_KEYS = [
59
+ ['Machine', String.raw `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`],
60
+ ['User', String.raw `HKCU\Environment`],
57
61
  ];
62
+ const WINDOWS_PATH_REGISTRY_KEYS = WINDOWS_ENVIRONMENT_REGISTRY_KEYS
63
+ .map(([, key]) => [key, 'Path']);
58
64
  // Resolve reg.exe by absolute path rather than letting the OS search PATH for it. This
59
65
  // module's whole purpose is to stop a PATH entry from deciding which binary FRAIM runs,
60
66
  // so invoking its own helper by bare name through that same untrusted PATH would undercut
@@ -163,3 +169,65 @@ function setMachinePathResolver(resolver) {
163
169
  machinePathResolverOverride = resolver;
164
170
  clearMachinePathCache();
165
171
  }
172
+ let windowsEnvScopeResolverOverride = null;
173
+ function defaultReadWindowsEnvVarScopes(name) {
174
+ if (process.platform !== 'win32')
175
+ return [];
176
+ // Same isolation rule as defaultResolveMachinePath: a sandboxed test run must not
177
+ // read this machine's real registry environment, or whatever happens to be set on
178
+ // the build agent would leak into a fixture's expectations.
179
+ if (process.env.FRAIM_TEST_SANDBOX === '1')
180
+ return [];
181
+ const scopes = [];
182
+ for (const [scope, key] of WINDOWS_ENVIRONMENT_REGISTRY_KEYS) {
183
+ const result = (0, child_process_1.spawnSync)(resolveRegExePath(), ['query', key, '/v', name], {
184
+ encoding: 'utf8',
185
+ timeout: MACHINE_PATH_TIMEOUT_MS,
186
+ windowsHide: true,
187
+ });
188
+ if (result.status !== 0 || result.error)
189
+ continue;
190
+ // A value name that does not exist makes `reg query` exit non-zero, so reaching
191
+ // here already means a hit; parsing confirms the line is a real string value and
192
+ // not, say, a subkey listing.
193
+ if (parseRegQueryPathValue(result.stdout || '') !== null)
194
+ scopes.push(scope);
195
+ }
196
+ return scopes;
197
+ }
198
+ /**
199
+ * The persisted Windows scopes an environment variable is set in, nearest-to-the-OS
200
+ * first (Machine, then User). Empty on non-Windows, and empty for a variable that only
201
+ * exists in the current process's inherited environment.
202
+ */
203
+ function readWindowsEnvVarScopes(name) {
204
+ if (windowsEnvScopeResolverOverride)
205
+ return windowsEnvScopeResolverOverride(name);
206
+ try {
207
+ return defaultReadWindowsEnvVarScopes(name);
208
+ }
209
+ catch {
210
+ return [];
211
+ }
212
+ }
213
+ function setWindowsEnvScopeResolver(resolver) {
214
+ windowsEnvScopeResolverOverride = resolver;
215
+ }
216
+ /**
217
+ * Where a variable currently in `env` actually lives, phrased for a user who has to go
218
+ * and remove it.
219
+ */
220
+ function describeEnvVarScope(name, env = process.env) {
221
+ // Not `env[name]`: a caller may pass an ordinary copy of the environment, where a
222
+ // Windows variable the user typed in another casing would read as absent.
223
+ if ((0, env_var_names_1.lookupEnvVar)(env, name) === undefined)
224
+ return 'not set';
225
+ if (process.platform !== 'win32') {
226
+ return 'set in this process (exported by your shell profile or the app that launched FRAIM)';
227
+ }
228
+ const scopes = readWindowsEnvVarScopes(name);
229
+ if (scopes.length === 0) {
230
+ return 'set in this process only (not persisted in the Windows registry — it came from the shell that launched FRAIM)';
231
+ }
232
+ return `persisted in the Windows ${scopes.join(' and ')} environment`;
233
+ }
@@ -192,6 +192,7 @@ function resolveNpmGlobalBinDirs(basePath, env) {
192
192
  ? ['/d', '/s', '/c', 'npm', 'prefix', '-g']
193
193
  : ['prefix', '-g'];
194
194
  const result = (0, child_process_1.spawnSync)(executable, args, {
195
+ windowsHide: true,
195
196
  encoding: 'utf8',
196
197
  timeout: 5000,
197
198
  env: childEnv,
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CLAUDE_API_KEY_DECISION_TAIL_LENGTH = exports.ANTHROPIC_DIRECT_AUTH_VARIABLES = void 0;
7
+ exports.claudeApiKeyDecisionTail = claudeApiKeyDecisionTail;
8
+ exports.claudeConfigFilePath = claudeConfigFilePath;
9
+ exports.parseClaudeApiKeyResponses = parseClaudeApiKeyResponses;
10
+ exports.readClaudeApiKeyResponses = readClaudeApiKeyResponses;
11
+ exports.readClaudeApiKeyResponsesCached = readClaudeApiKeyResponsesCached;
12
+ exports.clearClaudeApiKeyResponsesCache = clearClaudeApiKeyResponsesCache;
13
+ exports.classifyAmbientApiKey = classifyAmbientApiKey;
14
+ exports.assessAnthropicAuthRoute = assessAnthropicAuthRoute;
15
+ exports.anthropicEnvToUnsetForRecordedRoute = anthropicEnvToUnsetForRecordedRoute;
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const env_var_names_1 = require("./env-var-names");
20
+ // ─── Issue #1798: ambient Anthropic auth env vars vs. the user's intended route ───
21
+ //
22
+ // Claude Code resolves its credentials differently depending on how it was started,
23
+ // and the two paths disagree:
24
+ //
25
+ // Interactive TUI — an `ANTHROPIC_API_KEY` found in the environment produces a
26
+ // one-time "use this API key?" prompt. The answer is persisted
27
+ // in `~/.claude.json` under `customApiKeyResponses`, and a
28
+ // rejected key is thereafter ignored in favour of the
29
+ // claude.ai subscription OAuth login.
30
+ // Headless (`-p`) — there is nobody to prompt, and the stored answer is never
31
+ // consulted. The environment variable wins unconditionally.
32
+ //
33
+ // FRAIM Hub spawns agents headlessly, merging `process.env` into the child, so a
34
+ // stray `ANTHROPIC_API_KEY` left over from unrelated testing silently reroutes every
35
+ // Hub job onto a direct-API key the user already declined — while their own terminal
36
+ // keeps working. The observable symptom is an opaque `Credit balance is too low`.
37
+ //
38
+ // Verified live on the reporter's machine (issue #1798, and re-confirmed while
39
+ // implementing it) against a real subscription login, same config home for all runs:
40
+ //
41
+ // ANTHROPIC_API_KEY (recorded as rejected) → exit 1, "Credit balance is too low"
42
+ // ANTHROPIC_AUTH_TOKEN (bogus value) → exit 1, "Not logged in · Please run /login"
43
+ // ANTHROPIC_BASE_URL (unroutable value) → exit 1, "API Error: Connection refused"
44
+ // none of the above → exit 0, normal reply
45
+ //
46
+ // So all three override a working subscription login, but only `ANTHROPIC_API_KEY`
47
+ // has a recorded user decision to read back. That asymmetry is why this module
48
+ // reports on all three and authorises removing only the first — see
49
+ // `anthropicEnvToUnsetForRecordedRoute`.
50
+ /** The env vars confirmed above to take precedence over a claude.ai subscription login. */
51
+ exports.ANTHROPIC_DIRECT_AUTH_VARIABLES = [
52
+ 'ANTHROPIC_API_KEY',
53
+ 'ANTHROPIC_AUTH_TOKEN',
54
+ 'ANTHROPIC_BASE_URL',
55
+ ];
56
+ /**
57
+ * Claude Code keys a recorded accept/reject decision by the LAST 20 CHARACTERS of the
58
+ * key, not by the whole key or a hash of it. Confirmed against real data on the
59
+ * reporting machine: a 108-character `sk-ant-api03-…` key whose final 20 characters
60
+ * appear verbatim as the single entry in `customApiKeyResponses.rejected`.
61
+ */
62
+ exports.CLAUDE_API_KEY_DECISION_TAIL_LENGTH = 20;
63
+ /** The decision key Claude Code stores for an API key. Empty for a value too short to key on. */
64
+ function claudeApiKeyDecisionTail(apiKey) {
65
+ const value = String(apiKey || '');
66
+ return value.length >= exports.CLAUDE_API_KEY_DECISION_TAIL_LENGTH
67
+ ? value.slice(-exports.CLAUDE_API_KEY_DECISION_TAIL_LENGTH)
68
+ : '';
69
+ }
70
+ /**
71
+ * A printable stand-in for a credential. Six characters is enough for a user to tell
72
+ * which of their keys this is while leaving the value unusable — deliberately shorter
73
+ * than the 20-character decision tail, which is itself key material and must not be
74
+ * echoed to a console or a log.
75
+ */
76
+ const CREDENTIAL_HINT_CHARS = 6;
77
+ function credentialHint(value) {
78
+ const trimmed = String(value || '');
79
+ if (trimmed.length <= CREDENTIAL_HINT_CHARS)
80
+ return `set (${trimmed.length} chars)`;
81
+ return `set (${trimmed.length} chars, ending …${trimmed.slice(-CREDENTIAL_HINT_CHARS)})`;
82
+ }
83
+ function normalizeResponseList(value) {
84
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
85
+ }
86
+ /**
87
+ * The file Claude Code persists interactive answers in. The home-directory location is
88
+ * the confirmed one; no other location is guessed at, because a miss here degrades to
89
+ * "no recorded decision", which is the safe answer everywhere this module is used.
90
+ */
91
+ function claudeConfigFilePath(homeDir = os_1.default.homedir()) {
92
+ return path_1.default.join(homeDir, '.claude.json');
93
+ }
94
+ function parseClaudeApiKeyResponses(fileContents) {
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(fileContents);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ if (!parsed || typeof parsed !== 'object')
103
+ return null;
104
+ const responses = parsed.customApiKeyResponses;
105
+ if (!responses || typeof responses !== 'object')
106
+ return null;
107
+ return {
108
+ approved: normalizeResponseList(responses.approved),
109
+ rejected: normalizeResponseList(responses.rejected),
110
+ };
111
+ }
112
+ function readClaudeApiKeyResponses(homeDir = os_1.default.homedir()) {
113
+ try {
114
+ return parseClaudeApiKeyResponses(fs_1.default.readFileSync(claudeConfigFilePath(homeDir), 'utf8'));
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ // `~/.claude.json` is ~120KB on a well-used machine and is re-read on every agent
121
+ // launch, so the read is memoized. It expires rather than living for the process
122
+ // lifetime because the user can answer the interactive prompt in a terminal at any
123
+ // moment, and a Hub instance runs for days — the same reasoning as the machine-PATH
124
+ // cache in src/cli/utils/machine-path.ts.
125
+ const CLAUDE_API_KEY_RESPONSES_CACHE_TTL_MS = 60_000;
126
+ let cachedResponses;
127
+ let cachedResponsesAtMs = 0;
128
+ function readClaudeApiKeyResponsesCached(nowMs = Date.now()) {
129
+ if (cachedResponses !== undefined && nowMs - cachedResponsesAtMs < CLAUDE_API_KEY_RESPONSES_CACHE_TTL_MS) {
130
+ return cachedResponses;
131
+ }
132
+ cachedResponses = readClaudeApiKeyResponses();
133
+ cachedResponsesAtMs = nowMs;
134
+ return cachedResponses;
135
+ }
136
+ function clearClaudeApiKeyResponsesCache() {
137
+ cachedResponses = undefined;
138
+ cachedResponsesAtMs = 0;
139
+ }
140
+ function classifyAmbientApiKey(apiKey, responses) {
141
+ const tail = claudeApiKeyDecisionTail(apiKey || '');
142
+ if (!tail || !responses)
143
+ return 'unprompted';
144
+ if (responses.rejected.includes(tail))
145
+ return 'rejected';
146
+ if (responses.approved.includes(tail))
147
+ return 'approved';
148
+ return 'unprompted';
149
+ }
150
+ function isSet(value) {
151
+ return typeof value === 'string' && value.trim().length > 0;
152
+ }
153
+ const EFFECT_REJECTED_WITH_FALLBACK = 'You already declined this key in an interactive Claude Code session, so your terminal uses your claude.ai '
154
+ + 'subscription. Headless launches (which is how FRAIM Hub starts agents) never read that answer, so Hub bills this '
155
+ + 'key instead — typically failing with "Credit balance is too low".';
156
+ const EFFECT_REJECTED_NO_FALLBACK = 'You declined this key in an interactive Claude Code session, but it is the only credential on this machine — there '
157
+ + 'is no signed-in claude.ai subscription to fall back to. FRAIM leaves it in place rather than leaving you with '
158
+ + 'nothing to authenticate with. Run "claude" and sign in with /login if you meant to use your subscription, or '
159
+ + 'accept the key at the prompt if you meant to use it.';
160
+ const EFFECT_UNPROMPTED = 'This key takes precedence over your claude.ai subscription login for every headless launch, including every FRAIM '
161
+ + 'Hub agent. If it is exhausted or belongs to a different account, Hub jobs fail with "Credit balance is too low" '
162
+ + 'while your terminal keeps working.';
163
+ /**
164
+ * The finding for an ambient `ANTHROPIC_API_KEY`, or null when there is nothing to say.
165
+ *
166
+ * An approved key produces no finding at all: the user was asked, said yes, and chose
167
+ * direct-API billing. A rejected key with nothing to fall back to is reported but is not
168
+ * a conflict FRAIM can resolve — it is the only credential on the machine, so it is left
169
+ * in place rather than errored on and removed.
170
+ */
171
+ function apiKeyFindingFor(apiKey, responses, subscriptionFallbackAvailable) {
172
+ if (!isSet(apiKey))
173
+ return null;
174
+ const decision = classifyAmbientApiKey(apiKey, responses);
175
+ if (decision === 'approved')
176
+ return null;
177
+ const stranded = decision === 'rejected' && !subscriptionFallbackAvailable;
178
+ return {
179
+ variable: 'ANTHROPIC_API_KEY',
180
+ severity: decision === 'rejected' && !stranded ? 'conflict' : 'unreviewed',
181
+ decision,
182
+ valueHint: credentialHint(apiKey),
183
+ effect: stranded
184
+ ? EFFECT_REJECTED_NO_FALLBACK
185
+ : decision === 'rejected' ? EFFECT_REJECTED_WITH_FALLBACK : EFFECT_UNPROMPTED,
186
+ };
187
+ }
188
+ /**
189
+ * Describe every ambient variable that would reroute a Hub-launched Claude Code agent
190
+ * away from the route the user's recorded state says they intend.
191
+ *
192
+ * An `approved` key produces no finding at all: the user was asked, said yes, and
193
+ * direct-API billing is what they chose.
194
+ */
195
+ function assessAnthropicAuthRoute(input) {
196
+ const { env, apiKeyResponses, platform = process.platform, subscriptionFallbackAvailable = true } = input;
197
+ const findings = [];
198
+ const apiKey = (0, env_var_names_1.lookupEnvVar)(env, 'ANTHROPIC_API_KEY', platform);
199
+ const apiKeyFinding = apiKeyFindingFor(apiKey, apiKeyResponses, subscriptionFallbackAvailable);
200
+ if (apiKeyFinding)
201
+ findings.push(apiKeyFinding);
202
+ const authToken = (0, env_var_names_1.lookupEnvVar)(env, 'ANTHROPIC_AUTH_TOKEN', platform);
203
+ if (isSet(authToken)) {
204
+ findings.push({
205
+ variable: 'ANTHROPIC_AUTH_TOKEN',
206
+ severity: 'unreviewed',
207
+ valueHint: credentialHint(authToken),
208
+ effect: 'This token takes precedence over your claude.ai subscription login. If it is not a valid credential, Hub agents fail with "Not logged in · Please run /login". Claude Code records no accept/reject answer for this variable, so nothing can tell FRAIM whether you meant to set it.',
209
+ });
210
+ }
211
+ const baseUrl = (0, env_var_names_1.lookupEnvVar)(env, 'ANTHROPIC_BASE_URL', platform);
212
+ if (isSet(baseUrl)) {
213
+ findings.push({
214
+ variable: 'ANTHROPIC_BASE_URL',
215
+ severity: 'unreviewed',
216
+ // Not a credential: showing it in full is what makes the finding actionable.
217
+ valueHint: baseUrl,
218
+ effect: 'This sends every Hub agent request to that address instead of the Anthropic API. If it points at a proxy that is no longer running, Hub agents fail with a connection error.',
219
+ });
220
+ }
221
+ return { findings, hasConflict: findings.some((finding) => finding.severity === 'conflict') };
222
+ }
223
+ /**
224
+ * The variables FRAIM may remove from a headless child's environment on the user's
225
+ * behalf, given what they have already recorded.
226
+ *
227
+ * Deliberately narrow: only an `ANTHROPIC_API_KEY` the user explicitly rejected in an
228
+ * interactive session qualifies. That is not a new policy — it is the answer the user
229
+ * already gave, applied to the launch path that never asked. Everything else is left
230
+ * alone and merely reported, because an unprompted key may be the user's only
231
+ * credential, and removing it would break a working direct-API setup to fix a problem
232
+ * they do not have.
233
+ */
234
+ function anthropicEnvToUnsetForRecordedRoute(env, responses, platform = process.platform, subscriptionFallbackAvailable = true) {
235
+ const apiKey = (0, env_var_names_1.lookupEnvVar)(env, 'ANTHROPIC_API_KEY', platform);
236
+ if (!isSet(apiKey))
237
+ return [];
238
+ // Never remove the only credential on the machine. A user who declined this key once
239
+ // and later changed their mind without clearing the record still needs it to work.
240
+ if (!subscriptionFallbackAvailable)
241
+ return [];
242
+ return classifyAmbientApiKey(apiKey, responses) === 'rejected' ? ['ANTHROPIC_API_KEY'] : [];
243
+ }
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.claudeCredentialsFilePath = claudeCredentialsFilePath;
7
+ exports.parseSubscriptionLoginPresence = parseSubscriptionLoginPresence;
8
+ exports.hasUsableSubscriptionLogin = hasUsableSubscriptionLogin;
9
+ exports.hasUsableSubscriptionLoginCached = hasUsableSubscriptionLoginCached;
10
+ exports.clearSubscriptionLoginCache = clearSubscriptionLoginCache;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const os_1 = __importDefault(require("os"));
13
+ const path_1 = __importDefault(require("path"));
14
+ // ─── Is there a subscription session to fall back to? ───────────────────────
15
+ //
16
+ // Removing a credential is only safe when another one remains. FRAIM sheds an ambient
17
+ // `ANTHROPIC_API_KEY` that the user declined interactively (see anthropic-auth-route.ts),
18
+ // but a user whose only working credential is that same declined key — they said no once
19
+ // and later changed their mind without clearing the record — would be left with nothing
20
+ // to authenticate with, turning a working Hub into `Not logged in · Please run /login`.
21
+ // That is a worse outcome than the problem the removal exists to fix, so the removal is
22
+ // gated on this answer.
23
+ //
24
+ // The signal is `claudeAiOauth` in `~/.claude/.credentials.json`, where Claude Code keeps
25
+ // the subscription session. Only presence and the numeric expiry are ever read: no token
26
+ // value is assigned to a named variable, returned, logged, or compared.
27
+ //
28
+ // `~/.claude.json`'s `hasAvailableSubscription` is deliberately not used. It reads `false`
29
+ // on a machine whose subscription login demonstrably works, so it describes entitlement
30
+ // rather than whether a session exists to fall back to.
31
+ //
32
+ // Its own module rather than part of anthropic-auth-route.ts because "is the user signed
33
+ // in" is a different question from "what did the user decide about this key", and keeping
34
+ // them apart is what lets either be reused without dragging the other along.
35
+ function claudeCredentialsFilePath(homeDir = os_1.default.homedir()) {
36
+ return path_1.default.join(homeDir, '.claude', '.credentials.json');
37
+ }
38
+ function isNonEmptyString(value) {
39
+ return typeof value === 'string' && value.trim().length > 0;
40
+ }
41
+ /**
42
+ * Whether a subscription session exists that a child process could authenticate with.
43
+ * Every failure mode answers `false`, because "we could not tell" and "there is no
44
+ * fallback" must lead to the same safe outcome: leave the user's credential alone.
45
+ */
46
+ function parseSubscriptionLoginPresence(fileContents, nowMs = Date.now()) {
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(fileContents);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ if (!parsed || typeof parsed !== 'object')
55
+ return false;
56
+ const oauth = parsed.claudeAiOauth;
57
+ if (!oauth || typeof oauth !== 'object')
58
+ return false;
59
+ const record = oauth;
60
+ // A refresh token is enough on its own: Claude Code renews an expired access token from
61
+ // it without any user action. Without one, the access token must still be live.
62
+ if (isNonEmptyString(record.refreshToken))
63
+ return true;
64
+ if (!isNonEmptyString(record.accessToken))
65
+ return false;
66
+ return typeof record.expiresAt === 'number' ? record.expiresAt > nowMs : true;
67
+ }
68
+ function hasUsableSubscriptionLogin(homeDir = os_1.default.homedir(), nowMs = Date.now()) {
69
+ try {
70
+ return parseSubscriptionLoginPresence(fs_1.default.readFileSync(claudeCredentialsFilePath(homeDir), 'utf8'), nowMs);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ // Memoized for the same reason the sibling reads in anthropic-auth-route.ts and
77
+ // machine-path.ts are: the user can sign in from a terminal at any moment, while a Hub
78
+ // instance runs for days, so the answer must expire rather than live for the process.
79
+ const SUBSCRIPTION_LOGIN_CACHE_TTL_MS = 60_000;
80
+ let cached;
81
+ let cachedAtMs = 0;
82
+ function hasUsableSubscriptionLoginCached(nowMs = Date.now()) {
83
+ if (cached !== undefined && nowMs - cachedAtMs < SUBSCRIPTION_LOGIN_CACHE_TTL_MS)
84
+ return cached;
85
+ cached = hasUsableSubscriptionLogin(os_1.default.homedir(), nowMs);
86
+ cachedAtMs = nowMs;
87
+ return cached;
88
+ }
89
+ function clearSubscriptionLoginCache() {
90
+ cached = undefined;
91
+ cachedAtMs = 0;
92
+ }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ // ─── Windows environment names are case-insensitive; a copy of them is not ───
3
+ //
4
+ // Windows treats `anthropic_api_key` and `ANTHROPIC_API_KEY` as the same variable, and
5
+ // Node's `process.env` reproduces that: a read through any casing finds the value.
6
+ // `{ ...process.env }` does not — the spread produces an ordinary object keyed by
7
+ // whatever casing the user actually typed, where a differently-cased read returns
8
+ // undefined and a differently-cased delete silently does nothing:
9
+ //
10
+ // process.env.zz_probe = 'set'
11
+ // process.env.ZZ_PROBE -> 'set' (case-insensitive)
12
+ // { ...process.env }.ZZ_PROBE -> undefined (an ordinary, case-SENSITIVE object)
13
+ // delete copy.ZZ_PROBE -> 'zz_probe' still present
14
+ //
15
+ // Any code that builds a child environment works on exactly such a copy, so matching a
16
+ // variable by its canonical name there needs these helpers rather than `env[name]`.
17
+ // (`src/ai-hub/hosts.ts` already carried a narrower form of this workaround as
18
+ // `merged.PATH ?? merged.Path`; issue #1798 generalized it after a bug bash found that
19
+ // a lower-cased `anthropic_api_key` defeated both a detection and a removal.)
20
+ //
21
+ // POSIX environments are genuinely case-sensitive, where two differently-cased names
22
+ // are two different variables, so the exact match is the correct behaviour there and
23
+ // the platform is a parameter rather than an assumption — which also makes both
24
+ // branches testable from either host.
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.lookupEnvVar = lookupEnvVar;
27
+ exports.hasEnvVar = hasEnvVar;
28
+ exports.deleteEnvVar = deleteEnvVar;
29
+ /** The value of `name` under the platform's own name-matching rules, or undefined. */
30
+ function lookupEnvVar(env, name, platform = process.platform) {
31
+ const direct = env[name];
32
+ if (direct !== undefined || platform !== 'win32')
33
+ return direct;
34
+ const lower = name.toLowerCase();
35
+ for (const key of Object.keys(env)) {
36
+ if (key.toLowerCase() === lower)
37
+ return env[key];
38
+ }
39
+ return undefined;
40
+ }
41
+ /**
42
+ * Whether `name` is present at all — distinct from `lookupEnvVar(...) !== undefined`,
43
+ * because an explicitly empty value is still an explicit value.
44
+ */
45
+ function hasEnvVar(env, name, platform = process.platform) {
46
+ if (Object.prototype.hasOwnProperty.call(env, name))
47
+ return true;
48
+ if (platform !== 'win32')
49
+ return false;
50
+ const lower = name.toLowerCase();
51
+ return Object.keys(env).some((key) => key.toLowerCase() === lower);
52
+ }
53
+ /** Removes every casing of `name` on Windows; the exact name elsewhere. */
54
+ function deleteEnvVar(env, name, platform = process.platform) {
55
+ if (platform !== 'win32') {
56
+ delete env[name];
57
+ return;
58
+ }
59
+ const lower = name.toLowerCase();
60
+ for (const key of Object.keys(env)) {
61
+ if (key.toLowerCase() === lower)
62
+ delete env[key];
63
+ }
64
+ }
@@ -211,6 +211,55 @@ function resolvePersonalLearningFileTiers(repoBase, managerCacheBase, managerCac
211
211
  }
212
212
  return tiers;
213
213
  }
214
+ /**
215
+ * Issue #754 (OWASP API10): `userId` is untrusted at the point of file-path use.
216
+ *
217
+ * Every learning file name in this module is built as `${resolvedUserId}-<type>.md`
218
+ * and handed to `join(base, …)`. `path.join` NORMALIZES `..` and swallows a leading
219
+ * separator rather than blocking either, so an unvetted userId is a traversal
220
+ * primitive: `join(base, '../../evil-preferences.md')` lands outside `base`, and
221
+ * `join(base, '/victim@corp.com-preferences.md')` lands back inside it under a
222
+ * DIFFERENT user's name. Before #750 the value came only from local files; since
223
+ * #750 the Hub feeds it from the hosted `GET /api/personas/me` response, so it now
224
+ * crosses a network trust boundary before reaching this construction.
225
+ *
226
+ * REJECT, NOT STRIP. Stripping the offending characters is worse than the traversal
227
+ * it prevents: `../../victim@corp.com` would strip to `victim@corp.com`, turning a
228
+ * blocked traversal into a successful impersonation of a real account whose learning
229
+ * files are the very assets in this directory. A rejected identity instead resolves
230
+ * to no candidate at all, which this module already has a defined meaning for - the
231
+ * #1002 R4.1 contract below: an unresolved identity yields an EMPTY personal section
232
+ * rather than a guess. Rejection is a filter and not a throw, because
233
+ * `buildLearningContextSection` runs on MCP auto-load; throwing would turn a
234
+ * malformed identity into a hard session-startup failure.
235
+ */
236
+ function isPathSafeUserIdCandidate(candidate) {
237
+ // A candidate becomes a file NAME, never a path. Reject anything that can
238
+ // address a directory (`/`, `\`), a drive or an NTFS alternate data stream
239
+ // (`:`), or terminate a C string inside a syscall (NUL and the other control
240
+ // bytes). Hyphens, dots and `@` stay legal - real addresses carry them.
241
+ // eslint-disable-next-line no-control-regex
242
+ if (/[\\/:\u0000-\u001f]/.test(candidate))
243
+ return false;
244
+ // `..-preferences.md` is an ordinary file name, but a candidate that IS a
245
+ // relative-directory token is never a real identity.
246
+ if (candidate === '.' || candidate === '..')
247
+ return false;
248
+ // Structural backstop rather than a restatement of the check above: prove the
249
+ // candidate cannot leave a base directory no matter which mechanism it used, by
250
+ // resolving the real file name it produces against a synthetic base and requiring
251
+ // a direct child. This keeps holding if the character list is ever found to be
252
+ // incomplete on some platform.
253
+ //
254
+ // The base is a cwd-relative token resolved to absolute rather than a real
255
+ // directory: nothing is touched on disk, and a path-safety check must not depend
256
+ // on environment or configuration. `getUserFraimDirPath()` in particular throws
257
+ // under FRAIM_TEST_SANDBOX=1, which would turn a malformed identity into a crash -
258
+ // the exact outcome the reject-as-filter decision above exists to avoid.
259
+ const probeBase = (0, path_1.resolve)('__fraim_userid_probe__');
260
+ const probed = (0, path_1.resolve)((0, path_1.join)(probeBase, `${candidate}-preferences.md`));
261
+ return (0, path_1.dirname)(probed) === probeBase;
262
+ }
214
263
  function buildUserIdCandidates(userId) {
215
264
  const candidates = new Set();
216
265
  const trimmed = userId.trim();
@@ -219,7 +268,7 @@ function buildUserIdCandidates(userId) {
219
268
  const atIndex = trimmed.indexOf('@');
220
269
  if (atIndex > 0)
221
270
  candidates.add(trimmed.slice(0, atIndex));
222
- return Array.from(candidates);
271
+ return Array.from(candidates).filter(isPathSafeUserIdCandidate);
223
272
  }
224
273
  function countMatchingFilesByPrefix(dirPath, matcher) {
225
274
  if (!(0, fs_1.existsSync)(dirPath))
@@ -233,7 +282,13 @@ function countMatchingFilesByPrefix(dirPath, matcher) {
233
282
  }
234
283
  function resolveLearningUserId(workspaceRoot, userId, roots) {
235
284
  const candidates = buildUserIdCandidates(userId);
236
- let bestCandidate = candidates[0] || userId;
285
+ // Issue #754: falling back to the raw `userId` here would route every rejected
286
+ // candidate straight back into `join(base, "${resolvedUserId}-<type>.md")` and
287
+ // undo the filter above. The empty string is the module's existing "no identity"
288
+ // value - `src/ai-hub/server.ts` already calls in with `userEmail || ''` - so an
289
+ // identity that fails the path-safety check lands on the same defined, contained
290
+ // behavior as an identity that was never supplied: an empty personal section.
291
+ let bestCandidate = candidates[0] || '';
237
292
  let bestScore = -1;
238
293
  for (const candidate of candidates) {
239
294
  const score = ((0, fs_1.existsSync)((0, path_1.join)(roots.globalPersonalBase, `${candidate}-preferences.md`)) ? 1 : 0) +
@@ -630,14 +685,25 @@ const CONTEXT_CATEGORIES = {
630
685
  * resolutions that can drift.
631
686
  */
632
687
  function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain) {
633
- // Domain axis (issue #806): session startup stays broad enough for stable
634
- // preferences plus L0, while job contexts load the full global set and may
635
- // append the current job's domain files below the global files.
688
+ // Domain axis (issue #806) plus the connect/job single-injection contract (issue
689
+ // #860). Session startup stays broad enough for stable preferences plus L0, while
690
+ // job contexts add the remaining global categories and may append the current job's
691
+ // domain files below the global files.
692
+ //
693
+ // `sessionCats` is the single source of truth for what `fraim_connect` already put
694
+ // in the agent's context. A job's GLOBAL listing subtracts it, so nothing global is
695
+ // listed at both injection points, and adding a category to `sessionCats` later
696
+ // dedups it at job time with no other edit. DOMAIN files are never subtracted:
697
+ // connect has no job selected and so cannot know the domain, which means a job lists
698
+ // the domain file for EVERY category, including the ones deduped above.
636
699
  const sessionCats = ['pref'];
637
700
  const jobL2Cats = ['mistake', 'pref', 'coach', 'validated'];
638
701
  const jobL1Cats = ['pref', 'coach', 'mistake', 'validated'];
639
- const l2Cats = forJob ? jobL2Cats : sessionCats;
640
- const l1Cats = forJob ? jobL1Cats : sessionCats;
702
+ const notAlreadyInjectedAtConnect = (cats) => cats.filter(key => !sessionCats.includes(key));
703
+ const l2GlobalCats = forJob ? notAlreadyInjectedAtConnect(jobL2Cats) : sessionCats;
704
+ const l1GlobalCats = forJob ? notAlreadyInjectedAtConnect(jobL1Cats) : sessionCats;
705
+ const l2DomainCats = forJob ? jobL2Cats : [];
706
+ const l1DomainCats = forJob ? jobL1Cats : [];
641
707
  const activeDomain = forJob && domain ? domain : null;
642
708
  let multiTier = false;
643
709
  let coachPresent = false;
@@ -645,6 +711,10 @@ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJo
645
711
  // so a promoted entry never hides the rest of its family. `multiTier` is true
646
712
  // when at least one family exists at more than one level, which is when the
647
713
  // collision rule needs stating.
714
+ // `global` and `domain` are independent so a job can drop a global category already
715
+ // injected at connect (#860) while still listing that category's domain file (#806).
716
+ // They are one named argument rather than two positional ones of the same type: a
717
+ // swap would compile and silently invert both contracts at once.
648
718
  const collect = (cats, tiersFor, fallbackLevel) => {
649
719
  const globalFiles = [];
650
720
  const domainFiles = [];
@@ -666,16 +736,18 @@ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJo
666
736
  coachPresent = true;
667
737
  }
668
738
  };
669
- for (const key of cats) {
670
- const meta = CONTEXT_CATEGORIES[key];
671
- push(globalFiles, key, tiersFor(meta.ft));
672
- if (activeDomain)
673
- push(domainFiles, key, tiersFor(meta.ft, activeDomain));
739
+ for (const key of cats.global) {
740
+ push(globalFiles, key, tiersFor(CONTEXT_CATEGORIES[key].ft));
741
+ }
742
+ if (activeDomain) {
743
+ for (const key of cats.domain) {
744
+ push(domainFiles, key, tiersFor(CONTEXT_CATEGORIES[key].ft, activeDomain));
745
+ }
674
746
  }
675
747
  return [...globalFiles, ...domainFiles];
676
748
  };
677
- const l2 = collect(l2Cats, (ft, dom) => resolveOrgLearningFileTiers(roots.repoLearningsBase, dom ? `org-${dom}-${ft}.md` : `org-${ft}.md`), 'org');
678
- const l1 = collect(l1Cats, (ft, dom) => resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, dom ? `${resolvedUserId}-${dom}-${ft}.md` : `${resolvedUserId}-${ft}.md`), 'manager');
749
+ const l2 = collect({ global: l2GlobalCats, domain: l2DomainCats }, (ft, dom) => resolveOrgLearningFileTiers(roots.repoLearningsBase, dom ? `org-${dom}-${ft}.md` : `org-${ft}.md`), 'org');
750
+ const l1 = collect({ global: l1GlobalCats, domain: l1DomainCats }, (ft, dom) => resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, dom ? `${resolvedUserId}-${dom}-${ft}.md` : `${resolvedUserId}-${ft}.md`), 'manager');
679
751
  return { l2, l1, multiTier, coachPresent };
680
752
  }
681
753
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.329",
3
+ "version": "2.0.332",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {