pi-mono/packages/coding-agent/test/runtime-credentials.test.ts
Petr Houška b2cb629237
fix(coding-agent): wait for a started OAuth refresh to be saved before exit (#11812)
Fixes #11692.

## Problem

In RPC mode the startup catalog refresh runs in the background and also
refreshes an expired OAuth credential. If pi exits after the token
request went out but before the new token is written, the provider has
already spent the old single-use refresh token and auth.json keeps only
that one. The next request fails with `invalid_grant`.

Trace: `pi --mode rpc` starts, `get_state` is answered, catalog refresh
sends the token request, the client sends SIGTERM (or closes stdin),
`shutdown()` runs `runtimeHost.dispose()`, which does not know about the
refresh, and the process exits. Same for print and interactive mode, and
for a prompt-time refresh cut off by an exit.

## Fix

- `RuntimeCredentials.modify()` tracks every pending modification (same
idiom as the MCP `track()` / `McpAuthProvider.settled()`).
- `RuntimeCredentials.settled()` /
`ModelRuntime.settleCredentialWrites()` wait for them.
- `AgentSessionRuntime.dispose()` waits for them. All modes exit through
`dispose()`. No extra timeout: the OAuth refresh (15 s) and the
auth.json lock wait (30 s) are already bounded, and a second signal
still exits at once.

Not changed: the catalog refresh still refreshes expired OAuth
credentials, also for providers whose catalog does not use them (the
pi.dev catalog for `openai`, `anthropic`, ...). Skipping that would need
a per-provider "catalog needs auth" signal in pi-ai; it is optional now
that exits wait.

`docs/rpc.md` now says that pi waits for pending work before exiting.

## Testing

- `test/runtime-credentials.test.ts`: `settled()` waits until a pending
modification is written, and resolves after a failed one.
-
`test/suite/regressions/11692-dispose-waits-for-oauth-refresh.test.ts`:
`dispose()` waits until the token from a background catalog refresh is
saved. Fails without the fix.
- Real process: `pi --mode rpc` with an extension OAuth provider
(expired credential, 400 ms refresh), SIGTERM about 100 ms after the
token request started. Without the wait: 3 of 3 runs kept the spent
refresh token. With the fix: 3 of 3 saved the new one (exit 143).
Closing stdin instead of SIGTERM: new token saved, exit 0.
- `npm run check`; `test/rpc*`, `test/print-mode`,
`test/model-runtime*`, runtime and regression suites pass.
2026-11-05 00:32:41 +01:00

122 lines
4.9 KiB
TypeScript

import type { CredentialStore } from "@earendil-works/pi-ai";
import { describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { RuntimeCredentials } from "../src/core/runtime-credentials.ts";
describe("RuntimeCredentials", () => {
test("runtime overrides mask stored credentials without persisting", async () => {
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "stored-key" } });
const credentials = new RuntimeCredentials(storage);
credentials.setRuntimeApiKey("anthropic", "runtime-key");
expect(await credentials.read("anthropic")).toEqual({ type: "api_key", key: "runtime-key" });
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "stored-key" });
credentials.removeRuntimeApiKey("anthropic");
expect(await credentials.read("anthropic")).toEqual({ type: "api_key", key: "stored-key" });
});
test("enumeration merges overrides without exposing keys", async () => {
const storage = AuthStorage.inMemory({
anthropic: { type: "oauth", access: "access", refresh: "refresh", expires: Date.now() + 60_000 },
});
const credentials = new RuntimeCredentials(storage);
credentials.setRuntimeApiKey("anthropic", "runtime-key");
credentials.setRuntimeApiKey("openai", "other-runtime-key");
expect(await credentials.list()).toEqual([
{ providerId: "anthropic", type: "api_key" },
{ providerId: "openai", type: "api_key" },
]);
});
test("forwards operation signals to the persistent store", async () => {
const controller = new AbortController();
const received: AbortSignal[] = [];
const storage: CredentialStore = {
read: async (_providerId, options) => {
received.push(options?.signal as AbortSignal);
return undefined;
},
list: async (options) => {
received.push(options?.signal as AbortSignal);
return [];
},
modify: async (_providerId, _fn, options) => {
received.push(options?.signal as AbortSignal);
return undefined;
},
delete: async (_providerId, options) => {
received.push(options?.signal as AbortSignal);
},
};
const credentials = new RuntimeCredentials(storage);
await credentials.read("anthropic", { signal: controller.signal });
await credentials.list({ signal: controller.signal });
await credentials.modify("anthropic", async () => undefined, { signal: controller.signal });
await credentials.delete("anthropic", { signal: controller.signal });
expect(received).toEqual([controller.signal, controller.signal, controller.signal, controller.signal]);
});
test("keeps a runtime override when persistent deletion is cancelled", async () => {
const aborted = new Error("cancelled");
aborted.name = "AbortError";
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "stored-key" } });
const deleteSpy = vi.spyOn(storage, "delete").mockRejectedValueOnce(aborted);
const credentials = new RuntimeCredentials(storage);
credentials.setRuntimeApiKey("anthropic", "runtime-key");
await expect(credentials.delete("anthropic", { signal: new AbortController().signal })).rejects.toBe(aborted);
expect(deleteSpy).toHaveBeenCalledTimes(1);
expect(await credentials.read("anthropic")).toEqual({ type: "api_key", key: "runtime-key" });
});
// #11692: shutdown waits for an OAuth refresh in progress so the rotated token is not lost.
test("settled waits until pending modifications are written", async () => {
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "old" } });
const credentials = new RuntimeCredentials(storage);
let respond!: () => void;
const response = new Promise<void>((resolve) => {
respond = resolve;
});
const modification = credentials.modify("anthropic", async () => {
await response;
return { type: "api_key", key: "rotated" };
});
let settled = false;
const settling = credentials.settled().then(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(settled).toBe(false);
respond();
await settling;
expect(await storage.read("anthropic")).toEqual({ type: "api_key", key: "rotated" });
await modification;
});
test("settled resolves after a pending modification fails", async () => {
const credentials = new RuntimeCredentials(AuthStorage.inMemory());
const failed = credentials.modify("anthropic", async () => {
throw new Error("invalid_grant");
});
await credentials.settled();
await expect(failed).rejects.toThrow("invalid_grant");
});
test("delete clears both the override and persisted credential", async () => {
const storage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "stored-key" } });
const credentials = new RuntimeCredentials(storage);
credentials.setRuntimeApiKey("anthropic", "runtime-key");
await credentials.delete("anthropic");
expect(await credentials.read("anthropic")).toBeUndefined();
expect(await credentials.list()).toEqual([]);
});
});