fraim 2.0.328 → 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.
- package/dist/src/cli/commands/doctor.js +6 -1
- package/dist/src/cli/doctor/check-runner.js +2 -1
- package/dist/src/cli/doctor/checks/agent-cli-health-checks.js +15 -2
- package/dist/src/cli/doctor/checks/llm-auth-route-checks.js +95 -0
- package/dist/src/cli/doctor/redact-paths.js +78 -0
- package/dist/src/cli/doctor/reporters/console-reporter.js +2 -1
- package/dist/src/cli/setup/ide-detector.js +5 -5
- package/dist/src/cli/utils/machine-path.js +73 -5
- package/dist/src/cli/utils/managed-agent-paths.js +1 -0
- package/dist/src/core/utils/anthropic-auth-route.js +243 -0
- package/dist/src/core/utils/claude-subscription-login.js +92 -0
- package/dist/src/core/utils/env-var-names.js +64 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +86 -14
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
// composes them: Machine first, then User.
|
|
54
|
-
const
|
|
55
|
-
[String.raw `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment
|
|
56
|
-
[String.raw `HKCU\Environment
|
|
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
|
-
|
|
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)
|
|
634
|
-
//
|
|
635
|
-
//
|
|
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
|
|
640
|
-
const
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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(
|
|
678
|
-
const l1 = collect(
|
|
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
|
/**
|