Skip to content

Commit 859eb06

Browse files
vincentkocsteipete
authored andcommitted
refactor(auth): route codex runtimes through canonical oauth
1 parent f98e98a commit 859eb06

36 files changed

Lines changed: 830 additions & 108 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
0b0cf2ecc30501bb6381671e3704570f405655b026a0b8b6437c3a5677450b9b plugin-sdk-api-baseline.json
2-
cb72d7b5f73005280854654b51501ec82f5a2f23b7ccb915b63c6354300559d5 plugin-sdk-api-baseline.jsonl
1+
445130135f0037ca2f0877428d58deedf7a7f50e588af5505c1ba09d346663ae plugin-sdk-api-baseline.json
2+
147f6f63b835a92e24d6c93b91b0e2adbe1b8fb381d3bd45ef1ae63fd9b3386e plugin-sdk-api-baseline.jsonl
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5+
6+
const mocks = vi.hoisted(() => ({
7+
ensureAuthProfileStore: vi.fn(),
8+
}));
9+
10+
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
11+
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
12+
}));
13+
14+
let bridgeCodexAppServerStartOptions: typeof import("./auth-bridge.js").bridgeCodexAppServerStartOptions;
15+
16+
describe("bridgeCodexAppServerStartOptions", () => {
17+
const tempDirs: string[] = [];
18+
19+
beforeAll(async () => {
20+
({ bridgeCodexAppServerStartOptions } = await import("./auth-bridge.js"));
21+
});
22+
23+
afterEach(async () => {
24+
mocks.ensureAuthProfileStore.mockReset();
25+
await Promise.all(
26+
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
27+
);
28+
});
29+
30+
it("bridges canonical OpenClaw oauth into an isolated CODEX_HOME", async () => {
31+
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
32+
tempDirs.push(agentDir);
33+
mocks.ensureAuthProfileStore.mockReturnValue({
34+
version: 1,
35+
profiles: {
36+
"openai-codex:default": {
37+
type: "oauth",
38+
provider: "openai-codex",
39+
access: "access-token",
40+
refresh: "refresh-token",
41+
expires: Date.now() + 60_000,
42+
accountId: "acct-123",
43+
},
44+
},
45+
});
46+
47+
const result = await bridgeCodexAppServerStartOptions({
48+
startOptions: {
49+
command: "codex",
50+
args: ["app-server"],
51+
headers: { authorization: "Bearer dev-token" },
52+
env: { EXISTING: "1" },
53+
clearEnv: ["FOO"],
54+
},
55+
agentDir,
56+
});
57+
58+
expect(result).toMatchObject({
59+
env: {
60+
EXISTING: "1",
61+
CODEX_HOME: expect.stringContaining(path.join(agentDir, "harness-auth", "codex")),
62+
},
63+
clearEnv: expect.arrayContaining(["FOO", "OPENAI_API_KEY"]),
64+
});
65+
66+
const authFile = JSON.parse(
67+
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
68+
);
69+
expect(authFile).toEqual({
70+
auth_mode: "chatgpt",
71+
tokens: {
72+
access_token: "access-token",
73+
refresh_token: "refresh-token",
74+
account_id: "acct-123",
75+
},
76+
});
77+
});
78+
79+
it("leaves start options unchanged when canonical oauth is unavailable", async () => {
80+
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
81+
tempDirs.push(agentDir);
82+
const startOptions = {
83+
command: "codex",
84+
args: ["app-server"],
85+
headers: { authorization: "Bearer dev-token" },
86+
};
87+
mocks.ensureAuthProfileStore.mockReturnValue({
88+
version: 1,
89+
profiles: {},
90+
});
91+
92+
await expect(
93+
bridgeCodexAppServerStartOptions({
94+
startOptions,
95+
agentDir,
96+
authProfileId: "openai-codex:missing",
97+
}),
98+
).resolves.toEqual(startOptions);
99+
});
100+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import crypto from "node:crypto";
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import { ensureAuthProfileStore, type OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
5+
import type { CodexAppServerStartOptions } from "./config.js";
6+
7+
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
8+
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const;
9+
10+
function isBridgeableCodexOAuthCredential(value: unknown): value is OAuthCredential {
11+
return Boolean(
12+
value &&
13+
typeof value === "object" &&
14+
value !== null &&
15+
"type" in value &&
16+
"provider" in value &&
17+
"access" in value &&
18+
"refresh" in value &&
19+
value.type === "oauth" &&
20+
value.provider === "openai-codex" &&
21+
typeof value.access === "string" &&
22+
value.access.trim().length > 0 &&
23+
typeof value.refresh === "string" &&
24+
value.refresh.trim().length > 0,
25+
);
26+
}
27+
28+
function resolveCodexBridgeHome(agentDir: string, profileId: string): string {
29+
const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16);
30+
return path.join(agentDir, "harness-auth", "codex", digest);
31+
}
32+
33+
function buildCodexAuthFile(credential: OAuthCredential): string {
34+
return `${JSON.stringify(
35+
{
36+
auth_mode: "chatgpt",
37+
tokens: {
38+
access_token: credential.access,
39+
refresh_token: credential.refresh,
40+
...(credential.accountId ? { account_id: credential.accountId } : {}),
41+
},
42+
},
43+
null,
44+
2,
45+
)}\n`;
46+
}
47+
48+
export async function bridgeCodexAppServerStartOptions(params: {
49+
startOptions: CodexAppServerStartOptions;
50+
agentDir: string;
51+
authProfileId?: string;
52+
}): Promise<CodexAppServerStartOptions> {
53+
const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID;
54+
const store = ensureAuthProfileStore(params.agentDir, {
55+
allowKeychainPrompt: false,
56+
});
57+
const credential = store.profiles[profileId];
58+
if (!isBridgeableCodexOAuthCredential(credential)) {
59+
return params.startOptions;
60+
}
61+
62+
const codexHome = resolveCodexBridgeHome(params.agentDir, profileId);
63+
await fs.mkdir(codexHome, { recursive: true });
64+
await fs.writeFile(path.join(codexHome, "auth.json"), buildCodexAuthFile(credential));
65+
66+
return {
67+
...params.startOptions,
68+
env: {
69+
...params.startOptions.env,
70+
CODEX_HOME: codexHome,
71+
},
72+
clearEnv: Array.from(
73+
new Set([...(params.startOptions.clearEnv ?? []), ...CODEX_AUTH_ENV_CLEAR_KEYS]),
74+
),
75+
};
76+
}

extensions/codex/src/app-server/compact.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,38 @@ describe("maybeCompactCodexAppServerSession", () => {
105105
},
106106
});
107107
});
108+
109+
it("reuses the bound auth profile for native compaction", async () => {
110+
const fake = createFakeCodexClient();
111+
let seenAuthProfileId: string | undefined;
112+
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
113+
seenAuthProfileId = authProfileId;
114+
return fake.client;
115+
});
116+
const sessionFile = path.join(tempDir, "session.jsonl");
117+
await writeCodexAppServerBinding(sessionFile, {
118+
threadId: "thread-1",
119+
cwd: tempDir,
120+
authProfileId: "openai-codex:work",
121+
});
122+
123+
const pendingResult = maybeCompactCodexAppServerSession({
124+
sessionId: "session-1",
125+
sessionKey: "agent:main:session-1",
126+
sessionFile,
127+
workspaceDir: tempDir,
128+
});
129+
await vi.waitFor(() => {
130+
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
131+
});
132+
fake.emit({
133+
method: "thread/compacted",
134+
params: { threadId: "thread-1", turnId: "turn-1" },
135+
});
136+
await pendingResult;
137+
138+
expect(seenAuthProfileId).toBe("openai-codex:work");
139+
});
108140
});
109141

110142
function createFakeCodexClient(): {

extensions/codex/src/app-server/compact.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getSharedCodexAppServerClient } from "./shared-client.js";
1111

1212
type CodexAppServerClientFactory = (
1313
startOptions?: CodexAppServerStartOptions,
14+
authProfileId?: string,
1415
) => Promise<CodexAppServerClient>;
1516
type CodexNativeCompactionCompletion = {
1617
signal: "thread/compacted" | "item/completed";
@@ -25,8 +26,8 @@ type CodexNativeCompactionWaiter = {
2526

2627
const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
2728

28-
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
29-
getSharedCodexAppServerClient({ startOptions });
29+
let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) =>
30+
getSharedCodexAppServerClient({ startOptions, authProfileId });
3031

3132
export async function maybeCompactCodexAppServerSession(
3233
params: CompactEmbeddedPiSessionParams,
@@ -38,7 +39,7 @@ export async function maybeCompactCodexAppServerSession(
3839
return { ok: false, compacted: false, reason: "no codex app-server thread binding" };
3940
}
4041

41-
const client = await clientFactory(appServer.start);
42+
const client = await clientFactory(appServer.start, binding.authProfileId);
4243
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
4344
let completion: CodexNativeCompactionCompletion;
4445
try {
@@ -212,6 +213,7 @@ export const __testing = {
212213
clientFactory = factory;
213214
},
214215
resetCodexAppServerClientFactoryForTests(): void {
215-
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
216+
clientFactory = (startOptions, authProfileId) =>
217+
getSharedCodexAppServerClient({ startOptions, authProfileId });
216218
},
217219
} as const;

extensions/codex/src/app-server/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type CodexAppServerStartOptions = {
1212
url?: string;
1313
authToken?: string;
1414
headers: Record<string, string>;
15+
env?: Record<string, string>;
16+
clearEnv?: string[];
1517
};
1618

1719
export type CodexAppServerRuntimeOptions = {
@@ -158,6 +160,8 @@ export function codexAppServerStartOptionsKey(options: CodexAppServerStartOption
158160
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
159161
left.localeCompare(right),
160162
),
163+
env: Object.entries(options.env ?? {}).toSorted(([left], [right]) => left.localeCompare(right)),
164+
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
161165
});
162166
}
163167

extensions/codex/src/app-server/models.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type CodexAppServerListModelsOptions = {
2828
includeHidden?: boolean;
2929
timeoutMs?: number;
3030
startOptions?: CodexAppServerStartOptions;
31+
authProfileId?: string;
3132
sharedClient?: boolean;
3233
};
3334

@@ -40,10 +41,12 @@ export async function listCodexAppServerModels(
4041
? await getSharedCodexAppServerClient({
4142
startOptions: options.startOptions,
4243
timeoutMs,
44+
authProfileId: options.authProfileId,
4345
})
4446
: await createIsolatedCodexAppServerClient({
4547
startOptions: options.startOptions,
4648
timeoutMs,
49+
authProfileId: options.authProfileId,
4750
});
4851
try {
4952
const response = await client.request<JsonObject>(

extensions/codex/src/app-server/request.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
88
requestParams?: JsonValue;
99
timeoutMs?: number;
1010
startOptions?: CodexAppServerStartOptions;
11+
authProfileId?: string;
1112
}): Promise<T> {
1213
const timeoutMs = params.timeoutMs ?? 60_000;
1314
return await withTimeout(
1415
(async () => {
1516
const client = await getSharedCodexAppServerClient({
1617
startOptions: params.startOptions,
1718
timeoutMs,
19+
authProfileId: params.authProfileId,
1820
});
1921
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
2022
})(),

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,53 @@ describe("runCodexAppServerAttempt", () => {
313313
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
314314
});
315315

316+
it("passes the selected auth profile into app-server startup", async () => {
317+
const seenAuthProfileIds: Array<string | undefined> = [];
318+
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
319+
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
320+
seenAuthProfileIds.push(authProfileId);
321+
return {
322+
request: async (method: string) => {
323+
if (method === "thread/start") {
324+
return {
325+
thread: { id: "thread-1" },
326+
model: "gpt-5.4-codex",
327+
modelProvider: "openai",
328+
};
329+
}
330+
if (method === "turn/start") {
331+
return { turn: { id: "turn-1", status: "inProgress" } };
332+
}
333+
return {};
334+
},
335+
addNotificationHandler: (handler: typeof notify) => {
336+
notify = handler;
337+
return () => undefined;
338+
},
339+
addRequestHandler: () => () => undefined,
340+
} as never;
341+
});
342+
const params = createParams(
343+
path.join(tempDir, "session.jsonl"),
344+
path.join(tempDir, "workspace"),
345+
);
346+
params.authProfileId = "openai-codex:work";
347+
348+
const run = runCodexAppServerAttempt(params);
349+
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:work"]));
350+
await notify({
351+
method: "turn/completed",
352+
params: {
353+
threadId: "thread-1",
354+
turnId: "turn-1",
355+
turn: { id: "turn-1", status: "completed" },
356+
},
357+
});
358+
await run;
359+
360+
expect(seenAuthProfileIds).toEqual(["openai-codex:work"]);
361+
});
362+
316363
it("times out turn start before the active run handle is installed", async () => {
317364
const request = vi.fn(
318365
async (method: string, _params?: unknown, options?: { timeoutMs?: number }) => {

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
3737

3838
type CodexAppServerClientFactory = (
3939
startOptions?: CodexAppServerStartOptions,
40+
authProfileId?: string,
4041
) => Promise<CodexAppServerClient>;
4142

42-
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
43-
getSharedCodexAppServerClient({ startOptions });
43+
let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) =>
44+
getSharedCodexAppServerClient({ startOptions, authProfileId });
4445

4546
export async function runCodexAppServerAttempt(
4647
params: EmbeddedRunAttemptParams,
@@ -101,7 +102,7 @@ export async function runCodexAppServerAttempt(
101102
timeoutMs: params.timeoutMs,
102103
signal: runAbortController.signal,
103104
operation: async () => {
104-
const startupClient = await clientFactory(appServer.start);
105+
const startupClient = await clientFactory(appServer.start, params.authProfileId);
105106
const startupThread = await startOrResumeThread({
106107
client: startupClient,
107108
params,
@@ -487,6 +488,7 @@ export const __testing = {
487488
clientFactory = factory;
488489
},
489490
resetCodexAppServerClientFactoryForTests(): void {
490-
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
491+
clientFactory = (startOptions, authProfileId) =>
492+
getSharedCodexAppServerClient({ startOptions, authProfileId });
491493
},
492494
} as const;

0 commit comments

Comments
 (0)