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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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!));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user