426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
/**
|
|
* Anthropic OAuth credential store with multi-account support.
|
|
*
|
|
* If the host runtime has installed the account-pool bridge on
|
|
* `globalThis` (app-core does this when the multi-account `LinkedAccountConfig`
|
|
* store is non-empty), token reads route through the pool: `select` picks
|
|
* the active account, the OAuth fetch wrapper retries on 401 against a
|
|
* different account, and the pool tracks rate-limited / invalid health.
|
|
*
|
|
* Without the bridge, behavior uses the single-source flow
|
|
* (env var → keychain → ~/.claude/.credentials.json).
|
|
*/
|
|
|
|
import { type AnthropicAccountPoolBridge, ElizaError } from "@elizaos/core";
|
|
|
|
interface OAuthToken {
|
|
accessToken: string;
|
|
expiresAt: number;
|
|
/**
|
|
* Account identifier resolved through the pool; undefined when the token
|
|
* came from the env var or the single-source reader.
|
|
*/
|
|
accountId?: string;
|
|
}
|
|
|
|
interface ClaudeCredentials {
|
|
claudeAiOauth?: {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresAt: number;
|
|
scopes: string[];
|
|
subscriptionType: string;
|
|
rateLimitTier: string;
|
|
};
|
|
}
|
|
|
|
function getAccountPoolBridge(): AnthropicAccountPoolBridge | undefined {
|
|
const { getAnthropicAccountPoolBridge } =
|
|
require("@elizaos/core") as typeof import("@elizaos/core");
|
|
return getAnthropicAccountPoolBridge() ?? undefined;
|
|
}
|
|
|
|
const tokenCache = new Map<string, OAuthToken>();
|
|
const ENV_CACHE_KEY = "__env__";
|
|
const APP_CREDENTIAL_CACHE_KEY = "__app_anthropic_subscription__";
|
|
|
|
interface AppSubscriptionCredentials {
|
|
credentials?: {
|
|
access?: string;
|
|
expires?: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ref: https://code.claude.com/docs/en/authentication
|
|
*
|
|
* Returns a synchronously-loaded token. When the multi-account bridge is
|
|
* installed, callers should prefer `getClaudeOAuthTokenAsync` so the pool
|
|
* can pick a fresh account on cache miss; the sync path falls back to the
|
|
* file/keychain reader.
|
|
*/
|
|
export function getClaudeOAuthToken(opts?: { accountId?: string }): OAuthToken {
|
|
const appToken = readAppManagedAnthropicToken();
|
|
if (appToken) {
|
|
tokenCache.set(APP_CREDENTIAL_CACHE_KEY, appToken);
|
|
return appToken;
|
|
}
|
|
|
|
const cacheKey = opts?.accountId ?? ENV_CACHE_KEY;
|
|
const cached = tokenCache.get(cacheKey);
|
|
if (cached && Date.now() < cached.expiresAt - 60_000) {
|
|
return cached;
|
|
}
|
|
|
|
const envToken = getEnvVar("CLAUDE_CODE_OAUTH_TOKEN") ?? getEnvVar("ANTHROPIC_OAUTH_TOKEN");
|
|
if (envToken) {
|
|
const token: OAuthToken = {
|
|
accessToken: envToken,
|
|
expiresAt: Number.POSITIVE_INFINITY,
|
|
};
|
|
tokenCache.set(ENV_CACHE_KEY, token);
|
|
return token;
|
|
}
|
|
|
|
const credentials = readFromCredentialStore();
|
|
if (!credentials?.claudeAiOauth?.accessToken) {
|
|
throw new Error(
|
|
"[Anthropic] Could not read Claude OAuth token. " +
|
|
"Either set CLAUDE_CODE_OAUTH_TOKEN env var (via `claude setup-token`), " +
|
|
"or ensure Claude Code is authenticated (run `claude auth login`)."
|
|
);
|
|
}
|
|
|
|
const token: OAuthToken = {
|
|
accessToken: credentials.claudeAiOauth.accessToken,
|
|
expiresAt: credentials.claudeAiOauth.expiresAt,
|
|
};
|
|
tokenCache.set(cacheKey, token);
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Multi-account aware token resolution. Picks an account through the pool
|
|
* if installed; otherwise falls back to `getClaudeOAuthToken`.
|
|
*
|
|
* `exclude` is propagated to the pool so callers can request a *different*
|
|
* account after a 401 / 429.
|
|
*/
|
|
export async function getClaudeOAuthTokenAsync(opts?: {
|
|
sessionKey?: string;
|
|
exclude?: string[];
|
|
}): Promise<OAuthToken> {
|
|
const appToken = readAppManagedAnthropicToken();
|
|
if (appToken) {
|
|
tokenCache.set(APP_CREDENTIAL_CACHE_KEY, appToken);
|
|
return appToken;
|
|
}
|
|
|
|
const envToken = getEnvVar("CLAUDE_CODE_OAUTH_TOKEN") ?? getEnvVar("ANTHROPIC_OAUTH_TOKEN");
|
|
if (envToken) {
|
|
const token: OAuthToken = {
|
|
accessToken: envToken,
|
|
expiresAt: Number.POSITIVE_INFINITY,
|
|
};
|
|
tokenCache.set(ENV_CACHE_KEY, token);
|
|
return token;
|
|
}
|
|
|
|
const bridge = getAccountPoolBridge();
|
|
if (bridge) {
|
|
const account = await bridge.selectAnthropicSubscription(opts);
|
|
if (account) {
|
|
const cached = tokenCache.get(account.id);
|
|
if (cached && Date.now() < cached.expiresAt - 60_000) {
|
|
return cached;
|
|
}
|
|
const access = await bridge.getAccessToken("anthropic-subscription", account.id);
|
|
if (access) {
|
|
const token: OAuthToken = {
|
|
accessToken: access,
|
|
expiresAt: account.expiresAt || Number.POSITIVE_INFINITY,
|
|
accountId: account.id,
|
|
};
|
|
tokenCache.set(account.id, token);
|
|
return token;
|
|
}
|
|
}
|
|
}
|
|
|
|
return getClaudeOAuthToken();
|
|
}
|
|
|
|
/**
|
|
* Notify the pool that the supplied account is no longer valid (e.g. 401
|
|
* after refresh) and clear the in-memory cache entry. Returns true when the
|
|
* pool was notified, false when no bridge is installed.
|
|
*/
|
|
export function reportClaudeOAuthInvalid(accountId: string | undefined, detail?: string): boolean {
|
|
if (accountId) {
|
|
tokenCache.delete(accountId);
|
|
} else {
|
|
tokenCache.clear();
|
|
}
|
|
const bridge = getAccountPoolBridge();
|
|
if (!bridge || !accountId) return false;
|
|
void bridge.markInvalid(accountId, detail);
|
|
return true;
|
|
}
|
|
|
|
export function reportClaudeOAuthRateLimited(
|
|
accountId: string | undefined,
|
|
untilMs: number,
|
|
detail?: string
|
|
): boolean {
|
|
if (accountId) {
|
|
tokenCache.delete(accountId);
|
|
}
|
|
const bridge = getAccountPoolBridge();
|
|
if (!bridge || !accountId) return false;
|
|
void bridge.markRateLimited(accountId, untilMs, detail);
|
|
return true;
|
|
}
|
|
|
|
export function getClaudeOAuthMeta(): ClaudeCredentials["claudeAiOauth"] | null {
|
|
const appToken = readAppManagedAnthropicToken();
|
|
if (appToken) {
|
|
return {
|
|
accessToken: appToken.accessToken,
|
|
refreshToken: "",
|
|
expiresAt: appToken.expiresAt,
|
|
scopes: [],
|
|
subscriptionType: "app-managed",
|
|
rateLimitTier: "",
|
|
};
|
|
}
|
|
|
|
const envToken = getEnvVar("CLAUDE_CODE_OAUTH_TOKEN") ?? getEnvVar("ANTHROPIC_OAUTH_TOKEN");
|
|
if (envToken) return null;
|
|
|
|
const credentials = readFromCredentialStore();
|
|
return credentials?.claudeAiOauth ?? null;
|
|
}
|
|
|
|
export function clearTokenCache(accountId?: string): void {
|
|
if (accountId) {
|
|
tokenCache.delete(accountId);
|
|
return;
|
|
}
|
|
tokenCache.clear();
|
|
}
|
|
|
|
function getEnvVar(key: string): string | undefined {
|
|
if (typeof process === "undefined") return undefined;
|
|
return process.env[key];
|
|
}
|
|
|
|
function readAppManagedAnthropicToken(): OAuthToken | null {
|
|
if (typeof process === "undefined") return null;
|
|
|
|
const cached = tokenCache.get(APP_CREDENTIAL_CACHE_KEY);
|
|
if (cached && Date.now() < cached.expiresAt - 60_000) {
|
|
return cached;
|
|
}
|
|
|
|
const { join } = require("node:path") as typeof import("node:path");
|
|
const { homedir } = require("node:os") as typeof import("node:os");
|
|
const { readFileSync } = require("node:fs") as typeof import("node:fs");
|
|
const { resolveStateDir } = require("@elizaos/core") as typeof import("@elizaos/core");
|
|
const stateDir = resolveStateDir();
|
|
const accountId = getEnvVar("ANTHROPIC_SUBSCRIPTION_ACCOUNT_ID")?.trim() || "default";
|
|
const paths = [
|
|
join(stateDir, "auth", "anthropic-subscription", `${accountId}.json`),
|
|
join(homedir(), ".eliza", "auth", "anthropic-subscription", `${accountId}.json`),
|
|
join(homedir(), ".eliza", "auth", "anthropic-subscription.json"),
|
|
];
|
|
|
|
for (const credentialPath of paths) {
|
|
try {
|
|
const parsed = JSON.parse(
|
|
readFileSync(credentialPath, "utf-8")
|
|
) as AppSubscriptionCredentials;
|
|
const access = parsed.credentials?.access?.trim();
|
|
if (!access) continue;
|
|
const expires = parsed.credentials?.expires;
|
|
const token: OAuthToken = {
|
|
accessToken: access,
|
|
expiresAt:
|
|
typeof expires === "number" && Number.isFinite(expires)
|
|
? expires
|
|
: Number.POSITIVE_INFINITY,
|
|
};
|
|
if (Date.now() < token.expiresAt - 60_000) {
|
|
return token;
|
|
}
|
|
} catch {
|
|
// error-policy:J3 untrusted-input sanitizing — this probes a fixed list of
|
|
// known app-managed credential locations of which at most one exists;
|
|
// a read/parse miss at one location is the expected "not here" signal and
|
|
// moves to the next. Exhausting all yields the honest null below.
|
|
}
|
|
}
|
|
|
|
tokenCache.delete(APP_CREDENTIAL_CACHE_KEY);
|
|
return null;
|
|
}
|
|
|
|
function readFromCredentialStore(): ClaudeCredentials | null {
|
|
if (typeof process === "undefined") return null;
|
|
|
|
const { join } = require("node:path") as typeof import("node:path");
|
|
const { homedir } = require("node:os") as typeof import("node:os");
|
|
|
|
const configDirOverride = getEnvVar("CLAUDE_CONFIG_DIR")?.trim();
|
|
const configDir = configDirOverride || join(homedir(), ".claude");
|
|
|
|
if (!configDirOverride && process.platform === "darwin") {
|
|
const fromKeychain = readFromMacKeychain();
|
|
if (fromKeychain) return fromKeychain;
|
|
}
|
|
|
|
const credPath = join(configDir, ".credentials.json");
|
|
const { readFileSync } = require("node:fs") as typeof import("node:fs");
|
|
|
|
let raw: string;
|
|
try {
|
|
raw = readFileSync(credPath, "utf-8");
|
|
} catch (error) {
|
|
// error-policy:J3 untrusted-input sanitizing — a missing credential file is
|
|
// the expected "not authenticated this way" signal (null). Any other read
|
|
// failure (permissions, I/O) is a real problem the caller must not read as
|
|
// "no credentials", so it surfaces as a typed error.
|
|
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return null;
|
|
throw new ElizaError("Failed to read Claude credential file", {
|
|
code: "CREDENTIALS_UNREADABLE",
|
|
cause: error,
|
|
context: { credPath },
|
|
});
|
|
}
|
|
|
|
// A file that exists but does not parse is corrupt, not absent — surfacing it
|
|
// prevents a silent downgrade of auth that would look identical to "no creds".
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (error) {
|
|
// error-policy:J2 context-adding rethrow — typed CREDENTIALS_CORRUPT with cause.
|
|
throw new ElizaError("Claude credential file is corrupt (invalid JSON)", {
|
|
code: "CREDENTIALS_CORRUPT",
|
|
cause: error,
|
|
context: { credPath },
|
|
});
|
|
}
|
|
}
|
|
|
|
function readFromMacKeychain(): ClaudeCredentials | null {
|
|
const { execSync } = require("node:child_process") as typeof import("node:child_process");
|
|
|
|
let raw: string;
|
|
try {
|
|
raw = execSync('security find-generic-password -s "Claude Code-credentials" -w', {
|
|
encoding: "utf-8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
}).trim();
|
|
} catch {
|
|
// error-policy:J3 untrusted-input sanitizing — a non-zero `security` exit
|
|
// means no matching keychain entry (absent → null), the expected "not stored
|
|
// here" outcome. The file-based reader is the next source tried by the caller.
|
|
return null;
|
|
}
|
|
|
|
// The entry exists; if its payload does not parse it is corrupt, not absent.
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (error) {
|
|
// error-policy:J2 context-adding rethrow — typed CREDENTIALS_CORRUPT with cause.
|
|
throw new ElizaError("Claude keychain credential is corrupt (invalid JSON)", {
|
|
code: "CREDENTIALS_CORRUPT",
|
|
cause: error,
|
|
context: { source: "macos-keychain" },
|
|
});
|
|
}
|
|
}
|