Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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" },
});
}
}