Skip to content

Commit 20ff49f

Browse files
pashpashpashsteipete
authored andcommitted
fix(codex): auto-clear api key for subscription auth
1 parent aeb007e commit 20ff49f

4 files changed

Lines changed: 229 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- Providers/Codex: pass agent and workspace directories into provider stream wrappers so Codex native `web_search` activation can evaluate the correct auth context, and smoke-test the built status-message runtime by resolving the emitted bundle name. Carries forward #67843; refs #65909. Thanks @neilofneils404.
2828
- Cron/models: keep `payload.model` as a per-job primary that can use configured fallbacks, while still letting `payload.fallbacks: []` make cron runs strict and avoid hidden agent-primary retries. Refs #73023. Thanks @pavelyortho-cyber.
2929
- Models/fallbacks: treat user-selected session models as exact choices, so `/model ollama/...` and model-picker switches fail visibly when the selected provider is unreachable instead of answering from an unrelated configured fallback. Fixes #73023. Thanks @pavelyortho-cyber.
30-
- Codex harness: expose `appServer.clearEnv` in the plugin config schema so deployments can keep Gateway-level `OPENAI_API_KEY` for embeddings and direct OpenAI models while removing it from the spawned native Codex app-server process. Fixes #73057. Thanks @holgergruenhagen.
30+
- Codex harness: automatically clear inherited `OPENAI_API_KEY` from spawned Codex app-server processes when the harness is using ChatGPT subscription auth, while keeping explicit Codex API-key profiles and the manual `appServer.clearEnv` escape hatch available. Fixes #73057. Thanks @holgergruenhagen.
3131
- CLI/model probes: fail local `infer model run` probes when the provider returns no text output, so unreachable local providers and empty completions no longer look like successful smoke tests. Refs #73023. Thanks @pavelyortho-cyber.
3232
- CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020.
3333
- Doctor/gateway services: ignore launchd/systemd companion services that only reference the gateway as a dependency, suppress inactive Linux extra-service warnings, and avoid rewriting a running systemd gateway command/entrypoint during doctor repair. Carries forward #39118. Thanks @therk.

docs/plugins/codex-harness.md

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -509,9 +509,15 @@ For an already-running app-server, use WebSocket transport:
509509
```
510510

511511
Stdio app-server launches inherit OpenClaw's process environment by default.
512-
When the Gateway needs `OPENAI_API_KEY` for embeddings or direct OpenAI models
513-
but Codex should use the local ChatGPT login, clear that variable only for the
514-
Codex child:
512+
When OpenClaw sees that the Codex harness is using a ChatGPT subscription-style
513+
auth profile, including the local Codex CLI login imported as
514+
`openai-codex:default`, it automatically removes `OPENAI_API_KEY` from the
515+
spawned Codex child process. That keeps Gateway-level API keys available for
516+
embeddings or direct OpenAI models without making native Codex app-server turns
517+
bill through the API by accident.
518+
519+
Explicit Codex API-key profiles are left alone. If a deployment needs additional
520+
environment isolation, add those variables to `appServer.clearEnv`:
515521

516522
```json5
517523
{
@@ -534,21 +540,21 @@ Codex child:
534540

535541
Supported `appServer` fields:
536542

537-
| Field | Default | Meaning |
538-
| ------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
539-
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
540-
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
541-
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
542-
| `url` | unset | WebSocket app-server URL. |
543-
| `authToken` | unset | Bearer token for WebSocket transport. |
544-
| `headers` | `{}` | Extra WebSocket headers. |
545-
| `clearEnv` | `[]` | Environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
546-
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
547-
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
548-
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
549-
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
550-
| `approvalsReviewer` | `"user"` | Use `"auto_review"` to let Codex review native approval prompts. `guardian_subagent` remains a legacy alias. |
551-
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
543+
| Field | Default | Meaning |
544+
| ------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
545+
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
546+
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
547+
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
548+
| `url` | unset | WebSocket app-server URL. |
549+
| `authToken` | unset | Bearer token for WebSocket transport. |
550+
| `headers` | `{}` | Extra WebSocket headers. |
551+
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
552+
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
553+
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
554+
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
555+
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
556+
| `approvalsReviewer` | `"user"` | Use `"auto_review"` to let Codex review native approval prompts. `guardian_subagent` remains a legacy alias. |
557+
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
552558

553559
Environment overrides remain available for local testing:
554560

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

Lines changed: 159 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
bridgeCodexAppServerStartOptions,
99
refreshCodexAppServerAuthTokens,
1010
} from "./auth-bridge.js";
11+
import type { CodexAppServerStartOptions } from "./config.js";
1112

1213
const oauthMocks = vi.hoisted(() => ({
1314
refreshOpenAICodexToken: vi.fn(),
@@ -96,25 +97,54 @@ afterEach(() => {
9697
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear();
9798
});
9899

100+
function createStartOptions(
101+
overrides: Partial<CodexAppServerStartOptions> = {},
102+
): CodexAppServerStartOptions {
103+
return {
104+
transport: "stdio",
105+
command: "codex",
106+
args: ["app-server"],
107+
headers: { authorization: "Bearer dev-token" },
108+
...overrides,
109+
};
110+
}
111+
112+
async function writeCodexCliAuthFile(codexHome: string): Promise<void> {
113+
await fs.mkdir(codexHome, { recursive: true });
114+
await fs.writeFile(
115+
path.join(codexHome, "auth.json"),
116+
JSON.stringify({
117+
tokens: {
118+
access_token: "cli-access-token",
119+
refresh_token: "cli-refresh-token",
120+
account_id: "cli-account-123",
121+
},
122+
}),
123+
);
124+
}
125+
99126
describe("bridgeCodexAppServerStartOptions", () => {
100-
it("leaves Codex app-server start options unchanged", async () => {
127+
it("clears an inherited OpenAI API key when local Codex CLI OAuth is available", async () => {
101128
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
102-
const startOptions = {
103-
transport: "stdio" as const,
104-
command: "codex",
105-
args: ["app-server"],
106-
headers: { authorization: "Bearer dev-token" },
129+
const codexHome = path.join(agentDir, "codex-home");
130+
const startOptions = createStartOptions({
107131
env: { CODEX_HOME: "/tmp/source-codex-home", EXISTING: "1" },
108132
clearEnv: ["FOO"],
109-
};
133+
});
134+
vi.stubEnv("CODEX_HOME", codexHome);
110135
try {
136+
await writeCodexCliAuthFile(codexHome);
137+
111138
await expect(
112139
bridgeCodexAppServerStartOptions({
113140
startOptions,
114141
agentDir,
115-
authProfileId: "openai-codex:default",
116142
}),
117-
).resolves.toBe(startOptions);
143+
).resolves.toEqual({
144+
...startOptions,
145+
clearEnv: ["FOO", "OPENAI_API_KEY"],
146+
});
147+
expect(startOptions.clearEnv).toEqual(["FOO"]);
118148
await expect(fs.access(path.join(agentDir, "harness-auth"))).rejects.toMatchObject({
119149
code: "ENOENT",
120150
});
@@ -123,6 +153,126 @@ describe("bridgeCodexAppServerStartOptions", () => {
123153
}
124154
});
125155

156+
it("clears an inherited OpenAI API key for an explicit Codex OAuth profile", async () => {
157+
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
158+
const startOptions = createStartOptions({ clearEnv: ["FOO"] });
159+
try {
160+
upsertAuthProfile({
161+
agentDir,
162+
profileId: "openai-codex:work",
163+
credential: {
164+
type: "oauth",
165+
provider: "openai-codex",
166+
access: "access-token",
167+
refresh: "refresh-token",
168+
expires: Date.now() + 24 * 60 * 60_000,
169+
accountId: "account-123",
170+
},
171+
});
172+
173+
await expect(
174+
bridgeCodexAppServerStartOptions({
175+
startOptions,
176+
agentDir,
177+
authProfileId: "openai-codex:work",
178+
}),
179+
).resolves.toEqual({
180+
...startOptions,
181+
clearEnv: ["FOO", "OPENAI_API_KEY"],
182+
});
183+
} finally {
184+
await fs.rm(agentDir, { recursive: true, force: true });
185+
}
186+
});
187+
188+
it("clears an inherited OpenAI API key for an explicit Codex token profile", async () => {
189+
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
190+
const startOptions = createStartOptions({ clearEnv: ["FOO"] });
191+
try {
192+
upsertAuthProfile({
193+
agentDir,
194+
profileId: "openai-codex:work",
195+
credential: {
196+
type: "token",
197+
provider: "openai-codex",
198+
token: "access-token",
199+
},
200+
});
201+
202+
await expect(
203+
bridgeCodexAppServerStartOptions({
204+
startOptions,
205+
agentDir,
206+
authProfileId: "openai-codex:work",
207+
}),
208+
).resolves.toEqual({
209+
...startOptions,
210+
clearEnv: ["FOO", "OPENAI_API_KEY"],
211+
});
212+
} finally {
213+
await fs.rm(agentDir, { recursive: true, force: true });
214+
}
215+
});
216+
217+
it("keeps an inherited OpenAI API key for an explicit Codex api-key profile", async () => {
218+
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
219+
const startOptions = createStartOptions({ clearEnv: ["FOO"] });
220+
try {
221+
upsertAuthProfile({
222+
agentDir,
223+
profileId: "openai-codex:work",
224+
credential: {
225+
type: "api_key",
226+
provider: "openai-codex",
227+
key: "explicit-api-key",
228+
},
229+
});
230+
231+
await expect(
232+
bridgeCodexAppServerStartOptions({
233+
startOptions,
234+
agentDir,
235+
authProfileId: "openai-codex:work",
236+
}),
237+
).resolves.toBe(startOptions);
238+
} finally {
239+
await fs.rm(agentDir, { recursive: true, force: true });
240+
}
241+
});
242+
243+
it("does not clear process environment for websocket app-server connections", async () => {
244+
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
245+
const startOptions = createStartOptions({
246+
transport: "websocket",
247+
url: "ws://127.0.0.1:1455",
248+
clearEnv: ["FOO"],
249+
});
250+
try {
251+
upsertAuthProfile({
252+
agentDir,
253+
profileId: "openai-codex:work",
254+
credential: {
255+
type: "oauth",
256+
provider: "openai-codex",
257+
access: "access-token",
258+
refresh: "refresh-token",
259+
expires: Date.now() + 24 * 60 * 60_000,
260+
accountId: "account-123",
261+
},
262+
});
263+
264+
await expect(
265+
bridgeCodexAppServerStartOptions({
266+
startOptions,
267+
agentDir,
268+
authProfileId: "openai-codex:work",
269+
}),
270+
).resolves.toBe(startOptions);
271+
} finally {
272+
await fs.rm(agentDir, { recursive: true, force: true });
273+
}
274+
});
275+
126276
it("applies an OpenAI Codex OAuth profile through app-server login", async () => {
127277
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
128278
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,25 @@ import type { ChatgptAuthTokensRefreshResponse } from "./protocol-generated/type
1313
import type { LoginAccountParams } from "./protocol-generated/typescript/v2/LoginAccountParams.js";
1414

1515
const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex";
16+
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
17+
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
1618

1719
export async function bridgeCodexAppServerStartOptions(params: {
1820
startOptions: CodexAppServerStartOptions;
1921
agentDir: string;
2022
authProfileId?: string;
2123
}): Promise<CodexAppServerStartOptions> {
22-
void params.agentDir;
23-
void params.authProfileId;
24-
return params.startOptions;
24+
if (params.startOptions.transport !== "stdio") {
25+
return params.startOptions;
26+
}
27+
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
28+
const shouldClearInheritedOpenAiApiKey = shouldClearOpenAiApiKeyForCodexAuthProfile({
29+
store,
30+
authProfileId: params.authProfileId,
31+
});
32+
return shouldClearInheritedOpenAiApiKey
33+
? withClearedEnvironmentVariable(params.startOptions, OPENAI_API_KEY_ENV_VAR)
34+
: params.startOptions;
2535
}
2636

2737
export async function applyCodexAppServerAuthProfile(params: {
@@ -161,6 +171,38 @@ function isCodexAppServerAuthProvider(provider: string): boolean {
161171
return resolveProviderIdForAuth(provider) === CODEX_APP_SERVER_AUTH_PROVIDER;
162172
}
163173

174+
function shouldClearOpenAiApiKeyForCodexAuthProfile(params: {
175+
store: ReturnType<typeof ensureAuthProfileStore>;
176+
authProfileId?: string;
177+
}): boolean {
178+
const profileId = params.authProfileId?.trim();
179+
const credential = profileId
180+
? params.store.profiles[profileId]
181+
: params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID];
182+
return isCodexSubscriptionCredential(credential);
183+
}
184+
185+
function isCodexSubscriptionCredential(credential: AuthProfileCredential | undefined): boolean {
186+
if (!credential || !isCodexAppServerAuthProvider(credential.provider)) {
187+
return false;
188+
}
189+
return credential.type === "oauth" || credential.type === "token";
190+
}
191+
192+
function withClearedEnvironmentVariable(
193+
startOptions: CodexAppServerStartOptions,
194+
envVar: string,
195+
): CodexAppServerStartOptions {
196+
const clearEnv = startOptions.clearEnv ?? [];
197+
if (clearEnv.includes(envVar)) {
198+
return startOptions;
199+
}
200+
return {
201+
...startOptions,
202+
clearEnv: [...clearEnv, envVar],
203+
};
204+
}
205+
164206
function buildChatgptAuthTokensParams(
165207
profileId: string,
166208
credential: AuthProfileCredential,

0 commit comments

Comments
 (0)