Skip to content

Commit 401ae38

Browse files
pashpashpashsteipete
authored andcommitted
fix(codex): keep env fallback local to stdio app-server
1 parent 5f15bea commit 401ae38

7 files changed

Lines changed: 84 additions & 18 deletions

File tree

docs/plugins/codex-harness.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,9 @@ Codex after changing config.
185185
The plugin blocks older or unversioned app-server handshakes. That keeps
186186
OpenClaw on the protocol surface it has been tested against.
187187

188-
For live and Docker smoke tests, auth usually comes from the Codex CLI account,
189-
an OpenClaw `openai-codex` auth profile, or `CODEX_API_KEY` /
190-
`OPENAI_API_KEY` as a fallback when no account is present.
188+
For live and Docker smoke tests, auth usually comes from the Codex CLI account
189+
or an OpenClaw `openai-codex` auth profile. Local stdio app-server launches can
190+
also fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` when no account is present.
191191

192192
## Minimal config
193193

@@ -514,15 +514,18 @@ order:
514514

515515
1. An explicit OpenClaw Codex auth profile for the agent.
516516
2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in.
517-
3. `CODEX_API_KEY`, then `OPENAI_API_KEY`, only when no app-server account is
518-
present and OpenAI auth is still required.
517+
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
518+
`OPENAI_API_KEY`, when no app-server account is present and OpenAI auth is
519+
still required.
519520

520521
When OpenClaw sees a ChatGPT subscription-style Codex auth profile, it removes
521522
`CODEX_API_KEY` and `OPENAI_API_KEY` from the spawned Codex child process. That
522523
keeps Gateway-level API keys available for embeddings or direct OpenAI models
523524
without making native Codex app-server turns bill through the API by accident.
524-
Explicit Codex API-key profiles and env-key fallback use app-server login
525-
instead of inherited child-process env.
525+
Explicit Codex API-key profiles and local stdio env-key fallback use app-server
526+
login instead of inherited child-process env. WebSocket app-server connections
527+
do not receive Gateway env API-key fallback; use an explicit auth profile or the
528+
remote app-server's own account.
526529

527530
If a deployment needs additional environment isolation, add those variables to
528531
`appServer.clearEnv`:

docs/providers/openai.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -293,15 +293,17 @@ selects auth in this order:
293293

294294
1. An explicit OpenClaw `openai-codex` auth profile bound to the agent.
295295
2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in.
296-
3. `CODEX_API_KEY`, then `OPENAI_API_KEY`, only when the app-server reports no
297-
account and still requires OpenAI auth.
296+
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
297+
`OPENAI_API_KEY`, when the app-server reports no account and still requires
298+
OpenAI auth.
298299

299300
That means a local ChatGPT/Codex subscription sign-in is not replaced just
300301
because the gateway process also has `OPENAI_API_KEY` for direct OpenAI models
301-
or embeddings. API-key fallback is only the no-account path. When a
302-
subscription-style Codex profile is selected, OpenClaw also keeps
303-
`CODEX_API_KEY` and `OPENAI_API_KEY` out of the spawned stdio app-server child
304-
and sends the selected credentials through the app-server login RPC.
302+
or embeddings. Env API-key fallback is only the local stdio no-account path; it
303+
is not sent to WebSocket app-server connections. When a subscription-style Codex
304+
profile is selected, OpenClaw also keeps `CODEX_API_KEY` and `OPENAI_API_KEY`
305+
out of the spawned stdio app-server child and sends the selected credentials
306+
through the app-server login RPC.
305307

306308
## Image generation
307309

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
389389
await applyCodexAppServerAuthProfile({
390390
client: { request } as never,
391391
agentDir,
392+
startOptions: createStartOptions(),
392393
});
393394

394395
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
@@ -415,6 +416,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
415416
await applyCodexAppServerAuthProfile({
416417
client: { request } as never,
417418
agentDir,
419+
startOptions: createStartOptions(),
418420
});
419421

420422
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
@@ -443,6 +445,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
443445
await applyCodexAppServerAuthProfile({
444446
client: { request } as never,
445447
agentDir,
448+
startOptions: createStartOptions(),
446449
});
447450

448451
expect(request).toHaveBeenCalledTimes(1);
@@ -465,6 +468,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
465468
await applyCodexAppServerAuthProfile({
466469
client: { request } as never,
467470
agentDir,
471+
startOptions: createStartOptions(),
468472
});
469473

470474
expect(request).toHaveBeenCalledTimes(1);
@@ -474,6 +478,32 @@ describe("bridgeCodexAppServerStartOptions", () => {
474478
}
475479
});
476480

481+
it("does not send env API-key fallback to websocket app-server connections", async () => {
482+
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
483+
const request = vi.fn(async (method: string) => {
484+
if (method === "account/read") {
485+
return { account: null, requiresOpenaiAuth: true };
486+
}
487+
return { type: "apiKey" };
488+
});
489+
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
490+
vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key");
491+
try {
492+
await applyCodexAppServerAuthProfile({
493+
client: { request } as never,
494+
agentDir,
495+
startOptions: createStartOptions({
496+
transport: "websocket",
497+
url: "ws://127.0.0.1:1455",
498+
}),
499+
});
500+
501+
expect(request).not.toHaveBeenCalled();
502+
} finally {
503+
await fs.rm(agentDir, { recursive: true, force: true });
504+
}
505+
});
506+
477507
it("applies an OpenAI Codex token profile backed by a secret ref", async () => {
478508
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
479509
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,16 @@ export async function applyCodexAppServerAuthProfile(params: {
4141
client: CodexAppServerClient;
4242
agentDir: string;
4343
authProfileId?: string;
44+
startOptions?: CodexAppServerStartOptions;
4445
}): Promise<void> {
4546
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
4647
agentDir: params.agentDir,
4748
authProfileId: params.authProfileId,
4849
});
4950
if (!loginParams) {
51+
if (params.startOptions?.transport !== "stdio") {
52+
return;
53+
}
5054
const fallbackLoginParams = await resolveCodexAppServerEnvApiKeyLoginParams({
5155
client: params.client,
5256
env: process.env,

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,16 @@ describe("Codex app-server config", () => {
311311
});
312312

313313
expect(first).not.toEqual(second);
314+
expect(
315+
codexAppServerStartOptionsKey({
316+
transport: "websocket",
317+
command: "codex",
318+
args: [],
319+
url: "ws://127.0.0.1:39175",
320+
authToken: "tok_first",
321+
headers: {},
322+
}),
323+
).toEqual(first);
314324
expect(first).not.toContain("tok_first");
315325
expect(second).not.toContain("tok_second");
316326
});
@@ -332,6 +342,15 @@ describe("Codex app-server config", () => {
332342
});
333343

334344
expect(first).not.toEqual(second);
345+
expect(
346+
codexAppServerStartOptionsKey({
347+
transport: "stdio",
348+
command: "codex",
349+
args: ["app-server"],
350+
headers: {},
351+
env: { OPENAI_API_KEY: "sk-first" },
352+
}),
353+
).toEqual(first);
335354
expect(first).not.toContain("sk-first");
336355
expect(second).not.toContain("sk-second");
337356
});

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { createHash } from "node:crypto";
1+
import { createHmac, randomBytes } from "node:crypto";
22
import { z } from "zod";
33
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
44

5+
const START_OPTIONS_KEY_SECRET = randomBytes(32);
6+
57
export type CodexAppServerTransportMode = "stdio" | "websocket";
68
export type CodexAppServerPolicyMode = "yolo" | "guardian";
79
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
@@ -300,13 +302,13 @@ export function codexAppServerStartOptionsKey(
300302
commandSource: options.commandSource ?? null,
301303
args: options.args,
302304
url: options.url ?? null,
303-
authToken: hashSecretForKey(options.authToken),
305+
authToken: hashSecretForKey(options.authToken, "authToken"),
304306
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
305307
left.localeCompare(right),
306308
),
307309
env: Object.entries(options.env ?? {})
308310
.toSorted(([left], [right]) => left.localeCompare(right))
309-
.map(([key, value]) => [key, hashSecretForKey(value)]),
311+
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
310312
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
311313
authProfileId: params.authProfileId ?? null,
312314
});
@@ -431,11 +433,15 @@ function readNonEmptyString(value: unknown): string | undefined {
431433
return trimmed || undefined;
432434
}
433435

434-
function hashSecretForKey(value: string | undefined): string | null {
436+
function hashSecretForKey(value: string | undefined, label: string): string | null {
435437
if (!value) {
436438
return null;
437439
}
438-
return createHash("sha256").update(value).digest("hex");
440+
return createHmac("sha256", START_OPTIONS_KEY_SECRET)
441+
.update(label)
442+
.update("\0")
443+
.update(value)
444+
.digest("hex");
439445
}
440446

441447
function splitShellWords(value: string): string[] {

extensions/codex/src/app-server/shared-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export async function getSharedCodexAppServerClient(options?: {
5959
client,
6060
agentDir,
6161
authProfileId: options?.authProfileId,
62+
startOptions,
6263
});
6364
return client;
6465
} catch (error) {
@@ -104,6 +105,7 @@ export async function createIsolatedCodexAppServerClient(options?: {
104105
client,
105106
agentDir,
106107
authProfileId: options?.authProfileId,
108+
startOptions,
107109
});
108110
return client;
109111
} catch (error) {

0 commit comments

Comments
 (0)