Skip to content

Commit 65c7c97

Browse files
jeffjhuntersteipete
authored andcommitted
Fix Codex app-server OAuth harness auth
1 parent 9139ce0 commit 65c7c97

10 files changed

Lines changed: 314 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
6969
- Memory: cap dreaming promotion writes to `MEMORY.md` by compacting oldest auto-promoted sections while preserving user-authored notes, keeping active memory below the bootstrap budget. Fixes #73691. (#74088) Thanks @YB0y.
7070
- Telegram: show resolved thinking defaults in native `/status` and `/think` menus while preserving explicit session overrides. (#80341) Thanks @VACInc.
7171
- Channels: cache selected channel registry lookups against the active fallback snapshot so pinned-empty registries refresh native command and alias routing after active registry swaps. (#80333) Thanks @samzong.
72+
- Codex app-server: reuse native Codex CLI OAuth for isolated app-server harness login, refresh, and app inventory cache keys so ChatGPT-authenticated Codex runs no longer fall back to unauthenticated OpenAI API calls. (#79877) Thanks @jeffjhunter.
7273
- Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong.
7374
- Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong.
7475
- Gateway: consolidate duplicate `openclaw doctor` service config panels while preserving the declined-repair `--force` hint. Fixes #80287. (#78688) Thanks @YB0y.

extensions/codex/src/app-server/auth-bridge.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
9797
? { apiKey, provider: oauthCredential.provider, email: oauthCredential.email }
9898
: null;
9999
},
100+
refreshOAuthCredentialForRuntime: async (
101+
params: Parameters<typeof actual.refreshOAuthCredentialForRuntime>[0],
102+
) => {
103+
const refreshed = await providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin({
104+
provider: params.credential.provider,
105+
context: params.credential,
106+
});
107+
return refreshed
108+
? {
109+
...params.credential,
110+
...refreshed,
111+
type: "oauth" as const,
112+
}
113+
: null;
114+
},
100115
};
101116
});
102117

@@ -142,6 +157,20 @@ function expectOAuthProfile(
142157
return profile;
143158
}
144159

160+
async function writeCodexCliAuthFile(codexHome: string): Promise<void> {
161+
await fs.mkdir(codexHome, { recursive: true });
162+
await fs.writeFile(
163+
path.join(codexHome, "auth.json"),
164+
`${JSON.stringify({
165+
tokens: {
166+
access_token: "cli-access-token",
167+
refresh_token: "cli-refresh-token",
168+
account_id: "account-cli",
169+
},
170+
})}\n`,
171+
);
172+
}
173+
145174
describe("bridgeCodexAppServerStartOptions", () => {
146175
it("sets agent-owned CODEX_HOME and HOME for local app-server launches", async () => {
147176
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
@@ -584,6 +613,81 @@ describe("bridgeCodexAppServerStartOptions", () => {
584613
}
585614
});
586615

616+
it("applies native Codex CLI OAuth when no OpenClaw auth profile exists", async () => {
617+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
618+
const agentDir = path.join(root, "agent");
619+
const codexHome = path.join(root, "codex-cli");
620+
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
621+
vi.stubEnv("CODEX_HOME", codexHome);
622+
try {
623+
await writeCodexCliAuthFile(codexHome);
624+
625+
await applyCodexAppServerAuthProfile({
626+
client: { request } as never,
627+
agentDir,
628+
});
629+
630+
expect(request).toHaveBeenCalledWith("account/login/start", {
631+
type: "chatgptAuthTokens",
632+
accessToken: "cli-access-token",
633+
chatgptAccountId: "account-cli",
634+
chatgptPlanType: null,
635+
});
636+
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles).not.toHaveProperty(
637+
"openai-codex:default",
638+
);
639+
} finally {
640+
await fs.rm(root, { recursive: true, force: true });
641+
}
642+
});
643+
644+
it("answers refresh from native Codex CLI OAuth without persisting an OpenClaw profile", async () => {
645+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
646+
const agentDir = path.join(root, "agent");
647+
const codexHome = path.join(root, "codex-cli");
648+
const authProfileStorePath = path.join(agentDir, "auth-profiles.json");
649+
vi.stubEnv("CODEX_HOME", codexHome);
650+
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
651+
access: "fresh-cli-access-token",
652+
refresh: "fresh-cli-refresh-token",
653+
expires: Date.now() + 60_000,
654+
accountId: "account-cli-refreshed",
655+
});
656+
try {
657+
await writeCodexCliAuthFile(codexHome);
658+
659+
await expect(refreshCodexAppServerAuthTokens({ agentDir })).resolves.toEqual({
660+
accessToken: "fresh-cli-access-token",
661+
chatgptAccountId: "account-cli-refreshed",
662+
chatgptPlanType: null,
663+
});
664+
665+
await expectPathMissing(authProfileStorePath);
666+
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("cli-refresh-token");
667+
} finally {
668+
await fs.rm(root, { recursive: true, force: true });
669+
}
670+
});
671+
672+
it("uses native Codex CLI OAuth when deriving cache keys from a supplied base store", async () => {
673+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
674+
const agentDir = path.join(root, "agent");
675+
const codexHome = path.join(root, "codex-cli");
676+
vi.stubEnv("CODEX_HOME", codexHome);
677+
try {
678+
await writeCodexCliAuthFile(codexHome);
679+
680+
await expect(
681+
resolveCodexAppServerAuthAccountCacheKey({
682+
agentDir,
683+
authProfileStore: { version: 1, profiles: {} },
684+
}),
685+
).resolves.toBe("account-cli");
686+
} finally {
687+
await fs.rm(root, { recursive: true, force: true });
688+
}
689+
});
690+
587691
it("honors config auth order when selecting an implicit Codex profile", async () => {
588692
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
589693
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

extensions/codex/src/app-server/auth-bridge.ts

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import {
55
ensureAuthProfileStore,
6+
ensureAuthProfileStoreWithoutExternalProfiles,
67
loadAuthProfileStoreForSecretsRuntime,
8+
refreshOAuthCredentialForRuntime,
79
resolveAuthProfileOrder,
810
resolveProviderIdForAuth,
911
resolveApiKeyForProfile,
@@ -50,7 +52,11 @@ export async function bridgeCodexAppServerStartOptions(params: {
5052
params.startOptions,
5153
params.agentDir,
5254
);
53-
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
55+
const store = ensureCodexAppServerAuthProfileStore({
56+
agentDir: params.agentDir,
57+
authProfileId: params.authProfileId,
58+
config: params.config,
59+
});
5460
const authProfileId = resolveCodexAppServerAuthProfileId({
5561
authProfileId: params.authProfileId,
5662
store,
@@ -88,23 +94,75 @@ export function resolveCodexAppServerAuthProfileIdForAgent(params: {
8894
config?: AuthProfileOrderConfig;
8995
}): string | undefined {
9096
const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {});
91-
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
97+
const store = ensureCodexAppServerAuthProfileStore({
98+
agentDir,
99+
authProfileId: params.authProfileId,
100+
config: params.config,
101+
});
92102
return resolveCodexAppServerAuthProfileId({
93103
authProfileId: params.authProfileId,
94104
store,
95105
config: params.config,
96106
});
97107
}
98108

109+
function ensureCodexAppServerAuthProfileStore(params: {
110+
agentDir?: string;
111+
authProfileId?: string;
112+
config?: AuthProfileOrderConfig;
113+
}): ReturnType<typeof ensureAuthProfileStore> {
114+
return ensureAuthProfileStore(params.agentDir, {
115+
allowKeychainPrompt: false,
116+
config: params.config,
117+
externalCliProviderIds: [CODEX_APP_SERVER_AUTH_PROVIDER],
118+
...(params.authProfileId ? { externalCliProfileIds: [params.authProfileId] } : {}),
119+
});
120+
}
121+
122+
function resolveCodexAppServerAuthProfileStore(params: {
123+
agentDir?: string;
124+
authProfileId?: string;
125+
authProfileStore?: AuthProfileStore;
126+
config?: AuthProfileOrderConfig;
127+
}): AuthProfileStore {
128+
const overlaidStore = ensureCodexAppServerAuthProfileStore({
129+
agentDir: params.agentDir,
130+
authProfileId: params.authProfileId,
131+
config: params.config,
132+
});
133+
if (!params.authProfileStore) {
134+
return overlaidStore;
135+
}
136+
const order =
137+
params.authProfileStore.order || overlaidStore.order
138+
? {
139+
...overlaidStore.order,
140+
...params.authProfileStore.order,
141+
}
142+
: undefined;
143+
return {
144+
...params.authProfileStore,
145+
...(order ? { order } : {}),
146+
profiles: {
147+
...overlaidStore.profiles,
148+
...params.authProfileStore.profiles,
149+
},
150+
};
151+
}
152+
99153
export async function resolveCodexAppServerAuthAccountCacheKey(params: {
100154
authProfileId?: string;
101155
authProfileStore?: AuthProfileStore;
102156
agentDir?: string;
103157
config?: AuthProfileOrderConfig;
104158
}): Promise<string | undefined> {
105159
const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {});
106-
const store =
107-
params.authProfileStore ?? ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
160+
const store = resolveCodexAppServerAuthProfileStore({
161+
agentDir,
162+
authProfileId: params.authProfileId,
163+
authProfileStore: params.authProfileStore,
164+
config: params.config,
165+
});
108166
const profileId = resolveCodexAppServerAuthProfileId({
109167
authProfileId: params.authProfileId,
110168
store,
@@ -292,7 +350,11 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
292350
forceOAuthRefresh?: boolean;
293351
config?: AuthProfileOrderConfig;
294352
}): Promise<CodexLoginAccountParams | undefined> {
295-
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
353+
const store = ensureCodexAppServerAuthProfileStore({
354+
agentDir: params.agentDir,
355+
authProfileId: params.authProfileId,
356+
config: params.config,
357+
});
296358
const profileId = resolveCodexAppServerAuthProfileId({
297359
authProfileId: params.authProfileId,
298360
store,
@@ -388,14 +450,38 @@ async function resolveOAuthCredentialForCodexAppServer(
388450
agentDir: params.agentDir,
389451
profileId,
390452
});
391-
const store = ensureAuthProfileStore(ownerAgentDir, { allowKeychainPrompt: false });
453+
const store = ensureCodexAppServerAuthProfileStore({
454+
agentDir: ownerAgentDir,
455+
authProfileId: profileId,
456+
config: params.config,
457+
});
458+
const persistedStore = ensureAuthProfileStoreWithoutExternalProfiles(ownerAgentDir, {
459+
allowKeychainPrompt: false,
460+
});
461+
const persistedCredential = persistedStore.profiles[profileId];
462+
const persistedOAuthCredential =
463+
persistedCredential?.type === "oauth" &&
464+
isCodexAppServerAuthProvider(persistedCredential.provider, params.config)
465+
? persistedCredential
466+
: undefined;
392467
const ownerCredential = store.profiles[profileId];
393-
const credentialForOwner =
468+
const overlaidOAuthCredential =
394469
ownerCredential?.type === "oauth" &&
395470
isCodexAppServerAuthProvider(ownerCredential.provider, params.config)
396471
? ownerCredential
397-
: credential;
398-
if (params.forceRefresh) {
472+
: undefined;
473+
const credentialForOwner = persistedOAuthCredential ?? overlaidOAuthCredential ?? credential;
474+
if (params.forceRefresh && !persistedOAuthCredential && overlaidOAuthCredential) {
475+
const refreshedRuntimeCredential = await refreshOAuthCredentialForRuntime({
476+
credential: overlaidOAuthCredential,
477+
});
478+
if (!refreshedRuntimeCredential?.access?.trim()) {
479+
throw new Error(`Codex app-server auth profile "${profileId}" could not refresh.`);
480+
}
481+
store.profiles[profileId] = refreshedRuntimeCredential;
482+
return refreshedRuntimeCredential;
483+
}
484+
if (params.forceRefresh && persistedOAuthCredential) {
399485
store.profiles[profileId] = { ...credentialForOwner, expires: 0 };
400486
saveAuthProfileStore(store, ownerAgentDir);
401487
}

extensions/codex/src/app-server/session-binding.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import {
66
clearCodexAppServerBinding,
77
readCodexAppServerBinding,
@@ -27,12 +27,27 @@ const nativeAuthLookup: Pick<CodexAppServerAuthProfileLookup, "authProfileStore"
2727
},
2828
};
2929

30+
async function writeCodexCliAuthFile(codexHome: string): Promise<void> {
31+
await fs.mkdir(codexHome, { recursive: true });
32+
await fs.writeFile(
33+
path.join(codexHome, "auth.json"),
34+
`${JSON.stringify({
35+
tokens: {
36+
access_token: "cli-access-token",
37+
refresh_token: "cli-refresh-token",
38+
account_id: "account-cli",
39+
},
40+
})}\n`,
41+
);
42+
}
43+
3044
describe("codex app-server session binding", () => {
3145
beforeEach(async () => {
3246
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-binding-"));
3347
});
3448

3549
afterEach(async () => {
50+
vi.unstubAllEnvs();
3651
await fs.rm(tempDir, { recursive: true, force: true });
3752
});
3853

@@ -230,6 +245,33 @@ describe("codex app-server session binding", () => {
230245
expect(binding?.modelProvider).toBe("openai");
231246
});
232247

248+
it("normalizes Codex CLI OAuth bindings even without a local auth profile slot", async () => {
249+
const sessionFile = path.join(tempDir, "session.json");
250+
const codexHome = path.join(tempDir, "codex-cli");
251+
const agentDir = path.join(tempDir, "agent");
252+
vi.stubEnv("CODEX_HOME", codexHome);
253+
await writeCodexCliAuthFile(codexHome);
254+
255+
await writeCodexAppServerBinding(
256+
sessionFile,
257+
{
258+
threadId: "thread-123",
259+
cwd: tempDir,
260+
authProfileId: "openai-codex:default",
261+
model: "gpt-5.4-mini",
262+
modelProvider: "openai",
263+
},
264+
{ agentDir },
265+
);
266+
267+
const raw = await fs.readFile(resolveCodexAppServerBindingPath(sessionFile), "utf8");
268+
const binding = await readCodexAppServerBinding(sessionFile, { agentDir });
269+
270+
expect(raw).not.toContain('"modelProvider": "openai"');
271+
expect(binding?.authProfileId).toBe("openai-codex:default");
272+
expect(binding?.modelProvider).toBeUndefined();
273+
});
274+
233275
it("clears missing bindings without throwing", async () => {
234276
const sessionFile = path.join(tempDir, "missing.json");
235277
await clearCodexAppServerBinding(sessionFile);

extensions/codex/src/app-server/session-binding.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -271,17 +271,29 @@ function resolveCodexAppServerAuthProfileCredential(
271271
return undefined;
272272
}
273273
const store =
274-
lookup.authProfileStore ?? loadCodexAppServerAuthProfileStore(lookup.agentDir, lookup.config);
274+
lookup.authProfileStore ??
275+
loadCodexAppServerAuthProfileStore({
276+
agentDir: lookup.agentDir,
277+
authProfileId,
278+
config: lookup.config,
279+
});
275280
return store.profiles[authProfileId];
276281
}
277282

278-
function loadCodexAppServerAuthProfileStore(
279-
agentDir: string | undefined,
280-
config?: ProviderAuthAliasConfig,
281-
): AuthProfileStore {
282-
return ensureAuthProfileStore(agentDir?.trim() || resolveDefaultAgentDir(config ?? {}), {
283-
allowKeychainPrompt: false,
284-
});
283+
function loadCodexAppServerAuthProfileStore(params: {
284+
agentDir: string | undefined;
285+
authProfileId: string;
286+
config?: ProviderAuthAliasConfig;
287+
}): AuthProfileStore {
288+
return ensureAuthProfileStore(
289+
params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {}),
290+
{
291+
allowKeychainPrompt: false,
292+
config: params.config,
293+
externalCliProviderIds: [CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER],
294+
externalCliProfileIds: [params.authProfileId],
295+
},
296+
);
285297
}
286298

287299
function isCodexAppServerNativeAuthProvider(params: {

0 commit comments

Comments
 (0)