mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
This commit is contained in:
parent
07c9e5b6fd
commit
b2cb629237
7 changed files with 188 additions and 2 deletions
|
|
@ -73,6 +73,42 @@ describe("RuntimeCredentials", () => {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import type { Model, OAuthCredential, Provider } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AgentSessionRuntime,
|
||||
type AgentSessionServices,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
} from "../../../src/core/agent-session-runtime.ts";
|
||||
import { AuthStorage } from "../../../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../../../src/core/model-runtime.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
const providerId = "rotating-oauth";
|
||||
|
||||
function oauthProvider(
|
||||
refresh: (credential: OAuthCredential) => Promise<OAuthCredential>,
|
||||
): Provider<"openai-completions"> {
|
||||
const model: Model<"openai-completions"> = {
|
||||
id: "model",
|
||||
name: "Model",
|
||||
api: "openai-completions",
|
||||
provider: providerId,
|
||||
baseUrl: "https://example.test/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
return {
|
||||
id: providerId,
|
||||
name: providerId,
|
||||
auth: {
|
||||
oauth: {
|
||||
name: "OAuth",
|
||||
login: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
refresh,
|
||||
toAuth: async (credential) => ({ apiKey: credential.access }),
|
||||
},
|
||||
},
|
||||
getModels: () => [model],
|
||||
// Like the built-in catalogs, the model list does not need the credential, but the catalog
|
||||
// refresh still resolves (and so refreshes) an expired one.
|
||||
refreshModels: async () => {},
|
||||
stream: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
streamSimple: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("regression #11692: OAuth refresh in flight at shutdown", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispose waits until the rotated token from a background catalog refresh is saved", async () => {
|
||||
const credentials = AuthStorage.inMemory({
|
||||
[providerId]: { type: "oauth", access: "old-access", refresh: "old-refresh", expires: Date.now() - 1000 },
|
||||
});
|
||||
let markRefreshStarted!: () => void;
|
||||
const refreshStarted = new Promise<void>((resolve) => {
|
||||
markRefreshStarted = resolve;
|
||||
});
|
||||
let respond!: () => void;
|
||||
const response = new Promise<void>((resolve) => {
|
||||
respond = resolve;
|
||||
});
|
||||
const modelRuntime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
|
||||
modelRuntime.registerNativeProvider(
|
||||
oauthProvider(async () => {
|
||||
// The token endpoint has already spent "old-refresh" when this request is in flight.
|
||||
markRefreshStarted();
|
||||
await response;
|
||||
return { type: "oauth", access: "new-access", refresh: "new-refresh", expires: Date.now() + 3_600_000 };
|
||||
}),
|
||||
);
|
||||
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const services: AgentSessionServices = {
|
||||
cwd: harness.tempDir,
|
||||
agentDir: harness.tempDir,
|
||||
modelRuntime,
|
||||
settingsManager: harness.settingsManager,
|
||||
resourceLoader: harness.session.resourceLoader,
|
||||
diagnostics: [],
|
||||
};
|
||||
const unusedFactory: CreateAgentSessionRuntimeFactory = async () => {
|
||||
throw new Error("runtime replacement is not used in this test");
|
||||
};
|
||||
const runtime = new AgentSessionRuntime(harness.session, services, unusedFactory);
|
||||
|
||||
// The RPC startup catalog refresh runs in the background and is not awaited by shutdown.
|
||||
const catalogRefresh = modelRuntime.refresh({ allowNetwork: true, providers: [providerId] });
|
||||
await refreshStarted;
|
||||
|
||||
let disposed = false;
|
||||
const disposing = runtime.dispose().then(() => {
|
||||
disposed = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(disposed).toBe(false);
|
||||
|
||||
respond();
|
||||
await disposing;
|
||||
expect(await credentials.read(providerId)).toMatchObject({ access: "new-access", refresh: "new-refresh" });
|
||||
await catalogRefresh;
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue