fix(codex): auto-clear api key for subscription auth

This commit is contained in:
pashpashpash
2026-04-27 18:52:46 -04:00
committed by Peter Steinberger
parent aeb007e4e5
commit 20ff49f7c8
4 changed files with 229 additions and 31 deletions

View File

@@ -8,6 +8,7 @@ import {
bridgeCodexAppServerStartOptions,
refreshCodexAppServerAuthTokens,
} from "./auth-bridge.js";
import type { CodexAppServerStartOptions } from "./config.js";
const oauthMocks = vi.hoisted(() => ({
refreshOpenAICodexToken: vi.fn(),
@@ -96,25 +97,54 @@ afterEach(() => {
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear();
});
function createStartOptions(
overrides: Partial<CodexAppServerStartOptions> = {},
): CodexAppServerStartOptions {
return {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
...overrides,
};
}
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("leaves Codex app-server start options unchanged", async () => {
it("clears an inherited OpenAI API key when local Codex CLI OAuth is available", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = {
transport: "stdio" as const,
command: "codex",
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
const codexHome = path.join(agentDir, "codex-home");
const startOptions = createStartOptions({
env: { CODEX_HOME: "/tmp/source-codex-home", EXISTING: "1" },
clearEnv: ["FOO"],
};
});
vi.stubEnv("CODEX_HOME", codexHome);
try {
await writeCodexCliAuthFile(codexHome);
await expect(
bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
authProfileId: "openai-codex:default",
}),
).resolves.toBe(startOptions);
).resolves.toEqual({
...startOptions,
clearEnv: ["FOO", "OPENAI_API_KEY"],
});
expect(startOptions.clearEnv).toEqual(["FOO"]);
await expect(fs.access(path.join(agentDir, "harness-auth"))).rejects.toMatchObject({
code: "ENOENT",
});
@@ -123,6 +153,126 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("clears an inherited OpenAI API key for an explicit Codex OAuth profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions({ clearEnv: ["FOO"] });
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
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({
startOptions,
agentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toEqual({
...startOptions,
clearEnv: ["FOO", "OPENAI_API_KEY"],
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("clears an inherited OpenAI API key for an explicit Codex token profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions({ clearEnv: ["FOO"] });
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "token",
provider: "openai-codex",
token: "access-token",
},
});
await expect(
bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toEqual({
...startOptions,
clearEnv: ["FOO", "OPENAI_API_KEY"],
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps an inherited OpenAI API key for an explicit Codex api-key profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions({ clearEnv: ["FOO"] });
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "api_key",
provider: "openai-codex",
key: "explicit-api-key",
},
});
await expect(
bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toBe(startOptions);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("does not clear process environment for websocket app-server connections", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions({
transport: "websocket",
url: "ws://127.0.0.1:1455",
clearEnv: ["FOO"],
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
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({
startOptions,
agentDir,
authProfileId: "openai-codex:work",
}),
).resolves.toBe(startOptions);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies an OpenAI Codex OAuth profile through app-server login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

View File

@@ -13,15 +13,25 @@ import type { ChatgptAuthTokensRefreshResponse } from "./protocol-generated/type
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 OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId?: string;
}): Promise<CodexAppServerStartOptions> {
void params.agentDir;
void params.authProfileId;
return params.startOptions;
if (params.startOptions.transport !== "stdio") {
return params.startOptions;
}
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const shouldClearInheritedOpenAiApiKey = shouldClearOpenAiApiKeyForCodexAuthProfile({
store,
authProfileId: params.authProfileId,
});
return shouldClearInheritedOpenAiApiKey
? withClearedEnvironmentVariable(params.startOptions, OPENAI_API_KEY_ENV_VAR)
: params.startOptions;
}
export async function applyCodexAppServerAuthProfile(params: {
@@ -161,6 +171,38 @@ function isCodexAppServerAuthProvider(provider: string): boolean {
return resolveProviderIdForAuth(provider) === CODEX_APP_SERVER_AUTH_PROVIDER;
}
function shouldClearOpenAiApiKeyForCodexAuthProfile(params: {
store: ReturnType<typeof ensureAuthProfileStore>;
authProfileId?: string;
}): boolean {
const profileId = params.authProfileId?.trim();
const credential = profileId
? params.store.profiles[profileId]
: params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID];
return isCodexSubscriptionCredential(credential);
}
function isCodexSubscriptionCredential(credential: AuthProfileCredential | undefined): boolean {
if (!credential || !isCodexAppServerAuthProvider(credential.provider)) {
return false;
}
return credential.type === "oauth" || credential.type === "token";
}
function withClearedEnvironmentVariable(
startOptions: CodexAppServerStartOptions,
envVar: string,
): CodexAppServerStartOptions {
const clearEnv = startOptions.clearEnv ?? [];
if (clearEnv.includes(envVar)) {
return startOptions;
}
return {
...startOptions,
clearEnv: [...clearEnv, envVar],
};
}
function buildChatgptAuthTokensParams(
profileId: string,
credential: AuthProfileCredential,