From e3051677dca3454d132ab8b77e424770fd7282c4 Mon Sep 17 00:00:00 2001 From: seongwoong Date: Thu, 12 Nov 2026 16:03:14 +0900 Subject: [PATCH 1/5] feat(anthropic): keep one OAuth account per organization One Claude account (one account.uuid) can belong to several organizations, and each OAuth grant is scoped to exactly one of them with its own subscription and limits. The store matched Anthropic rows by account identity only, so logging in to a second organization overwrote the first organization's row and its grant. Record the grant's organization inside the private bearer-bound proof (token response, else the same bearer's profile read) and treat it as a slot scope, never as identity: - login and Claude Code import write the same-organization row, else a row whose organization is unknown; a proven other organization gets a new row. Organization-less rows are asked through their own unexpired bearer before the store lock; the answer is honored only while the row still holds that bearer and is never written to it. - reauth refuses a grant for another organization, or one another row of the account already proves. - CLI continuity, the cross-pool duplicate check and plan labels treat proven different organizations as different accounts. - own-grant refresh keeps the stored organization when the response does not repeat it, and never throws over it after rotation. Unknown on either side keeps the historical same-account replacement, so existing rows are never duplicated by re-login. Co-Authored-By: Claude Opus 5.5 --- .../docs/reference/cli/providers-accounts.md | 8 + src/adapters/anthropic/account-metadata.ts | 5 +- src/oauth/anthropic-continuity.ts | 7 + src/oauth/anthropic-identity.ts | 53 ++++- src/oauth/anthropic-login-slot.ts | 112 +++++++++ src/oauth/anthropic.ts | 8 +- src/oauth/index.ts | 8 +- src/oauth/plan.ts | 13 ++ src/oauth/store-anthropic-instance.ts | 4 +- src/oauth/store.ts | 12 +- src/oauth/types.ts | 7 +- structure/providers/anthropic-account-pool.md | 6 + tests/oauth/oauth-anthropic-identity.test.ts | 220 ++++++++++++++++++ 13 files changed, 442 insertions(+), 21 deletions(-) create mode 100644 src/oauth/anthropic-login-slot.ts diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 81b00029396..ed34904cd06 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -316,6 +316,14 @@ with the list of providers that do. For Codex accounts use the device-code login The paste window is 10 minutes, and each login accepts one code: if the exchange fails, start a new login. A successful login becomes the provider's active account, exactly like a browser login. +A Claude account that belongs to several organizations can be added once per organization: run +`ocx login anthropic` again and pick the other organization on Claude's consent screen. Each +organization has its own subscription and limits, so it becomes its own pool account instead of +replacing the first. Logging in to an organization that already has an account refreshes that +account. Reauthenticating an account into a different organization is refused; add it as a new +login instead. Accounts saved before this release learn their organization on their next token +refresh. + OAuth reauthentication preserves operator settings such as model selections, pricing overrides, and account failover preferences. Login-owned transport/authentication fields and registry-owned catalog metadata are refreshed. A live-discovery provider keeps its selected default model; a diff --git a/src/adapters/anthropic/account-metadata.ts b/src/adapters/anthropic/account-metadata.ts index a1474f34c91..728958af9ac 100644 --- a/src/adapters/anthropic/account-metadata.ts +++ b/src/adapters/anthropic/account-metadata.ts @@ -5,7 +5,10 @@ const MAX_USER_ID_BYTES = 4096; type Rec = Record; const isRec = (value: unknown): value is Rec => value !== null && typeof value === "object" && !Array.isArray(value); -/** Deterministic per-account 64-hex device_id so pooled accounts never share one client device_id. */ +/** + * Deterministic per-account 64-hex device_id so pooled accounts never share one client device_id. + * Organization slots of one Claude account share it: they are one person on one device. + */ export function deriveAnthropicAccountDeviceId(accountUuid: string): string { return createHash("sha256").update(`ocx-claude-code-device-v1:${accountUuid.trim().toLowerCase()}`, "utf8").digest("hex"); } diff --git a/src/oauth/anthropic-continuity.ts b/src/oauth/anthropic-continuity.ts index 2f51db09877..60f7296a9af 100644 --- a/src/oauth/anthropic-continuity.ts +++ b/src/oauth/anthropic-continuity.ts @@ -42,6 +42,13 @@ export async function newerClaudeCredentialForInstance( const newIdentity = normalizeAnthropicIdentity(newProof, disk.access); if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration }; if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration }; + // The same person signed in to another organization holds a different grant and quota. A stored + // proof saved before organizations were recorded asks its own still-usable bearer once. + const oldOrganization = oldIdentity.organizationUuid ?? (newIdentity.organizationUuid && stored.expires > now + ? normalizeAnthropicIdentity(await resolveIdentity(stored.access, signal), stored.access)?.organizationUuid : undefined); + if (oldOrganization && newIdentity.organizationUuid && oldOrganization !== newIdentity.organizationUuid) { + return { kind: "different", diskGeneration }; + } return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: { ...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity, plan: newProof?.plan ?? null, } }; diff --git a/src/oauth/anthropic-identity.ts b/src/oauth/anthropic-identity.ts index 44c355c76f0..d9c2c44327e 100644 --- a/src/oauth/anthropic-identity.ts +++ b/src/oauth/anthropic-identity.ts @@ -12,19 +12,43 @@ const PROFILE_MAX_BYTES = 65_536; export type AnthropicIdentity = NonNullable; export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<(AnthropicIdentity & { plan?: string }) | undefined>; -export function bindAnthropicIdentity(access: string, accountUuid: unknown): AnthropicIdentity | undefined { +const ORGANIZATION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** A well-formed organization UUID in canonical lowercase, or undefined. */ +function organizationUuidOf(value: unknown): string | undefined { + return typeof value === "string" && ORGANIZATION_UUID_RE.test(value) ? value.toLowerCase() : undefined; +} + +export function bindAnthropicIdentity(access: string, accountUuid: unknown, organizationUuid?: 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") }; + const organization = organizationUuidOf(organizationUuid); + return { v: 1, accountUuid, bearerSha256: createHash("sha256").update(access).digest("hex"), + ...(organization ? { organizationUuid: organization } : {}) }; } export function normalizeAnthropicIdentity(value: unknown, access: string): AnthropicIdentity | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const proof = value as Partial; - const bound = bindAnthropicIdentity(access, proof.accountUuid); + const bound = bindAnthropicIdentity(access, proof.accountUuid, proof.organizationUuid); return proof.v === 1 && bound && proof.bearerSha256 === bound.bearerSha256 ? bound : undefined; } +/** + * The organization a credential's grant is scoped to, when its own bearer proved it. One Claude + * account can belong to several organizations, and each OAuth grant serves exactly one of them. + */ +export function anthropicOrganization(credential: OAuthCredentials): string | undefined { + return normalizeAnthropicIdentity(credential.anthropicIdentity, credential.access)?.organizationUuid; +} + +/** True only when both credentials prove different organizations; an unknown side never conflicts. */ +export function anthropicOrganizationsConflict(a: OAuthCredentials, b: OAuthCredentials): boolean { + const left = anthropicOrganization(a); + const right = anthropicOrganization(b); + return left !== undefined && right !== undefined && left !== right; +} + /** 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; @@ -49,12 +73,12 @@ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async const data: unknown = JSON.parse(body.text); if (!data || typeof data !== "object" || !("account" in data)) return undefined; const account = data.account; - const identity = account && typeof account === "object" && "uuid" in account - ? bindAnthropicIdentity(access, account.uuid) : undefined; - if (!identity) return undefined; const organization = "organization" in data ? data.organization : undefined; const org = organization && typeof organization === "object" ? organization as Record : undefined; + const identity = account && typeof account === "object" && "uuid" in account + ? bindAnthropicIdentity(access, account.uuid, org?.uuid) : undefined; + if (!identity) return undefined; const plan = normalizeAnthropicPlan(org?.rate_limit_tier) ?? normalizeAnthropicPlan(org?.organization_type); return { ...identity, ...(plan ? { plan } : {}) }; } catch { @@ -63,13 +87,24 @@ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async } }; -/** A changed access bearer cannot inherit the previous bearer's proof. */ -export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthCredentials): Pick { +/** + * A changed access bearer cannot inherit the previous bearer's proof. `sameGrant` marks a + * provider refresh of this row's own refresh token: the grant's organization cannot change + * there, so a freshly proven same account keeps the stored organization when the refresh + * response and its profile read did not repeat it. The organization is a scope, not identity: + * a refresh that reports another one keeps the fresh answer instead of discarding a rotated grant. + */ +export function mergeAnthropicIdentity( + fresh: OAuthCredentials, previous: OAuthCredentials, opts: { sameGrant?: boolean } = {}, +): Pick { 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"); } + if (opts.sameGrant && before?.organizationUuid && after && !after.organizationUuid) { + return { anthropicIdentity: { ...after, organizationUuid: before.organizationUuid } }; + } return { anthropicIdentity: after ?? (fresh.access === previous.access ? before : undefined) }; } @@ -77,6 +112,6 @@ export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthC 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; + if (before && after) return before.accountUuid === after.accountUuid && !anthropicOrganizationsConflict(stored, imported); return hasClaudeCredentialContinuity(stored, imported); } diff --git a/src/oauth/anthropic-login-slot.ts b/src/oauth/anthropic-login-slot.ts new file mode 100644 index 00000000000..fc282843875 --- /dev/null +++ b/src/oauth/anthropic-login-slot.ts @@ -0,0 +1,112 @@ +/** + * Which stored Anthropic row a login, a Claude Code import or a reauth may write. + * + * One Claude account (one `account.uuid`) can belong to several organizations, and every OAuth + * grant is scoped to exactly one of them, with its own subscription and usage limits. A login + * therefore replaces the row of the same account AND organization; a proven different + * organization gets its own row instead of overwriting the first one. + * + * The organization only scopes a slot. It never proves identity: matching still starts from + * the same account identity the store always used. When the incoming organization is unknown, + * the login keeps the historical same-account replacement, so a row stored before + * organizations were recorded is never duplicated by re-login. + */ +import { createHash } from "node:crypto"; +import { anthropicOrganization, matchesAnthropicImport, normalizeAnthropicIdentity, + resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; +import type { OAuthCredentials, ProviderAccount } from "./types"; + +/** Interactive login waits on these reads; a slow profile falls back to the historical match. */ +const LOGIN_OBSERVATION_TIMEOUT_MS = 5_000; + +/** Organizations observed for stored rows, keyed by the SHA-256 of the bearer that proved them. */ +export type AnthropicSlotOrganizations = ReadonlyMap; + +function bearerSha256(access: string): string { + return createHash("sha256").update(access).digest("hex"); +} + +function loginIdentity(credential: OAuthCredentials): string | undefined { + return credential.accountId ?? credential.email; +} + +/** + * Before the store lock: read the organization of each same-account row that has none recorded + * (rows saved before organizations were tracked), so the login can tell its org from theirs. + * Only an unexpired bearer is asked, each answer is bound to that exact bearer, and nothing is + * written here; a row whose bearer changes before the write simply stays unknown. + */ +export async function observeAnthropicSlotOrganizations( + accounts: readonly ProviderAccount[], + incoming: OAuthCredentials, + resolve: AnthropicIdentityResolver = resolveAnthropicAccountIdentity, + now: () => number = Date.now, +): Promise { + const identity = loginIdentity(incoming); + if (!identity || !anthropicOrganization(incoming)) return new Map(); + const pending = accounts.filter(row => loginIdentity(row.credential) === identity + && anthropicOrganization(row.credential) === undefined + && row.credential.access && row.credential.expires > now()); + if (pending.length === 0) return new Map(); + const signal = AbortSignal.timeout(LOGIN_OBSERVATION_TIMEOUT_MS); + const proofs = await Promise.all(pending.map(row => resolve(row.credential.access, signal))); + const observed = new Map(); + pending.forEach((row, index) => { + const proof = normalizeAnthropicIdentity(proofs[index], row.credential.access); + if (proof?.organizationUuid && proof.accountUuid === row.credential.accountId) { + observed.set(proof.bearerSha256, proof.organizationUuid); + } + }); + return observed; +} + +/** + * One rule for every writer. A known incoming organization takes its own row, else a row whose + * organization is still unknown; a row proven to serve another organization is never chosen. + * An unknown incoming organization prefers an unknown row, then the active one, so it never + * displaces a proven slot while a better candidate exists. + */ +function pickSlot( + candidates: readonly ProviderAccount[], incoming: OAuthCredentials, + observed: AnthropicSlotOrganizations, activeAccountId?: string, +): ProviderAccount | undefined { + const organizationOf = (row: ProviderAccount) => anthropicOrganization(row.credential) + ?? observed.get(bearerSha256(row.credential.access)); + const organization = anthropicOrganization(incoming); + if (organization) { + return candidates.find(row => organizationOf(row) === organization) + ?? candidates.find(row => organizationOf(row) === undefined); + } + return candidates.find(row => organizationOf(row) === undefined) + ?? candidates.find(row => row.id === activeAccountId) ?? candidates[0]; +} + +/** Inside the store lock: the row a browser login replaces, or undefined for a new row. */ +export function findAnthropicLoginSlot( + accounts: readonly ProviderAccount[], incoming: OAuthCredentials, + observed: AnthropicSlotOrganizations = new Map(), activeAccountId?: string, +): ProviderAccount | undefined { + const identity = loginIdentity(incoming); + return pickSlot(accounts.filter(row => loginIdentity(row.credential) === identity), incoming, observed, activeAccountId); +} + +/** Inside the store lock: the row a Claude Code credential import enriches, or undefined for a new row. */ +export function findAnthropicImportSlot( + accounts: readonly ProviderAccount[], incoming: OAuthCredentials, + observed: AnthropicSlotOrganizations = new Map(), activeAccountId?: string, +): ProviderAccount | undefined { + return pickSlot(accounts.filter(row => matchesAnthropicImport(row.credential, incoming)), incoming, observed, activeAccountId); +} + +/** + * A reauth must not turn a row into a second copy of an organization another row of the same + * account already proves; that would leave two rows on one quota and drop the first grant. + */ +export function anthropicOrganizationHeldElsewhere( + accounts: readonly ProviderAccount[], accountId: string, incoming: OAuthCredentials, +): boolean { + const organization = anthropicOrganization(incoming); + const identity = loginIdentity(incoming); + return organization !== undefined && accounts.some(row => row.id !== accountId + && loginIdentity(row.credential) === identity && anthropicOrganization(row.credential) === organization); +} diff --git a/src/oauth/anthropic.ts b/src/oauth/anthropic.ts index e9f3440cc71..c16bcd6284a 100644 --- a/src/oauth/anthropic.ts +++ b/src/oauth/anthropic.ts @@ -48,6 +48,7 @@ interface AnthropicTokenResponse { refresh_token: string; expires_in: number; account?: { uuid?: string; email_address?: string }; + organization?: { uuid?: string }; } export class AnthropicTokenError extends Error { @@ -155,7 +156,7 @@ function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAut refresh: data.refresh_token || refreshFallback || "", access: data.access_token, expires, - anthropicIdentity: bindAnthropicIdentity(data.access_token, accountUuid), + anthropicIdentity: bindAnthropicIdentity(data.access_token, accountUuid, data.organization?.uuid), accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined, email: typeof email === "string" && email.length > 0 ? email : undefined, }; @@ -167,7 +168,10 @@ async function withAccountProfile(credential: OAuthCredentials, signal?: AbortSi // Token-response identity remains authoritative here; enrichment changes display metadata only. const identity = normalizeAnthropicIdentity(credential.anthropicIdentity, credential.access); if (identity && identity.accountUuid !== profile.accountUuid) return credential; - return { ...credential, plan: profile.plan ?? null }; + // The same bearer's profile names the grant's organization when the token response did not. + const organization = identity && !identity.organizationUuid && profile.organizationUuid + ? { anthropicIdentity: { ...identity, organizationUuid: profile.organizationUuid } } : {}; + return { ...credential, ...organization, plan: profile.plan ?? null }; } export class AnthropicOAuthFlow extends OAuthCallbackFlow { diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 7e67e9a9651..7d1c7a5cf79 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -99,7 +99,8 @@ import { REFRESH_SKEW_MS } from "./refresh-policy"; import { newerClaudeCredentialForInstance, captureAnthropicCredentialOwnerForInstance, claudeCredentialGenerationCurrent } from "./anthropic-continuity"; import { anthropicSubscriptionLabel } from "../providers/anthropic-subscription"; import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan"; -import { mergeAnthropicIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; +import { anthropicOrganizationsConflict, mergeAnthropicIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; +import { anthropicOrganizationHeldElsewhere } from "./anthropic-login-slot"; import { AnthropicCrossInstanceDuplicateError, AnthropicLocalCliImportError, assertAnthropicCredentialSource } from "./store-anthropic-instance"; export interface OAuthAccessSnapshot { workbuddy?: import("./types").WorkBuddyOAuthMetadata; @@ -786,7 +787,7 @@ function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OA function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials { return { ...fresh, - ...mergeAnthropicIdentity(fresh, previous), + ...mergeAnthropicIdentity(fresh, previous, { sameGrant: true }), plan: mergeOAuthPlan(fresh, previous), // Shared: a refresh function returns "local-cli" only when the credential it hands back // still is the local CLI's (Devin re-reading the CLI file, Meta Muse echoing its durable @@ -1668,7 +1669,8 @@ export async function runLogin( ? kiroReauthIdentityMatches(existing, cred, getAccountSet("kiro")?.accounts .filter(account => account.id !== opts.reauthAccountId).map(account => account.credential)) : existing.accountId && cred.accountId - ? existing.accountId === cred.accountId + ? existing.accountId === cred.accountId && !anthropicOrganizationsConflict(existing, cred) + && !anthropicOrganizationHeldElsewhere(getAccountSet(provider)?.accounts ?? [], opts.reauthAccountId, cred) : existing.email && cred.email ? existing.email.trim().toLowerCase() === cred.email.trim().toLowerCase() : false; diff --git a/src/oauth/plan.ts b/src/oauth/plan.ts index 917ece100f6..83e9df1e8e6 100644 --- a/src/oauth/plan.ts +++ b/src/oauth/plan.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { OAuthCredentials } from "./types"; /** Display-only upstream tier; never infer a plan from normalized quota percentages. */ @@ -12,6 +13,10 @@ export function normalizeOAuthPlan(value: unknown): string | undefined { * (Anthropic UUID, then accountId, then case-insensitive email); undefined when none overlap. */ function sameAccount(a: OAuthCredentials, b: OAuthCredentials): boolean | undefined { + // One Claude account in two organizations is two subscriptions; neither inherits the other's tier. + const orgA = boundOrganization(a); + const orgB = boundOrganization(b); + if (orgA !== undefined && orgB !== undefined && orgA !== orgB) return false; const pairs: Array<[string | undefined, string | undefined]> = [ [a.anthropicIdentity?.accountUuid, b.anthropicIdentity?.accountUuid], [a.accountId, b.accountId], @@ -21,6 +26,14 @@ function sameAccount(a: OAuthCredentials, b: OAuthCredentials): boolean | undefi return undefined; } +/** The proof's organization, only while it is bound to this exact bearer. */ +function boundOrganization(credential: OAuthCredentials): string | undefined { + const proof = credential.anthropicIdentity; + if (!proof?.organizationUuid || !credential.access + || proof.bearerSha256 !== createHash("sha256").update(credential.access).digest("hex")) return undefined; + return proof.organizationUuid.toLowerCase(); +} + /** An observed unknown clears old metadata; an account replacement cannot inherit a plan. */ export function mergeOAuthPlan(fresh: OAuthCredentials, previous: OAuthCredentials): string | null | undefined { if (fresh.plan !== undefined) return normalizeOAuthPlan(fresh.plan) ?? null; diff --git a/src/oauth/store-anthropic-instance.ts b/src/oauth/store-anthropic-instance.ts index dde516a5e45..ba13589fb53 100644 --- a/src/oauth/store-anthropic-instance.ts +++ b/src/oauth/store-anthropic-instance.ts @@ -87,7 +87,9 @@ export function assertNoCrossAnthropicRegistration(store: AuthStore, instance: A const refresh = tokenFingerprint(row.credential.refresh); const existingIdentity = normalizeAnthropicIdentity(row.credential.anthropicIdentity, row.credential.access); if ((access !== undefined && tokens.has(access)) || (refresh !== undefined && tokens.has(refresh)) - || (identity && existingIdentity && identity.accountUuid === existingIdentity.accountUuid)) { + || (identity && existingIdentity && identity.accountUuid === existingIdentity.accountUuid + && !(identity.organizationUuid && existingIdentity.organizationUuid + && identity.organizationUuid !== existingIdentity.organizationUuid))) { throw new AnthropicCrossInstanceDuplicateError(); } } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index cd5e709d6fb..3e1caf88800 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -23,7 +23,8 @@ import { publishCredentialSync } from "../local-credentials/generation"; import { notifyAuthStoreLoaded } from "../lib/decision-quota-sink"; import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause"; import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan"; -import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity"; +import { normalizeAnthropicIdentity, mergeAnthropicIdentity } from "./anthropic-identity"; +import { findAnthropicImportSlot, findAnthropicLoginSlot, observeAnthropicSlotOrganizations } from "./anthropic-login-slot"; import { normalizeWorkBuddyMetadata } from "../providers/workbuddy"; import { findKiroAccountForLogin, withRetainedKiroUser } from "./kiro-account-identity"; import { isAnthropicInstanceId, type AnthropicInstanceId } from "../providers/anthropic-instance-id"; @@ -961,6 +962,9 @@ export async function saveCredentialWithReceipt( if (isAnthropicInstanceId(provider)) assertAnthropicCredentialSource(provider, cred); const safe = normalizeCredential(cred); if (!safe) return null; + // Network reads stay outside the store lock; the answers are bound to bearers rechecked inside. + const anthropicOrganizations = isAnthropicInstanceId(provider) + ? await observeAnthropicSlotOrganizations(getAccountSet(provider)?.accounts ?? [], safe) : undefined; // A login that clears an automatic pause publishes it once the write commits (#8398). let pauseCleared = false; const receipt = await mutateStore(store => { @@ -982,7 +986,7 @@ export async function saveCredentialWithReceipt( store[provider] = { activeAccountId: id, accounts: [{ id, credential: safe, addedAt: Date.now() }] }; accountId = id; } else if (provider === "anthropic" && safe.source === "local-cli") { - const existing = set.accounts.find(a => matchesAnthropicImport(a.credential, safe)); + const existing = findAnthropicImportSlot(set.accounts, safe, anthropicOrganizations, set.activeAccountId); if (existing) { existing.credential = { ...safe, ...mergeAnthropicIdentity(safe, existing.credential), plan: mergeOAuthPlan(safe, existing.credential), @@ -1004,7 +1008,9 @@ export async function saveCredentialWithReceipt( // same-session whoami email (#6641); the helper never guesses between identified users. const existing = provider === "kiro" ? findKiroAccountForLogin(set.accounts, safe) - : set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity); + : isAnthropicInstanceId(provider) + ? findAnthropicLoginSlot(set.accounts, safe, anthropicOrganizations, set.activeAccountId) + : set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity); if (existing) { const incoming = provider === "kiro" ? withRetainedKiroUser(safe, existing.credential) : safe; // Same identity: a login whose tier read failed keeps the stored label (plan absent), diff --git a/src/oauth/types.ts b/src/oauth/types.ts index ec5a9a4ae38..40c9310f836 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -73,8 +73,11 @@ export type OAuthCredentials = { source?: OAuthCredentialSource; /** Validated upstream subscription label; null is an observed unknown, absent is not read. */ plan?: string | null; - /** Private authenticated account proof; never projected into account summaries. */ - anthropicIdentity?: { v: 1; accountUuid: string; bearerSha256: string }; + /** + * Private authenticated account proof; never projected into account summaries. The optional + * organization is the grant's scope, observed from the same bearer; it never proves identity. + */ + anthropicIdentity?: { v: 1; accountUuid: string; bearerSha256: string; organizationUuid?: string }; /** Google Antigravity (Cloud Code Assist) discovered project id; injected into the CCA envelope. */ projectId?: string; /** diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 327581571fd..ddd96aa8beb 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -322,6 +322,12 @@ Its private versioned proof binds the UUID to SHA-256 of the access bearer. Stor in `src/oauth/store.ts` drops malformed or stale proofs; account summaries omit the entire field. Profile observations reject redirects and use a ten-second deadline and 64 KiB body limit. Organization, email, generic account ID, disk location and active selection do not establish proof. +An account can join several organizations; each grant serves one. The proof may carry the bearer-observed +`organizationUuid` (UUID, lowercased), a slot scope, never identity. `src/oauth/anthropic-login-slot.ts` lets login and +CLI import write the same-organization row, else an unknown one; a proven other organization gets a new row. Unknown +rows are asked through their own unexpired bearer before the lock, never rewritten. Reauth refuses an organization +another row proves; continuity, cross-pool checks and plan labels treat proven organizations as different accounts. +Own-grant refresh keeps the stored organization unless fresh evidence names one; it never throws over it. `src/oauth/anthropic-continuity.ts` permits usable changed local-CLI generations with a shared nonempty token. A fully rotated pair instead requires matching authenticated account UUIDs. diff --git a/tests/oauth/oauth-anthropic-identity.test.ts b/tests/oauth/oauth-anthropic-identity.test.ts index 8d8395c09f4..80ddd545122 100644 --- a/tests/oauth/oauth-anthropic-identity.test.ts +++ b/tests/oauth/oauth-anthropic-identity.test.ts @@ -13,6 +13,15 @@ import { join } from "node:path"; import { getValidAccessToken, OAuthTokenRefreshStaleError } from "../../src/oauth"; import { credentialGeneration, getAccountCredential, getAccountSet, readOAuthRefreshIntent, saveCredential, writeOAuthRefreshIntent } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { OAuthReauthIdentityMismatchError, runLogin } from "../../src/oauth"; +import { mergeAnthropicIdentity } from "../../src/oauth/anthropic-identity"; +import { newerClaudeCredential } from "../../src/oauth/anthropic-continuity"; +import { findAnthropicLoginSlot } from "../../src/oauth/anthropic-login-slot"; +import { mergeOAuthPlan } from "../../src/oauth/plan"; +import { AnthropicCrossInstanceDuplicateError, assertNoCrossAnthropicRegistration } from "../../src/oauth/store-anthropic-instance"; +import type { AuthStore } from "../../src/oauth/store"; +import type { OAuthController } from "../../src/oauth/types"; +import type { OcxConfig } from "../../src/config"; const originalEnv = { HOME: process.env.HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR }; const originalFetch = globalThis.fetch; @@ -545,3 +554,214 @@ test("adoption reads the CLI generation only at observation and inside persisten expect(reads).toEqual([false, true]); } finally { detect.mockRestore(); } }); + +// One Claude account can belong to several organizations; each grant serves one of them. +const ORG_A = "0a0a0a0a-0000-4000-8000-00000000000a"; +const ORG_B = "0b0b0b0b-0000-4000-8000-00000000000b"; +function orgCredential(access: string, organizationUuid?: string, accountUuid = "synthetic-account-a"): OAuthCredentials { + return { access, refresh: `${access}-refresh`, expires: Date.now() + 3600_000, source: "oauth", + accountId: accountUuid, email: "shared@example.test", + anthropicIdentity: { ...proof(access, accountUuid), ...(organizationUuid ? { organizationUuid } : {}) } }; +} +/** Profile answers per bearer; a bearer without an entry is unavailable. Returns the bearers asked. */ +function orgProfiles(organizationByBearer: Record, accountUuid = "synthetic-account-a"): string[] { + const asked: string[] = []; + globalThis.fetch = (async (_url, init) => { + const bearer = new Headers(init?.headers).get("authorization")?.replace(/^Bearer /, "") ?? ""; + asked.push(bearer); + const organization = organizationByBearer[bearer]; + return organization === undefined ? new Response(null, { status: 503 }) + : Response.json({ account: { uuid: accountUuid }, organization: { uuid: organization } }); + }) as typeof fetch; + return asked; +} +const anthropicRows = () => getAccountSet("anthropic")!.accounts; + +test("profile binds the grant's organization in canonical form; a malformed organization stays unknown", async () => { + orgProfiles({ "synthetic-access": ORG_A.toUpperCase(), "synthetic-malformed": "not-an-organization" }); + expect(await resolveAnthropicAccountIdentity("synthetic-access")).toEqual({ ...proof("synthetic-access"), organizationUuid: ORG_A }); + expect(await resolveAnthropicAccountIdentity("synthetic-malformed")).toEqual(proof("synthetic-malformed")); +}); + +test("token response organization is bound to the returned bearer", async () => { + globalThis.fetch = (async (_url, init) => init?.method === "POST" + ? Response.json({ access_token: "synthetic-fresh", refresh_token: "synthetic-fresh-refresh", expires_in: 3600, + account: { uuid: "synthetic-account-a" }, organization: { uuid: ORG_B } }) + : new Response(null, { status: 503 })) as typeof fetch; + expect((await refreshAnthropicToken("synthetic-old-refresh")).anthropicIdentity) + .toEqual({ ...proof("synthetic-fresh"), organizationUuid: ORG_B }); +}); + +test("login of the same account in another organization adds a slot; same-organization login replaces its own", async () => { + orgProfiles({}); + await saveCredential("anthropic", orgCredential("synthetic-org-a", ORG_A)); + const first = anthropicRows()[0]!.id; + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + expect(anthropicRows()).toHaveLength(2); + const second = anthropicRows().find(row => row.id !== first)!.id; + expect(getAccountSet("anthropic")!.activeAccountId).toBe(second); + expect(getAccountCredential("anthropic", first)!.access).toBe("synthetic-org-a"); + await saveCredential("anthropic", orgCredential("synthetic-org-b-2", ORG_B)); + expect(anthropicRows()).toHaveLength(2); + expect(getAccountCredential("anthropic", second)!.access).toBe("synthetic-org-b-2"); + expect(getAccountCredential("anthropic", first)!.access).toBe("synthetic-org-a"); +}); + +test("a row saved without an organization is observed by its own bearer before a login picks its slot", async () => { + await saveCredential("anthropic", orgCredential("synthetic-legacy")); + const legacy = anthropicRows()[0]!; + const asked = orgProfiles({ "synthetic-legacy": ORG_A }); + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + expect(asked).toEqual(["synthetic-legacy"]); + expect(anthropicRows()).toHaveLength(2); + // Observation decides the slot only; the legacy row itself is not rewritten. + expect(getAccountCredential("anthropic", legacy.id)).toEqual(legacy.credential); + await saveCredential("anthropic", orgCredential("synthetic-org-a", ORG_A)); + expect(anthropicRows()).toHaveLength(2); + expect(getAccountCredential("anthropic", legacy.id)!.anthropicIdentity) + .toEqual({ ...proof("synthetic-org-a"), organizationUuid: ORG_A }); +}); + +test("an unobservable or unproven organization keeps the historical same-account replacement", async () => { + await saveCredential("anthropic", orgCredential("synthetic-legacy")); + orgProfiles({}); + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + expect(anthropicRows()).toHaveLength(1); + expect(anthropicRows()[0]!.credential.access).toBe("synthetic-org-b"); + await saveCredential("anthropic", orgCredential("synthetic-unproven")); + expect(anthropicRows()).toHaveLength(1); + expect(anthropicRows()[0]!.credential.access).toBe("synthetic-unproven"); +}); + +test("reauth of an organization slot refuses a grant for another organization", async () => { + orgProfiles({}); + await saveCredential("anthropic", orgCredential("synthetic-org-a", ORG_A)); + const slot = anthropicRows()[0]!.id; + const original = OAUTH_PROVIDERS.anthropic!.login; + const config = (): OcxConfig => ({ port: 10100, defaultProvider: "openai", openaiProviderTierVersion: 2, providers: {} }); + const reauthWith = (credential: OAuthCredentials) => { + OAUTH_PROVIDERS.anthropic!.login = async () => credential; + return runLogin("anthropic", {} as OAuthController, { reauthAccountId: slot }, { loadConfig: config, saveConfig: () => {} }); + }; + try { + await expect(reauthWith(orgCredential("synthetic-org-b", ORG_B))).rejects.toBeInstanceOf(OAuthReauthIdentityMismatchError); + expect(getAccountCredential("anthropic", slot)!.access).toBe("synthetic-org-a"); + await reauthWith(orgCredential("synthetic-org-a-2", ORG_A)); + expect(getAccountCredential("anthropic", slot)!.access).toBe("synthetic-org-a-2"); + } finally { OAUTH_PROVIDERS.anthropic!.login = original; } +}); + +test("own-grant refresh keeps the stored organization and never discards a rotated grant over it", () => { + const stored = orgCredential("synthetic-old", ORG_A); + const fresh = orgCredential("synthetic-new"); + expect(mergeAnthropicIdentity(fresh, stored, { sameGrant: true }).anthropicIdentity) + .toEqual({ ...proof("synthetic-new"), organizationUuid: ORG_A }); + expect(mergeAnthropicIdentity(fresh, stored).anthropicIdentity).toEqual(proof("synthetic-new")); + // The organization is a scope, not identity: fresh evidence wins instead of throwing after rotation. + expect(mergeAnthropicIdentity(orgCredential("synthetic-new", ORG_B), stored, { sameGrant: true }).anthropicIdentity) + .toEqual({ ...proof("synthetic-new"), organizationUuid: ORG_B }); +}); + +test("Claude Code signed in to another organization is a different account, not a rotation", async () => { + const stored: OAuthCredentials = { ...orgCredential("synthetic-old", ORG_A), source: "local-cli" }; + disk(); + const resolveTo = (organizationUuid: string) => async (access: string) => ({ ...proof(access), organizationUuid }); + expect((await newerClaudeCredential(stored, Date.now(), undefined, resolveTo(ORG_B))).kind).toBe("different"); + expect((await newerClaudeCredential(stored, Date.now(), undefined, resolveTo(ORG_A))).kind).toBe("adopt"); +}); + +test("the other pool may hold the same account only for a proven different organization", () => { + const store = (credential: OAuthCredentials): AuthStore => ({ anthropic: { activeAccountId: "a", accounts: [{ id: "a", credential }] } }); + const incoming = orgCredential("synthetic-pool-2", ORG_B); + expect(() => assertNoCrossAnthropicRegistration(store(orgCredential("synthetic-pool-1", ORG_A)), "anthropic2", incoming)).not.toThrow(); + expect(() => assertNoCrossAnthropicRegistration(store(orgCredential("synthetic-pool-1", ORG_B)), "anthropic2", incoming)) + .toThrow(AnthropicCrossInstanceDuplicateError); + expect(() => assertNoCrossAnthropicRegistration(store(orgCredential("synthetic-pool-1")), "anthropic2", incoming)) + .toThrow(AnthropicCrossInstanceDuplicateError); +}); + +test("a subscription label never crosses organizations of one account", () => { + const previous = { ...orgCredential("synthetic-old", ORG_A), plan: "max_5x" }; + expect(mergeOAuthPlan(orgCredential("synthetic-new", ORG_B), previous)).toBeUndefined(); + expect(mergeOAuthPlan(orgCredential("synthetic-new", ORG_A), previous)).toBe("max_5x"); +}); + +test("profile read fills the organization a token response left out", async () => { + globalThis.fetch = (async (_url, init) => init?.method === "POST" + ? Response.json({ access_token: "synthetic-fresh", refresh_token: "synthetic-fresh-refresh", expires_in: 3600, + account: { uuid: "synthetic-account-a" } }) + : Response.json({ account: { uuid: "synthetic-account-a" }, organization: { uuid: ORG_A } })) as typeof fetch; + expect((await refreshAnthropicToken("synthetic-old-refresh")).anthropicIdentity) + .toEqual({ ...proof("synthetic-fresh"), organizationUuid: ORG_A }); +}); + +test("an expired organization-less row is not asked and keeps the historical replacement", async () => { + await saveCredential("anthropic", { ...orgCredential("synthetic-legacy"), expires: Date.now() - 1 }); + const asked = orgProfiles({ "synthetic-legacy": ORG_A }); + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + expect(asked).toEqual([]); + expect(anthropicRows()).toHaveLength(1); + expect(anthropicRows()[0]!.credential.access).toBe("synthetic-org-b"); +}); + +test("an observation applies only while the row still holds the bearer that answered", () => { + const row = { id: "legacy", credential: orgCredential("synthetic-refreshed") }; + const sha = (access: string) => createHash("sha256").update(access).digest("hex"); + const incoming = orgCredential("synthetic-org-b", ORG_B); + expect(findAnthropicLoginSlot([row], incoming, new Map([[sha("synthetic-before-refresh"), ORG_A]]))).toBe(row); + expect(findAnthropicLoginSlot([row], incoming, new Map([[sha("synthetic-refreshed"), ORG_A]]))).toBeUndefined(); +}); + +test("a login with no proven organization never displaces a proven slot ahead of the active one", async () => { + orgProfiles({}); + await saveCredential("anthropic", orgCredential("synthetic-org-a", ORG_A)); + const a = anthropicRows()[0]!.id; + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + const b = getAccountSet("anthropic")!.activeAccountId; + await saveCredential("anthropic", orgCredential("synthetic-unproven")); + expect(anthropicRows()).toHaveLength(2); + expect(getAccountCredential("anthropic", b)!.access).toBe("synthetic-unproven"); + expect(getAccountCredential("anthropic", a)!.access).toBe("synthetic-org-a"); +}); + +/** A legacy row (really org A) plus a proven org B row, the shape a first second-org login leaves. */ +async function legacyAndOrgB() { + await saveCredential("anthropic", orgCredential("synthetic-legacy")); + const legacy = anthropicRows()[0]!; + orgProfiles({ "synthetic-legacy": ORG_A }); + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + const orgB = anthropicRows().find(row => row.id !== legacy.id)!.id; + return { legacy, orgB }; +} + +test("Claude Code import for an organization lands in that organization's row, not a legacy one", async () => { + const { legacy, orgB } = await legacyAndOrgB(); + await saveCredential("anthropic", { ...orgCredential("synthetic-cli-b", ORG_B), source: "local-cli" }); + expect(anthropicRows()).toHaveLength(2); + expect(getAccountCredential("anthropic", legacy.id)).toEqual(legacy.credential); + expect(getAccountCredential("anthropic", orgB)!.access).toBe("synthetic-cli-b"); +}); + +test("reauth cannot turn a legacy row into a second copy of an organization another row holds", async () => { + const { legacy } = await legacyAndOrgB(); + const original = OAUTH_PROVIDERS.anthropic!.login; + const config = (): OcxConfig => ({ port: 10100, defaultProvider: "openai", openaiProviderTierVersion: 2, providers: {} }); + try { + OAUTH_PROVIDERS.anthropic!.login = async () => orgCredential("synthetic-org-b-again", ORG_B); + await expect(runLogin("anthropic", {} as OAuthController, { reauthAccountId: legacy.id }, { loadConfig: config, saveConfig: () => {} })) + .rejects.toBeInstanceOf(OAuthReauthIdentityMismatchError); + expect(getAccountCredential("anthropic", legacy.id)).toEqual(legacy.credential); + } finally { OAUTH_PROVIDERS.anthropic!.login = original; } +}); + +test("a stored proof without an organization asks its own bearer before adopting another organization", async () => { + const stored: OAuthCredentials = { ...orgCredential("synthetic-old"), source: "local-cli" }; + disk(); + const asked: string[] = []; + const resolve = async (access: string) => { + asked.push(access); + return { ...proof(access), organizationUuid: access === "synthetic-other" ? ORG_B : ORG_A }; + }; + expect((await newerClaudeCredential(stored, Date.now(), undefined, resolve)).kind).toBe("different"); + expect(asked.sort()).toEqual(["synthetic-old", "synthetic-other"]); +}); From bf30126c844028103adf93ece38b2e0a7416838c Mon Sep 17 00:00:00 2001 From: seongwoong Date: Thu, 12 Nov 2026 16:26:09 +0900 Subject: [PATCH 2/5] docs(anthropic): state when another organization can still replace an account Co-Authored-By: Claude Opus 5.5 --- .../content/docs/reference/cli/providers-accounts.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index ed34904cd06..0f49ad54126 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -321,8 +321,14 @@ A Claude account that belongs to several organizations can be added once per org organization has its own subscription and limits, so it becomes its own pool account instead of replacing the first. Logging in to an organization that already has an account refreshes that account. Reauthenticating an account into a different organization is refused; add it as a new -login instead. Accounts saved before this release learn their organization on their next token -refresh. +login instead. + +A separate account needs proof of both organizations. Claude reports a grant's organization at +login and on token refresh, but only when that report succeeds is it recorded. An existing account +with no recorded organization (for example, one saved before this release) is checked through its +own token when you log in; if that token has expired or the check fails, the new login replaces +that account, as earlier releases did. Before adding another organization, make sure the existing +account is signed in and its token is current, for example by sending it one request. OAuth reauthentication preserves operator settings such as model selections, pricing overrides, and account failover preferences. Login-owned transport/authentication fields and registry-owned From 6022bd6997b798109084139ede208769d6b0e254 Mon Sep 17 00:00:00 2001 From: seongwoong Date: Thu, 12 Nov 2026 16:59:12 +0900 Subject: [PATCH 3/5] fix(oauth): keep plan.ts free of node:crypto for the GUI bundle The GUI build compiles src/oauth/plan.ts through anthropic-subscription, and its tsconfig has no Node types, so the bearer hash broke tsc -b. Callers already pass store-normalized, bearer-bound proofs. Co-Authored-By: Claude Opus 5.5 --- src/oauth/plan.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/oauth/plan.ts b/src/oauth/plan.ts index 83e9df1e8e6..03bee26a643 100644 --- a/src/oauth/plan.ts +++ b/src/oauth/plan.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import type { OAuthCredentials } from "./types"; /** Display-only upstream tier; never infer a plan from normalized quota percentages. */ @@ -26,12 +25,12 @@ function sameAccount(a: OAuthCredentials, b: OAuthCredentials): boolean | undefi return undefined; } -/** The proof's organization, only while it is bound to this exact bearer. */ +/** + * The proof's organization. This module is also bundled into the browser GUI, so it cannot hash + * the bearer; callers pass store-normalized credentials, whose proofs are already bearer-bound. + */ function boundOrganization(credential: OAuthCredentials): string | undefined { - const proof = credential.anthropicIdentity; - if (!proof?.organizationUuid || !credential.access - || proof.bearerSha256 !== createHash("sha256").update(credential.access).digest("hex")) return undefined; - return proof.organizationUuid.toLowerCase(); + return credential.anthropicIdentity?.organizationUuid?.toLowerCase(); } /** An observed unknown clears old metadata; an account replacement cannot inherit a plan. */ From a64943088ea0aaa5b979ec83a9a8693bcf0170b3 Mon Sep 17 00:00:00 2001 From: seongwoong Date: Thu, 12 Nov 2026 17:35:29 +0900 Subject: [PATCH 4/5] docs(anthropic): qualify when organization evidence separates accounts Co-Authored-By: Claude Opus 5.5 --- .../docs/reference/cli/providers-accounts.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 0f49ad54126..93e06319218 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -320,15 +320,20 @@ A Claude account that belongs to several organizations can be added once per org `ocx login anthropic` again and pick the other organization on Claude's consent screen. Each organization has its own subscription and limits, so it becomes its own pool account instead of replacing the first. Logging in to an organization that already has an account refreshes that -account. Reauthenticating an account into a different organization is refused; add it as a new -login instead. +account. -A separate account needs proof of both organizations. Claude reports a grant's organization at -login and on token refresh, but only when that report succeeds is it recorded. An existing account +All of this depends on organization evidence, which Claude reports at login and on token refresh; +it is recorded only when that report succeeds. A login gets its own account only when both +organizations are known and differ. When the login's organization is known, an existing account with no recorded organization (for example, one saved before this release) is checked through its -own token when you log in; if that token has expired or the check fails, the new login replaces -that account, as earlier releases did. Before adding another organization, make sure the existing -account is signed in and its token is current, for example by sending it one request. +own token, if that token is still current. An account of the login's organization is preferred. +Otherwise, if the existing account's organization stays unknown (expired token, failed check, or a +login whose own organization was not reported), the login may replace it, as earlier releases did. +Reauthenticating an account is refused when its known organization differs from the login's, or +when another account already holds the login's organization; add the other organization as a new +login instead. With an unknown organization on either side, reauthentication proceeds as before. Before adding another +organization, make sure the existing account is signed in and its token is current, for example +by sending it one request. OAuth reauthentication preserves operator settings such as model selections, pricing overrides, and account failover preferences. Login-owned transport/authentication fields and registry-owned From 7df99123ad28144b2d4003aa7e07fb594c6221b6 Mon Sep 17 00:00:00 2001 From: seongwoong Date: Thu, 12 Nov 2026 17:37:45 +0900 Subject: [PATCH 5/5] docs(anthropic): an unknown organization still meets the held-elsewhere refusal Co-Authored-By: Claude Opus 5.5 --- docs-site/src/content/docs/reference/cli/providers-accounts.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 93e06319218..c84c80280fa 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -331,7 +331,8 @@ Otherwise, if the existing account's organization stays unknown (expired token, login whose own organization was not reported), the login may replace it, as earlier releases did. Reauthenticating an account is refused when its known organization differs from the login's, or when another account already holds the login's organization; add the other organization as a new -login instead. With an unknown organization on either side, reauthentication proceeds as before. Before adding another +login instead. An unknown organization on either side never counts as a conflict, but the +second rule still applies: another account holding the login's known organization refuses it. Before adding another organization, make sure the existing account is signed in and its token is current, for example by sending it one request.