Skip to content

Commit e47d153

Browse files
George-iamclaude
andcommitted
feat(auth): user-selectable auth mode (subscription vs api_key)
Adds detection, persistent choice, and runtime enforcement of the credential Claude Code subprocesses use for LLM scanner/auditor work. Fixes "Credit balance is too low" and 401 authentication_error when users have both an empty-balance ANTHROPIC_API_KEY lingering in shell rc and an active Claude Code subscription — Claude Code prefers the env var over OAuth, so axme-code now deletes it from the spawn env when the user chose subscription mode. - detectAuthOptions reports API key (masked) + subscription (macOS Keychain or ~/.claude/.credentials.json). No live API probe. - User-level config at ~/.config/axme-code/auth.yaml (per machine, not per project — D-132). - axme-code setup prompts once on first interactive run; stores choice. Non-TTY contexts skip prompt, fall back to heuristic without persisting. - New subcommands: axme-code auth / auth status / auth use <mode> for re-detection and non-interactive override. - buildAgentEnv() in agent-options.ts strips ANTHROPIC_API_KEY when mode=subscription; session-auditor and memory-extractor switched to share the same helper so auth behavior is consistent across every Claude Code subprocess we spawn. - Tests: auth-detect (env handling, masking), auth-config (save/load roundtrip, corrupt YAML, unknown mode, HOME-based path). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> #!axme pr=none repo=AxmeAI/axme-code
1 parent 1f756eb commit e47d153

10 files changed

Lines changed: 542 additions & 11 deletions

File tree

src/agents/memory-extractor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import type { Memory } from "../types.js";
1313
import { extractCostFromResult, zeroCost, type CostInfo } from "../utils/cost-extractor.js";
1414
import { toMemorySlug } from "../storage/memory.js";
15-
import { findClaudePath } from "../utils/agent-options.js";
15+
import { buildAgentEnv, findClaudePath } from "../utils/agent-options.js";
1616

1717
export interface MemoryExtractionResult {
1818
memories: Memory[];
@@ -78,6 +78,7 @@ export async function runMemoryExtraction(opts: {
7878
allowDangerouslySkipPermissions: true,
7979
allowedTools: [] as string[],
8080
disallowedTools: ["Write", "Edit", "Bash", "Glob", "Grep", "Read", "Agent", "NotebookEdit", "Skill", "TodoWrite"],
81+
env: buildAgentEnv(),
8182
};
8283

8384
const prompt = `${EXTRACTION_PROMPT}\n\nSession ID: ${opts.sessionId}\n\nSession transcript:\n${opts.sessionEvents}`;

src/agents/session-auditor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { basename, relative } from "node:path";
2121
import type { Memory, Decision, SessionHandoff, WorkspaceInfo } from "../types.js";
2222
import { DEFAULT_AUDITOR_MODEL } from "../types.js";
2323
import { extractCostFromResult, zeroCost, type CostInfo } from "../utils/cost-extractor.js";
24-
import { findClaudePath } from "../utils/agent-options.js";
24+
import { buildAgentEnv, findClaudePath } from "../utils/agent-options.js";
2525
import { toMemorySlug } from "../storage/memory.js";
2626
import { toSlug, listDecisions } from "../storage/decisions.js";
2727
import { listMemories } from "../storage/memory.js";
@@ -647,7 +647,7 @@ async function runSingleAuditCall(opts: {
647647
// auto-loading the project's .claude/settings.json, but users or CI may
648648
// register hooks via environment or other means, so the belt-and-braces
649649
// env check in every hook handler is what actually stops the recursion.
650-
env: { ...process.env, AXME_SKIP_HOOKS: "1", AXME_TELEMETRY_DISABLED: "1" },
650+
env: buildAgentEnv(),
651651
};
652652

653653
const isMultiChunk = opts.totalChunks > 1;
@@ -901,7 +901,7 @@ ${freeTextAnalysis}`;
901901
"Read", "Grep", "Glob", "Write", "Edit", "NotebookEdit", "Agent",
902902
"Skill", "TodoWrite", "WebFetch", "WebSearch", "Bash", "ToolSearch",
903903
],
904-
env: { ...process.env, AXME_SKIP_HOOKS: "1", AXME_TELEMETRY_DISABLED: "1" },
904+
env: buildAgentEnv(),
905905
};
906906

907907
const q = sdk.query({ prompt: formatPrompt, options: queryOpts });

src/cli.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import { statusTool } from "./tools/status.js";
1616
import { detectWorkspace } from "./utils/workspace-detector.js";
1717
import { atomicWrite, ensureDir } from "./storage/engine.js";
1818
import { saveMemory, toMemorySlug } from "./storage/memory.js";
19-
import type { WorkspaceInfo } from "./types.js";
19+
import { detectAuthOptions } from "./utils/auth-detect.js";
20+
import { authConfigPath, loadAuthConfig, saveAuthConfig } from "./utils/auth-config.js";
21+
import { formatDetectionBlock, hasAnyAuth, promptAuthChoice } from "./utils/auth-prompt.js";
22+
import type { AuthMode, WorkspaceInfo } from "./types.js";
2023
import { AXME_CODE_DIR } from "./types.js";
2124

2225
const args = process.argv.slice(2);
@@ -150,6 +153,49 @@ function hasAuth(): boolean {
150153
return false;
151154
}
152155

156+
/**
157+
* Print detection block + saved choice (if any).
158+
*/
159+
function printAuthStatus(): void {
160+
const options = detectAuthOptions();
161+
console.log(formatDetectionBlock(options));
162+
const saved = loadAuthConfig();
163+
if (saved) {
164+
console.log(`\nCurrent mode: ${saved.mode} (saved ${saved.chosenAt})`);
165+
console.log(`Config file: ${authConfigPath()}`);
166+
} else {
167+
console.log("\nCurrent mode: not configured (using heuristic fallback)");
168+
console.log(`Config file: ${authConfigPath()} (will be created on first choice)`);
169+
}
170+
}
171+
172+
/**
173+
* Called from `axme-code setup` before LLM scanners launch. Persists a user
174+
* choice to ~/.config/axme-code/auth.yaml exactly once on first setup, so
175+
* subsequent runs (and all MCP server scanner/auditor subprocesses) use the
176+
* same mode without re-prompting. In non-TTY contexts (CI, scripts) we skip
177+
* the prompt and let `resolveAuthMode()` fall back to its heuristic — we do
178+
* NOT persist a guessed choice silently.
179+
*/
180+
async function ensureAuthConfiguredForSetup(): Promise<void> {
181+
if (loadAuthConfig()) return;
182+
if (!process.stdin.isTTY) return;
183+
184+
const options = detectAuthOptions();
185+
if (!hasAnyAuth(options)) return; // hasAuth preflight will fail anyway
186+
187+
console.log("\nAuthentication setup for LLM scanners");
188+
console.log(formatDetectionBlock(options));
189+
console.log("");
190+
const choice = await promptAuthChoice(options);
191+
if (!choice) {
192+
console.log(" Auth selection cancelled. Heuristic fallback will be used.");
193+
return;
194+
}
195+
saveAuthConfig(choice);
196+
console.log(` Saved auth mode: ${choice} (${authConfigPath()})`);
197+
}
198+
153199
function generateWorkspaceYaml(workspacePath: string, ws: WorkspaceInfo): void {
154200
const wsYaml = yaml.dump({
155201
name: workspacePath.split("/").pop(),
@@ -256,6 +302,9 @@ Usage:
256302
axme-code setup [path] [--force] Initialize project (LLM scan + .mcp.json + CLAUDE.md)
257303
axme-code serve Start MCP server (stdio transport)
258304
axme-code status [path] Show project status
305+
axme-code auth Re-detect and choose auth mode (subscription/api_key)
306+
axme-code auth status Show current auth mode + detected options
307+
axme-code auth use <subscription|api_key> Set auth mode non-interactively
259308
axme-code cleanup legacy-artifacts [--dry-run] Remove pre-PR#7 sessions/logs
260309
axme-code cleanup decisions-normalize [--dry-run] Add status:active to decisions
261310
axme-code audit-kb [path] [--all-repos] KB audit: dedup, conflicts, compaction
@@ -339,6 +388,11 @@ async function main() {
339388
process.exit(1);
340389
}
341390

391+
// Auth mode selection — prompt once on first interactive setup, persist
392+
// to ~/.config/axme-code/auth.yaml. Later runs and scanner subprocesses
393+
// read the saved mode via resolveAuthMode() in buildAgentEnv().
394+
await ensureAuthConfiguredForSetup();
395+
342396
// Init with LLM scanners (parallel)
343397
try {
344398
if (isWorkspace) {
@@ -649,6 +703,51 @@ Do NOT skip — without context you will miss critical project rules.
649703
break;
650704
}
651705

706+
case "auth": {
707+
const sub = args[1];
708+
if (sub === "status" || sub === "show") {
709+
printAuthStatus();
710+
break;
711+
}
712+
if (sub === "use" || sub === "set") {
713+
const mode = args[2];
714+
if (mode !== "subscription" && mode !== "api_key") {
715+
console.error("Usage: axme-code auth use <subscription|api_key>");
716+
process.exit(1);
717+
}
718+
saveAuthConfig(mode as AuthMode);
719+
console.log(`Saved auth mode: ${mode} (${authConfigPath()})`);
720+
break;
721+
}
722+
if (sub === undefined || sub === "choose") {
723+
const options = detectAuthOptions();
724+
console.log("Authentication setup for LLM scanners");
725+
console.log(formatDetectionBlock(options));
726+
const saved = loadAuthConfig();
727+
if (saved) console.log(`\nCurrent mode: ${saved.mode} (saved ${saved.chosenAt})`);
728+
console.log("");
729+
if (!hasAnyAuth(options)) {
730+
console.error("No authentication detected. Set ANTHROPIC_API_KEY or run `claude /login`, then re-run `axme-code auth`.");
731+
process.exit(1);
732+
}
733+
if (!process.stdin.isTTY) {
734+
console.error("`axme-code auth` requires an interactive terminal. Use `axme-code auth use <subscription|api_key>` non-interactively.");
735+
process.exit(1);
736+
}
737+
const choice = await promptAuthChoice(options);
738+
if (!choice) {
739+
console.log("Cancelled. No change.");
740+
break;
741+
}
742+
saveAuthConfig(choice);
743+
console.log(`Saved auth mode: ${choice} (${authConfigPath()})`);
744+
break;
745+
}
746+
console.error(`Unknown 'auth' subcommand: ${sub}`);
747+
console.error("Available: (none)|choose, status|show, use|set <subscription|api_key>");
748+
process.exit(1);
749+
}
750+
652751
case "help":
653752
case "--help":
654753
case "-h":

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,12 @@ export const DEFAULT_AGENT_PERMISSIONS: AgentPermissions = {
370370
};
371371

372372
export type E2EMode = "after-task" | "after-stage" | "manual";
373+
374+
// --- Auth ---
375+
376+
export type AuthMode = "subscription" | "api_key";
377+
378+
export interface AuthConfig {
379+
mode: AuthMode;
380+
chosenAt: string;
381+
}

src/utils/agent-options.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { execSync } from "node:child_process";
6+
import { resolveAuthMode } from "./auth-config.js";
67

78
type Options = import("@anthropic-ai/claude-agent-sdk").Options;
89

@@ -54,6 +55,35 @@ const ROLE_TOOLS: Record<AgentRole, { allowed: string[]; disallowed: string[] }>
5455
},
5556
};
5657

58+
/**
59+
* Build the env passed to every Claude Code subprocess we spawn for LLM work.
60+
*
61+
* Two things happen here:
62+
* 1. `AXME_TELEMETRY_DISABLED` and `AXME_SKIP_HOOKS` are set to suppress
63+
* recursive startup events and ghost AXME sessions when the sub-claude
64+
* inadvertently launches axme-code as its own MCP server.
65+
* 2. If the user has selected `subscription` as the auth mode (either via
66+
* `axme-code auth` / `axme-code setup`, or by heuristic when only the
67+
* subscription is detected), we delete `ANTHROPIC_API_KEY` before
68+
* handing env to the subprocess. Claude Code checks the env var before
69+
* its OAuth credentials, so leaving an empty-balance key in env would
70+
* surface as "Credit balance is too low" or 401 auth errors even when
71+
* the user has an active subscription. Delete, not empty string: Claude
72+
* Code treats an empty-string value as "set" and still prefers it over
73+
* OAuth.
74+
*/
75+
export function buildAgentEnv(): NodeJS.ProcessEnv {
76+
const env: NodeJS.ProcessEnv = {
77+
...process.env,
78+
AXME_TELEMETRY_DISABLED: "1",
79+
AXME_SKIP_HOOKS: "1",
80+
};
81+
if (resolveAuthMode() === "subscription") {
82+
delete env.ANTHROPIC_API_KEY;
83+
}
84+
return env;
85+
}
86+
5787
export function buildAgentQueryOptions(base: {
5888
cwd: string;
5989
model: string;
@@ -78,11 +108,6 @@ export function buildAgentQueryOptions(base: {
78108
allowedTools: tools.allowed,
79109
disallowedTools: tools.disallowed,
80110
includePartialMessages: true,
81-
// Disable telemetry in spawned subprocesses. Sub-claude sessions started
82-
// by scanners/auditors may pick up the parent's .mcp.json and re-launch
83-
// axme-code as an MCP server. Each re-launch would otherwise fire its
84-
// own startup event, inflating DAU and skewing scanner cost metrics.
85-
// The parent process owns the lifecycle event for this user action.
86-
env: { ...process.env, AXME_TELEMETRY_DISABLED: "1", AXME_SKIP_HOOKS: "1" },
111+
env: buildAgentEnv(),
87112
};
88113
}

src/utils/auth-config.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* User-level authentication mode config at ~/.config/axme-code/auth.yaml.
3+
*
4+
* Auth mode is a per-machine concern (which credential should Claude Code
5+
* subprocesses use), not a per-project one, so it lives outside the repo's
6+
* .axme-code/ storage. One choice applies to every project on this machine.
7+
*
8+
* The file stores only the selected mode and the timestamp of the choice.
9+
* `resolveAuthMode()` returns the persisted mode when present, otherwise
10+
* falls back to a detection-based heuristic without writing anything — so
11+
* non-interactive callers (scanner subprocesses, auditor) never surprise
12+
* the user by persisting a guessed choice.
13+
*/
14+
15+
import { homedir } from "node:os";
16+
import { join } from "node:path";
17+
import yaml from "js-yaml";
18+
import { atomicWrite, ensureDir, readSafe, pathExists } from "../storage/engine.js";
19+
import type { AuthConfig, AuthMode } from "../types.js";
20+
import { detectAuthOptions, type AuthOptions } from "./auth-detect.js";
21+
22+
/**
23+
* Resolve paths lazily (not at module load) so tests can swap $HOME between
24+
* cases without needing to bust the ESM module cache.
25+
*/
26+
function configDir(): string {
27+
return join(homedir(), ".config", "axme-code");
28+
}
29+
30+
export function authConfigPath(): string {
31+
return join(configDir(), "auth.yaml");
32+
}
33+
34+
export function loadAuthConfig(): AuthConfig | null {
35+
const file = authConfigPath();
36+
if (!pathExists(file)) return null;
37+
const raw = readSafe(file);
38+
if (!raw) return null;
39+
try {
40+
const parsed = yaml.load(raw) as Partial<AuthConfig> | null;
41+
if (!parsed || typeof parsed !== "object") return null;
42+
if (parsed.mode !== "subscription" && parsed.mode !== "api_key") return null;
43+
const chosenAt = typeof parsed.chosenAt === "string" ? parsed.chosenAt : new Date().toISOString();
44+
return { mode: parsed.mode, chosenAt };
45+
} catch {
46+
return null;
47+
}
48+
}
49+
50+
export function saveAuthConfig(mode: AuthMode): AuthConfig {
51+
ensureDir(configDir());
52+
const config: AuthConfig = { mode, chosenAt: new Date().toISOString() };
53+
atomicWrite(authConfigPath(), yaml.dump(config));
54+
return config;
55+
}
56+
57+
/**
58+
* Choose the sensible default when no saved choice exists and we can't ask
59+
* the user. If an API key is set (regardless of subscription state) we keep
60+
* the existing behavior: pass env through to Claude Code and let it decide.
61+
* If only subscription is available, prefer it. If neither, return api_key
62+
* so we fail the same way Claude Code would fail on its own.
63+
*/
64+
function heuristicMode(options: AuthOptions): AuthMode {
65+
if (options.subscription.present && !options.apiKey.present) return "subscription";
66+
return "api_key";
67+
}
68+
69+
/**
70+
* Effective auth mode for a scanner call. Reads the saved config if present,
71+
* otherwise returns a heuristic without persisting anything.
72+
*/
73+
export function resolveAuthMode(): AuthMode {
74+
const saved = loadAuthConfig();
75+
if (saved) return saved.mode;
76+
return heuristicMode(detectAuthOptions());
77+
}

0 commit comments

Comments
 (0)