feat: implemented credential lifecycle tracking and management APIs

- Added `listDisabledCredentials` and `refreshSnapshot` methods to credential stores along with API endpoints and wire schemas.
- Added `authorizedAt` timestamps and Anthropic OAuth grant TTL constants to track credential lifecycles.
- Updated the usage CLI to render auto-disabled credential tombstones and grant expiration warnings.
- Added comprehensive unit and broker integration tests covering the new credential management features.
This commit is contained in:
can1357
2026-07-25 18:02:43 +02:00
parent 59619623e1
commit 667111575e
15 changed files with 677 additions and 18 deletions
+3
View File
@@ -34,9 +34,12 @@ ENV BUN_INSTALL=/opt/bun \
PATH=/opt/bun/bin:/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin \
CARGO_TERM_COLOR=never
# clang/libclang-dev: bindgen for maudio-sys (miniaudio); cmake/make/ninja-build:
# audiopus_sys builds bundled libopus via CMake (native audio stack, 17.1.1+).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl ca-certificates pkg-config libssl-dev unzip git \
clang libclang-dev cmake make ninja-build \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
### Added
- OAuth logins now stamp `authorizedAt` (epoch ms of the interactive login) on the stored credential, and every refresh-persist path preserves it. Anthropic expires the whole OAuth grant family ~30 days after authorization regardless of refresh-token rotation (observed as `invalid_grant: "Refresh token expired"` on the latest rotated token, exactly 30 days after login, across four production accounts), so the login anchor is what makes re-login deadlines computable. Exported `ANTHROPIC_OAUTH_GRANT_TTL_MS` alongside the anthropic OAuth flow.
- Added `GET /v1/credentials/disabled` to the auth broker and `AuthBrokerClient.listDisabledCredentials`: disabled-credential tombstones (`DisabledCredentialSummary` — identity, verbatim disable cause, disable timestamp; never token material) so auto-disabled accounts stay visible to clients instead of silently vanishing from the snapshot. `AuthStorage.listDisabledCredentials` serves the same data locally from SQLite; clients of brokers predating the endpoint get an empty list (404 mapped, no error).
- Added `AuthStorage.revalidateCredentials()` and the optional `AuthCredentialStore.refreshSnapshot` hook: remote broker stores re-fetch `GET /v1/snapshot` on demand so callers pairing live per-credential data with stored identities (`omp usage`) never render against the up-to-an-hour-stale disk-cached snapshot; local SQLite stores are always current and only reload.
## [17.1.3] - 2026-07-24
### Fixed
+24 -1
View File
@@ -7,7 +7,7 @@
*/
import { readSseEvents } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import type { AuthCredential } from "../auth-storage";
import type { AuthCredential, DisabledCredentialSummary } from "../auth-storage";
import type {
ClientUsageReportRequest,
ClientUsageReportResponse,
@@ -20,6 +20,7 @@ import type {
CredentialRefreshResponse,
CredentialUploadRequest,
CredentialUploadResponse,
DisabledCredentialsResponse,
HealthzResponse,
SnapshotResponse,
SnapshotStreamEvent,
@@ -35,6 +36,7 @@ import {
credentialDisableResponseSchema,
credentialRefreshResponseSchema,
credentialUploadResponseSchema,
disabledCredentialsResponseSchema,
healthzResponseSchema,
snapshotResponseSchema,
snapshotStreamEventSchema,
@@ -306,6 +308,27 @@ export class AuthBrokerClient {
});
}
/**
* Disabled-credential tombstones (identity + cause, no token material).
* Returns an empty list against brokers predating `GET
* /v1/credentials/disabled` (404).
*/
async listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
const params = new URLSearchParams();
if (provider) params.set("provider", provider);
const path = `/v1/credentials/disabled${params.size > 0 ? `?${params.toString()}` : ""}`;
try {
const response = await this.#request<DisabledCredentialsResponse>("GET", path, {
schema: disabledCredentialsResponseSchema,
signal,
});
return response.disabled;
} catch (error) {
if (error instanceof AuthBrokerError && error.status === 404) return [];
throw error;
}
}
async uploadCredential(
provider: string,
credential: AuthCredential,
@@ -14,6 +14,7 @@ import {
type AuthCredential,
type AuthCredentialSnapshotEntry,
type AuthCredentialStore,
type DisabledCredentialSummary,
type OAuthCredential,
REMOTE_REFRESH_SENTINEL,
type StoredAuthCredential,
@@ -471,6 +472,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
return out;
}
/** Broker-backed disabled tombstones; empty against brokers predating the endpoint. */
listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
return this.#client.listDisabledCredentials(provider, signal);
}
getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
const nowMs = Date.now();
this.cleanExpiredCredentialBlocks(nowMs);
+7
View File
@@ -22,6 +22,7 @@ import type {
CredentialDisableResponse,
CredentialRefreshResponse,
CredentialUploadResponse,
DisabledCredentialsResponse,
HealthzResponse,
RefresherSchedule,
SnapshotEntry,
@@ -659,6 +660,12 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
return json(500, { error: message });
}
}
if (req.method === "GET" && pathname === "/v1/credentials/disabled") {
const provider = url.searchParams.get("provider") ?? undefined;
const disabled = await opts.storage.listDisabledCredentials(provider, req.signal);
const body: DisabledCredentialsResponse = { generatedAt: Date.now(), disabled };
return json(200, body);
}
const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
if (refreshMatch) {
const id = Number.parseInt(refreshMatch[1], 10);
+7
View File
@@ -10,6 +10,7 @@ import type {
AuthCredential,
AuthCredentialSnapshot,
AuthCredentialSnapshotEntry,
DisabledCredentialSummary,
StoredCredentialBlock,
} from "../auth-storage";
import type { ClientUsageClientSummary, ClientUsageReport, UsageHistoryEntry, UsageReport } from "../usage";
@@ -86,6 +87,12 @@ export interface CredentialDisableResponse {
ok: boolean;
}
/** GET /v1/credentials/disabled response body — tombstones of auto-disabled rows. */
export interface DisabledCredentialsResponse {
generatedAt: number;
disabled: DisabledCredentialSummary[];
}
/** POST /v1/credential/:id/block request body. */
export type CredentialBlockRequest = CredentialBlockSnapshot;
@@ -34,6 +34,7 @@ export const oauthCredentialSchema = type({
"accountId?": "string",
"orgId?": "string",
"orgName?": "string",
"authorizedAt?": "number",
});
/** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */
@@ -49,6 +50,7 @@ export const remoteOauthCredentialSchema = type({
"accountId?": "string",
"orgId?": "string",
"orgName?": "string",
"authorizedAt?": "number",
});
export const apiKeyCredentialSchema = type({
@@ -320,6 +322,27 @@ export const credentialDisableResponseSchema = type({
ok: "boolean",
});
/** One disabled-credential tombstone — identity + cause, never token material. */
export const disabledCredentialSummarySchema = type({
"+": "reject",
id: "number.integer",
provider: type("string").atLeastLength(1),
type: "'oauth' | 'api_key'",
"email?": "string",
"accountId?": "string",
"orgId?": "string",
"orgName?": "string",
cause: "string",
"disabledAtMs?": "number",
});
/** Broker `GET /v1/credentials/disabled` response. */
export const disabledCredentialsResponseSchema = type({
"+": "reject",
generatedAt: "number",
disabled: disabledCredentialSummarySchema.array(),
});
// ─── Credential blocks ──────────────────────────────────────────────────────
export const credentialBlockRequestSchema = credentialBlockSnapshotSchema;
+105 -2
View File
@@ -170,6 +170,28 @@ export interface StoredCredentialBlock {
updatedAtMs?: number;
}
/**
* Identity slice of a disabled (soft-deleted) credential tombstone — cause and
* account identity only, never token material. Surfaced so auto-disabled
* accounts (e.g. an expired Anthropic OAuth grant) stay visible in `omp usage`
* instead of silently vanishing until the user notices missing quota.
*/
export interface DisabledCredentialSummary {
/** Database row id (matches {@link StoredAuthCredential.id}). */
id: number;
provider: string;
type: AuthCredential["type"];
email?: string;
accountId?: string;
/** Organization/workspace the credential was scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
/** Verbatim disable cause captured when the row was torn down. */
cause: string;
/** Epoch ms the row was disabled (SQLite `updated_at`), when known. */
disabledAtMs?: number;
}
/**
* Per-credential health record returned by {@link AuthStorage.checkCredentials}.
*
@@ -353,6 +375,21 @@ export interface AuthCredentialStore {
/** Optional hook to notify the underlying store that usage report cache is stale. */
invalidateUsageCache?(signal?: AbortSignal): Promise<void>;
listAuthCredentials(provider?: string): StoredAuthCredential[];
/**
* Optional store hook to re-hydrate the credential snapshot from its
* backing source. Remote broker stores re-fetch `GET /v1/snapshot` so a
* disk-cached snapshot (up to an hour stale) cannot be paired with live
* per-credential data; local SQLite stores omit it — their reads are
* always current.
*/
refreshSnapshot?(): Promise<unknown>;
/**
* Disabled credential tombstones (see {@link DisabledCredentialSummary}).
* Optional: remote stores forward to the broker's
* `GET /v1/credentials/disabled` (empty list when the broker predates the
* endpoint); stores without tombstones omit it.
*/
listDisabledCredentials?(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]>;
updateAuthCredential(id: number, credential: AuthCredential): void;
deleteAuthCredential(id: number, disabledCause: string): void;
tryDisableAuthCredentialIfMatches(
@@ -2708,7 +2745,10 @@ export class AuthStorage {
this.#resetProviderAssignments(provider);
return { type: "api_key" };
}
const newCredential: OAuthCredential = { type: "oauth", ...result };
// Stamp the interactive-login instant: providers with an absolute grant
// lifetime (Anthropic) need it to surface re-login deadlines, and token
// refreshes only ever merge over this credential without clearing it.
const newCredential: OAuthCredential = { type: "oauth", ...result, authorizedAt: Date.now() };
// Use #upsertOAuthCredential to upsert the new credential.
// Any legacy api_key rows from older versions will be cleaned up so they do not
// shadow the new OAuth row, while preserving other active OAuth credentials.
@@ -2927,6 +2967,9 @@ export class AuthStorage {
apiEndpoint: next.apiEndpoint,
orgId: next.orgId ?? entry.credential.orgId,
orgName: next.orgName ?? entry.credential.orgName,
// Not part of UsageCredential — carried from the stored row so the
// interactive-login anchor survives usage-path refresh persists.
authorizedAt: entry.credential.authorizedAt,
});
}
@@ -4933,6 +4976,7 @@ export class AuthStorage {
apiEndpoint: result.newCredentials.apiEndpoint ?? selection.credential.apiEndpoint,
orgId: result.newCredentials.orgId ?? selection.credential.orgId,
orgName: result.newCredentials.orgName ?? selection.credential.orgName,
authorizedAt: result.newCredentials.authorizedAt ?? selection.credential.authorizedAt,
};
if (credentialId !== undefined) {
const idx = this.#replaceCredentialById(provider, credentialId, updated);
@@ -5892,6 +5936,28 @@ export class AuthStorage {
return { generation: this.#generation, generatedAt: Date.now(), credentials: entries };
}
/**
* Disabled credential tombstones for display surfaces (`omp usage`,
* broker `GET /v1/credentials/disabled`). Empty when the backing store
* keeps no tombstones or the remote broker predates the endpoint.
*/
async listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
if (!this.#store.listDisabledCredentials) return [];
return this.#store.listDisabledCredentials(provider, signal);
}
/**
* Force the backing store to revalidate its credential snapshot, then
* reload. Remote broker stores re-fetch the snapshot; local stores are
* always current, so only the reload runs. Callers that pair live
* per-credential data with stored identities (`omp usage`) use this so a
* disk-cached snapshot cannot misattribute fresh reports.
*/
async revalidateCredentials(): Promise<void> {
if (this.#store.refreshSnapshot) await this.#store.refreshSnapshot();
await this.reload();
}
/**
* Refresh the OAuth credential with the given id through a per-credential
* single-flight. Concurrent callers for the same row await the same upstream
@@ -5982,6 +6048,7 @@ export class AuthStorage {
apiEndpoint: refreshed.apiEndpoint ?? attempted.apiEndpoint,
orgId: refreshed.orgId ?? attempted.orgId,
orgName: refreshed.orgName ?? attempted.orgName,
authorizedAt: refreshed.authorizedAt ?? attempted.authorizedAt,
};
// Persist by id: the array may have been reordered/shrunk while the
// refresh was in flight, so the pre-await positional index is unsafe. A
@@ -6152,6 +6219,9 @@ type AuthRow = {
identity_key: string | null;
};
/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */
type DisabledAuthRow = AuthRow & { updated_at: number | null };
type CredentialBlockRow = {
credential_id: number;
provider_key: string;
@@ -6426,6 +6496,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
#db: Database;
#listActiveStmt: Statement;
#listActiveByProviderStmt: Statement;
#listDisabledStmt: Statement;
#listDisabledByProviderStmt: Statement;
#insertStmt: Statement;
#updateStmt: Statement;
@@ -6469,8 +6540,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
this.#listActiveByProviderStmt = this.#db.prepare(
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
);
this.#listDisabledStmt = this.#db.prepare(
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC",
);
this.#listDisabledByProviderStmt = this.#db.prepare(
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
);
this.#insertStmt = this.#db.prepare(
`INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
@@ -6979,6 +7053,34 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
return results;
}
async listDisabledCredentials(provider?: string): Promise<DisabledCredentialSummary[]> {
const rows =
(provider
? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[])
: (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? [];
const results: DisabledCredentialSummary[] = [];
for (const row of rows) {
const credential = deserializeCredential(row);
const summary: DisabledCredentialSummary = {
id: row.id,
provider: row.provider,
type: row.credential_type === "api_key" ? "api_key" : "oauth",
cause: row.disabled_cause ?? "disabled",
};
if (credential?.type === "oauth") {
if (credential.email) summary.email = credential.email;
if (credential.accountId) summary.accountId = credential.accountId;
if (credential.orgId) summary.orgId = credential.orgId;
if (credential.orgName) summary.orgName = credential.orgName;
}
if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) {
summary.disabledAtMs = row.updated_at * 1000;
}
results.push(summary);
}
return results;
}
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
@@ -7651,6 +7753,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
this.#closed = true;
this.#listActiveStmt.finalize();
this.#listActiveByProviderStmt.finalize();
this.#listDisabledStmt.finalize();
this.#listDisabledByProviderStmt.finalize();
this.#insertStmt.finalize();
this.#updateStmt.finalize();
@@ -24,6 +24,19 @@ const CALLBACK_PATH = "/callback";
const SCOPES =
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
/**
* Absolute lifetime of an Anthropic OAuth grant family, anchored at the
* interactive login. Refresh-token rotation does NOT extend it: ~30 days
* after authorization the token endpoint returns
* `invalid_grant: "Refresh token expired"` for the latest rotated token and
* only a fresh interactive login recovers the account. Observed against
* production (grants authorized 2026-06-20/06-25 died 30d later to the hour
* despite healthy 8h rotations); matches Claude Code's documented monthly
* re-login. Consumers use this to warn before the deadline — it is a display
* heuristic, not a wire contract.
*/
export const ANTHROPIC_OAUTH_GRANT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
function formatErrorDetails(error: unknown): string {
if (error instanceof Error) {
const details: string[] = [`${error.name}: ${error.message}`];
+1
View File
@@ -12,6 +12,7 @@ import type {
OAuthProviderInterface,
} from "./types";
export * from "./anthropic";
export * from "./device-code";
export type * from "./types";
+8
View File
@@ -19,6 +19,14 @@ export type OAuthCredentials = {
orgId?: string;
/** Human-readable organization name for display (may embed the email). */
orgName?: string;
/**
* Epoch ms of the interactive login that minted this grant. Set by
* `AuthStorage.login`; token refreshes preserve it. Providers with an
* absolute grant lifetime (Anthropic expires the whole refresh-token
* family ~30 days after authorization regardless of rotation) use it to
* surface re-login deadlines before the grant dies.
*/
authorizedAt?: number;
};
export type OAuthProvider = OAuthProviderUnion;
@@ -0,0 +1,217 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
AuthStorage,
type OAuthCredential,
registerOAuthProvider,
SqliteAuthCredentialStore,
unregisterOAuthProviders,
} from "@oh-my-pi/pi-ai";
import {
AuthBrokerClient,
type AuthBrokerServerHandle,
RemoteAuthCredentialStore,
startAuthBroker,
} from "@oh-my-pi/pi-ai/auth-broker";
import { removeWithRetries } from "../../utils/src/temp";
const DISABLE_CAUSE =
'oauth refresh failed: OAuthError: Anthropic token refresh request failed. url=https://api.anthropic.com/v1/oauth/token; body={"error": "invalid_grant", "error_description": "Refresh token expired"}';
function mintOAuth(email: string): OAuthCredential {
return {
type: "oauth",
access: `access-${email}`,
refresh: `refresh-${email}`,
expires: Date.now() + 60_000,
email,
accountId: `account-${email}`,
};
}
describe("disabled credential tombstones", () => {
let tempDir = "";
let store: SqliteAuthCredentialStore | undefined;
let storage: AuthStorage | undefined;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-disabled-creds-"));
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
storage = new AuthStorage(store);
await storage.reload();
});
afterEach(async () => {
storage?.close();
await removeWithRetries(tempDir);
});
test("sqlite store lists identity + cause + disabledAtMs and never token material", async () => {
store!.saveOAuth("anthropic", mintOAuth("dead@example.test"));
store!.saveOAuth("openai-codex", mintOAuth("alive@example.test"));
const row = store!.listAuthCredentials("anthropic")[0];
store!.deleteAuthCredential(row.id, DISABLE_CAUSE);
const all = await storage!.listDisabledCredentials();
expect(all).toHaveLength(1);
const summary = all[0];
expect(summary).toMatchObject({
id: row.id,
provider: "anthropic",
type: "oauth",
email: "dead@example.test",
accountId: "account-dead@example.test",
cause: DISABLE_CAUSE,
});
expect(typeof summary.disabledAtMs).toBe("number");
// Tombstones are display-only: no token bytes may leak through them.
const serialized = JSON.stringify(summary);
expect(serialized).not.toContain("access-dead");
expect(serialized).not.toContain("refresh-dead");
// Provider filter is exact; a provider with only active rows yields [].
expect(await storage!.listDisabledCredentials("anthropic")).toHaveLength(1);
expect(await storage!.listDisabledCredentials("openai-codex")).toHaveLength(0);
});
test("client maps a broker without the endpoint (404) to an empty list", async () => {
const fetchImpl: typeof fetch = Object.assign(async () => new Response("not found", { status: 404 }), {
preconnect: fetch.preconnect,
});
const client = new AuthBrokerClient({ url: "http://127.0.0.1:9", token: "unused", fetchImpl });
expect(await client.listDisabledCredentials()).toEqual([]);
});
});
describe("broker /v1/credentials/disabled round-trip", () => {
let tempDir = "";
let serverStore: SqliteAuthCredentialStore | undefined;
let serverStorage: AuthStorage | undefined;
let handle: AuthBrokerServerHandle | undefined;
let clientStorage: AuthStorage | undefined;
const token = "disabled-creds-bearer";
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-broker-disabled-"));
serverStore = await SqliteAuthCredentialStore.open(path.join(tempDir, "broker.db"));
serverStorage = new AuthStorage(serverStore);
await serverStorage.reload();
handle = startAuthBroker({
storage: serverStorage,
bind: "127.0.0.1:0",
bearerTokens: [token],
disableRefresher: true,
});
clientStorage = new AuthStorage(
new RemoteAuthCredentialStore({
client: new AuthBrokerClient({ url: handle.url, token }),
streamSnapshots: false,
}),
);
await clientStorage.reload();
});
afterEach(async () => {
clientStorage?.close();
await handle?.close();
serverStorage?.close();
await removeWithRetries(tempDir);
});
test("a row disabled on the broker surfaces to remote clients as a tombstone", async () => {
serverStore!.saveOAuth("anthropic", mintOAuth("gone@example.test"));
const row = serverStore!.listAuthCredentials("anthropic")[0];
serverStore!.deleteAuthCredential(row.id, DISABLE_CAUSE);
const disabled = await clientStorage!.listDisabledCredentials("anthropic");
expect(disabled).toHaveLength(1);
expect(disabled[0]).toMatchObject({
id: row.id,
provider: "anthropic",
type: "oauth",
email: "gone@example.test",
cause: DISABLE_CAUSE,
});
expect(JSON.stringify(disabled[0])).not.toContain("refresh-gone");
});
test("revalidateCredentials re-hydrates broker-side identity changes past a stale snapshot", async () => {
// Client connected before this credential existed (e.g. a re-login that
// swapped an org-less row for an org-scoped one while a disk-cached
// snapshot was still fresh).
serverStore!.saveOAuth("anthropic", { ...mintOAuth("late@example.test"), orgId: "org-late" });
await clientStorage!.revalidateCredentials();
const rows = clientStorage!.getAll().anthropic;
const list = Array.isArray(rows) ? rows : [rows];
const late = list.find(entry => entry?.type === "oauth" && entry.email === "late@example.test");
if (late?.type !== "oauth") throw new Error("expected refreshed oauth credential");
expect(late.orgId).toBe("org-late");
});
});
describe("OAuth login stamps authorizedAt", () => {
const PROVIDER_ID = "test-authorized-at-oauth";
let tempDir = "";
let store: SqliteAuthCredentialStore | undefined;
let storage: AuthStorage | undefined;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-authorized-at-"));
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
storage = new AuthStorage(store);
await storage.reload();
registerOAuthProvider({
id: PROVIDER_ID,
name: "AuthorizedAt Test",
sourceId: "authorized-at-test",
login: async () => ({
refresh: "refresh-initial",
access: "access-initial",
expires: Date.now() + 60_000,
email: "stamped@example.test",
}),
});
});
afterEach(async () => {
unregisterOAuthProviders("authorized-at-test");
storage?.close();
await removeWithRetries(tempDir);
});
test("login records the interactive-login instant; refresh persists keep it while rotating tokens", async () => {
const before = Date.now();
await storage!.login(PROVIDER_ID, {
onAuth: () => {},
onPrompt: async () => "",
});
const stored = store!.listAuthCredentials(PROVIDER_ID)[0];
if (stored.credential.type !== "oauth") throw new Error("expected oauth credential");
const authorizedAt = stored.credential.authorizedAt;
expect(typeof authorizedAt).toBe("number");
expect(authorizedAt!).toBeGreaterThanOrEqual(before);
expect(authorizedAt!).toBeLessThanOrEqual(Date.now());
// Refresh rotates tokens but must not touch the login anchor — the
// rebuild in refreshCredentialById previously dropped unknown fields.
const refreshingStorage = new AuthStorage(store!, {
refreshOAuthCredential: async () => ({
access: "access-rotated",
refresh: "refresh-rotated",
expires: Date.now() + 120_000,
}),
});
try {
await refreshingStorage.reload();
await refreshingStorage.forceRefreshCredentialById(stored.id);
const after = store!.listAuthCredentials(PROVIDER_ID)[0];
if (after.credential.type !== "oauth") throw new Error("expected oauth credential");
expect(after.credential.refresh).toBe("refresh-rotated");
expect(after.credential.authorizedAt).toBe(authorizedAt);
} finally {
refreshingStorage.close();
}
});
});
+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
### Added
- `omp usage` now surfaces auto-disabled credentials as red `✗` tombstone rows (identity, how long ago, the shortened upstream cause — e.g. `Refresh token expired` — and a re-login hint), including a provider section when no active credential remains. User-driven tombstones (`replaced by newer credential`, `deleted by user`) and API-key rows stay hidden. Requires a broker with `GET /v1/credentials/disabled`; older brokers degrade to no tombstone rows.
- `omp usage` warns about Anthropic's ~30-day OAuth grant lifetime: accounts whose interactive login (`authorizedAt`) is within a week of the deadline get a yellow `⚠ re-login within <time>` line, and past-deadline accounts a red one. Grants die server-side exactly ~30 days after login regardless of refresh rotation, so this is the only warning before the broker auto-disables the row.
### Fixed
- Fixed the Docker `natives-builder` stage failing to build releases ≥ 17.1.1: the native audio stack added bindgen (miniaudio needs libclang) and a bundled-opus CMake build (needs cmake + make), none of which were installed in the slim builder image.
- Fixed `omp usage` duplicating org-less legacy accounts as "no usage data" rows whenever any sibling report carried an organization (mixed pools of pre-org-capture rows and fresh org-scoped logins): an org-less account is now covered by its own org-less report, while org-attributed sibling reports still never count as its coverage.
- `omp usage` revalidates the broker credential snapshot before rendering: live usage reports were previously paired with a disk-cached account list up to an hour old, so a just-completed re-login (org-less row upserted to org-scoped) rendered as a phantom duplicate until the cache expired.
## [17.1.3] - 2026-07-24
### Fixed
+144 -15
View File
@@ -8,7 +8,9 @@
* always covers the full credential pool.
*/
import {
ANTHROPIC_OAUTH_GRANT_TTL_MS,
type AuthStorage,
type DisabledCredentialSummary,
resolveUsedFraction,
type UsageHistoryEntry,
type UsageLimit,
@@ -44,6 +46,8 @@ export interface UsageAccountIdentity {
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
orgId?: string;
orgName?: string;
/** Epoch ms of the interactive login that minted the OAuth grant (see `OAuthCredentials.authorizedAt`). */
authorizedAt?: number;
}
/**
@@ -126,7 +130,11 @@ function findDistinguishingInfix(value: string, peers: string[]): string | undef
}
/** Every identity string the output could surface — input for {@link buildRedactionMap}. */
function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountIdentity[]): string[] {
function collectIdentityStrings(
reports: UsageReport[],
accounts: UsageAccountIdentity[],
disabled: DisabledCredentialSummary[] = [],
): string[] {
const values: string[] = [];
const add = (value: unknown): void => {
if (typeof value === "string" && value) values.push(value);
@@ -152,6 +160,12 @@ function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountId
add(account.orgName);
add(account.enterpriseUrl);
}
for (const summary of disabled) {
add(summary.email);
add(summary.accountId);
add(summary.orgId);
add(summary.orgName);
}
return values;
}
@@ -312,13 +326,15 @@ export function collectUnreportedAccounts(
// multi-subscription): two orgs share every other identifier, so an
// 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
// 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.
// report — its own fetch failing must surface as "no usage data". Its
// own ORG-LESS report still covers it, though: a mixed pool (fresh
// org-scoped logins beside pre-org-capture rows) must not duplicate
// every legacy account. The 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 ids = [account.email, account.accountId, account.projectId]
.filter((value): value is string => typeof value === "string" && value.length > 0)
@@ -333,9 +349,15 @@ export function collectUnreportedAccounts(
}
}
if (accountOrg || sawReportOrg) {
if (!accountOrg || sameOrgReports.length === 0) return true;
const candidates = accountOrg
? sameOrgReports
: providerReports.filter(report => {
const metaOrg = report.metadata?.orgId;
return !(typeof metaOrg === "string" && metaOrg);
});
if (candidates.length === 0) return true;
if (ids.length === 0) return false;
return !sameOrgReports.some(report => {
return !candidates.some(report => {
const identifiers = reportIdentifiers(report);
return ids.some(id => identifiers.has(id));
});
@@ -509,6 +531,58 @@ export function computeProviderWindowStats(reports: UsageReport[]): ProviderWind
});
}
/** Re-login warnings render once remaining grant life drops below this. */
const RELOGIN_WARN_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Re-login deadline line for providers whose OAuth grants expire a fixed
* period after the interactive login (today: Anthropic, ~30 days regardless
* of refresh rotation). Silent until the deadline is under a week out — a
* nudge before the broker auto-disables the row, not a permanent countdown.
*/
function formatReloginDeadline(
account: UsageAccountIdentity,
nowMs: number,
redaction?: Map<string, string>,
): string | undefined {
if (account.provider !== "anthropic" || account.type !== "oauth" || !account.authorizedAt) return undefined;
const remaining = account.authorizedAt + ANTHROPIC_OAUTH_GRANT_TTL_MS - nowMs;
if (remaining > RELOGIN_WARN_WINDOW_MS) return undefined;
const label = accountIdentityLabel(account, redaction);
if (remaining <= 0) {
return ` ${chalk.red(`⚠ ${label} — grant is past Anthropic's ~30d lifetime; re-login now`)}`;
}
return ` ${chalk.yellow(`⚠ ${label} — re-login within ${formatDuration(remaining)} (Anthropic expires OAuth grants ~30d after login)`)}`;
}
/**
* Tombstones worth a row in `omp usage`: OAuth credentials torn down
* automatically (refresh failure, upstream invalidation). Rows the user
* replaced or deleted deliberately are lifecycle noise, not lost capacity.
*/
function isActionableDisable(summary: DisabledCredentialSummary): boolean {
if (summary.type !== "oauth") return false;
return !/^(replaced by|deleted by user)/i.test(summary.cause);
}
/** Human-sized disable cause: the upstream `error_description` when embedded, else the first clause. */
function shortDisableCause(cause: string): string {
const description = cause.match(/\\?"error_description\\?"\s*:\s*\\?"([^"\\]+)/)?.[1];
if (description) return description;
const stripped = cause.replace(/^oauth refresh failed:\s*/i, "");
const clause = stripped.split(/[;\n]/, 1)[0] ?? stripped;
return clause.length > 80 ? `${clause.slice(0, 77)}…` : clause;
}
/** Label for a disabled tombstone, masking each identity part under `--redact`. */
function disabledIdentityLabel(summary: DisabledCredentialSummary, redaction?: Map<string, string>): string {
const base = summary.email ?? summary.accountId ?? "OAuth account";
const masked = redaction?.get(base) ?? base;
const org = summary.orgName ?? summary.orgId;
if (!org || org === base) return masked;
return `${masked} · ${redaction?.get(org) ?? org}`;
}
/**
* Render the full text breakdown: per provider, per account, every limit
* with a bar, amounts, and reset times; unattributed credentials trail
@@ -519,6 +593,7 @@ export function formatUsageBreakdown(
accounts: UsageAccountIdentity[],
nowMs: number,
redaction?: Map<string, string>,
disabled: DisabledCredentialSummary[] = [],
): string {
const reportsByProvider = new Map<string, UsageReport[]>();
for (const report of reports) {
@@ -533,10 +608,17 @@ export function formatUsageBreakdown(
list.push(account);
unreportedByProvider.set(account.provider, list);
}
const disabledByProvider = new Map<string, DisabledCredentialSummary[]>();
for (const summary of disabled) {
if (!isActionableDisable(summary)) continue;
const list = disabledByProvider.get(summary.provider) ?? [];
list.push(summary);
disabledByProvider.set(summary.provider, list);
}
const providers = [...new Set([...reportsByProvider.keys(), ...unreportedByProvider.keys()])].sort((a, b) =>
a.localeCompare(b),
);
const providers = [
...new Set([...reportsByProvider.keys(), ...unreportedByProvider.keys(), ...disabledByProvider.keys()]),
].sort((a, b) => a.localeCompare(b));
const lines: string[] = [];
const latestFetchedAt = Math.max(0, ...reports.map(report => report.fetchedAt ?? 0));
@@ -582,6 +664,20 @@ export function formatUsageBreakdown(
lines.push(` ${chalk.dim("○")} ${chalk.dim(`${label} — no usage data`)}`);
}
for (const summary of disabledByProvider.get(provider) ?? []) {
const label = disabledIdentityLabel(summary, redaction);
const ago = summary.disabledAtMs !== undefined ? ` ${formatDuration(nowMs - summary.disabledAtMs)} ago` : "";
lines.push(
` ${chalk.red(`✗ ${label} — disabled${ago}: ${sanitizeText(shortDisableCause(summary.cause))}`)} ${chalk.dim("(re-login to restore)")}`,
);
}
for (const account of accounts) {
if (account.provider !== provider) continue;
const warning = formatReloginDeadline(account, nowMs, redaction);
if (warning) lines.push(warning);
}
const stats = computeProviderWindowStats(providerReports);
if (stats.length > 0) {
const parts = stats.map(
@@ -750,6 +846,7 @@ function collectStoredAccounts(authStorage: AuthStorage): UsageAccountIdentity[]
enterpriseUrl: credential.enterpriseUrl,
orgId: credential.orgId,
orgName: credential.orgName,
authorizedAt: credential.authorizedAt,
});
} else {
accounts.push({ provider, type: "api_key" });
@@ -862,20 +959,41 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
(await authStorage.fetchUsageReports({
baseUrlResolver: provider => modelRegistry.getProviderBaseUrl(provider),
})) ?? [];
// Reports are always fresh (broker-side fetch) but the account list can
// come from a disk-cached snapshot up to an hour old — revalidate so a
// just-logged-in (or just-rotated-identity) credential isn't rendered
// as a stale duplicate. Best-effort: offline broker keeps the cache.
try {
await authStorage.revalidateCredentials();
} catch {
// Stale identities beat no output.
}
const storedAccounts = collectStoredAccounts(authStorage);
let accounts = selectReportableAccounts(
storedAccounts,
provider => authStorage.usageProviderFor(provider) !== undefined,
cmd.provider,
);
// Tombstones ride alongside the live pool so an auto-disabled account
// (e.g. an expired Anthropic grant) is loudly visible instead of just
// missing. Best-effort: a broker predating the endpoint yields [].
let disabled: DisabledCredentialSummary[] = [];
try {
disabled = await authStorage.listDisabledCredentials();
} catch {
// Usage output must not fail because tombstone listing did.
}
let filteredReports = reports;
if (cmd.provider) {
const wanted = cmd.provider.toLowerCase();
filteredReports = reports.filter(report => report.provider.toLowerCase() === wanted);
accounts = accounts.filter(account => account.provider.toLowerCase() === wanted);
disabled = disabled.filter(summary => summary.provider.toLowerCase() === wanted);
}
const redaction = cmd.redact ? buildRedactionMap(collectIdentityStrings(filteredReports, accounts)) : undefined;
const redaction = cmd.redact
? buildRedactionMap(collectIdentityStrings(filteredReports, accounts, disabled))
: undefined;
if (cmd.json) {
// Drop the heavy provider-specific `raw` payload — same shape as the
@@ -900,10 +1018,21 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
const stats = computeProviderWindowStats(filteredReports.filter(peer => peer.provider === report.provider));
if (stats.length > 0) capacity[report.provider] = stats;
}
let disabledForJson = disabled.filter(isActionableDisable);
if (redaction) {
disabledForJson = disabledForJson.map(summary => ({
...summary,
email: maskIdentity(redaction, summary.email),
accountId: maskIdentity(redaction, summary.accountId),
orgId: maskIdentity(redaction, summary.orgId),
orgName: maskIdentity(redaction, summary.orgName),
}));
}
const payload = {
generatedAt: Date.now(),
reports: trimmed,
accountsWithoutUsage: unreportedAccounts,
disabledCredentials: disabledForJson,
capacity,
};
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@@ -923,7 +1052,7 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
return;
}
process.stdout.write(`${formatUsageBreakdown(filteredReports, accounts, Date.now(), redaction)}\n`);
process.stdout.write(`${formatUsageBreakdown(filteredReports, accounts, Date.now(), redaction, disabled)}\n`);
} finally {
authStorage.close();
}
@@ -191,6 +191,36 @@ describe("collectUnreportedAccounts", () => {
// stays covered by any same-org report.
expect(collectUnreportedAccounts([aliceReport], [alice, bob, orgOnly])).toEqual([bob]);
});
it("keeps an org-less account covered by its own org-less report when org-scoped siblings exist", () => {
// Live incident shape: legacy org-less rows (pre-org-capture logins)
// beside fresh org-scoped logins. Every account fetched successfully —
// nobody may be duplicated into a "no usage data" row.
const legacy: UsageAccountIdentity = {
provider: "anthropic",
type: "oauth",
email: "legacy@example.test",
accountId: "account-legacy",
};
const fresh: UsageAccountIdentity = {
provider: "anthropic",
type: "oauth",
email: "fresh@example.test",
accountId: "account-fresh",
orgId: "org-fresh",
};
const legacyReport = {
...makeReport("anthropic", legacy.email!, []),
metadata: { email: legacy.email, accountId: legacy.accountId },
};
const freshReport = {
...makeReport("anthropic", fresh.email!, []),
metadata: { email: fresh.email, accountId: fresh.accountId, orgId: "org-fresh" },
};
expect(collectUnreportedAccounts([legacyReport, freshReport], [legacy, fresh])).toEqual([]);
// The org-attributed sibling alone still does NOT cover the legacy row.
expect(collectUnreportedAccounts([freshReport], [legacy, fresh])).toEqual([legacy]);
});
});
describe("formatUsageBreakdown", () => {
@@ -290,6 +320,78 @@ describe("formatUsageBreakdown", () => {
for (const mask of redaction.values()) expect(text).toContain(mask);
});
it("renders auto-disabled tombstones with the upstream error_description and hides lifecycle noise", () => {
const now = Date.now();
const disabled = [
{
id: 26,
provider: "anthropic",
type: "oauth" as const,
email: "dead@example.test",
cause: 'oauth refresh failed: OAuthError: refresh request failed; body={"error": "invalid_grant", "error_description": "Refresh token expired"}',
disabledAtMs: now - 4 * HOUR,
},
{
id: 27,
provider: "anthropic",
type: "oauth" as const,
email: "rotated@example.test",
cause: "replaced by newer credential",
},
{
id: 28,
provider: "fireworks",
type: "api_key" as const,
cause: "oauth refresh failed: whatever",
},
];
const text = stripVTControlCharacters(formatUsageBreakdown(reports, accounts, now, undefined, disabled));
// Auto-disabled OAuth row: identity, age, shortened upstream cause, and the fix.
expect(text).toContain("✗ dead@example.test — disabled 4h ago: Refresh token expired (re-login to restore)");
// User-driven replacement and api_key tombstones are lifecycle noise, not lost capacity.
expect(text).not.toContain("rotated@example.test");
expect(text).not.toContain("Fireworks");
});
it("renders a tombstone-only provider section even when no active credential remains", () => {
const disabled = [
{
id: 50,
provider: "anthropic",
type: "oauth" as const,
email: "last@example.test",
cause: "oauth refresh failed: token endpoint said no",
},
];
const text = stripVTControlCharacters(formatUsageBreakdown([], [], Date.now(), undefined, disabled));
expect(text).toContain("Anthropic");
expect(text).toContain("✗ last@example.test — disabled: token endpoint said no (re-login to restore)");
});
it("warns about Anthropic's ~30d grant lifetime only inside the final week", () => {
const now = Date.now();
const DAY = 24 * HOUR;
const withAge = (email: string, ageDays: number): UsageAccountIdentity => ({
provider: "anthropic",
type: "oauth",
email,
authorizedAt: now - ageDays * DAY,
});
const text = stripVTControlCharacters(
formatUsageBreakdown(
[],
[withAge("fresh@example.test", 10), withAge("closing@example.test", 27), withAge("dead@example.test", 31)],
now,
),
);
// 10d-old grant: no countdown noise.
expect(text).not.toContain("fresh@example.test — re-login");
// 27d-old grant: 3 days left.
expect(text).toContain("⚠ closing@example.test — re-login within 3d");
// Past the lifetime: hard warning.
expect(text).toContain("⚠ dead@example.test — grant is past Anthropic's ~30d lifetime; re-login now");
});
it("renders provider-level notes once per provider, not duplicated per account or limit", () => {
const disclaimer = "OMP-observed spend only; OpenCode usage outside OMP is not included.";
const multiAccount = [