mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
fix(codex): keep env fallback local to stdio app-server
This commit is contained in:
committed by
Peter Steinberger
parent
5f15bea6ce
commit
401ae38f13
@@ -185,9 +185,9 @@ Codex after changing config.
|
||||
The plugin blocks older or unversioned app-server handshakes. That keeps
|
||||
OpenClaw on the protocol surface it has been tested against.
|
||||
|
||||
For live and Docker smoke tests, auth usually comes from the Codex CLI account,
|
||||
an OpenClaw `openai-codex` auth profile, or `CODEX_API_KEY` /
|
||||
`OPENAI_API_KEY` as a fallback when no account is present.
|
||||
For live and Docker smoke tests, auth usually comes from the Codex CLI account
|
||||
or an OpenClaw `openai-codex` auth profile. Local stdio app-server launches can
|
||||
also fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` when no account is present.
|
||||
|
||||
## Minimal config
|
||||
|
||||
@@ -514,15 +514,18 @@ order:
|
||||
|
||||
1. An explicit OpenClaw Codex auth profile for the agent.
|
||||
2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in.
|
||||
3. `CODEX_API_KEY`, then `OPENAI_API_KEY`, only when no app-server account is
|
||||
present and OpenAI auth is still required.
|
||||
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
|
||||
`OPENAI_API_KEY`, when no app-server account is present and OpenAI auth is
|
||||
still required.
|
||||
|
||||
When OpenClaw sees a ChatGPT subscription-style Codex auth profile, it removes
|
||||
`CODEX_API_KEY` and `OPENAI_API_KEY` from the spawned Codex child process. That
|
||||
keeps Gateway-level API keys available for embeddings or direct OpenAI models
|
||||
without making native Codex app-server turns bill through the API by accident.
|
||||
Explicit Codex API-key profiles and env-key fallback use app-server login
|
||||
instead of inherited child-process env.
|
||||
Explicit Codex API-key profiles and local stdio env-key fallback use app-server
|
||||
login instead of inherited child-process env. WebSocket app-server connections
|
||||
do not receive Gateway env API-key fallback; use an explicit auth profile or the
|
||||
remote app-server's own account.
|
||||
|
||||
If a deployment needs additional environment isolation, add those variables to
|
||||
`appServer.clearEnv`:
|
||||
|
||||
@@ -293,15 +293,17 @@ selects auth in this order:
|
||||
|
||||
1. An explicit OpenClaw `openai-codex` auth profile bound to the agent.
|
||||
2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in.
|
||||
3. `CODEX_API_KEY`, then `OPENAI_API_KEY`, only when the app-server reports no
|
||||
account and still requires OpenAI auth.
|
||||
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
|
||||
`OPENAI_API_KEY`, when the app-server reports no account and still requires
|
||||
OpenAI auth.
|
||||
|
||||
That means a local ChatGPT/Codex subscription sign-in is not replaced just
|
||||
because the gateway process also has `OPENAI_API_KEY` for direct OpenAI models
|
||||
or embeddings. API-key fallback is only the no-account path. When a
|
||||
subscription-style Codex profile is selected, OpenClaw also keeps
|
||||
`CODEX_API_KEY` and `OPENAI_API_KEY` out of the spawned stdio app-server child
|
||||
and sends the selected credentials through the app-server login RPC.
|
||||
or embeddings. Env API-key fallback is only the local stdio no-account path; it
|
||||
is not sent to WebSocket app-server connections. When a subscription-style Codex
|
||||
profile is selected, OpenClaw also keeps `CODEX_API_KEY` and `OPENAI_API_KEY`
|
||||
out of the spawned stdio app-server child and sends the selected credentials
|
||||
through the app-server login RPC.
|
||||
|
||||
## Image generation
|
||||
|
||||
|
||||
@@ -389,6 +389,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
startOptions: createStartOptions(),
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
|
||||
@@ -415,6 +416,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
startOptions: createStartOptions(),
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
|
||||
@@ -443,6 +445,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
startOptions: createStartOptions(),
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
@@ -465,6 +468,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
startOptions: createStartOptions(),
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
@@ -474,6 +478,32 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not send env API-key fallback to websocket app-server connections", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "account/read") {
|
||||
return { account: null, requiresOpenaiAuth: true };
|
||||
}
|
||||
return { type: "apiKey" };
|
||||
});
|
||||
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
|
||||
vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key");
|
||||
try {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
startOptions: createStartOptions({
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:1455",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies an OpenAI Codex token profile backed by a secret ref", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
|
||||
|
||||
@@ -41,12 +41,16 @@ export async function applyCodexAppServerAuthProfile(params: {
|
||||
client: CodexAppServerClient;
|
||||
agentDir: string;
|
||||
authProfileId?: string;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
}): Promise<void> {
|
||||
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
|
||||
agentDir: params.agentDir,
|
||||
authProfileId: params.authProfileId,
|
||||
});
|
||||
if (!loginParams) {
|
||||
if (params.startOptions?.transport !== "stdio") {
|
||||
return;
|
||||
}
|
||||
const fallbackLoginParams = await resolveCodexAppServerEnvApiKeyLoginParams({
|
||||
client: params.client,
|
||||
env: process.env,
|
||||
|
||||
@@ -311,6 +311,16 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
expect(first).not.toEqual(second);
|
||||
expect(
|
||||
codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
authToken: "tok_first",
|
||||
headers: {},
|
||||
}),
|
||||
).toEqual(first);
|
||||
expect(first).not.toContain("tok_first");
|
||||
expect(second).not.toContain("tok_second");
|
||||
});
|
||||
@@ -332,6 +342,15 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
expect(first).not.toEqual(second);
|
||||
expect(
|
||||
codexAppServerStartOptionsKey({
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: {},
|
||||
env: { OPENAI_API_KEY: "sk-first" },
|
||||
}),
|
||||
).toEqual(first);
|
||||
expect(first).not.toContain("sk-first");
|
||||
expect(second).not.toContain("sk-second");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
|
||||
const START_OPTIONS_KEY_SECRET = randomBytes(32);
|
||||
|
||||
export type CodexAppServerTransportMode = "stdio" | "websocket";
|
||||
export type CodexAppServerPolicyMode = "yolo" | "guardian";
|
||||
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
|
||||
@@ -300,13 +302,13 @@ export function codexAppServerStartOptionsKey(
|
||||
commandSource: options.commandSource ?? null,
|
||||
args: options.args,
|
||||
url: options.url ?? null,
|
||||
authToken: hashSecretForKey(options.authToken),
|
||||
authToken: hashSecretForKey(options.authToken, "authToken"),
|
||||
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
env: Object.entries(options.env ?? {})
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, hashSecretForKey(value)]),
|
||||
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
|
||||
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
|
||||
authProfileId: params.authProfileId ?? null,
|
||||
});
|
||||
@@ -431,11 +433,15 @@ function readNonEmptyString(value: unknown): string | undefined {
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function hashSecretForKey(value: string | undefined): string | null {
|
||||
function hashSecretForKey(value: string | undefined, label: string): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
return createHmac("sha256", START_OPTIONS_KEY_SECRET)
|
||||
.update(label)
|
||||
.update("\0")
|
||||
.update(value)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function splitShellWords(value: string): string[] {
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function getSharedCodexAppServerClient(options?: {
|
||||
client,
|
||||
agentDir,
|
||||
authProfileId: options?.authProfileId,
|
||||
startOptions,
|
||||
});
|
||||
return client;
|
||||
} catch (error) {
|
||||
@@ -104,6 +105,7 @@ export async function createIsolatedCodexAppServerClient(options?: {
|
||||
client,
|
||||
agentDir,
|
||||
authProfileId: options?.authProfileId,
|
||||
startOptions,
|
||||
});
|
||||
return client;
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user