diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9f2e17c1c..9bd988b0d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed usage-path OAuth refreshes on broker-backed credentials persisting the rotated token into the wrong row: the shared `REMOTE_REFRESH_SENTINEL` refresh value is no longer treated as row identity when locating the row to update, so with two same-email organizations the refreshed token lands on the org that was actually refreshed. - Fixed the org qualifier only riding on email-based Anthropic identities: when the login email cannot be recovered (token response omits it and the bootstrap fallback fails), the account/project fallback keys are now org-qualified too — the account UUID is identical across the orgs of one login account, so a second subscription could otherwise still replace the first on the no-email path. The same one-way legacy upgrade applies to bare account/project keys, usage-report dedupe falls back to the org-qualified account for no-email reports, and broker report routing is org-decisive on either side (an org-less legacy credential no longer receives an org-attributed sibling's pool). - Fixed an org-only Anthropic credential row (stored when login recovered neither email nor account) never being claimed by a later login of the same organization that does recover the identity — the row is now upgraded and re-keyed in place instead of duplicating the subscription. +- Fixed broker usage report routing and header-overlay keying/merging to require the member's own identity (account/email/project) within a shared organization, so two Team members sharing one org id no longer receive each other's per-user pools — a member's missing report surfaces as "no usage data" instead of a sibling's numbers, while an org-only credential still matches its lone same-org report. ## [16.4.3] - 2026-07-11 diff --git a/packages/ai/src/auth-broker/remote-store.ts b/packages/ai/src/auth-broker/remote-store.ts index 54b99eff4..a1e67a084 100644 --- a/packages/ai/src/auth-broker/remote-store.ts +++ b/packages/ai/src/auth-broker/remote-store.ts @@ -148,14 +148,19 @@ function usageOverlayKey( // Org first: one account email can hold several organizations (Anthropic // Team seat + personal Max), each with its own limit pools. Keying the // overlay by account/email would merge the two pools' header ingests. - const orgId = ids.orgId?.trim().toLowerCase(); - if (orgId) return `${provider}\0org:${orgId}`; + // But the org alone is not enough either: two Team members share the org + // id while drawing on per-user pools, so the key stays qualified by the + // member's own base identity whenever one is known. + let base: string | undefined; const accountId = ids.accountId?.trim().toLowerCase(); - if (accountId) return `${provider}\0account:${accountId}`; const email = ids.email?.trim().toLowerCase(); - if (email) return `${provider}\0email:${email}`; const projectId = ids.projectId?.trim().toLowerCase(); - if (projectId) return `${provider}\0project:${projectId}`; + if (accountId) base = `account:${accountId}`; + else if (email) base = `email:${email}`; + else if (projectId) base = `project:${projectId}`; + const orgId = ids.orgId?.trim().toLowerCase(); + if (orgId) return base ? `${provider}\0org:${orgId}|${base}` : `${provider}\0org:${orgId}`; + if (base) return `${provider}\0${base}`; return undefined; } @@ -994,28 +999,43 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential // Max exhausted via Team's report, or rank a legacy row on a sibling's // numbers). const orgId = credential.orgId?.trim().toLowerCase(); + const accountId = credential.accountId?.trim().toLowerCase(); + const email = credential.email?.trim().toLowerCase(); + const projectId = credential.projectId?.trim().toLowerCase(); if (orgId) { + const sameOrg: UsageReport[] = []; let sawReportOrg = false; for (const report of all) { const metaOrg = readMetadataString((report.metadata ?? {}) as Record, "orgId"); if (metaOrg) { sawReportOrg = true; - if (metaOrg.toLowerCase() === orgId) return report; + if (metaOrg.toLowerCase() === orgId) sameOrg.push(report); } } - // Org-attributed reports exist but none is ours: report "no usage data" - // rather than mis-attributing another org's pool. When NO report carries - // an org (legacy broker aggregate), fall through with all candidates. - if (sawReportOrg) return null; + // Org-attributed reports exist: the shared org is a GATE, not a match. + // Two Team members share the org id while drawing on per-user pools, + // so the credential's own base identity must still line up inside the + // same-org subset — a lone sibling report is NOT ours. An org-only + // credential (no base identifiers) takes the lone same-org report and + // treats several as ambiguous. None in our org → "no usage data" + // rather than mis-attributing another org's pool. When NO report + // carries an org (legacy broker aggregate), fall through with all + // candidates. + if (sawReportOrg) { + if (accountId || email || projectId) { + for (const report of sameOrg) { + if (reportMatchesIdentity(report, accountId, email, projectId)) return report; + } + return null; + } + return sameOrg.length === 1 ? sameOrg[0]! : null; + } } const candidates = orgId ? all : all.filter(report => !readMetadataString((report.metadata ?? {}) as Record, "orgId")); if (candidates.length === 0) return null; if (candidates.length === 1) return candidates[0]; - const accountId = credential.accountId?.trim().toLowerCase(); - const email = credential.email?.trim().toLowerCase(); - const projectId = credential.projectId?.trim().toLowerCase(); for (const report of candidates) { if (reportMatchesIdentity(report, accountId, email, projectId)) return report; } @@ -1029,19 +1049,33 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): if (all.length === 0) return -1; const metadata = (overlay.metadata ?? {}) as Record; // Org precedence — mirror matchUsageReport: an org-attributed overlay may - // only merge into the report of the SAME org, and an org-less overlay may - // only merge into an org-less report. + // only merge into a report of the SAME org, and an org-less overlay may + // only merge into an org-less report. Within the same org the overlay's + // base identity must still match — two Team members' reports share the + // org id but must not swallow each other's header ingests. const overlayOrg = readMetadataString(metadata, "orgId")?.toLowerCase(); + const accountId = readMetadataString(metadata, "accountId")?.toLowerCase(); + const email = readMetadataString(metadata, "email")?.toLowerCase(); + const projectId = readMetadataString(metadata, "projectId")?.toLowerCase(); if (overlayOrg) { + const sameOrg: { report: UsageReport; index: number }[] = []; let sawReportOrg = false; for (const candidate of all) { const candidateOrg = readMetadataString((candidate.report.metadata ?? {}) as Record, "orgId"); if (candidateOrg) { sawReportOrg = true; - if (candidateOrg.toLowerCase() === overlayOrg) return candidate.index; + if (candidateOrg.toLowerCase() === overlayOrg) sameOrg.push(candidate); } } - if (sawReportOrg) return -1; + if (sawReportOrg) { + if (accountId || email || projectId) { + for (const candidate of sameOrg) { + if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index; + } + return -1; + } + return sameOrg.length === 1 ? sameOrg[0]!.index : -1; + } } const candidates = overlayOrg ? all @@ -1050,9 +1084,6 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): ); if (candidates.length === 0) return -1; if (candidates.length === 1) return candidates[0]!.index; - const accountId = readMetadataString(metadata, "accountId")?.toLowerCase(); - const email = readMetadataString(metadata, "email")?.toLowerCase(); - const projectId = readMetadataString(metadata, "projectId")?.toLowerCase(); for (const candidate of candidates) { if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index; } diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index 255ccd36c..c04f339b0 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -505,6 +505,112 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => { } }); + test("getUsageReport gates same-org siblings on the member's own identity", async () => { + // Two Team members share the org id but draw on per-user pools: the + // shared org is a gate, not a match, so Bob must never receive Alice's + // report just because it is the first (or only) same-org candidate. + const brokerClient = new AuthBrokerClient({ url: "http://127.0.0.1:9", token: "unused" }); + const now = Date.now(); + const makeMemberCredential = (name: string, orgId?: string) => ({ + type: "oauth" as const, + access: `remote-access-${name}`, + refresh: REMOTE_REFRESH_SENTINEL, + expires: now + 120_000, + ...(name === "org-only" ? {} : { accountId: `account-${name}`, email: `${name}@example.com` }), + orgId, + }); + const makeMemberReport = ( + name: string, + orgId: string, + usedFraction: number, + status: "ok" | "exhausted", + ): UsageReport => ({ + provider: "anthropic", + fetchedAt: now, + limits: [ + { + id: "anthropic:5h", + label: "Claude 5 Hour", + scope: { provider: "anthropic", windowId: "5h" }, + window: { id: "5h", label: "5 Hour" }, + amount: { used: usedFraction * 100, limit: 100, usedFraction, unit: "percent" }, + status, + }, + ], + metadata: { email: `${name}@example.com`, accountId: `account-${name}`, orgId }, + }); + // Bob's report deliberately precedes Alice's so a first-same-org match + // would hand his pool to Alice; org-duo holds only Dave's report. + vi.spyOn(brokerClient, "fetchUsage").mockResolvedValue({ + generatedAt: now, + reports: [ + makeMemberReport("bob", "org-team", 0.1, "ok"), + makeMemberReport("alice", "org-team", 1, "exhausted"), + makeMemberReport("dave", "org-duo", 0.5, "ok"), + ], + }); + const remoteStore = new RemoteAuthCredentialStore({ + client: brokerClient, + streamSnapshots: false, + initialSnapshot: { + generation: 1, + generatedAt: now, + serverNowMs: now, + refresher: { enabled: false, intervalMs: 0, skewMs: 0, nextSweepInMs: Number.MAX_SAFE_INTEGER }, + credentials: [], + }, + }); + try { + // Each member routes to their OWN pool inside the shared org. + const aliceReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("alice", "org-team")); + expect(aliceReport?.metadata?.accountId).toBe("account-alice"); + expect(requireLimit(aliceReport!, "anthropic:5h").status).toBe("exhausted"); + const bobReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("bob", "org-team")); + expect(bobReport?.metadata?.accountId).toBe("account-bob"); + expect(requireLimit(bobReport!, "anthropic:5h").status).toBe("ok"); + + // Erin's own report is missing: the lone same-org sibling report + // (Dave's) must not stand in for hers — "no usage data" is correct. + expect(await remoteStore.getUsageReport("anthropic", makeMemberCredential("erin", "org-duo"))).toBeNull(); + + // An org-only credential (no base identifiers) still matches on the + // org alone, but only when the same-org report is unambiguous. + const duoReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("org-only", "org-duo")); + expect(duoReport?.metadata?.accountId).toBe("account-dave"); + expect(await remoteStore.getUsageReport("anthropic", makeMemberCredential("org-only", "org-team"))).toBeNull(); + + // Header-ingest overlays partition per member too: Alice's ingest + // must merge into HER aggregate row, not Bob's earlier same-org row. + const overlay: UsageReport = { + provider: "anthropic", + fetchedAt: now, + limits: [ + { + id: "anthropic:5h", + label: "Claude 5 Hour", + scope: { provider: "anthropic", windowId: "5h" }, + window: { id: "5h", label: "5 Hour" }, + amount: { used: 90, limit: 100, usedFraction: 0.9, unit: "percent" }, + status: "ok", + }, + ], + metadata: { email: "alice@example.com", accountId: "account-alice", orgId: "org-team" }, + }; + expect(remoteStore.ingestUsageReport("anthropic", makeMemberCredential("alice", "org-team"), overlay)).toBe( + true, + ); + const merged = await remoteStore.fetchUsageReports(); + const mergedAlice = merged?.find(report => report.metadata?.accountId === "account-alice"); + const mergedBob = merged?.find(report => report.metadata?.accountId === "account-bob"); + expect(requireLimit(mergedAlice!, "anthropic:5h").amount.used).toBe(90); + expect(requireLimit(mergedBob!, "anthropic:5h").amount.used).toBe(10); + const bobAfterIngest = await remoteStore.getUsageReport("anthropic", makeMemberCredential("bob", "org-team")); + expect(requireLimit(bobAfterIngest!, "anthropic:5h").amount.used).toBe(10); + } finally { + remoteStore.close(); + } + }); + test("RemoteAuthCredentialStore reads snapshot blocks and applies upserts before broker acknowledgement", () => { const futureBlock = Date.now() + 60_000; const laterBlock = futureBlock + 60_000; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 28daaa166..9063003ac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -48,6 +48,7 @@ - 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. - `omp usage` "no usage data" attribution is org-decisive whenever either the stored account or a report carries an organization: an org-less legacy credential whose own fetch failed is no longer hidden by an org-attributed sibling report sharing the same email. - Active-account matching for `/usage`, `/logout`, and `omp token --list` now treats a shared organization as a qualifier rather than a match: two Anthropic Team seats in one org (same org id, per-user pools) no longer flag each other's rows or reports as "in use by this session" — the base identity (account/email/project) is still required, with org-only sessions matching on the org alone. +- `omp usage` "no usage data" coverage now requires the member's own identity within a shared organization: a sibling Team member's same-org report no longer counts as coverage for an account whose own report is missing, while an org-only account remains covered by any same-org report. ## [16.4.3] - 2026-07-11 diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index 318287a5a..724985d3f 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -303,20 +303,33 @@ export function collectUnreportedAccounts( // org-scoped account is covered only by its own org's report, and an // org-less legacy account is never covered by an org-attributed sibling // report — its own fetch failing must surface as "no usage data". The - // email/account fallback below applies only when both sides are - // org-less. + // shared org is a GATE, not a match: two Team members share the org id + // while drawing on per-user pools, so coverage also requires the + // account's own base identity inside the same-org subset (an org-only + // account, with no base identifiers, is covered by any same-org + // report). The email/account fallback below applies only when both + // sides are org-less. const accountOrg = account.orgId?.toLowerCase(); - const reportedOrgs = new Set(); - for (const report of providerReports) { - const metaOrg = report.metadata?.orgId; - if (typeof metaOrg === "string" && metaOrg) reportedOrgs.add(metaOrg.toLowerCase()); - } - if (accountOrg || reportedOrgs.size > 0) { - return !(accountOrg !== undefined && reportedOrgs.has(accountOrg)); - } const ids = [account.email, account.accountId, account.projectId] .filter((value): value is string => typeof value === "string" && value.length > 0) .map(value => value.toLowerCase()); + const sameOrgReports: UsageReport[] = []; + let sawReportOrg = false; + for (const report of providerReports) { + const metaOrg = report.metadata?.orgId; + if (typeof metaOrg === "string" && metaOrg) { + sawReportOrg = true; + if (accountOrg !== undefined && metaOrg.toLowerCase() === accountOrg) sameOrgReports.push(report); + } + } + if (accountOrg || sawReportOrg) { + if (!accountOrg || sameOrgReports.length === 0) return true; + if (ids.length === 0) return false; + return !sameOrgReports.some(report => { + const identifiers = reportIdentifiers(report); + return ids.some(id => identifiers.has(id)); + }); + } if (ids.length === 0) return false; const reported = new Set(); let anyIdentified = false; diff --git a/packages/coding-agent/test/usage-cli.test.ts b/packages/coding-agent/test/usage-cli.test.ts index 948173f3f..3d45710de 100644 --- a/packages/coding-agent/test/usage-cli.test.ts +++ b/packages/coding-agent/test/usage-cli.test.ts @@ -163,6 +163,34 @@ describe("collectUnreportedAccounts", () => { const orglessAccounts: UsageAccountIdentity[] = [{ provider: "anthropic", type: "oauth", email: shared }]; expect(collectUnreportedAccounts([orglessReport], orglessAccounts)).toEqual([]); }); + + it("gates same-org coverage on the member's own identity", () => { + const org = "org-team"; + const alice: UsageAccountIdentity = { + provider: "anthropic", + type: "oauth", + email: "alice@example.test", + accountId: "account-alice", + orgId: org, + }; + const bob: UsageAccountIdentity = { + provider: "anthropic", + type: "oauth", + email: "bob@example.test", + accountId: "account-bob", + orgId: org, + }; + const orgOnly: UsageAccountIdentity = { provider: "anthropic", type: "oauth", orgId: org }; + const aliceReport = { + ...makeReport("anthropic", alice.email!, []), + metadata: { email: alice.email, accountId: alice.accountId, orgId: org }, + }; + // Alice reported, Bob not: the sibling's same-org report must not count + // as Bob's coverage — two Team members share the org id but draw on + // per-user pools. An org-only account (no base identifiers to gate on) + // stays covered by any same-org report. + expect(collectUnreportedAccounts([aliceReport], [alice, bob, orgOnly])).toEqual([bob]); + }); }); describe("formatUsageBreakdown", () => {