From 6b4efa896f926aefa2ded9bc33528bb7e6abfe91 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Jul 2026 06:14:53 +0200 Subject: [PATCH] feat(coding-agent): implemented oauth credential pin persistence and seeding - Added hashing utilities and session entry definitions for OAuth credential pins. - Added session manager methods to append and retrieve credential pins with backdated timestamp support. - Added credential pin recording after assistant turns and seeding during session restoration. - Added comprehensive unit tests covering credential pin recording, persistence, and seeding. --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth-storage.ts | 23 ++- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/session/agent-session.ts | 18 ++ .../src/session/credential-pin.ts | 93 +++++++++ .../src/session/session-entries.ts | 22 ++- .../src/session/session-manager.ts | 37 ++++ .../coding-agent/test/credential-pin.test.ts | 185 ++++++++++++++++++ 8 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/src/session/credential-pin.ts create mode 100644 packages/coding-agent/test/credential-pin.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6aec9a351..000e05848 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ - Added interactive Exa API-key login through `/login exa`, opening the official API-key dashboard and saving pasted keys to the credential store ([#1798](https://github.com/can1357/oh-my-pi/issues/1798)). - Cursor's modern exec wire protocol is now handled end to end. `agent.proto` models the frames current Cursor CLI builds emit — the seven Pi tools (`ExecServerMessage` 45-51), hooks, subagents, allowlist prechecks, MCP state, smart-mode classification, canvas diagnostics, conversation search, agent-store conflicts and git diff — and every one of them gets a typed answer. The Pi frames run their local equivalents (`read`/`bash`/`edit`/`write`/`grep`/`glob`); the rest answer with the error, not-found or empty-but-valid variant that is actually true of this client. Frames this build cannot name at all now raise `ExecClientControlMessage.throw` with `unknown_exec_variant`, and recognised frames with no truthful answer (`git_diff_request`, whose `GetDiffResponse` has no error variant) raise `exec_variant_unsupported`, instead of a silent ack that leaves the server waiting. - `lsp` is advertised in the MCP tool catalog again. It was filtered out as a Cursor-native tool, but the native `diagnostics` frame covers one of roughly ten LSP actions, so the other nine were unreachable. +- Added `pinSessionOAuthAccount` support for backdating the sticky's last-use timestamp (`options.lastUsedAtMs`), so pins restored from persisted sessions keep the provider's warm-window semantics: resumes inside the prompt-cache TTL reuse the account, stale resumes still re-rank. ### Changed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 583c6ab72..dd60b2a1c 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -1811,15 +1811,20 @@ export class AuthStorage { } } - /** Records which credential was used for a session (for rate-limit switching). */ + /** + * Records which credential was used for a session (for rate-limit switching). + * `lastUsedAtMs` backdates the sticky (session-file pin restores on resume); + * it defaults to now for live selections. + */ #recordSessionCredential( provider: string, sessionId: string | undefined, type: AuthCredential["type"], index: number, + lastUsedAtMs?: number, ): void { if (!sessionId) return; - const nowMs = Date.now(); + const nowMs = lastUsedAtMs ?? Date.now(); const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map(); sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs }); this.#sessionLastCredential.set(provider, sessionMap); @@ -5403,8 +5408,18 @@ export class AuthStorage { * The durable credential id keeps the pin stable across credential refreshes, * storage reordering, and process restarts. Normal auth retry and usage-limit * handling may still route around an unavailable account. + * + * `options.lastUsedAtMs` backdates the sticky's last-use timestamp so a pin + * restored from a persisted session keeps the provider's warm-window + * semantics: a resume inside the prompt-cache TTL reuses the account, a + * stale resume still re-ranks. */ - pinSessionOAuthAccount(provider: string, sessionId: string, credentialId: number): boolean { + pinSessionOAuthAccount( + provider: string, + sessionId: string, + credentialId: number, + options?: { lastUsedAtMs?: number }, + ): boolean { if (!sessionId || this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) { return false; } @@ -5412,7 +5427,7 @@ export class AuthStorage { const index = stored.findIndex(entry => entry.id === credentialId); const target = stored[index]; if (target?.credential.type !== "oauth") return false; - this.#recordSessionCredential(provider, sessionId, "oauth", index); + this.#recordSessionCredential(provider, sessionId, "oauth", index, options?.lastUsedAtMs); return true; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 92cead391..b30b584a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -22,6 +22,7 @@ ### Fixed +- Fixed Anthropic prompt-cache cold misses on session resume with multiple OAuth accounts: the account that served a session is now recorded in the session file (as a `credential_pin` sha-256 of the account + org/project scope, so exports carry no plaintext identity) and re-pinned on resume with the session's effective last-use time, so a fresh process no longer re-ranks accounts by usage headroom — which systematically routed away from the just-used account and cold-missed the entire account-scoped cache prefix. Sticky routing was previously stored only in the auth store's KV cache, which is in-memory when a remote auth broker is configured. - Fixed concurrent `createAgentSession` calls with the default agent id failing initialization with `Agent "Main" was replaced during session initialization` — each in-process embedder (e.g. the edit benchmark runner) can now pass a private registry via the newly exported `AgentRegistry`, keeping every top-level session's "Main" out of the process-global roster race. - Fixed task tool blocks duplicating their per-agent progress rows into terminal scrollback on every update: live task frames now pin the transcript live region so mid-run rows are never recorded as frozen snapshots, and a detached background task freezes its progress the moment any of its rows commit to scrollback instead of mutating committed history. - Fixed Codex reset fireworks comparing different quota tiers or plans, preventing false celebrations when usage reports switch between Spark and base weekly limits. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 4117c17aa..2011e7b61 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -249,6 +249,7 @@ import { shouldEvaluateCodexAutoRedeem, shouldPromptCodexAutoRedeem, } from "./codex-auto-reset"; +import { recordCredentialPin, seedCredentialPins } from "./credential-pin"; import { EvalRunner, type EvalRunnerHost } from "./eval-runner"; import { collectPendingToolCalls, @@ -2428,6 +2429,15 @@ export class AgentSession { }, costUsd: assistantMsg.usage.cost.total, }); + // Persist which account served this turn so a resumed process can + // re-pin it and keep the provider's account-scoped prompt cache + // warm (broker-mode sticky routing is process-local). + recordCredentialPin( + this.#modelRegistry.authStorage, + this.sessionManager, + this.sessionId, + assistantMsg.provider, + ); } if (event.message.role === "toolResult") { const { toolName, toolCallId, isError, content } = event.message; @@ -3400,6 +3410,14 @@ export class AgentSession { this.agent.setMetadataResolver((provider: string) => buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage), ); + // Restore the session's recorded provider accounts before the first + // request routes: sticky rows are process-local under a remote auth + // broker, and losing them re-ranks onto a different account, cold-missing + // the account-scoped prompt cache. Skipped for fresh provider sessions — + // those explicitly want new routing identity. + if (!this.#freshProviderSessionId) { + seedCredentialPins(this.#modelRegistry.authStorage, this.sessionManager, sid); + } // Keep every live advisor's provider identity in lockstep with the primary's // across every session-boundary transition — including branch paths that // skip conversation restore — so advisors never emit the previous diff --git a/packages/coding-agent/src/session/credential-pin.ts b/packages/coding-agent/src/session/credential-pin.ts new file mode 100644 index 000000000..c7c6b99f2 --- /dev/null +++ b/packages/coding-agent/src/session/credential-pin.ts @@ -0,0 +1,93 @@ +/** + * Session-file persistence of the OAuth account that served a session. + * + * Provider prompt caches are account-scoped (Anthropic bills a full cache + * re-write after an account flip), and the auth store's session-sticky routing + * is process-local when a remote auth broker is configured — the broker + * store's KV cache is in-memory, so sticky rows die with the CLI process. + * Resuming a session in a fresh process then re-ranks accounts by usage + * headroom, which is biased *away* from the account that just served the + * session (it has the highest recent burn), cold-missing the entire prefix. + * + * These helpers close the loop through the session file itself: after each + * assistant turn the serving account is recorded as a `credential_pin` entry, + * and on session adoption the pin is matched against the stored accounts and + * seeded back into the auth store with the session's effective last-use + * timestamp, so the provider's warm-window semantics still decide whether to + * stick or re-rank. + */ + +import type { AuthStorage } from "./auth-storage"; +import type { SessionManager } from "./session-manager"; + +/** Account fields shared by `OAuthAccountIdentity` and `OAuthAccountSummary`. */ +interface CredentialPinIdentity { + accountId?: string; + email?: string; + projectId?: string; + orgId?: string; +} + +/** + * Stable identifier for a provider account within its billing scope. The + * digest covers the full scope tuple — the same account in two orgs (Anthropic + * multi-subscription) or projects (Gemini) is two distinct cache domains and + * must produce two distinct pins. The digest input is the persisted contract + * for `CredentialPinEntry.hash` — changing it orphans every recorded pin. + * + * Hashing avoids embedding raw emails/uuids in session files, but an unsalted + * digest of a guessable email is still linkable — treat exported sessions + * accordingly. + * + * Returns `undefined` when the identity carries no account key at all. + */ +export function credentialPinHash(provider: string, identity: CredentialPinIdentity): string | undefined { + if (!identity.accountId && !identity.email) return undefined; + const key = [ + provider, + identity.accountId ?? "", + identity.email ?? "", + identity.orgId ?? "", + identity.projectId ?? "", + ].join("\0"); + return new Bun.CryptoHasher("sha256").update(key).digest("hex"); +} + +/** + * Record the account that served the latest assistant turn for `provider`. + * Appends a `credential_pin` entry only when the account differs from the + * branch's latest pin, so steady-state sessions add a single entry; the + * effective last-use time is derived from later assistant turns on read + * (see `SessionManager.getCredentialPins`). + */ +export function recordCredentialPin( + authStorage: AuthStorage, + sessionManager: SessionManager, + sessionId: string, + provider: string, +): void { + const identity = authStorage.getOAuthAccountIdentity(provider, sessionId); + if (!identity) return; + const hash = credentialPinHash(provider, identity); + if (!hash || sessionManager.getCredentialPins().get(provider)?.hash === hash) return; + sessionManager.appendCredentialPin(provider, hash); +} + +/** + * Re-pin the accounts recorded in the session file onto the auth store's + * session stickiness. No-op per provider when the account is gone (logged out) + * or when a live sticky already exists (same-process branch/session switches + * must not clobber fresher routing). Seeds with the session's effective + * last-use time so stale resumes still fall through to usage ranking. + */ +export function seedCredentialPins(authStorage: AuthStorage, sessionManager: SessionManager, sessionId: string): void { + for (const [provider, pin] of sessionManager.getCredentialPins()) { + const accounts = authStorage.listOAuthAccounts(provider, sessionId); + if (accounts.length === 0 || accounts.some(account => account.active)) continue; + const match = accounts.find(account => credentialPinHash(provider, account) === pin.hash); + if (!match) continue; + authStorage.pinSessionOAuthAccount(provider, sessionId, match.credentialId, { + lastUsedAtMs: pin.lastUsedAt, + }); + } +} diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index 630361a8f..2386ed829 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -154,6 +154,7 @@ export interface TitleChangeEntry extends SessionEntryBase { declare module "@oh-my-pi/pi-agent-core/compaction/entries" { interface CustomCompactionSessionEntries { titleChange: TitleChangeEntry; + credentialPin: CredentialPinEntry; } } @@ -164,6 +165,24 @@ export interface TtsrInjectionEntry extends SessionEntryBase { injectedRules: string[]; } +/** + * Records which OAuth account served this session's requests for a provider. + * + * Provider prompt caches (Anthropic in particular) are account-scoped, and the + * auth store's session-sticky routing is process-local under a remote auth + * broker, so resume must re-pin the same account to reuse the warm cache + * prefix. Stores a sha-256 of the account + billing-scope tuple instead of + * the raw email/uuid/org; note an unsalted digest of a guessable email is + * still linkable, so exported sessions are pseudonymous, not anonymous. + */ +export interface CredentialPinEntry extends SessionEntryBase { + type: "credential_pin"; + /** Provider id the pin applies to (e.g. "anthropic"). */ + provider: string; + /** `credentialPinHash()` of the serving account's identity + scope tuple. */ + hash: string; +} + /** Session init entry - captures initial context for subagent sessions (debugging/replay). */ export interface SessionInitEntry extends SessionEntryBase { type: "session_init"; @@ -230,7 +249,8 @@ export type SessionEntry = | TitleChangeEntry | TtsrInjectionEntry | SessionInitEntry - | ModeChangeEntry; + | ModeChangeEntry + | CredentialPinEntry; /** Raw logical file entry after loaders strip any fixed-width title slot. */ export type FileEntry = SessionHeader | SessionEntry; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 083d6cd2a..ffe1dd712 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -35,6 +35,7 @@ import { type BuildSessionContextOptions, buildSessionContext, type SessionConte import { type BranchSummaryEntry, type CompactionEntry, + type CredentialPinEntry, CURRENT_SESSION_VERSION, type CustomEntry, type CustomMessageEntry, @@ -179,6 +180,7 @@ function isDraftOnlyMetadataEntry(entry: SessionEntry): boolean { case "thinking_level_change": case "service_tier_change": case "mode_change": + case "credential_pin": return true; default: return false; @@ -2062,6 +2064,41 @@ export class SessionManager { return [...names]; } + /** Append a credential pin recording which OAuth account served `provider`. */ + appendCredentialPin(provider: string, hash: string): string { + const entry: CredentialPinEntry = { + type: "credential_pin", + ...this.#freshEntryFields(), + provider, + hash, + }; + this.#recordEntry(entry); + return entry.id; + } + + /** + * Latest credential pin per provider on the current branch (root → leaf), + * with the effective last-use time of the pinned account. + * + * Pins are appended only when the serving account *changes*, so a long + * session on one account carries a single old pin entry. Any assistant turn + * for the same provider after that pin was necessarily served by the pinned + * account, so its timestamp advances `lastUsedAt` — a resume seconds after + * the last turn seeds a warm sticky instead of a stale one. + */ + getCredentialPins(): Map { + const pins = new Map(); + for (const entry of this.getBranch()) { + if (entry.type === "credential_pin") { + pins.set(entry.provider, { hash: entry.hash, lastUsedAt: new Date(entry.timestamp).getTime() }); + } else if (entry.type === "message" && entry.message.role === "assistant") { + const pin = pins.get(entry.message.provider); + if (pin) pin.lastUsedAt = Math.max(pin.lastUsedAt, entry.message.timestamp); + } + } + return pins; + } + getLeafId(): string | null { return this.#index.leafId(); } diff --git a/packages/coding-agent/test/credential-pin.test.ts b/packages/coding-agent/test/credential-pin.test.ts new file mode 100644 index 000000000..5763976b1 --- /dev/null +++ b/packages/coding-agent/test/credential-pin.test.ts @@ -0,0 +1,185 @@ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { AuthStorage, SqliteAuthCredentialStore } from "../src/session/auth-storage"; +import { credentialPinHash, recordCredentialPin, seedCredentialPins } from "../src/session/credential-pin"; +import { SessionManager } from "../src/session/session-manager"; + +const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const; +const savedEnv: Partial> = {}; + +function mintOAuthCredential(suffix: string, extra?: { orgId?: string }) { + return { + type: "oauth" as const, + access: `access-${suffix}`, + refresh: `refresh-${suffix}`, + expires: Date.now() + 60_000, + accountId: `account-${suffix}`, + email: `${suffix}@example.com`, + ...extra, + }; +} + +function assistantMessage(provider: string, timestamp: number) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hi" }], + api: "anthropic-messages", + provider, + model: "claude-test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp, + }; +} + +describe("credential pins", () => { + let tempDir: TempDir; + let storage: AuthStorage; + + beforeEach(async () => { + for (const key of ANTHROPIC_ENV) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + tempDir = TempDir.createSync("@pi-credential-pin-"); + const store = new SqliteAuthCredentialStore(new Database(":memory:")); + store.saveOAuth("anthropic", mintOAuthCredential("a")); + store.saveOAuth("anthropic", mintOAuthCredential("b")); + storage = new AuthStorage(store); + await storage.reload(); + }); + + afterEach(() => { + for (const key of ANTHROPIC_ENV) { + const value = savedEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + tempDir[Symbol.dispose](); + }); + + test("pin entries survive a session reload and the latest pin per provider wins", async () => { + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + manager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() }); + manager.appendMessage(assistantMessage("anthropic", Date.now())); + manager.appendCredentialPin("anthropic", "hash-old"); + manager.appendCredentialPin("openai-codex", "hash-codex"); + manager.appendCredentialPin("anthropic", "hash-new"); + await manager.flush(); + const file = manager.getSessionFile(); + if (!file) throw new Error("expected a persisted session file"); + + const reopened = await SessionManager.open(file); + const pins = reopened.getCredentialPins(); + expect(pins.get("anthropic")?.hash).toBe("hash-new"); + expect(pins.get("openai-codex")?.hash).toBe("hash-codex"); + }); + + test("later assistant turns advance the pin's effective last-use; other providers and new pins do not", () => { + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + const pinId = manager.appendCredentialPin("anthropic", "hash-a"); + const pinnedAt = new Date(manager.getEntry(pinId)!.timestamp).getTime(); + + // Long session on one account: no new pin entries, only assistant turns. + const lastTurnAt = pinnedAt + 3 * 60 * 60 * 1000; + manager.appendMessage(assistantMessage("anthropic", pinnedAt + 60_000)); + manager.appendMessage(assistantMessage("anthropic", lastTurnAt)); + expect(manager.getCredentialPins().get("anthropic")?.lastUsedAt).toBe(lastTurnAt); + + // A different provider's turn never advances this provider's pin. + manager.appendMessage(assistantMessage("openai-codex", lastTurnAt + 60_000)); + expect(manager.getCredentialPins().get("anthropic")?.lastUsedAt).toBe(lastTurnAt); + + // An account change re-bases last-use at the new pin. + const newPinId = manager.appendCredentialPin("anthropic", "hash-b"); + const newPinnedAt = new Date(manager.getEntry(newPinId)!.timestamp).getTime(); + expect(manager.getCredentialPins().get("anthropic")?.lastUsedAt).toBe(newPinnedAt); + }); + + test("seeding re-pins the recorded account in a store with no session stickiness", () => { + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + const sessionId = manager.getSessionId(); + const hash = credentialPinHash("anthropic", { accountId: "account-b", email: "b@example.com" }); + if (!hash) throw new Error("expected a pin hash"); + manager.appendCredentialPin("anthropic", hash); + + // Fresh process: no sticky exists yet (the broker-mode resume scenario). + expect(storage.listOAuthAccounts("anthropic", sessionId).some(account => account.active)).toBe(false); + + seedCredentialPins(storage, manager, sessionId); + + const active = storage.listOAuthAccounts("anthropic", sessionId).find(account => account.active); + expect(active?.accountId).toBe("account-b"); + }); + + test("pins are org-scoped: the same account in two orgs re-pins the matching org credential", async () => { + const store = new SqliteAuthCredentialStore(new Database(":memory:")); + store.saveOAuth("anthropic", mintOAuthCredential("x", { orgId: "org-1" })); + store.saveOAuth("anthropic", mintOAuthCredential("x", { orgId: "org-2" })); + const orgStorage = new AuthStorage(store); + await orgStorage.reload(); + + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + const sessionId = manager.getSessionId(); + const identity = { accountId: "account-x", email: "x@example.com" }; + const orgTwoHash = credentialPinHash("anthropic", { ...identity, orgId: "org-2" }); + if (!orgTwoHash) throw new Error("expected a pin hash"); + expect(orgTwoHash).not.toBe(credentialPinHash("anthropic", { ...identity, orgId: "org-1" })); + manager.appendCredentialPin("anthropic", orgTwoHash); + + seedCredentialPins(orgStorage, manager, sessionId); + + const active = orgStorage.listOAuthAccounts("anthropic", sessionId).find(account => account.active); + expect(active?.orgId).toBe("org-2"); + }); + + test("seeding never clobbers a live sticky from the same process", () => { + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + const sessionId = manager.getSessionId(); + const accounts = storage.listOAuthAccounts("anthropic", sessionId); + const accountA = accounts.find(account => account.accountId === "account-a"); + expect(storage.pinSessionOAuthAccount("anthropic", sessionId, accountA!.credentialId)).toBe(true); + + const hash = credentialPinHash("anthropic", { accountId: "account-b", email: "b@example.com" }); + manager.appendCredentialPin("anthropic", hash!); + seedCredentialPins(storage, manager, sessionId); + + const active = storage.listOAuthAccounts("anthropic", sessionId).find(account => account.active); + expect(active?.accountId).toBe("account-a"); + }); + + test("seeding is a no-op when the pinned account is no longer stored", () => { + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + const sessionId = manager.getSessionId(); + const hash = credentialPinHash("anthropic", { accountId: "account-gone" }); + manager.appendCredentialPin("anthropic", hash!); + + seedCredentialPins(storage, manager, sessionId); + + expect(storage.listOAuthAccounts("anthropic", sessionId).some(account => account.active)).toBe(false); + }); + + test("recording appends the serving account's hash once and dedupes repeats", () => { + const manager = SessionManager.create(tempDir.path(), tempDir.path()); + const sessionId = manager.getSessionId(); + const accounts = storage.listOAuthAccounts("anthropic", sessionId); + const accountA = accounts.find(account => account.accountId === "account-a"); + storage.pinSessionOAuthAccount("anthropic", sessionId, accountA!.credentialId); + + recordCredentialPin(storage, manager, sessionId, "anthropic"); + recordCredentialPin(storage, manager, sessionId, "anthropic"); + + const entries = manager.getBranch().filter(entry => entry.type === "credential_pin"); + expect(entries).toHaveLength(1); + const identity = storage.getOAuthAccountIdentity("anthropic", sessionId); + expect(manager.getCredentialPins().get("anthropic")?.hash).toBe(credentialPinHash("anthropic", identity!)); + }); +});