From a47c3c90ec710e2433a64dc441f77937b0638fcf Mon Sep 17 00:00:00 2001 From: chan1103 Date: Sat, 11 Jul 2026 17:47:23 +0900 Subject: [PATCH] fix(ai): org-decisive active matching on either side; org in status-line cache key and health results Addresses the second review round (internal re-review + Codex on c38840482): - Active-account matching (logout preselection, /usage in-use marker) is org-decisive when EITHER side carries an org: a legacy bare-email active row no longer flags org-scoped siblings via the shared email (reverse of the previous fix). Both-org-less keeps the email/account fallback, so providers without orgs are unaffected. - Status-line usage context key includes orgId, so rotating between two same-email subscriptions invalidates the cached quota immediately instead of showing the previous org's numbers for the cache TTL. - CredentialHealthResult carries orgId/orgName and auth-gateway check labels rows with the org, so a failing row names the subscription. - getOAuthAccountIdentity preserves org-only identities; the login success message renders them. - ACP /usage account-id fallback labels get the org suffix too. - Regression tests for both matching directions (marker + logout). --- packages/ai/CHANGELOG.md | 2 +- packages/ai/src/auth-storage.ts | 9 ++++-- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/cli/auth-gateway-cli.ts | 6 +++- .../modes/components/status-line/component.ts | 11 +++++++- .../modes/controllers/selector-controller.ts | 9 ++---- .../helpers/active-oauth-account.ts | 15 +++++----- .../src/slash-commands/helpers/logout.ts | 13 +++++---- .../slash-commands/helpers/usage-report.ts | 4 ++- .../test/active-oauth-account.test.ts | 28 +++++++++++++++++++ 10 files changed, 73 insertions(+), 25 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 06fa69855..36a6fa5bf 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,7 +8,7 @@ - Healed the same `arg_key`/`arg_value` spill when it arrives through native tool calling (provider parses the in-band syntax server-side): as a last resort after validation and coercion fail, contaminated string arguments are split at the spill boundary and the swallowed pairs restored. - Fixed Anthropic logins silently replacing the stored credential when one account email holds multiple organizations (e.g. a Team seat plus a personal Max plan). Credentials are now identified by email + organization: the login flow captures the organization from the token exchange (with a `claude_cli/bootstrap` fallback), both subscriptions store side by side, and the existing multi-account rotation treats them as separate accounts. Legacy email-keyed rows are claimed in place by the first org-scoped login with the same email, and an org-less credential never clobbers org-scoped rows. Usage reports and the per-credential usage cache also partition by organization so the two subscriptions' limit pools no longer merge into one confused row. - Fixed broker-served usage routing for org-scoped credentials: `RemoteAuthCredentialStore` now matches aggregate reports and keys header-ingest overlays by organization first, so with a Team seat exhausted and a personal Max healthy under one email, each credential receives its own pool instead of whichever report appeared first. The Anthropic usage-cache key version was bumped so pre-org cache entries (including the 24h last-good fallback) cannot be replayed across organizations. -- Fixed OAuth access results (`getOAuthAccess`, `getOAuthAccesses`, `getOAuthAccessAt`) dropping the organization: they now carry `orgId`/`orgName` so consumers that key or label per-account results (e.g. `omp dry-balance --bench`) can tell two same-email subscriptions apart, and an org-scoped active session no longer marks org-less legacy rows/reports as active via the shared email. +- Fixed OAuth access results (`getOAuthAccess`, `getOAuthAccesses`, `getOAuthAccessAt`) and `checkCredentials` health results dropping the organization: they now carry `orgId`/`orgName` so consumers that key or label per-account results (e.g. `omp dry-balance --bench`, `omp auth-gateway check`) can tell two same-email subscriptions apart. Active-account matching is org-decisive whenever either side carries an organization — an org-scoped session no longer flags the legacy bare-email row, and a legacy-row session no longer flags org-scoped siblings, via the shared email. `getOAuthAccountIdentity` also preserves org-only identities instead of discarding them. ## [16.4.3] - 2026-07-11 diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index a75c8216f..a9b2f1139 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -160,8 +160,11 @@ export interface CredentialHealthResult { type: AuthCredential["type"]; /** OAuth email if known on the stored credential or surfaced by the probe. */ email?: string; - /** OAuth account id / org id if known. */ + /** OAuth account id if known. */ accountId?: string; + /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */ + orgId?: string; + orgName?: string; /** `true` when the refresh token lives on a remote broker (sentinel was present). */ remoteRefresh?: true; ok: boolean | null; @@ -2319,7 +2322,7 @@ export class AuthStorage { if (typeof preferred.orgName === "string" && preferred.orgName.length > 0) { identity.orgName = preferred.orgName; } - if (!identity.accountId && !identity.email && !identity.projectId) return undefined; + if (!identity.accountId && !identity.email && !identity.projectId && !identity.orgId) return undefined; return identity; } @@ -3366,6 +3369,8 @@ export class AuthStorage { if (row.credential.type === "oauth") { if (row.credential.email) base.email = row.credential.email; if (row.credential.accountId) base.accountId = row.credential.accountId; + if (row.credential.orgId) base.orgId = row.credential.orgId; + if (row.credential.orgName) base.orgName = row.credential.orgName; if (row.credential.refresh === REMOTE_REFRESH_SENTINEL) base.remoteRefresh = true; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bc521af8f..6d19e665d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -45,6 +45,7 @@ - `omp usage` and the in-session `/usage` view now show the Anthropic organization next to the account for org-scoped credentials (with `--redact` masking applied per part in the CLI, falling back to the org id when no display name is available), attribute "no usage data" rows per organization, and match the "in use by this session" marker by organization so only the active subscription is flagged. The OAuth login success message names the account and organization that was stored — a login landing on an unintended subscription is visible immediately. - `/logout` and `omp token --list` label Anthropic accounts with their organization and mark only the credential of the active organization as active, so two subscriptions sharing one email are distinguishable when selecting which to remove or mint a token for. - `omp auth-broker migrate --from-local` dedupes Anthropic OAuth identities per organization, so a Team seat already on the broker no longer blocks uploading the personal plan under the same email. +- The status line invalidates its cached usage when the session rotates to a different Anthropic organization (previously the old subscription's quota could linger for the cache TTL), and `omp auth-gateway check` labels each credential with its organization so a failing row says which subscription needs re-login. ## [16.4.3] - 2026-07-11 diff --git a/packages/coding-agent/src/cli/auth-gateway-cli.ts b/packages/coding-agent/src/cli/auth-gateway-cli.ts index d21a85409..73776916c 100644 --- a/packages/coding-agent/src/cli/auth-gateway-cli.ts +++ b/packages/coding-agent/src/cli/auth-gateway-cli.ts @@ -573,8 +573,12 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise { : row.ok === false ? chalk.red("FAIL ") : chalk.yellow("unknown "); - const identity = + const base = row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)"); + // Two subscriptions (orgs) can share one email — without the org a + // failed row can't say which subscription needs re-login. + const org = row.orgName ?? row.orgId; + const identity = org && org !== base ? `${base} (${org})` : base; const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : ""; const reasonParts: string[] = []; if (row.reason) reasonParts.push(row.reason); diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index e709f9d78..0ccf0a2b5 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -780,7 +780,16 @@ export class StatusLineComponent implements Component { const activeProvider = session.state.model?.provider ?? session.model?.provider ?? ""; if (!activeProvider) return ""; const identity = session.modelRegistry?.authStorage?.getOAuthAccountIdentity(activeProvider, session.sessionId); - return [activeProvider, identity?.accountId ?? "", identity?.email ?? "", identity?.projectId ?? ""].join("\0"); + // orgId is part of the key: rotating between two same-email Anthropic + // subscriptions must invalidate the cached usage immediately instead of + // showing the previous org's quota for the rest of the cache TTL. + return [ + activeProvider, + identity?.accountId ?? "", + identity?.email ?? "", + identity?.projectId ?? "", + identity?.orgId ?? "", + ].join("\0"); } /** diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index c833888f1..ef0aff45e 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1222,12 +1222,9 @@ export class SelectorController { // Name the account (and Anthropic organization) that was stored so a // login that lands on an unintended account/subscription is visible // immediately instead of silently replacing an existing registration. - const who = - identity?.type === "oauth" && (identity.email || identity.accountId) - ? ` as ${identity.email ?? identity.accountId}${ - identity.orgName || identity.orgId ? ` (${identity.orgName ?? identity.orgId})` : "" - }` - : ""; + const whoBase = identity?.type === "oauth" ? (identity.email ?? identity.accountId) : undefined; + const whoOrg = identity?.type === "oauth" ? (identity.orgName ?? identity.orgId) : undefined; + const who = whoBase ? ` as ${whoBase}${whoOrg ? ` (${whoOrg})` : ""}` : whoOrg ? ` as ${whoOrg}` : ""; block.addChild( new Text( theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}${who}`), diff --git a/packages/coding-agent/src/slash-commands/helpers/active-oauth-account.ts b/packages/coding-agent/src/slash-commands/helpers/active-oauth-account.ts index 9c4b71b23..950a53c64 100644 --- a/packages/coding-agent/src/slash-commands/helpers/active-oauth-account.ts +++ b/packages/coding-agent/src/slash-commands/helpers/active-oauth-account.ts @@ -10,9 +10,11 @@ function normalizeIdentityValue(value: unknown): string | undefined { * * Single definition of the matching rules for both `/usage` renderers: * - `orgId` ↔ report metadata `orgId` — checked first and DECISIVE when - * the active identity carries it: two subscriptions (orgs) can share one - * email, so an org-scoped active identity matches only its own org's - * report (org-less reports included — they are a different registration) + * EITHER side carries it: two subscriptions (orgs) can share one email, so + * an org-scoped identity matches only its own org's report and an org-less + * legacy identity never claims an org-attributed report via the shared + * email. The email/account fallback applies only when both sides are + * org-less (providers without orgs keep their former behavior). * - `accountId` ↔ report metadata `accountId`/`account_id` or `limit.scope.accountId` * - `email` ↔ report metadata `email` * - `projectId` ↔ report metadata `projectId` or `limit.scope.projectId` @@ -26,10 +28,9 @@ export function limitMatchesActiveAccount( if (!identity) return false; const metadata = report.metadata ?? {}; const activeOrgId = normalizeIdentityValue(identity.orgId); - // Org-scoped active identity: only the same org's report can be "in use". - // This also excludes org-less reports (pre-upgrade cache leftovers) — the - // shared email would otherwise attach the marker to another registration. - if (activeOrgId) return normalizeIdentityValue(metadata.orgId) === activeOrgId; + const reportOrgId = normalizeIdentityValue(metadata.orgId); + // Org-decisive when either side is org-scoped (see doc comment above). + if (activeOrgId || reportOrgId) return activeOrgId === reportOrgId; const activeAccountId = normalizeIdentityValue(identity.accountId); if (activeAccountId) { const reportAccountId = normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id); diff --git a/packages/coding-agent/src/slash-commands/helpers/logout.ts b/packages/coding-agent/src/slash-commands/helpers/logout.ts index 3cab55c5f..344fa65cb 100644 --- a/packages/coding-agent/src/slash-commands/helpers/logout.ts +++ b/packages/coding-agent/src/slash-commands/helpers/logout.ts @@ -56,12 +56,13 @@ function oauthMatchesActiveIdentity( ): boolean { if (!activeIdentity || row.credential.type !== "oauth") return false; const credential = row.credential; - // Org precedence: when the active credential is org-scoped, only the row of - // the SAME org can be active. This also excludes org-less legacy rows — an - // org-scoped active identity means the active credential carries an org, so - // a bare-email row is by definition a different registration; falling back - // to the shared email would preselect it in the logout list. - if (activeIdentity.orgId !== undefined) { + // Org-decisive when EITHER side is org-scoped: an org-scoped active session + // must not preselect the bare-email legacy row, and a bare-email active row + // must not mark org-scoped siblings active via the shared email. The + // email/account fallback applies only when both sides are org-less + // (providers without orgs keep their former behavior; the bare active row + // still matches itself through the fallback). + if (activeIdentity.orgId !== undefined || credential.orgId !== undefined) { return credential.orgId === activeIdentity.orgId; } return ( diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts index 435bad9e6..34794e259 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -42,7 +42,9 @@ function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: // a valid scoped fallback (e.g. metadata.accountId="" hides limit.scope.accountId). const metaAccountId = report.metadata?.accountId; const accountId = typeof metaAccountId === "string" && metaAccountId ? metaAccountId : limit.scope.accountId; - if (typeof accountId === "string" && accountId) return accountId; + if (typeof accountId === "string" && accountId) { + return org && org !== accountId ? `${accountId} (${org})` : accountId; + } const metaProjectId = report.metadata?.projectId; const projectId = typeof metaProjectId === "string" && metaProjectId ? metaProjectId : limit.scope.projectId; if (typeof projectId === "string" && projectId) return projectId; diff --git a/packages/coding-agent/test/active-oauth-account.test.ts b/packages/coding-agent/test/active-oauth-account.test.ts index 359535d22..4786bca8b 100644 --- a/packages/coding-agent/test/active-oauth-account.test.ts +++ b/packages/coding-agent/test/active-oauth-account.test.ts @@ -88,6 +88,24 @@ describe("limitMatchesActiveAccount", () => { limitMatchesActiveAccount(makeReport({ metadata: { email: "shared@example.com" } }), makeLimit(), identity), ).toBe(false); }); + + test("org-less identity never claims an org-attributed report via the shared email", () => { + // Reverse direction: the active session runs on a legacy bare-email row + // while reports are org-attributed — the marker must not appear on + // another registration's report. + const identity = { email: "shared@example.com", accountId: "account-shared" }; + expect( + limitMatchesActiveAccount( + makeReport({ metadata: { email: "shared@example.com", orgId: "org-team" } }), + makeLimit(), + identity, + ), + ).toBe(false); + // Both sides org-less: providers without orgs keep the email fallback. + expect( + limitMatchesActiveAccount(makeReport({ metadata: { email: "shared@example.com" } }), makeLimit(), identity), + ).toBe(true); + }); }); describe("reportMatchesActiveAccount", () => { @@ -134,6 +152,16 @@ describe("toLogoutAccounts org scoping", () => { expect(activeIds).toEqual([2]); }); + test("bare-email active row marks only itself active — never org-scoped siblings", () => { + const accounts = toLogoutAccounts( + "anthropic", + [oauthRow(1, "org-team", "Team Workspace"), oauthRow(2, "org-max", "Personal Max"), oauthRow(3)], + { activeIdentity: { email: "shared@example.com", accountId: "account-shared" } }, + ); + const activeIds = accounts.filter(account => account.active).map(account => account.credentialId); + expect(activeIds).toEqual([3]); + }); + test("labels distinguish the two orgs and the legacy row", () => { const accounts = toLogoutAccounts("anthropic", [ oauthRow(1, "org-team", "Team Workspace"),