Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ Codex pool selection applies to the next request after clearing existing affinit

### `ocx account pause|resume anthropic <id|alias> [--json]`

Anthropic의 자동 대체 계정 선택은 기존 계정 순서를 유지하며, 일시 정지되었거나 재인증이 필요한 계정과 60초 이내에 만료되는 Claude Code 가져오기를 건너뜁니다. 출처가 기록되지 않은 레거시 계정도 선택할 수 있지만, 자체 저장 자격 증명과 일반적인 저장 토큰 갱신만 사용하며 CLI 디스크 자격 증명을 가져오지 않습니다. 60초 넘게 남은 유효한 Claude Code 계정은 선택할 수 있습니다. 이후 자동 가져오기는 비어 있지 않은 access 또는 refresh 토큰이 일치하면 허용됩니다. 두 토큰이 모두 바뀌면 저장된 bearer와 새 bearer에서 인증된 계정 UUID가 같아야 합니다. 로그인·갱신·프로필 조회에서 얻은 증명은 비공개로 저장하며, 계정 라벨·이메일·조직·파일 경로로 대체하지 않습니다.

기존 bearer가 만료되었고 해당 토큰에 묶인 계정 증명이 없으면 자동 복구가 불가능할 수 있습니다. 프로필을 조회할 수 없거나 교체된 토큰의 계정을 확인하지 못하면 저장된 계정을 유지하고, 이미 소비되었을 수 있는 refresh 토큰을 다시 사용하지 않습니다. 현재 Claude Code 자격 증명을 가져오려면 명시적으로 로그인하세요. 가져오기는 관련 없는 신원 미확인 슬롯을 보존하므로 별도 계정이 생길 수 있습니다. 사용할 계정을 선택하고 확인한 뒤 오래된 슬롯을 삭제하세요. 프로필 조회에 실패한 새 가져오기도 신원 미확인 상태로 남으며 같은 자동 복구 제한이 적용됩니다.

허용된 대체 계정이 없으면 사용 가능한 활성 계정이 선택될 때까지 할당량 조회와 실시간 모델 검색이 대기합니다.
`ocx account use anthropic <account-id-or-alias>`로 일시 정지되지 않은 기존 레거시 계정을 명시적으로 선택할 수 있습니다.
원래 자격 증명의 출처가 기록되지 않았더라도 해당 계정의 유효한 자격 증명과 일반적인 저장 토큰 갱신을 계속 사용할 수 있습니다.

CLI 명령은 Anthropic OAuth 계정을 id 또는 유일한 alias로 일시 정지하거나 재개합니다. alias는 정확히 일치하는 값을 먼저 찾고, 없으면 대소문자를 구분하지 않고 찾습니다. 대시보드와 같은 `PUT /api/oauth/accounts/pause`에 `{ provider: "anthropic", accountId, paused }`를 보냅니다. 계정에 저장되는 `paused` 상태는 `GET /api/oauth/accounts`에도 표시됩니다. 사전 계정 전환 풀이 꺼져 있어도 정지된 계정은 선택, 세션 바인딩, 429 대체 후보에서 제외됩니다. 모든 계정이 정지되면 하나를 재개할 때까지 요청은 403을 반환합니다. 이미 전송한 요청은 유지하며 자격 증명과 건강 상태를 지우지 않습니다. 재시작·재로그인 후에도 정지는 유지되고, 계정을 삭제하면 함께 제거됩니다. 계정별 전환 임계값은 이 기능에 포함되지 않습니다.

### `ocx account clear <provider> [--json]`
Expand Down
21 changes: 21 additions & 0 deletions docs-site/src/content/docs/reference/cli/providers-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,27 @@ Anthropic pause applies even when proactive pooling is disabled, including sessi
and does not interrupt a turn already sent. Removing the account removes its pause state.
Per-account Anthropic auto-switch thresholds are not part of this control.

Anthropic's automatic pause fallback keeps account order, skips paused accounts and accounts
requiring reauthentication, and excludes Claude Code imports expiring within 60 seconds.
Legacy accounts without a recorded source remain eligible using only their own stored credentials
and normal stored-token refresh; they never adopt CLI-disk credentials. A still-valid Claude Code import with more time remaining
can be selected. Later automatic re-adoption accepts a shared, nonempty access or refresh token.
If both tokens rotate, OpenCodex requires authenticated account UUID proof for both the stored
and imported bearer. It saves that proof privately when available from login, refresh, or profile
lookup; account labels, email, organization and credential-file location cannot substitute for it.

When the old bearer has expired and no bound account proof was saved, automatic recovery may be
impossible. An unavailable profile or unverified rotated pair leaves the stored account unchanged
and does not replay a potentially consumed refresh token. Use explicit login to import the current
Claude Code credential. Import preserves unrelated identityless slots and may create a separate
account; select the intended account and remove obsolete slots only after checking them. A profile
lookup failure can leave a new import identityless, with the same automatic-recovery limitation.

If no permitted fallback remains, quota and live model discovery wait for a usable active account.
You can explicitly select an existing unpaused legacy account with
`ocx account use anthropic <account-id-or-alias>`; its own valid credential and normal stored-token
refresh remain available even when its original credential source was not recorded.

```bash
ocx account pause google-antigravity <account-id-or-alias>
ocx account resume google-antigravity <account-id-or-alias>
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"version": 1,
"root": "tests",
"explicit": {
"oauth-anthropic-identity.test.ts": "oauth",
"provider-antigravity-wire-snapshot.test.ts": "providers",
"antigravity-discovered-families.test.ts": "adapters/google",
"provider-antigravity-effort-families.test.ts": "providers",
Expand Down Expand Up @@ -57,7 +58,6 @@
"openai-chat-tool-call-id-remint.test.ts": "adapters/openai",
"coding-agent-json-lines-scaling.test.ts": "providers",
"usage-snapshot-digest-reuse.test.ts": "usage",
"release-desktop-scripts.test.ts": "ci-workflows",
"installed-gate-drivers.test.ts": "ci-workflows",
"gui-desktop-sidecar-script.test.ts": "gui",
"standalone-build-script.test.ts": "gui",
Expand Down
65 changes: 65 additions & 0 deletions src/oauth/anthropic-continuity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { detectClaudeCodeToken, hasClaudeCredentialContinuity } from "./local-token-detect";
import { normalizeAnthropicIdentity, resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity";
import { credentialGeneration, type AuthStore } from "./store";
import { REFRESH_SKEW_MS } from "./refresh-policy";
import type { OAuthCredentials, ProviderAccountSet } from "./types";

export type ClaudeCredentialObservation =
| { kind: "absent" }
| { kind: "unresolved" | "different"; diskGeneration: string }
| { kind: "adopt"; credential: OAuthCredentials; diskGeneration: string };

export async function newerClaudeCredential(
stored: OAuthCredentials, now: number, signal?: AbortSignal,
resolveIdentity: AnthropicIdentityResolver = resolveAnthropicAccountIdentity,
): Promise<ClaudeCredentialObservation> {
if (stored.source !== "local-cli") return { kind: "absent" };
const disk = detectClaudeCodeToken();
if (!disk || !Number.isFinite(disk.expires) || disk.expires <= now + REFRESH_SKEW_MS
|| credentialGeneration(disk) === credentialGeneration(stored)) return { kind: "absent" };
const diskGeneration = credentialGeneration(disk);
const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access);
if (hasClaudeCredentialContinuity(stored, disk)) {
return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
...stored, ...disk, anthropicIdentity: disk.access === stored.access ? before : undefined,
} };
}
// Independent observations share a deadline through the caller signal; neither extends the other.
const [oldProof, newProof] = await Promise.all([
before ?? resolveIdentity(stored.access, signal), resolveIdentity(disk.access, signal),
]);
const oldIdentity = normalizeAnthropicIdentity(oldProof, stored.access);
const newIdentity = normalizeAnthropicIdentity(newProof, disk.access);
if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration };
if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration };
return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity,
} };
}

/** Capture primitives before network awaits; check them again inside the serialized writer. */
export function captureAnthropicCredentialOwner(set: ProviderAccountSet, accountId: string) {
const row = set.accounts.find(account => account.id === accountId)!;
const loginId = row.loginId;
const generation = credentialGeneration(row.credential);
const metadata = identityMetadata(row.credential);
const needsReauth = row.needsReauth;
const selection = { id: set.activeAccountId, revision: set.selectionRevision };
return (store: AuthStore, diskGeneration?: string, allowPaused = false): boolean => {
const current = store["anthropic"];
const account = current?.accounts.find(candidate => candidate.id === accountId);
if (!account || (!allowPaused && account.paused) || account.loginId !== loginId
|| account.needsReauth !== needsReauth || credentialGeneration(account.credential) !== generation
|| identityMetadata(account.credential) !== metadata) return false;
if (diskGeneration !== undefined) {
if (current?.activeAccountId !== selection.id || current?.selectionRevision !== selection.revision) return false;
const disk = detectClaudeCodeToken();
if (!disk || disk.expires <= Date.now() + REFRESH_SKEW_MS || credentialGeneration(disk) !== diskGeneration) return false;
}
Comment thread
lidge-jun marked this conversation as resolved.
return true;
};
}

function identityMetadata(credential: OAuthCredentials): string {
return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity]);
}
75 changes: 75 additions & 0 deletions src/oauth/anthropic-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/** Private account proof from an authenticated Anthropic response, bound to its exact bearer. */
import { hasClaudeCredentialContinuity } from "./local-token-detect";
import { createHash } from "node:crypto";
import { readBoundedResponseBody } from "../lib/bounded-body";
import { CLAUDE_CLI_USER_AGENT } from "../providers/claude-cli-identity";
import type { OAuthCredentials } from "./types";

const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
const PROFILE_TIMEOUT_MS = 10_000;
const PROFILE_MAX_BYTES = 65_536;
export type AnthropicIdentity = NonNullable<OAuthCredentials["anthropicIdentity"]>;
export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<AnthropicIdentity | undefined>;

export function bindAnthropicIdentity(access: string, accountUuid: unknown): AnthropicIdentity | undefined {
if (!access || typeof accountUuid !== "string" || !accountUuid.length || accountUuid.length > 128
|| accountUuid !== accountUuid.trim() || /[\x00-\x1f\x7f]/.test(accountUuid)) return undefined;
return { v: 1, accountUuid, bearerSha256: createHash("sha256").update(access).digest("hex") };
}

export function normalizeAnthropicIdentity(value: unknown, access: string): AnthropicIdentity | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const proof = value as Partial<AnthropicIdentity>;
const bound = bindAnthropicIdentity(access, proof.accountUuid);
return proof.v === 1 && bound && proof.bearerSha256 === bound.bearerSha256 ? bound : undefined;
}

/** Fixed destination, no redirects, bounded body and deadline; failures never include response data. */
export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async (access, signal) => {
if (!access || signal?.aborted) return undefined;
const deadline = AbortSignal.timeout(PROFILE_TIMEOUT_MS);
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
try {
const response = await fetch(PROFILE_URL, {
method: "GET", redirect: "error", signal: combined,
headers: { Authorization: `Bearer ${access}`, Accept: "application/json",
"anthropic-beta": "oauth-2025-04-20",
"Cache-Control": "no-cache", "User-Agent": CLAUDE_CLI_USER_AGENT },
Comment thread
lidge-jun marked this conversation as resolved.
});
if (!response.ok || response.redirected) {
void response.body?.cancel().catch(() => {});
return undefined;
}
const body = await readBoundedResponseBody(response, {
signal: combined, maxBytes: PROFILE_MAX_BYTES, fatalUtf8: true,
totalTimeoutMs: PROFILE_TIMEOUT_MS, inactivityTimeoutMs: PROFILE_TIMEOUT_MS,
});
if (body.truncated || body.oversized || body.timedOut || combined.aborted) return undefined;
const data: unknown = JSON.parse(body.text);
if (!data || typeof data !== "object" || !("account" in data)) return undefined;
const account = data.account;
return account && typeof account === "object" && "uuid" in account
? bindAnthropicIdentity(access, account.uuid) : undefined;
} catch {
// Unavailable identity is an explicit unresolved result, never an ownership guess.
return undefined;
}
};

/** A changed access bearer cannot inherit the previous bearer's proof. */
export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthCredentials): Pick<OAuthCredentials, "anthropicIdentity"> {
const before = normalizeAnthropicIdentity(previous.anthropicIdentity, previous.access);
const after = normalizeAnthropicIdentity(fresh.anthropicIdentity, fresh.access);
if (before && after && before.accountUuid !== after.accountUuid) {
throw new Error("Anthropic credential identity changed; explicit account login required");
}
return { anthropicIdentity: after ?? (fresh.access === previous.access ? before : undefined) };
}

/** Explicit imports enrich only a proven slot; labels and paths never establish ownership. */
export function matchesAnthropicImport(stored: OAuthCredentials, imported: OAuthCredentials): boolean {
const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access);
const after = normalizeAnthropicIdentity(imported.anthropicIdentity, imported.access);
if (before && after) return before.accountUuid === after.accountUuid;
return hasClaudeCredentialContinuity(stored, imported);
}
6 changes: 3 additions & 3 deletions src/oauth/anthropic-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { anthropicFamilyRejected, anthropicFamilyRetryAt, anthropicModelExhauste
* Token refresh failures retain the existing store needsReauth policy.
*/
import { anthropicRatePauseUntil, clearAnthropicRatePauses, classifyAnthropic429, anthropicRetryAfterMs, pauseAnthropicRateAdmission } from "./anthropic-rate-limit-policy";
import { REFRESH_SKEW_MS } from "./refresh-policy";
import { createHash } from "node:crypto";
import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
import type { OAuthAccessSnapshot } from "./index";
Expand Down Expand Up @@ -353,15 +354,14 @@ function usageScore(config: OcxConfig, accountId: string, model?: string): numbe
}
}

const TOKEN_SKEW_MS = 60_000;

/** Background `local-cli` slots with expired access are not pool-eligible (identity adoption risk). */
function isPoolCredentialUsable(accountId: string, now: number): boolean {
const cred = getAccountCredential(PROVIDER, accountId);
if (!cred) return false;
if (cred.source !== "local-cli") return true;
if (canRefreshAnthropicPoolAccount(accountId)) return true;
return cred.expires > now + TOKEN_SKEW_MS;
return cred.expires > now + REFRESH_SKEW_MS;
}

export function getEligibleAnthropicAccounts(now = Date.now(), model?: string): string[] {
Expand Down Expand Up @@ -1093,7 +1093,7 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise<st
const { OAuthLoginRequiredError } = await import("./index");
throw new OAuthLoginRequiredError(PROVIDER);
}
if (stored.expires > Date.now() + TOKEN_SKEW_MS) return stored.access;
if (stored.expires > Date.now() + REFRESH_SKEW_MS) return stored.access;
if (!canRefreshAnthropicPoolAccount(accountId)) {
throw new Error("background local-cli token expired; refuse CLI-adopting refresh for pool");
}
Expand Down
8 changes: 7 additions & 1 deletion src/oauth/anthropic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** Anthropic OAuth flow (Claude Pro/Max). Ported from jawcode oauth/anthropic.ts. */
import { OAuthCallbackFlow } from "./callback-server";
import { bindAnthropicIdentity, resolveAnthropicAccountIdentity } from "./anthropic-identity";
import { generatePKCE } from "./pkce";
import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";

Expand Down Expand Up @@ -96,6 +97,7 @@ function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAut
refresh: data.refresh_token || refreshFallback || "",
access: data.access_token,
expires,
anthropicIdentity: bindAnthropicIdentity(data.access_token, accountUuid),
accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined,
email: typeof email === "string" && email.length > 0 ? email : undefined,
};
Expand Down Expand Up @@ -159,7 +161,11 @@ export async function loginAnthropic(
const local = detectClaudeCodeToken();
if (local) {
ctrl.onProgress?.("Found Claude Code token, importing automatically");
if (local.expires >= Date.now() + 60_000) return local;
if (local.expires >= Date.now() + 60_000) {
const identity = await resolveAnthropicAccountIdentity(local.access, ctrl.signal);
ctrl.signal?.throwIfAborted();
return { ...local, ...(identity ? { accountId: identity.accountUuid, anthropicIdentity: identity } : {}) };
}
try {
return { ...(await refreshAnthropicToken(local.refresh)), source: "local-cli" };
} catch (error) {
Expand Down
Loading
Loading