mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:10:45 +00:00
fix(codex): stop materializing auth bridges
This commit is contained in:
@@ -16,8 +16,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI: surface selected-model capacity failures with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc.
|
||||
- Providers/OpenAI: stop advertising the removed `gpt-5.3-codex-spark` Codex model through fallback catalogs, and suppress stale rows with a GPT-5.5 recovery hint.
|
||||
- Plugins/QR: replace legacy `qrcode-terminal` QR rendering with bounded `qrcode-tui` helpers for plugin login/setup flows. (#65969) Thanks @vincentkoc.
|
||||
- ACPX/Codex: stop the embedded Codex ACP auth bridge from falling back to raw `~/.codex` file copies; ACPX now only uses OpenClaw's canonical Codex OAuth bridge.
|
||||
- Voice-call/realtime: wait for OpenAI session configuration before greeting or forwarding buffered audio, and reject non-allowlisted Twilio callers before stream setup. (#43501) Thanks @forrestblount.
|
||||
- ACPX/Codex: stop materializing `auth.json` bridge files for Codex ACP, Codex app-server, and Codex CLI runs; Codex-owned runtimes now use their normal `CODEX_HOME`/`~/.codex` auth path directly.
|
||||
- Auto-reply/system events: route async exec-event completion replies through the persisted session delivery context, so long-running command results return to the originating channel instead of being dropped when live origin metadata is missing. (#70258) Thanks @wzfukui.
|
||||
- OpenAI/image generation: send reference-image edits as guarded multipart uploads instead of JSON data URLs, restoring complex multi-reference `gpt-image-2` edits. Fixes #70642. Thanks @dashhuang.
|
||||
- QA channel/security: reject non-HTTP(S) inbound attachment URLs before media fetch, and log rejected schemes so suspicious or misconfigured payloads are visible during debugging. (#70708) Thanks @vincentkoc.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
|
||||
import { resolveAcpxPluginConfig } from "./config.js";
|
||||
@@ -28,10 +27,6 @@ function restoreEnv(name: keyof typeof previousEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
function unquoteCommandPath(command: string): string {
|
||||
return command.replace(/^'|'$/g, "").replace(/'\\''/g, "'");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("CODEX_HOME");
|
||||
restoreEnv("OPENCLAW_AGENT_DIR");
|
||||
@@ -42,28 +37,10 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("prepareAcpxCodexAuthConfig", () => {
|
||||
it("wraps built-in Codex ACP with an isolated CODEX_HOME from canonical OpenClaw OAuth", async () => {
|
||||
it("does not synthesize a Codex ACP auth home from canonical OpenClaw OAuth", async () => {
|
||||
const root = await makeTempDir();
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "acct-123",
|
||||
idToken: "id-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
@@ -76,44 +53,16 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
stateDir,
|
||||
});
|
||||
|
||||
const wrapperPath = unquoteCommandPath(resolved.agents.codex ?? "");
|
||||
expect(wrapperPath).toBe(path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"));
|
||||
await expect(fs.access(wrapperPath)).resolves.toBeUndefined();
|
||||
|
||||
const bridgeRoot = path.join(agentDir, "acp-auth", "codex");
|
||||
const bridgeDirs = await fs.readdir(bridgeRoot);
|
||||
expect(bridgeDirs).toHaveLength(1);
|
||||
const bridgeDir = bridgeDirs[0];
|
||||
if (!bridgeDir) {
|
||||
throw new Error("expected one Codex auth bridge directory");
|
||||
}
|
||||
const isolatedAuthPath = path.join(bridgeRoot, bridgeDir, "auth.json");
|
||||
const copiedAuth = JSON.parse(await fs.readFile(isolatedAuthPath, "utf8")) as {
|
||||
auth_mode?: string;
|
||||
tokens?: Record<string, unknown>;
|
||||
};
|
||||
expect(copiedAuth).toEqual({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: expect.any(String),
|
||||
});
|
||||
expect((await fs.stat(isolatedAuthPath)).mode & 0o777).toBe(0o600);
|
||||
expect(resolved.agents.codex).toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(agentDir, "acp-auth", "codex-source", "auth.json")),
|
||||
fs.access(path.join(stateDir, "acpx", "codex-acp-wrapper.mjs")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(
|
||||
fs.access(path.join(agentDir, "acp-auth", "codex", "auth.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const wrapper = await fs.readFile(wrapperPath, "utf8");
|
||||
expect(wrapper).toContain(`CODEX_HOME: ${JSON.stringify(path.dirname(isolatedAuthPath))}`);
|
||||
expect(wrapper).toContain("for (const key of [])");
|
||||
expect(wrapper).not.toContain("access-token");
|
||||
});
|
||||
|
||||
it("does not copy source Codex auth when canonical OpenClaw OAuth is unavailable", async () => {
|
||||
it("does not copy source Codex auth", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const agentDir = path.join(root, "agent");
|
||||
@@ -139,6 +88,9 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
await expect(
|
||||
fs.access(path.join(agentDir, "acp-auth", "codex-source", "auth.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(
|
||||
fs.access(path.join(agentDir, "acp-auth", "codex", "auth.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("does not override an explicitly configured Codex agent command", async () => {
|
||||
|
||||
@@ -1,89 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { prepareCodexAuthBridge } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import type { PluginLogger } from "../runtime-api.js";
|
||||
import type { ResolvedAcpxPluginConfig } from "./config.js";
|
||||
|
||||
const CODEX_AGENT_ID = "codex";
|
||||
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
|
||||
// acpx selects ACP auth methods from the OpenClaw process env before the wrapper
|
||||
// launches. Keep those env vars visible to the child so its auth method matches.
|
||||
const CODEX_AUTH_ENV_CLEAR_KEYS: string[] = [];
|
||||
|
||||
function shellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function writeCodexAcpWrapper(params: {
|
||||
wrapperPath: string;
|
||||
codexHome: string;
|
||||
clearEnv: string[];
|
||||
}): Promise<string> {
|
||||
await fs.mkdir(path.dirname(params.wrapperPath), { recursive: true, mode: 0o700 });
|
||||
const content = `#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const env = { ...process.env, CODEX_HOME: ${JSON.stringify(params.codexHome)} };
|
||||
for (const key of ${JSON.stringify(params.clearEnv)}) {
|
||||
delete env[key];
|
||||
}
|
||||
|
||||
const child = spawn("npx", ["@zed-industries/codex-acp@^0.11.1"], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
`;
|
||||
await fs.writeFile(params.wrapperPath, content, { mode: 0o700 });
|
||||
await fs.chmod(params.wrapperPath, 0o700);
|
||||
return shellArg(params.wrapperPath);
|
||||
}
|
||||
|
||||
export async function prepareAcpxCodexAuthConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
stateDir: string;
|
||||
logger?: PluginLogger;
|
||||
logger?: unknown;
|
||||
}): Promise<ResolvedAcpxPluginConfig> {
|
||||
if (params.pluginConfig.agents[CODEX_AGENT_ID]) {
|
||||
return params.pluginConfig;
|
||||
}
|
||||
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const bridge = await prepareCodexAuthBridge({
|
||||
agentDir,
|
||||
bridgeDir: "acp-auth",
|
||||
profileId: DEFAULT_CODEX_AUTH_PROFILE_ID,
|
||||
});
|
||||
|
||||
if (!bridge) {
|
||||
params.logger?.debug?.("codex ACP auth bridge skipped: no canonical OpenClaw OAuth found");
|
||||
return params.pluginConfig;
|
||||
}
|
||||
|
||||
const wrapperCommand = await writeCodexAcpWrapper({
|
||||
wrapperPath: path.join(params.stateDir, "acpx", "codex-acp-wrapper.mjs"),
|
||||
codexHome: bridge.codexHome,
|
||||
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
|
||||
});
|
||||
|
||||
return {
|
||||
...params.pluginConfig,
|
||||
agents: {
|
||||
...params.pluginConfig.agents,
|
||||
[CODEX_AGENT_ID]: wrapperCommand,
|
||||
},
|
||||
};
|
||||
void params.stateDir;
|
||||
void params.logger;
|
||||
return params.pluginConfig;
|
||||
}
|
||||
|
||||
@@ -1,306 +1,33 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let bridgeCodexAppServerStartOptions: typeof import("./auth-bridge.js").bridgeCodexAppServerStartOptions;
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js";
|
||||
|
||||
describe("bridgeCodexAppServerStartOptions", () => {
|
||||
const tempDirs: string[] = [];
|
||||
const resolveHashedCodexHome = (agentDir: string, profileId: string) =>
|
||||
path.join(
|
||||
agentDir,
|
||||
"harness-auth",
|
||||
"codex",
|
||||
crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16),
|
||||
);
|
||||
|
||||
async function createAgentDirWithDefaultProfile(
|
||||
profile: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
it("leaves Codex app-server start options unchanged", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
tempDirs.push(agentDir);
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
...profile,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
return agentDir;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ bridgeCodexAppServerStartOptions } = await import("./auth-bridge.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("bridges canonical OpenClaw oauth into an isolated CODEX_HOME", async () => {
|
||||
const agentDir = await createAgentDirWithDefaultProfile({
|
||||
accountId: "acct-123",
|
||||
idToken: "id-token",
|
||||
});
|
||||
|
||||
const result = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: { authorization: "Bearer dev-token" },
|
||||
env: { EXISTING: "1" },
|
||||
clearEnv: ["FOO"],
|
||||
},
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
env: {
|
||||
EXISTING: "1",
|
||||
CODEX_HOME: expect.stringContaining(path.join(agentDir, "harness-auth", "codex")),
|
||||
},
|
||||
clearEnv: expect.arrayContaining(["FOO", "OPENAI_API_KEY"]),
|
||||
});
|
||||
|
||||
const authFile = JSON.parse(
|
||||
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(authFile).toEqual({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: expect.any(String),
|
||||
});
|
||||
if (process.platform !== "win32") {
|
||||
const authStat = await fs.stat(path.join(result.env?.CODEX_HOME ?? "", "auth.json"));
|
||||
expect(authStat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it("hydrates Codex-only auth fields from a matching Codex CLI auth file", async () => {
|
||||
const sourceCodexHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-source-home-"));
|
||||
tempDirs.push(sourceCodexHome);
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "source-id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: "2026-04-22T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const agentDir = await createAgentDirWithDefaultProfile({
|
||||
accountId: "acct-123",
|
||||
});
|
||||
|
||||
const result = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: {},
|
||||
env: { CODEX_HOME: sourceCodexHome },
|
||||
},
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(result.env?.CODEX_HOME).not.toBe(sourceCodexHome);
|
||||
const authFile = JSON.parse(
|
||||
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(authFile).toEqual({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "source-id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: "2026-04-22T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the selected profile tokens when hydrating from a same-account Codex CLI auth file", async () => {
|
||||
const sourceCodexHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-source-home-"));
|
||||
tempDirs.push(sourceCodexHome);
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "source-id-token",
|
||||
access_token: "stale-source-access-token",
|
||||
refresh_token: "stale-source-refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: "2026-04-22T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const agentDir = await createAgentDirWithDefaultProfile({
|
||||
access: "selected-profile-access-token",
|
||||
refresh: "selected-profile-refresh-token",
|
||||
accountId: "acct-123",
|
||||
idToken: "selected-profile-id-token",
|
||||
});
|
||||
|
||||
const result = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: {},
|
||||
env: { CODEX_HOME: sourceCodexHome },
|
||||
},
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(result.env?.CODEX_HOME).not.toBe(sourceCodexHome);
|
||||
const authFile = JSON.parse(
|
||||
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(authFile).toEqual({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "selected-profile-id-token",
|
||||
access_token: "selected-profile-access-token",
|
||||
refresh_token: "selected-profile-refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: "2026-04-22T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates from inherited CODEX_HOME when start options do not override it", async () => {
|
||||
const sourceCodexHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-source-home-"));
|
||||
tempDirs.push(sourceCodexHome);
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "source-id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: "2026-04-22T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = sourceCodexHome;
|
||||
try {
|
||||
const agentDir = await createAgentDirWithDefaultProfile({
|
||||
accountId: "acct-123",
|
||||
});
|
||||
|
||||
const result = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: {},
|
||||
},
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(result.env?.CODEX_HOME).not.toBe(sourceCodexHome);
|
||||
const authFile = JSON.parse(
|
||||
await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(authFile).toEqual({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "source-id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: "2026-04-22T00:00:00.000Z",
|
||||
});
|
||||
} finally {
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves start options unchanged when canonical oauth is unavailable", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
tempDirs.push(agentDir);
|
||||
const startOptions = {
|
||||
transport: "stdio" as const,
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: { authorization: "Bearer dev-token" },
|
||||
env: { CODEX_HOME: "/tmp/source-codex-home", EXISTING: "1" },
|
||||
clearEnv: ["FOO"],
|
||||
};
|
||||
saveAuthProfileStore({ version: 1, profiles: {} }, agentDir, {
|
||||
filterExternalAuthProfiles: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
startOptions,
|
||||
agentDir,
|
||||
authProfileId: "openai-codex:missing",
|
||||
}),
|
||||
).resolves.toEqual(startOptions);
|
||||
});
|
||||
|
||||
it("refuses to overwrite a symlinked auth bridge file", async () => {
|
||||
const agentDir = await createAgentDirWithDefaultProfile();
|
||||
|
||||
const codexHome = resolveHashedCodexHome(agentDir, "openai-codex:default");
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.symlink(path.join(agentDir, "outside.txt"), path.join(codexHome, "auth.json"));
|
||||
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
startOptions: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: {},
|
||||
},
|
||||
agentDir,
|
||||
}),
|
||||
).rejects.toThrow("must not be a symlink");
|
||||
try {
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
startOptions,
|
||||
agentDir,
|
||||
authProfileId: "openai-codex:default",
|
||||
}),
|
||||
).resolves.toBe(startOptions);
|
||||
await expect(fs.access(path.join(agentDir, "harness-auth"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import { prepareCodexAuthBridge } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
|
||||
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
|
||||
|
||||
export async function bridgeCodexAppServerStartOptions(params: {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
agentDir: string;
|
||||
authProfileId?: string;
|
||||
}): Promise<CodexAppServerStartOptions> {
|
||||
const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID;
|
||||
const bridge = await prepareCodexAuthBridge({
|
||||
agentDir: params.agentDir,
|
||||
bridgeDir: "harness-auth",
|
||||
profileId,
|
||||
sourceCodexHome: params.startOptions.env?.CODEX_HOME,
|
||||
});
|
||||
if (!bridge) {
|
||||
return params.startOptions;
|
||||
}
|
||||
|
||||
return {
|
||||
...params.startOptions,
|
||||
env: {
|
||||
...params.startOptions.env,
|
||||
CODEX_HOME: bridge.codexHome,
|
||||
},
|
||||
clearEnv: Array.from(new Set([...(params.startOptions.clearEnv ?? []), ...bridge.clearEnv])),
|
||||
};
|
||||
void params.agentDir;
|
||||
void params.authProfileId;
|
||||
return params.startOptions;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
CLI_FRESH_WATCHDOG_DEFAULTS,
|
||||
CLI_RESUME_WATCHDOG_DEFAULTS,
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
import { prepareOpenAICodexCliExecution } from "./openai-codex-cli-bridge.js";
|
||||
|
||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
const CODEX_CLI_DEFAULT_MODEL_REF = "codex-cli/gpt-5.5";
|
||||
|
||||
export function buildOpenAICodexCliBackend(): CliBackendPlugin {
|
||||
@@ -22,9 +20,6 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin {
|
||||
},
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "codex-config-overrides",
|
||||
defaultAuthProfileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
authEpochMode: "profile-only",
|
||||
prepareExecution: prepareOpenAICodexCliExecution,
|
||||
config: {
|
||||
command: "codex",
|
||||
args: [
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { prepareOpenAICodexCliExecution } from "./openai-codex-cli-bridge.js";
|
||||
|
||||
describe("prepareOpenAICodexCliExecution", () => {
|
||||
const tempDirs: string[] = [];
|
||||
const resolveHashedCodexHome = (agentDir: string, profileId: string) =>
|
||||
path.join(
|
||||
agentDir,
|
||||
"cli-auth",
|
||||
"codex",
|
||||
crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16),
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a private CODEX_HOME bridge from canonical OpenClaw oauth", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-cli-bridge-"));
|
||||
tempDirs.push(agentDir);
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "acct-123",
|
||||
idToken: "id-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
const result = await prepareOpenAICodexCliExecution({
|
||||
config: undefined,
|
||||
workspaceDir: agentDir,
|
||||
agentDir,
|
||||
provider: "codex-cli",
|
||||
modelId: "gpt-5.4",
|
||||
authProfileId: "openai-codex:default",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
env: {
|
||||
CODEX_HOME: expect.stringContaining(path.join(agentDir, "cli-auth", "codex")),
|
||||
},
|
||||
clearEnv: ["OPENAI_API_KEY"],
|
||||
});
|
||||
|
||||
const authFile = JSON.parse(
|
||||
await fs.readFile(path.join(result?.env?.CODEX_HOME ?? "", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(authFile).toEqual({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
last_refresh: expect.any(String),
|
||||
});
|
||||
if (process.platform !== "win32") {
|
||||
const authStat = await fs.stat(path.join(result?.env?.CODEX_HOME ?? "", "auth.json"));
|
||||
expect(authStat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when there is no bridgeable canonical oauth credential", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-cli-bridge-"));
|
||||
tempDirs.push(agentDir);
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "api_key",
|
||||
provider: "openai-codex",
|
||||
key: "sk-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareOpenAICodexCliExecution({
|
||||
config: undefined,
|
||||
workspaceDir: agentDir,
|
||||
agentDir,
|
||||
provider: "codex-cli",
|
||||
modelId: "gpt-5.4",
|
||||
authProfileId: "openai-codex:default",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to overwrite a symlinked codex cli auth bridge file", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-cli-bridge-"));
|
||||
tempDirs.push(agentDir);
|
||||
const codexHome = resolveHashedCodexHome(agentDir, "openai-codex:default");
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.symlink(path.join(agentDir, "outside.txt"), path.join(codexHome, "auth.json"));
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareOpenAICodexCliExecution({
|
||||
config: undefined,
|
||||
workspaceDir: agentDir,
|
||||
agentDir,
|
||||
provider: "codex-cli",
|
||||
modelId: "gpt-5.4",
|
||||
authProfileId: "openai-codex:default",
|
||||
}),
|
||||
).rejects.toThrow("must not be a symlink");
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import type {
|
||||
CliBackendPreparedExecution,
|
||||
CliBackendPrepareExecutionContext,
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
import { prepareCodexAuthBridge } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
|
||||
export async function prepareOpenAICodexCliExecution(
|
||||
ctx: CliBackendPrepareExecutionContext,
|
||||
): Promise<CliBackendPreparedExecution | null> {
|
||||
if (!ctx.agentDir || !ctx.authProfileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridge = await prepareCodexAuthBridge({
|
||||
agentDir: ctx.agentDir,
|
||||
bridgeDir: "cli-auth",
|
||||
profileId: ctx.authProfileId,
|
||||
});
|
||||
if (!bridge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
env: {
|
||||
CODEX_HOME: bridge.codexHome,
|
||||
},
|
||||
clearEnv: bridge.clearEnv,
|
||||
};
|
||||
}
|
||||
@@ -53,7 +53,6 @@ vi.mock("./cli-credentials.js", () => ({
|
||||
},
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: vi.fn(),
|
||||
writeCodexCliCredentials: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("ensureAuthProfileStore", () => {
|
||||
|
||||
@@ -16,7 +16,6 @@ vi.mock("../cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
writeCodexCliCredentials: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
|
||||
|
||||
@@ -24,10 +24,6 @@ const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({
|
||||
readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
}));
|
||||
|
||||
const { writeCodexCliCredentialsMock } = vi.hoisted(() => ({
|
||||
writeCodexCliCredentialsMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
const {
|
||||
refreshProviderOAuthCredentialWithPluginMock,
|
||||
formatProviderAuthProfileApiKeyWithPluginMock,
|
||||
@@ -42,7 +38,6 @@ const {
|
||||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
|
||||
writeCodexCliCredentials: writeCodexCliCredentialsMock,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
}));
|
||||
@@ -116,8 +111,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
|
||||
});
|
||||
readCodexCliCredentialsCachedMock.mockReset();
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(null);
|
||||
writeCodexCliCredentialsMock.mockReset();
|
||||
writeCodexCliCredentialsMock.mockReturnValue(true);
|
||||
refreshProviderOAuthCredentialWithPluginMock.mockReset();
|
||||
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined);
|
||||
formatProviderAuthProfileApiKeyWithPluginMock.mockReset();
|
||||
@@ -274,8 +267,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
|
||||
provider: "openai-codex",
|
||||
email: undefined,
|
||||
});
|
||||
expect(writeCodexCliCredentialsMock).not.toHaveBeenCalled();
|
||||
|
||||
const persisted = await readPersistedStore(agentDir);
|
||||
expect(persisted.profiles[profileId]).toMatchObject({
|
||||
type: "oauth",
|
||||
|
||||
@@ -230,9 +230,6 @@ beforeEach(() => {
|
||||
id: "codex-cli",
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "codex-config-overrides",
|
||||
defaultAuthProfileId: "openai-codex:default",
|
||||
authEpochMode: "profile-only",
|
||||
prepareExecution: async () => null,
|
||||
config: {
|
||||
command: "codex",
|
||||
args: [
|
||||
@@ -761,9 +758,9 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved?.bundleMcp).toBe(true);
|
||||
expect(resolved?.bundleMcpMode).toBe("codex-config-overrides");
|
||||
expect(resolved?.defaultAuthProfileId).toBe("openai-codex:default");
|
||||
expect(resolved?.authEpochMode).toBe("profile-only");
|
||||
expect(typeof resolved?.prepareExecution).toBe("function");
|
||||
expect(resolved?.defaultAuthProfileId).toBeUndefined();
|
||||
expect(resolved?.authEpochMode).toBeUndefined();
|
||||
expect(resolved?.prepareExecution).toBeUndefined();
|
||||
expect(resolved?.config.systemPromptFileConfigArg).toBe("-c");
|
||||
expect(resolved?.config.systemPromptFileConfigKey).toBe("model_instructions_file");
|
||||
expect(resolved?.config.systemPromptWhen).toBe("first");
|
||||
|
||||
@@ -12,8 +12,6 @@ let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").reset
|
||||
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
|
||||
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
|
||||
let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials;
|
||||
let writeCodexCliCredentials: typeof import("./cli-credentials.js").writeCodexCliCredentials;
|
||||
let writeCodexCliFileCredentials: typeof import("./cli-credentials.js").writeCodexCliFileCredentials;
|
||||
|
||||
function mockExistingClaudeKeychainItem() {
|
||||
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
|
||||
@@ -76,8 +74,6 @@ describe("cli credentials", () => {
|
||||
writeClaudeCliKeychainCredentials,
|
||||
writeClaudeCliCredentials,
|
||||
readCodexCliCredentials,
|
||||
writeCodexCliCredentials,
|
||||
writeCodexCliFileCredentials,
|
||||
} = await import("./cli-credentials.js"));
|
||||
});
|
||||
|
||||
@@ -366,114 +362,4 @@ describe("cli credentials", () => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates existing Codex auth.json in place", () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-write-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 });
|
||||
const authPath = path.join(tempHome, "auth.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: "sk-existing",
|
||||
tokens: {
|
||||
id_token: "id-token",
|
||||
access_token: "old-access",
|
||||
refresh_token: "old-refresh",
|
||||
account_id: "acct-old",
|
||||
},
|
||||
last_refresh: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ok = writeCodexCliFileCredentials({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
idToken: "new-id-token",
|
||||
accountId: "acct-new",
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
const persisted = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record<string, unknown>;
|
||||
expect(persisted).toMatchObject({
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: "sk-existing",
|
||||
});
|
||||
expect(persisted.tokens).toMatchObject({
|
||||
id_token: "new-id-token",
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
account_id: "acct-new",
|
||||
});
|
||||
expect(typeof persisted.last_refresh).toBe("string");
|
||||
} finally {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the existing Codex keychain entry over auth.json on darwin writes", () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-keychain-write-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const expSeconds = Math.floor(Date.parse("2026-03-26T12:34:56Z") / 1000);
|
||||
execSyncMock.mockImplementation((command: unknown) => {
|
||||
const cmd = String(command);
|
||||
expect(cmd).toContain("Codex Auth");
|
||||
return JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "id-token",
|
||||
access_token: createJwtWithExp(expSeconds),
|
||||
refresh_token: "old-refresh",
|
||||
account_id: "acct-old",
|
||||
},
|
||||
last_refresh: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
const ok = writeCodexCliCredentials(
|
||||
{
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
idToken: "new-id-token",
|
||||
accountId: "acct-new",
|
||||
},
|
||||
{
|
||||
platform: "darwin",
|
||||
execSync: execSyncMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(execFileSyncMock).toHaveBeenCalledTimes(1);
|
||||
const addCall = getAddGenericPasswordCall();
|
||||
expect(addCall?.[0]).toBe("security");
|
||||
const payload = (() => {
|
||||
const args = (addCall?.[1] as string[] | undefined) ?? [];
|
||||
const valueIndex = args.indexOf("-w");
|
||||
return valueIndex >= 0 ? args[valueIndex + 1] : undefined;
|
||||
})();
|
||||
expect(payload).toBeDefined();
|
||||
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
|
||||
expect(parsed.tokens).toMatchObject({
|
||||
id_token: "new-id-token",
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
account_id: "acct-new",
|
||||
});
|
||||
expect(parsed.auth_mode).toBe("chatgpt");
|
||||
} finally {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,26 +77,6 @@ type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
|
||||
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
|
||||
};
|
||||
|
||||
type CodexCliFileOptions = {
|
||||
codexHome?: string;
|
||||
};
|
||||
|
||||
type CodexCliWriteOptions = CodexCliFileOptions & {
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
execFileSync?: ExecFileSyncFn;
|
||||
writeKeychain?: (
|
||||
credentials: OAuthCredentials,
|
||||
options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
execFileSync?: ExecFileSyncFn;
|
||||
},
|
||||
) => boolean;
|
||||
writeFile?: (credentials: OAuthCredentials, options?: CodexCliFileOptions) => boolean;
|
||||
};
|
||||
|
||||
type ExecSyncFn = typeof execSync;
|
||||
type ExecFileSyncFn = typeof execFileSync;
|
||||
|
||||
@@ -533,125 +513,6 @@ export function writeClaudeCliCredentials(
|
||||
return writeFile(newCredentials, { homeDir: options?.homeDir });
|
||||
}
|
||||
|
||||
function buildUpdatedCodexAuthRecord(
|
||||
existing: Record<string, unknown> | null,
|
||||
newCredentials: OAuthCredentials,
|
||||
): Record<string, unknown> {
|
||||
const next = existing ? { ...existing } : {};
|
||||
const existingTokens =
|
||||
next.tokens && typeof next.tokens === "object" ? (next.tokens as Record<string, unknown>) : {};
|
||||
next.auth_mode = next.auth_mode ?? "chatgpt";
|
||||
next.tokens = {
|
||||
...existingTokens,
|
||||
access_token: newCredentials.access,
|
||||
refresh_token: newCredentials.refresh,
|
||||
...(typeof newCredentials.idToken === "string" && newCredentials.idToken.trim().length > 0
|
||||
? { id_token: newCredentials.idToken }
|
||||
: {}),
|
||||
...(typeof newCredentials.accountId === "string" && newCredentials.accountId.trim().length > 0
|
||||
? { account_id: newCredentials.accountId }
|
||||
: {}),
|
||||
};
|
||||
next.last_refresh = new Date().toISOString();
|
||||
return next;
|
||||
}
|
||||
|
||||
export function writeCodexCliKeychainCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
execFileSync?: ExecFileSyncFn;
|
||||
},
|
||||
): boolean {
|
||||
const { platform, codexHome } = resolveCodexKeychainParams(options);
|
||||
if (platform !== "darwin") {
|
||||
return false;
|
||||
}
|
||||
const existing = readCodexKeychainAuthRecord(options);
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const execFileSyncImpl = options?.execFileSync ?? execFileSync;
|
||||
const account = computeCodexKeychainAccount(codexHome);
|
||||
const next = buildUpdatedCodexAuthRecord(existing, newCredentials);
|
||||
|
||||
try {
|
||||
execFileSyncImpl(
|
||||
"security",
|
||||
["add-generic-password", "-U", "-s", "Codex Auth", "-a", account, "-w", JSON.stringify(next)],
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
codexCliCache = null;
|
||||
log.info("wrote refreshed credentials to codex cli keychain", {
|
||||
expires: new Date(newCredentials.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn("failed to write credentials to codex cli keychain", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCodexCliFileCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
options?: CodexCliFileOptions,
|
||||
): boolean {
|
||||
const codexHome = resolveCodexHomePath(options?.codexHome);
|
||||
const authPath = path.join(codexHome, CODEX_CLI_AUTH_FILENAME);
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = loadJsonFile(authPath);
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return false;
|
||||
}
|
||||
const next = buildUpdatedCodexAuthRecord(raw as Record<string, unknown>, newCredentials);
|
||||
saveJsonFile(authPath, next);
|
||||
codexCliCache = null;
|
||||
log.info("wrote refreshed credentials to codex cli file", {
|
||||
expires: new Date(newCredentials.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn("failed to write credentials to codex cli file", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCodexCliCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
options?: CodexCliWriteOptions,
|
||||
): boolean {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const writeKeychain = options?.writeKeychain ?? writeCodexCliKeychainCredentials;
|
||||
const writeFile =
|
||||
options?.writeFile ??
|
||||
((credentials, fileOptions) => writeCodexCliFileCredentials(credentials, fileOptions));
|
||||
|
||||
if (
|
||||
platform === "darwin" &&
|
||||
writeKeychain(newCredentials, {
|
||||
codexHome: options?.codexHome,
|
||||
platform,
|
||||
execSync: options?.execSync,
|
||||
execFileSync: options?.execFileSync,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return writeFile(newCredentials, { codexHome: options?.codexHome });
|
||||
}
|
||||
|
||||
export function readCodexCliCredentials(options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
|
||||
@@ -1,117 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { saveAuthProfileStore } from "./agent-runtime.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as providerAuthRuntime from "./provider-auth-runtime.js";
|
||||
|
||||
describe("plugin-sdk provider-auth-runtime", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-runtime-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("exports the runtime-ready auth helper", () => {
|
||||
expect(typeof providerAuthRuntime.getRuntimeAuthForModel).toBe("function");
|
||||
});
|
||||
|
||||
it("exports the Codex auth bridge helper", () => {
|
||||
expect(typeof providerAuthRuntime.prepareCodexAuthBridge).toBe("function");
|
||||
});
|
||||
|
||||
it("exports OAuth callback helpers", () => {
|
||||
expect(typeof providerAuthRuntime.generateOAuthState).toBe("function");
|
||||
expect(typeof providerAuthRuntime.parseOAuthCallbackInput).toBe("function");
|
||||
expect(typeof providerAuthRuntime.waitForLocalOAuthCallback).toBe("function");
|
||||
});
|
||||
|
||||
it("does not write incomplete Codex ChatGPT auth without an id token", async () => {
|
||||
const agentDir = await makeTempDir();
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
const bridge = await providerAuthRuntime.prepareCodexAuthBridge({
|
||||
agentDir,
|
||||
bridgeDir: "harness-auth",
|
||||
profileId: "openai-codex:default",
|
||||
});
|
||||
|
||||
expect(bridge).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hydrates missing Codex id token from a matching source auth file", async () => {
|
||||
const root = await makeTempDir();
|
||||
const agentDir = path.join(root, "agent");
|
||||
const sourceCodexHome = path.join(root, "codex-home");
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "source-id-token",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct-123",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
accountId: "acct-123",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
{ filterExternalAuthProfiles: false },
|
||||
);
|
||||
|
||||
const bridge = await providerAuthRuntime.prepareCodexAuthBridge({
|
||||
agentDir,
|
||||
bridgeDir: "harness-auth",
|
||||
profileId: "openai-codex:default",
|
||||
sourceCodexHome,
|
||||
});
|
||||
|
||||
expect(bridge?.codexHome).toContain(path.join(agentDir, "harness-auth", "codex"));
|
||||
const authFile = JSON.parse(
|
||||
await fs.readFile(path.join(bridge?.codexHome ?? "", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(authFile.tokens.id_token).toBe("source-id-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { ensureAuthProfileStoreForLocalUpdate } from "../agents/auth-profiles/store.js";
|
||||
import type { OAuthCredential } from "../agents/auth-profiles/types.js";
|
||||
import { resolveApiKeyForProvider as resolveModelApiKeyForProvider } from "../agents/model-auth.js";
|
||||
import { writePrivateSecretFileAtomic } from "../infra/secret-file.js";
|
||||
|
||||
export { resolveEnvApiKey } from "../agents/model-auth-env.js";
|
||||
export {
|
||||
@@ -25,15 +21,6 @@ export {
|
||||
export type { ProviderPreparedRuntimeAuth } from "../plugins/types.js";
|
||||
export type { ResolvedProviderRuntimeAuth } from "../plugins/runtime/model-auth-types.js";
|
||||
|
||||
export const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const;
|
||||
|
||||
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
||||
|
||||
export type PreparedCodexAuthBridge = {
|
||||
codexHome: string;
|
||||
clearEnv: string[];
|
||||
};
|
||||
|
||||
export type OAuthCallbackResult = { code: string; state: string };
|
||||
|
||||
export function generateOAuthState(): string {
|
||||
@@ -182,233 +169,6 @@ function escapeHtmlText(value: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function isCodexBridgeableOAuthCredential(value: unknown): value is OAuthCredential {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"type" in value &&
|
||||
"provider" in value &&
|
||||
"access" in value &&
|
||||
"refresh" in value &&
|
||||
value.type === "oauth" &&
|
||||
value.provider === OPENAI_CODEX_PROVIDER_ID &&
|
||||
typeof value.access === "string" &&
|
||||
value.access.trim().length > 0 &&
|
||||
typeof value.refresh === "string" &&
|
||||
value.refresh.trim().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
type CodexAuthBridgeRecord = {
|
||||
auth_mode?: unknown;
|
||||
tokens?: {
|
||||
id_token?: unknown;
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
account_id?: unknown;
|
||||
};
|
||||
last_refresh?: unknown;
|
||||
OPENAI_API_KEY?: unknown;
|
||||
};
|
||||
|
||||
type CodexAuthBridgeMaterial = {
|
||||
accountId?: string;
|
||||
idToken?: string;
|
||||
lastRefresh?: string | number;
|
||||
openaiApiKey?: string;
|
||||
};
|
||||
|
||||
export function resolveCodexAuthBridgeHome(params: {
|
||||
agentDir: string;
|
||||
bridgeDir: string;
|
||||
profileId: string;
|
||||
}): string {
|
||||
const digest = crypto.createHash("sha256").update(params.profileId).digest("hex").slice(0, 16);
|
||||
return path.join(params.agentDir, params.bridgeDir, "codex", digest);
|
||||
}
|
||||
|
||||
function assertExistingCodexAuthBridgeFileSafe(codexHome: string): void {
|
||||
const authFile = path.join(codexHome, "auth.json");
|
||||
try {
|
||||
const stat = fs.lstatSync(authFile);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`Private secret file ${authFile} must not be a symlink.`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Private secret file ${authFile} must be a regular file.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexAuthBridgeFile(
|
||||
credential: OAuthCredential,
|
||||
material: Partial<CodexAuthBridgeMaterial> = {},
|
||||
): string {
|
||||
const lastRefresh =
|
||||
normalizeCodexAuthLastRefresh(material.lastRefresh) ?? new Date().toISOString();
|
||||
const openaiApiKey = readCodexAuthString(material.openaiApiKey);
|
||||
const idToken = readCodexAuthString(credential.idToken) ?? readCodexAuthString(material.idToken);
|
||||
const accountId =
|
||||
readCodexAuthString(credential.accountId) ?? readCodexAuthString(material.accountId);
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
...(openaiApiKey ? { OPENAI_API_KEY: openaiApiKey } : {}),
|
||||
tokens: {
|
||||
...(idToken ? { id_token: idToken } : {}),
|
||||
access_token: credential.access,
|
||||
refresh_token: credential.refresh,
|
||||
...(accountId ? { account_id: accountId } : {}),
|
||||
},
|
||||
last_refresh: lastRefresh,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export async function prepareCodexAuthBridge(params: {
|
||||
agentDir: string;
|
||||
bridgeDir: string;
|
||||
profileId: string;
|
||||
sourceCodexHome?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<PreparedCodexAuthBridge | undefined> {
|
||||
const store = ensureAuthProfileStoreForLocalUpdate(params.agentDir);
|
||||
const credential = store.profiles[params.profileId];
|
||||
if (!isCodexBridgeableOAuthCredential(credential)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const codexHome = resolveCodexAuthBridgeHome(params);
|
||||
assertExistingCodexAuthBridgeFileSafe(codexHome);
|
||||
const material = resolveCodexAuthBridgeMaterial({
|
||||
credential,
|
||||
sourceCodexHome: params.sourceCodexHome,
|
||||
env: { ...process.env, ...params.env },
|
||||
});
|
||||
if (!readCodexAuthString(credential.idToken) && !readCodexAuthString(material.idToken)) {
|
||||
return undefined;
|
||||
}
|
||||
await writePrivateSecretFileAtomic({
|
||||
rootDir: params.agentDir,
|
||||
filePath: path.join(codexHome, "auth.json"),
|
||||
content: buildCodexAuthBridgeFile(credential, material),
|
||||
});
|
||||
|
||||
return {
|
||||
codexHome,
|
||||
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexAuthBridgeMaterial(params: {
|
||||
credential: OAuthCredential;
|
||||
sourceCodexHome?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Partial<CodexAuthBridgeMaterial> {
|
||||
const source = readCodexAuthBridgeSourceFile({
|
||||
codexHome: params.sourceCodexHome,
|
||||
env: params.env,
|
||||
});
|
||||
if (!source || source.auth_mode !== "chatgpt") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const tokens = source.tokens;
|
||||
if (!tokens || typeof tokens !== "object") {
|
||||
return {};
|
||||
}
|
||||
const access = readCodexAuthString(tokens.access_token);
|
||||
const refresh = readCodexAuthString(tokens.refresh_token);
|
||||
if (!access || !refresh) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const accountId = readCodexAuthString(tokens.account_id);
|
||||
if (!codexAuthSourceMatchesCredential(params.credential, { access, refresh, accountId })) {
|
||||
return {};
|
||||
}
|
||||
const idToken = readCodexAuthString(tokens.id_token);
|
||||
const lastRefresh = normalizeCodexAuthLastRefresh(source.last_refresh);
|
||||
const openaiApiKey = readCodexAuthString(source.OPENAI_API_KEY);
|
||||
|
||||
return {
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(idToken ? { idToken } : {}),
|
||||
...(lastRefresh ? { lastRefresh } : {}),
|
||||
...(openaiApiKey ? { openaiApiKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function codexAuthSourceMatchesCredential(
|
||||
credential: OAuthCredential,
|
||||
source: { access: string; refresh: string; accountId?: string },
|
||||
): boolean {
|
||||
if (credential.access === source.access && credential.refresh === source.refresh) {
|
||||
return true;
|
||||
}
|
||||
const credentialAccountId = credential.accountId?.trim();
|
||||
const sourceAccountId = source.accountId?.trim();
|
||||
return Boolean(credentialAccountId && sourceAccountId && credentialAccountId === sourceAccountId);
|
||||
}
|
||||
|
||||
function readCodexAuthBridgeSourceFile(params: {
|
||||
codexHome?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): CodexAuthBridgeRecord | undefined {
|
||||
const codexHome = resolveSourceCodexHome(params);
|
||||
if (!codexHome) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(path.join(codexHome, "auth.json"), "utf8"));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as CodexAuthBridgeRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceCodexHome(params: {
|
||||
codexHome?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string | undefined {
|
||||
const configured = params.codexHome?.trim() || params.env?.CODEX_HOME?.trim();
|
||||
if (configured) {
|
||||
return resolveTildePath(configured);
|
||||
}
|
||||
const home = os.homedir();
|
||||
return home ? path.join(home, ".codex") : undefined;
|
||||
}
|
||||
|
||||
function resolveTildePath(value: string): string {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function readCodexAuthString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeCodexAuthLastRefresh(value: unknown): string | number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return readCodexAuthString(value);
|
||||
}
|
||||
|
||||
type ResolveApiKeyForProvider = typeof import("../agents/model-auth.js").resolveApiKeyForProvider;
|
||||
type GetRuntimeAuthForModel =
|
||||
typeof import("../plugins/runtime/runtime-model-auth.runtime.js").getRuntimeAuthForModel;
|
||||
|
||||
Reference in New Issue
Block a user