Skip to content

Commit 5ce32a9

Browse files
authored
Merge pull request #13 from codebase/codex/acp-session-refresh
Fix OAuth refresh for long-lived ACP sessions
2 parents 54cbfb6 + 56a0e5a commit 5ce32a9

8 files changed

Lines changed: 139 additions & 12 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.82",
3+
"version": "2.0.0-pre.83",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/acp/server.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { fileURLToPath } from "node:url";
66
import * as acp from "@agentclientprotocol/sdk";
77
import type { Model } from "@earendil-works/pi-ai";
88
import { fauxAssistantMessage, fauxToolCall, registerFauxProvider } from "@earendil-works/pi-ai";
9-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
9+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10+
import { CredentialsStore } from "../auth/credentials.js";
1011
import { runAcpServer } from "./server.js";
1112

1213
const MOCK_MCP = fileURLToPath(new URL("../mcp/__test__/mock-server.mjs", import.meta.url));
@@ -136,6 +137,50 @@ describe("runAcpServer", () => {
136137
await serverDone;
137138
});
138139

140+
it("refreshes OAuth credentials before a long-lived server creates a new session", async () => {
141+
const store = new CredentialsStore();
142+
store.save({
143+
accessToken: "access-current",
144+
refreshToken: "refresh-current",
145+
expiresAt: Date.now() + 4 * 60_000,
146+
scopes: ["inference"],
147+
source: "codebase",
148+
});
149+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
150+
new Response(JSON.stringify({ access_token: "access-new", expires_in: 3600 }), {
151+
status: 200,
152+
headers: { "Content-Type": "application/json" },
153+
}),
154+
);
155+
156+
const toAgent = new PassThrough();
157+
const fromAgent = new PassThrough();
158+
const serverDone = runAcpServer({
159+
stdin: toAgent,
160+
stdout: fromAgent,
161+
configOverride: { model, apiKey: "faux-key", source: "byok" },
162+
});
163+
const stream = acp.ndJsonStream(Writable.toWeb(toAgent), Readable.toWeb(fromAgent) as ReadableStream<Uint8Array>);
164+
165+
await acp.client({ name: "buzz-refresh-test" }).connectWith(stream, async (client) => {
166+
await client.request(acp.methods.agent.initialize, {
167+
protocolVersion: acp.PROTOCOL_VERSION,
168+
clientInfo: { name: "buzz-acp", version: "test" },
169+
});
170+
await client.request(acp.methods.agent.session.new, {
171+
cwd,
172+
mcpServers: [],
173+
});
174+
});
175+
176+
const refreshCalls = fetchSpy.mock.calls.filter(([input]) => String(input).endsWith("/api/oauth/token"));
177+
expect(refreshCalls).toHaveLength(1);
178+
expect(store.load()?.accessToken).toBe("access-new");
179+
180+
toAgent.end();
181+
await serverDone;
182+
});
183+
139184
it("streams provider failures as visible ACP messages", async () => {
140185
faux.setResponses([
141186
fauxAssistantMessage([], {

src/acp/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
77
import type { ImageContent } from "@earendil-works/pi-ai";
88
import { type AgentBundle, type CreateAgentOptions, createAgent } from "../agent/agent.js";
99
import type { ModelOption } from "../agent/model-list.js";
10+
import { ensureFreshCredentials } from "../auth/ensure-fresh.js";
1011
import { latestAssistantError, userFacingErrorMessage } from "../errors/user-facing.js";
1112
import type { NamedServer } from "../mcp/config.js";
1213
import type { PermissionRequest, ResponseChoice } from "../permissions/store.js";
@@ -72,6 +73,7 @@ export async function runAcpServer(options: AcpServerOptions = {}): Promise<numb
7273
async function newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
7374
if (!isAbsolute(params.cwd)) throw new Error("ACP session cwd must be an absolute path");
7475

76+
await ensureFreshCredentials();
7577
const bundle = createAgent({
7678
cwd: params.cwd,
7779
autoApprove: false,
@@ -115,6 +117,7 @@ export async function runAcpServer(options: AcpServerOptions = {}): Promise<numb
115117
if (!selected) throw new Error(`unknown model: ${params.value}`);
116118
if (session.bundle.model.id === selected.id) return { configOptions: [modelConfig(session)] };
117119

120+
await ensureFreshCredentials();
118121
const previous = session.bundle;
119122
const next = createAgent({
120123
cwd: session.cwd,

src/agent/config.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@ describe("resolveConfig", () => {
7373
expect(config.model.contextWindow).toBe(131_072);
7474
});
7575

76+
it("constructs a proxy agent from expired OAuth credentials that can refresh", () => {
77+
credentials.save({
78+
accessToken: "expired-oauth-token",
79+
refreshToken: "refresh-token",
80+
expiresAt: Date.now() - 60_000,
81+
scopes: ["inference"],
82+
source: "codebase",
83+
});
84+
85+
const config = resolveConfig({ env: {}, credentials });
86+
87+
expect(config.source).toBe("proxy");
88+
expect(config.apiKey).toBe("expired-oauth-token");
89+
expect(config.model.provider).toBe("codebase");
90+
});
91+
92+
it("does not use an expired manual token that cannot refresh", () => {
93+
credentials.save({
94+
accessToken: "expired-manual-token",
95+
expiresAt: Date.now() - 60_000,
96+
scopes: ["inference"],
97+
source: "manual",
98+
});
99+
100+
expect(() => resolveConfig({ env: {}, credentials })).toThrow();
101+
});
102+
76103
it("treats provider=codebase model overrides as proxy-routed model ids", () => {
77104
credentials.save({
78105
accessToken: "oauth-token",

src/agent/config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,14 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
7878
// - byok → direct call against the provider's own API
7979
const useProxy = env.CODEBASE_DISABLE_PROXY !== "1";
8080
const creds = credentials.load();
81-
if (creds && !credentials.isExpired(creds)) {
81+
// Refreshable OAuth credentials remain sufficient to construct a proxy
82+
// agent even when the current access token is wire-dead. The agent's
83+
// TokenManager refreshes before the first provider request. Rejecting the
84+
// credentials here creates a chicken-and-egg for long-lived ACP servers:
85+
// they cannot create the next session that would perform the refresh.
86+
const usableCredentials =
87+
creds && (!credentials.isExpired(creds) || (creds.source === "codebase" && Boolean(creds.refreshToken)));
88+
if (usableCredentials) {
8289
if (creds.source === "byok" && creds.provider === "openai-compat" && creds.baseUrl) {
8390
// Custom Chat Completions endpoint saved by the wizard (Ollama,
8491
// LM Studio, vLLM, …). Same synthesis as the OPENAI_BASE_URL env

src/auth/ensure-fresh.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { CredentialsStore } from "./credentials.js";
6+
import { ensureFreshCredentials } from "./ensure-fresh.js";
7+
8+
describe("ensureFreshCredentials", () => {
9+
let home: string;
10+
let previousHome: string | undefined;
11+
12+
beforeEach(() => {
13+
previousHome = process.env.HOME;
14+
home = mkdtempSync(join(tmpdir(), "ensure-fresh-"));
15+
process.env.HOME = home;
16+
});
17+
18+
afterEach(() => {
19+
if (previousHome === undefined) delete process.env.HOME;
20+
else process.env.HOME = previousHome;
21+
rmSync(home, { recursive: true, force: true });
22+
vi.restoreAllMocks();
23+
});
24+
25+
it("refreshes within the five-minute preflight window", async () => {
26+
const store = new CredentialsStore();
27+
store.save({
28+
accessToken: "access-current",
29+
refreshToken: "refresh-current",
30+
expiresAt: Date.now() + 4 * 60_000,
31+
scopes: ["inference"],
32+
source: "codebase",
33+
});
34+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
35+
new Response(JSON.stringify({ access_token: "access-new", expires_in: 3600 }), {
36+
status: 200,
37+
headers: { "Content-Type": "application/json" },
38+
}),
39+
);
40+
41+
await ensureFreshCredentials();
42+
43+
expect(fetchSpy).toHaveBeenCalledTimes(1);
44+
expect(store.load()?.accessToken).toBe("access-new");
45+
});
46+
});

src/auth/ensure-fresh.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,22 @@ import { TokenManager } from "./token-manager.js";
1414
* succeeds. That's a chicken/egg the previous code didn't notice
1515
* because a fresh login + fresh process never tripped it.
1616
*
17-
* Safe to call on every launch. No-ops when:
17+
* Safe to call before every launch or long-lived server session. No-ops when:
1818
* - no credentials exist (wizard handles it)
1919
* - source isn't "codebase" (BYOK / env-key sessions don't refresh)
20-
* - access token is still good (TokenManager's preemptive window)
20+
* - access token is outside TokenManager's preemptive refresh window
2121
*
22-
* Errors are NOT thrown: a network blip during cold start should fall
23-
* through to `resolveConfig`, which will produce the existing
24-
* "please run `codebase auth login`" path. The user sees one error,
25-
* not two layered ones.
22+
* Errors are NOT thrown: a network blip during preflight should not prevent
23+
* a refreshable proxy session from being created. The agent's TokenManager
24+
* retries at the provider-call boundary, where callers can render the
25+
* canonical sign-in or network error instead of an opaque startup failure.
2626
*/
2727
export async function ensureFreshCredentials(): Promise<void> {
2828
const store = new CredentialsStore();
2929
const creds = store.load();
3030
if (!creds) return;
3131
if (creds.source !== "codebase") return;
3232
if (!creds.refreshToken) return;
33-
if (!store.isExpired(creds)) return;
3433

3534
const manager = new TokenManager({ store, oauthConfig: defaultOAuthConfig() });
3635
try {

0 commit comments

Comments
 (0)