diff --git a/Dockerfile b/Dockerfile index bc4c884a5..305ab362b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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}" \ diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 67c35dd17..98cad82ab 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/auth-broker/client.ts b/packages/ai/src/auth-broker/client.ts index aa03d1ec9..2b84ad26b 100644 --- a/packages/ai/src/auth-broker/client.ts +++ b/packages/ai/src/auth-broker/client.ts @@ -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 { + 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("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, diff --git a/packages/ai/src/auth-broker/remote-store.ts b/packages/ai/src/auth-broker/remote-store.ts index 7e5b27ddf..d87657628 100644 --- a/packages/ai/src/auth-broker/remote-store.ts +++ b/packages/ai/src/auth-broker/remote-store.ts @@ -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 { + return this.#client.listDisabledCredentials(provider, signal); + } + getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined { const nowMs = Date.now(); this.cleanExpiredCredentialBlocks(nowMs); diff --git a/packages/ai/src/auth-broker/server.ts b/packages/ai/src/auth-broker/server.ts index 367479e11..56eeec91f 100644 --- a/packages/ai/src/auth-broker/server.ts +++ b/packages/ai/src/auth-broker/server.ts @@ -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); diff --git a/packages/ai/src/auth-broker/types.ts b/packages/ai/src/auth-broker/types.ts index 1164022d1..83378b043 100644 --- a/packages/ai/src/auth-broker/types.ts +++ b/packages/ai/src/auth-broker/types.ts @@ -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; diff --git a/packages/ai/src/auth-broker/wire-schemas.ts b/packages/ai/src/auth-broker/wire-schemas.ts index d672941f5..b3f4038e3 100644 --- a/packages/ai/src/auth-broker/wire-schemas.ts +++ b/packages/ai/src/auth-broker/wire-schemas.ts @@ -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; diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 5f87d5365..078e7dfa1 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -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; 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; + /** + * 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; 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 { + 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 { + 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 { + 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(); diff --git a/packages/ai/src/registry/oauth/anthropic.ts b/packages/ai/src/registry/oauth/anthropic.ts index bdb84a5ca..5c8658caf 100644 --- a/packages/ai/src/registry/oauth/anthropic.ts +++ b/packages/ai/src/registry/oauth/anthropic.ts @@ -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}`]; diff --git a/packages/ai/src/registry/oauth/index.ts b/packages/ai/src/registry/oauth/index.ts index 31d402ce4..4b47d01de 100644 --- a/packages/ai/src/registry/oauth/index.ts +++ b/packages/ai/src/registry/oauth/index.ts @@ -12,6 +12,7 @@ import type { OAuthProviderInterface, } from "./types"; +export * from "./anthropic"; export * from "./device-code"; export type * from "./types"; diff --git a/packages/ai/src/registry/oauth/types.ts b/packages/ai/src/registry/oauth/types.ts index 3e4ff0996..78401a3c8 100644 --- a/packages/ai/src/registry/oauth/types.ts +++ b/packages/ai/src/registry/oauth/types.ts @@ -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; diff --git a/packages/ai/test/auth-broker-disabled-credentials.test.ts b/packages/ai/test/auth-broker-disabled-credentials.test.ts new file mode 100644 index 000000000..eba22b1b8 --- /dev/null +++ b/packages/ai/test/auth-broker-disabled-credentials.test.ts @@ -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(); + } + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7c9fcf50c..c4777f2be 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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