feat(ms-ai-architect): add lib/auth-mode for cron-safe auth detection [skip-docs]
Foundation lib for v1.12.0 cron rewrite. Detects which Claude auth mode is in scope and rejects modes that are architecturally incompatible with cron. Resolution order: - ANTHROPIC_API_KEY env-var → 'api-key' - CLAUDE_CODE_OAUTH_TOKEN env-var → 'long-oauth' - ~/.claude.json onboarded + runner exit 0 → 'subscription-browser-only' - otherwise → 'unauthenticated' Subscription browser-OAuth tokens expire ~15h and cannot survive cron — the detector flags them explicitly so validateAuthForCron throws EAUTHCRON with a remediation message pointing to `claude setup-token` or ANTHROPIC_API_KEY. Both runner (subprocess invoker) and claudeJsonPath (~/.claude.json) are dependency-injected. Tests stub them — no real subprocess spawn, no home- directory reads. 15/15 tests pass: precedence, env-var detection, onboarded subscription, non-onboarded fallback, validateAuthForCron throw paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d46f7a3459
commit
2b3f544f86
2 changed files with 280 additions and 0 deletions
99
plugins/ms-ai-architect/scripts/kb-update/lib/auth-mode.mjs
Normal file
99
plugins/ms-ai-architect/scripts/kb-update/lib/auth-mode.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
181
plugins/ms-ai-architect/tests/kb-update/test-auth-mode.test.mjs
Normal file
181
plugins/ms-ai-architect/tests/kb-update/test-auth-mode.test.mjs
Normal file
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue