diff --git a/plugins/ms-ai-architect/scripts/kb-update/lib/auth-mode.mjs b/plugins/ms-ai-architect/scripts/kb-update/lib/auth-mode.mjs new file mode 100644 index 0000000..3a544c2 --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/lib/auth-mode.mjs @@ -0,0 +1,99 @@ +// 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; +} diff --git a/plugins/ms-ai-architect/tests/kb-update/test-auth-mode.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-auth-mode.test.mjs new file mode 100644 index 0000000..9222a07 --- /dev/null +++ b/plugins/ms-ai-architect/tests/kb-update/test-auth-mode.test.mjs @@ -0,0 +1,181 @@ +// tests/kb-update/test-auth-mode.test.mjs +// Unit tests for scripts/kb-update/lib/auth-mode.mjs +// Note: Test fixture credential values are deliberately short (<8 chars) to +// stay below the secrets-scanner heuristic. They are stub markers, not keys. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + detectAuthMode, + validateAuthForCron, + readClaudeJson, +} from '../../scripts/kb-update/lib/auth-mode.mjs'; + +function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'auth-test-')); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function makeStubRunner(exitCode) { + const calls = []; + const runner = (cmd, args) => { + calls.push({ cmd, args }); + return exitCode; + }; + return { runner, calls }; +} + +const MISSING_PATH = '/__definitely__/__not__/__a__/__path__/.claude.json'; + +test('detectAuthMode — ANTHROPIC_API_KEY set → api-key', () => { + const { runner } = makeStubRunner(0); + const mode = detectAuthMode({ + env: { ANTHROPIC_API_KEY: 'fake' }, + runner, + claudeJsonPath: MISSING_PATH, + }); + assert.equal(mode, 'api-key'); +}); + +test('detectAuthMode — empty ANTHROPIC_API_KEY is ignored', () => { + const { runner } = makeStubRunner(1); + const mode = detectAuthMode({ + env: { ANTHROPIC_API_KEY: ' ' }, + runner, + claudeJsonPath: MISSING_PATH, + }); + assert.equal(mode, 'unauthenticated'); +}); + +test('detectAuthMode — CLAUDE_CODE_OAUTH set → long-oauth', () => { + const { runner } = makeStubRunner(0); + const mode = detectAuthMode({ + env: { CLAUDE_CODE_OAUTH_TOKEN: 'oat' }, + runner, + claudeJsonPath: MISSING_PATH, + }); + assert.equal(mode, 'long-oauth'); +}); + +test('detectAuthMode — both env vars set → api-key precedence', () => { + const { runner } = makeStubRunner(0); + const mode = detectAuthMode({ + env: { + ANTHROPIC_API_KEY: 'fake', + CLAUDE_CODE_OAUTH_TOKEN: 'oat', + }, + runner, + claudeJsonPath: MISSING_PATH, + }); + assert.equal(mode, 'api-key'); +}); + +test('detectAuthMode — neither env, no claude.json → unauthenticated', () => { + const { runner, calls } = makeStubRunner(0); + const mode = detectAuthMode({ + env: {}, + runner, + claudeJsonPath: MISSING_PATH, + }); + assert.equal(mode, 'unauthenticated'); + // Runner must NOT be invoked when claude.json is unreadable. + assert.equal(calls.length, 0); +}); + +test('detectAuthMode — claude.json onboarded + runner exit 0 → subscription-browser-only', () => { + withTmp((tmp) => { + const path = join(tmp, '.claude.json'); + writeFileSync( + path, + JSON.stringify({ hasCompletedOnboarding: true, userID: 'abc' }), + 'utf8' + ); + const { runner, calls } = makeStubRunner(0); + const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path }); + assert.equal(mode, 'subscription-browser-only'); + assert.deepEqual(calls, [{ cmd: 'claude', args: ['auth', 'status'] }]); + }); +}); + +test('detectAuthMode — claude.json onboarded + runner exit 1 → unauthenticated', () => { + withTmp((tmp) => { + const path = join(tmp, '.claude.json'); + writeFileSync( + path, + JSON.stringify({ hasCompletedOnboarding: true }), + 'utf8' + ); + const { runner } = makeStubRunner(1); + const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path }); + assert.equal(mode, 'unauthenticated'); + }); +}); + +test('detectAuthMode — claude.json present but not onboarded → unauthenticated', () => { + withTmp((tmp) => { + const path = join(tmp, '.claude.json'); + writeFileSync( + path, + JSON.stringify({ hasCompletedOnboarding: false }), + 'utf8' + ); + const { runner, calls } = makeStubRunner(0); + const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path }); + assert.equal(mode, 'unauthenticated'); + assert.equal(calls.length, 0); + }); +}); + +test('readClaudeJson — returns parsed object on valid JSON', () => { + withTmp((tmp) => { + const path = join(tmp, '.claude.json'); + writeFileSync(path, '{"hasCompletedOnboarding": true, "x": 42}', 'utf8'); + const obj = readClaudeJson(path); + assert.deepEqual(obj, { hasCompletedOnboarding: true, x: 42 }); + }); +}); + +test('readClaudeJson — returns null on missing file', () => { + assert.equal(readClaudeJson(MISSING_PATH), null); +}); + +test('readClaudeJson — returns null on malformed JSON', () => { + withTmp((tmp) => { + const path = join(tmp, 'bad.json'); + writeFileSync(path, 'not json {', 'utf8'); + assert.equal(readClaudeJson(path), null); + }); +}); + +test('validateAuthForCron — api-key passes silently', () => { + validateAuthForCron('api-key'); +}); + +test('validateAuthForCron — long-oauth passes silently', () => { + validateAuthForCron('long-oauth'); +}); + +test('validateAuthForCron — subscription-browser-only throws EAUTHCRON', () => { + assert.throws( + () => validateAuthForCron('subscription-browser-only'), + (err) => + err.code === 'EAUTHCRON' && + err.detectedMode === 'subscription-browser-only' && + /claude setup-token/.test(err.message) && + /ANTHROPIC_API_KEY/.test(err.message) + ); +}); + +test('validateAuthForCron — unauthenticated throws EAUTHCRON', () => { + assert.throws( + () => validateAuthForCron('unauthenticated'), + (err) => err.code === 'EAUTHCRON' && err.detectedMode === 'unauthenticated' + ); +});