// auth-mode.mjs — Detect and validate Claude auth mode for cron-safe runs. // Zero dependencies. The detector and validator are pure-testable: both // `runner` (claude CLI invoker) and `claudeJsonPath` (~/.claude.json) are // dependency-injected so tests stub them rather than spawning real subprocess // or touching the user's home directory. // // Subscription browser-OAuth tokens expire ~15h and are architecturally // incompatible with cron. This lib surfaces that case as a hard fail so the // installer/cron-runner can refuse to proceed. import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; /** * Default subprocess runner — invokes a command and returns its exit code. * Returns 0 on success, the actual exit code on failure, 127 on spawn error. */ function defaultRunner(cmd, args) { try { execFileSync(cmd, args, { stdio: 'ignore' }); return 0; } catch (err) { if (typeof err.status === 'number') return err.status; return 127; } } /** * Safely read and parse a Claude config JSON file. Returns null on any error. * @param {string} path * @returns {object|null} */ export function readClaudeJson(path) { try { const text = readFileSync(path, 'utf8'); const obj = JSON.parse(text); return obj && typeof obj === 'object' ? obj : null; } catch { return null; } } /** * Detect the active Claude authentication mode. * * Resolution order: * 1. ANTHROPIC_API_KEY env-var → 'api-key' * 2. CLAUDE_CODE_OAUTH_TOKEN env-var → 'long-oauth' * 3. ~/.claude.json onboarded + `claude auth status` exits 0 → 'subscription-browser-only' * 4. otherwise → 'unauthenticated' * * @param {object} [opts] * @param {(cmd: string, args: string[]) => number} [opts.runner] * @param {string} [opts.claudeJsonPath] * @param {object} [opts.env] — defaults to process.env * @returns {'api-key'|'long-oauth'|'subscription-browser-only'|'unauthenticated'} */ export function detectAuthMode(opts = {}) { const env = opts.env ?? process.env; const runner = opts.runner ?? defaultRunner; const claudeJsonPath = opts.claudeJsonPath ?? join(homedir(), '.claude.json'); if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_API_KEY.trim() !== '') { return 'api-key'; } if (env.CLAUDE_CODE_OAUTH_TOKEN && env.CLAUDE_CODE_OAUTH_TOKEN.trim() !== '') { return 'long-oauth'; } const claudeJson = readClaudeJson(claudeJsonPath); if (!claudeJson || claudeJson.hasCompletedOnboarding !== true) { return 'unauthenticated'; } const exitCode = runner('claude', ['auth', 'status']); return exitCode === 0 ? 'subscription-browser-only' : 'unauthenticated'; } /** * Throw a clear error if the detected mode is incompatible with cron. * Subscription-browser-only OAuth dies after ~15h; unauthenticated has no * credential. Both must be rejected before headless cron runs. * * @param {string} mode * @throws {Error} with code 'EAUTHCRON' if mode is not safe for cron */ export function validateAuthForCron(mode) { if (mode === 'api-key' || mode === 'long-oauth') return; const e = new Error( `Auth mode "${mode}" is not safe for cron. ` + 'Run `claude setup-token` to generate a long-lived OAuth, ' + 'or set ANTHROPIC_API_KEY in the cron environment.' ); e.code = 'EAUTHCRON'; e.detectedMode = mode; throw e; }