fix(auth): close codex review gaps

This commit is contained in:
Vincent Koc
2026-04-18 07:30:32 -07:00
committed by Peter Steinberger
parent 859eb06662
commit 78288e37ed
15 changed files with 487 additions and 23 deletions

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -15,6 +16,13 @@ let bridgeCodexAppServerStartOptions: typeof import("./auth-bridge.js").bridgeCo
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),
);
beforeAll(async () => {
({ bridgeCodexAppServerStartOptions } = await import("./auth-bridge.js"));
@@ -74,6 +82,10 @@ describe("bridgeCodexAppServerStartOptions", () => {
account_id: "acct-123",
},
});
if (process.platform !== "win32") {
const authStat = await fs.stat(path.join(result.env?.CODEX_HOME ?? "", "auth.json"));
expect(authStat.mode & 0o777).toBe(0o600);
}
});
it("leaves start options unchanged when canonical oauth is unavailable", async () => {
@@ -97,4 +109,35 @@ describe("bridgeCodexAppServerStartOptions", () => {
}),
).resolves.toEqual(startOptions);
});
it("refuses to overwrite a symlinked auth bridge file", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
tempDirs.push(agentDir);
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
});
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: {
command: "codex",
args: ["app-server"],
},
agentDir,
}),
).rejects.toThrow("must not be a symlink");
});
});

View File

@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { ensureAuthProfileStore, type OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
import type { CodexAppServerStartOptions } from "./config.js";
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
@@ -60,8 +60,11 @@ export async function bridgeCodexAppServerStartOptions(params: {
}
const codexHome = resolveCodexBridgeHome(params.agentDir, profileId);
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(path.join(codexHome, "auth.json"), buildCodexAuthFile(credential));
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthFile(credential),
});
return {
...params.startOptions,

View File

@@ -137,6 +137,33 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(seenAuthProfileId).toBe("openai-codex:work");
});
it("fails closed when the persisted binding auth profile disagrees with the runtime request", async () => {
const fake = createFakeCodexClient();
const factory = vi.fn(async () => fake.client);
__testing.setCodexAppServerClientFactoryForTests(factory);
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai-codex:binding",
});
const result = await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
authProfileId: "openai-codex:runtime",
});
expect(result).toEqual({
ok: false,
compacted: false,
reason: "auth profile mismatch for session binding",
});
expect(factory).not.toHaveBeenCalled();
});
});
function createFakeCodexClient(): {

View File

@@ -38,8 +38,19 @@ export async function maybeCompactCodexAppServerSession(
if (!binding?.threadId) {
return { ok: false, compacted: false, reason: "no codex app-server thread binding" };
}
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
if (
requestedAuthProfileId &&
binding.authProfileId &&
binding.authProfileId !== requestedAuthProfileId
) {
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
}
const client = await clientFactory(appServer.start, binding.authProfileId);
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
let completion: CodexNativeCompactionCompletion;
try {

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -6,6 +7,13 @@ 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(
@@ -52,6 +60,10 @@ describe("prepareOpenAICodexCliExecution", () => {
account_id: "acct-123",
},
});
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 () => {
@@ -74,4 +86,30 @@ describe("prepareOpenAICodexCliExecution", () => {
}),
).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"));
await expect(
prepareOpenAICodexCliExecution({
config: undefined,
workspaceDir: agentDir,
agentDir,
provider: "codex-cli",
modelId: "gpt-5.4",
authProfileId: "openai-codex:default",
authCredential: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
}),
).rejects.toThrow("must not be a symlink");
});
});

View File

@@ -1,11 +1,11 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type {
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
} from "openclaw/plugin-sdk/cli-backend";
import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const;
@@ -60,8 +60,11 @@ export async function prepareOpenAICodexCliExecution(
}
const codexHome = resolveCodexBridgeHome(ctx.agentDir, ctx.authProfileId);
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(path.join(codexHome, "auth.json"), buildCodexAuthFile(ctx.authCredential));
await writePrivateSecretFileAtomic({
rootDir: ctx.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthFile(ctx.authCredential),
});
return {
env: {