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.
This commit is contained in:
can1357
2026-07-30 06:14:53 +02:00
parent 4e5f480f14
commit 6b4efa896f
8 changed files with 375 additions and 5 deletions
+1
View File
@@ -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
+19 -4
View File
@@ -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;
}
+1
View File
@@ -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.
@@ -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
@@ -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,
});
}
}
@@ -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;
@@ -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<string, { hash: string; lastUsedAt: number }> {
const pins = new Map<string, { hash: string; lastUsedAt: number }>();
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();
}
@@ -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<Record<(typeof ANTHROPIC_ENV)[number], string | undefined>> = {};
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!));
});
});