mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:50:43 +00:00
fix(codex): bootstrap app-server auth fallback
This commit is contained in:
@@ -109,31 +109,26 @@ function createStartOptions(
|
||||
};
|
||||
}
|
||||
|
||||
async function writeCodexCliAuthFile(codexHome: string): Promise<void> {
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexHome, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "cli-access-token",
|
||||
refresh_token: "cli-refresh-token",
|
||||
account_id: "cli-account-123",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("bridgeCodexAppServerStartOptions", () => {
|
||||
it("clears an inherited OpenAI API key when local Codex CLI OAuth is available", async () => {
|
||||
it("clears inherited API-key env vars when the default Codex profile is subscription auth", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const codexHome = path.join(agentDir, "codex-home");
|
||||
const startOptions = createStartOptions({
|
||||
env: { CODEX_HOME: "/tmp/source-codex-home", EXISTING: "1" },
|
||||
env: { EXISTING: "1" },
|
||||
clearEnv: ["FOO"],
|
||||
});
|
||||
vi.stubEnv("CODEX_HOME", codexHome);
|
||||
try {
|
||||
await writeCodexCliAuthFile(codexHome);
|
||||
upsertAuthProfile({
|
||||
agentDir,
|
||||
profileId: "openai-codex:default",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 24 * 60 * 60_000,
|
||||
accountId: "account-123",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
@@ -142,7 +137,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
clearEnv: ["FOO", "OPENAI_API_KEY"],
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
expect(startOptions.clearEnv).toEqual(["FOO"]);
|
||||
await expect(fs.access(path.join(agentDir, "harness-auth"))).rejects.toMatchObject({
|
||||
@@ -178,7 +173,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
clearEnv: ["FOO", "OPENAI_API_KEY"],
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
@@ -207,7 +202,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
clearEnv: ["FOO", "OPENAI_API_KEY"],
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
@@ -380,6 +375,105 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to CODEX_API_KEY when no auth profile and no Codex account is available", 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,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
|
||||
expect(request).toHaveBeenNthCalledWith(2, "account/login/start", {
|
||||
type: "apiKey",
|
||||
apiKey: "codex-env-api-key",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to OPENAI_API_KEY when CODEX_API_KEY is not set", 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", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key");
|
||||
try {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false });
|
||||
expect(request).toHaveBeenNthCalledWith(2, "account/login/start", {
|
||||
type: "apiKey",
|
||||
apiKey: "openai-env-api-key",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an existing app-server ChatGPT account over env API-key fallback", 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: { type: "chatgpt", email: "codex@example.test", planType: "plus" },
|
||||
requiresOpenaiAuth: true,
|
||||
};
|
||||
}
|
||||
return { type: "apiKey" };
|
||||
});
|
||||
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
|
||||
try {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith("account/read", { refreshToken: false });
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips env API-key fallback when app-server does not require OpenAI auth", 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: false };
|
||||
}
|
||||
return { type: "apiKey" };
|
||||
});
|
||||
vi.stubEnv("CODEX_API_KEY", "codex-env-api-key");
|
||||
try {
|
||||
await applyCodexAppServerAuthProfile({
|
||||
client: { request } as never,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith("account/read", { refreshToken: false });
|
||||
} 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" }));
|
||||
|
||||
@@ -10,11 +10,14 @@ import {
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
import type { ChatgptAuthTokensRefreshResponse } from "./protocol-generated/typescript/v2/ChatgptAuthTokensRefreshResponse.js";
|
||||
import type { GetAccountResponse } from "./protocol-generated/typescript/v2/GetAccountResponse.js";
|
||||
import type { LoginAccountParams } from "./protocol-generated/typescript/v2/LoginAccountParams.js";
|
||||
|
||||
const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex";
|
||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
|
||||
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
|
||||
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
|
||||
|
||||
export async function bridgeCodexAppServerStartOptions(params: {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
@@ -30,7 +33,7 @@ export async function bridgeCodexAppServerStartOptions(params: {
|
||||
authProfileId: params.authProfileId,
|
||||
});
|
||||
return shouldClearInheritedOpenAiApiKey
|
||||
? withClearedEnvironmentVariable(params.startOptions, OPENAI_API_KEY_ENV_VAR)
|
||||
? withClearedEnvironmentVariables(params.startOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS)
|
||||
: params.startOptions;
|
||||
}
|
||||
|
||||
@@ -44,6 +47,13 @@ export async function applyCodexAppServerAuthProfile(params: {
|
||||
authProfileId: params.authProfileId,
|
||||
});
|
||||
if (!loginParams) {
|
||||
const fallbackLoginParams = await resolveCodexAppServerEnvApiKeyLoginParams({
|
||||
client: params.client,
|
||||
env: process.env,
|
||||
});
|
||||
if (fallbackLoginParams) {
|
||||
await params.client.request("account/login/start", fallbackLoginParams);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await params.client.request("account/login/start", loginParams);
|
||||
@@ -105,6 +115,23 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
|
||||
return loginParams;
|
||||
}
|
||||
|
||||
async function resolveCodexAppServerEnvApiKeyLoginParams(params: {
|
||||
client: CodexAppServerClient;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<LoginAccountParams | undefined> {
|
||||
const apiKey = readFirstNonEmptyEnv(params.env, CODEX_APP_SERVER_API_KEY_ENV_VARS);
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
const response = await params.client.request<GetAccountResponse>("account/read", {
|
||||
refreshToken: false,
|
||||
});
|
||||
if (response.account || !response.requiresOpenaiAuth) {
|
||||
return undefined;
|
||||
}
|
||||
return { type: "apiKey", apiKey };
|
||||
}
|
||||
|
||||
async function resolveLoginParamsForCredential(
|
||||
profileId: string,
|
||||
credential: AuthProfileCredential,
|
||||
@@ -189,20 +216,31 @@ function isCodexSubscriptionCredential(credential: AuthProfileCredential | undef
|
||||
return credential.type === "oauth" || credential.type === "token";
|
||||
}
|
||||
|
||||
function withClearedEnvironmentVariable(
|
||||
function withClearedEnvironmentVariables(
|
||||
startOptions: CodexAppServerStartOptions,
|
||||
envVar: string,
|
||||
envVars: readonly string[],
|
||||
): CodexAppServerStartOptions {
|
||||
const clearEnv = startOptions.clearEnv ?? [];
|
||||
if (clearEnv.includes(envVar)) {
|
||||
const missingEnvVars = envVars.filter((envVar) => !clearEnv.includes(envVar));
|
||||
if (missingEnvVars.length === 0) {
|
||||
return startOptions;
|
||||
}
|
||||
return {
|
||||
...startOptions,
|
||||
clearEnv: [...clearEnv, envVar],
|
||||
clearEnv: [...clearEnv, ...missingEnvVars],
|
||||
};
|
||||
}
|
||||
|
||||
function readFirstNonEmptyEnv(env: NodeJS.ProcessEnv, keys: readonly string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = env[key]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildChatgptAuthTokensParams(
|
||||
profileId: string,
|
||||
credential: AuthProfileCredential,
|
||||
|
||||
@@ -18,7 +18,6 @@ describe("Codex app-server config", () => {
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: { "X-Test": "yes" },
|
||||
clearEnv: ["OPENAI_API_KEY"],
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "guardian_subagent",
|
||||
@@ -41,12 +40,26 @@ describe("Codex app-server config", () => {
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: { "X-Test": "yes" },
|
||||
clearEnv: ["OPENAI_API_KEY"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores app-server environment clearing for websocket transports", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:39175",
|
||||
clearEnv: ["OPENAI_API_KEY"],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(runtime.start).not.toHaveProperty("clearEnv");
|
||||
});
|
||||
|
||||
it("normalizes app-server environment variables to clear", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: {
|
||||
|
||||
@@ -214,7 +214,7 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
...(url ? { url } : {}),
|
||||
...(authToken ? { authToken } : {}),
|
||||
headers,
|
||||
...(clearEnv.length > 0 ? { clearEnv } : {}),
|
||||
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
|
||||
},
|
||||
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
|
||||
approvalPolicy:
|
||||
|
||||
@@ -3,7 +3,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
import { resolveCodexAppServerSpawnInvocation } from "./transport-stdio.js";
|
||||
import {
|
||||
resolveCodexAppServerSpawnEnv,
|
||||
resolveCodexAppServerSpawnInvocation,
|
||||
} from "./transport-stdio.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -86,3 +89,26 @@ describe("resolveCodexAppServerSpawnInvocation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCodexAppServerSpawnEnv", () => {
|
||||
it("applies configured env overrides before clearing denied env vars", () => {
|
||||
expect(
|
||||
resolveCodexAppServerSpawnEnv(
|
||||
{
|
||||
env: {
|
||||
OPENAI_API_KEY: "configured-openai-key",
|
||||
KEEP: "override",
|
||||
},
|
||||
clearEnv: ["OPENAI_API_KEY", "CODEX_API_KEY", "MISSING"],
|
||||
},
|
||||
{
|
||||
OPENAI_API_KEY: "parent-openai-key",
|
||||
CODEX_API_KEY: "parent-codex-key",
|
||||
KEEP: "parent",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
KEEP: "override",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,14 +41,22 @@ export function resolveCodexAppServerSpawnInvocation(
|
||||
};
|
||||
}
|
||||
|
||||
export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
|
||||
export function resolveCodexAppServerSpawnEnv(
|
||||
options: Pick<CodexAppServerStartOptions, "env" | "clearEnv">,
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env = {
|
||||
...process.env,
|
||||
...baseEnv,
|
||||
...options.env,
|
||||
};
|
||||
for (const key of options.clearEnv ?? []) {
|
||||
delete env[key];
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
|
||||
const env = resolveCodexAppServerSpawnEnv(options);
|
||||
const invocation = resolveCodexAppServerSpawnInvocation(options, {
|
||||
platform: process.platform,
|
||||
env,
|
||||
|
||||
Reference in New Issue
Block a user