fix(auth): harden codex oauth bridge security

This commit is contained in:
Vincent Koc
2026-04-18 08:18:58 -07:00
committed by Peter Steinberger
parent f6921fd733
commit a018257487
19 changed files with 397 additions and 213 deletions

View File

@@ -1,2 +1,2 @@
57d6ea3acdbaff64d59293bb42224be02ca975c5554afacebb78677642355b6f plugin-sdk-api-baseline.json
faff507780c473574e22e73af63dcdc4f4043cea72fa3475606cd4eaf20c1b17 plugin-sdk-api-baseline.jsonl
3447b4257e1eeaf3388b2c148e0086c534ba43147004100e9baa7cb662835c79 plugin-sdk-api-baseline.json
78590addd53ec7db1ef5a56eecc93c39303344ccfad8349a14e8f97480fe64b2 plugin-sdk-api-baseline.jsonl

View File

@@ -2,15 +2,8 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureAuthProfileStore: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
}));
import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
let bridgeCodexAppServerStartOptions: typeof import("./auth-bridge.js").bridgeCodexAppServerStartOptions;
@@ -29,7 +22,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
});
afterEach(async () => {
mocks.ensureAuthProfileStore.mockReset();
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
@@ -38,19 +30,22 @@ describe("bridgeCodexAppServerStartOptions", () => {
it("bridges canonical OpenClaw oauth into an isolated CODEX_HOME", 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,
accountId: "acct-123",
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",
},
},
},
});
agentDir,
);
const result = await bridgeCodexAppServerStartOptions({
startOptions: {
@@ -98,10 +93,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
};
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {},
});
saveAuthProfileStore({ version: 1, profiles: {} }, agentDir);
await expect(
bridgeCodexAppServerStartOptions({
@@ -115,18 +107,21 @@ describe("bridgeCodexAppServerStartOptions", () => {
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,
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
});
agentDir,
);
const codexHome = resolveHashedCodexHome(agentDir, "openai-codex:default");
await fs.mkdir(codexHome, { recursive: true });

View File

@@ -1,49 +1,7 @@
import crypto from "node:crypto";
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 { prepareCodexAuthBridgeFromProfile } from "openclaw/plugin-sdk/codex-auth-bridge-runtime";
import type { CodexAppServerStartOptions } from "./config.js";
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const;
function isBridgeableCodexOAuthCredential(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" &&
typeof value.access === "string" &&
value.access.trim().length > 0 &&
typeof value.refresh === "string" &&
value.refresh.trim().length > 0,
);
}
function resolveCodexBridgeHome(agentDir: string, profileId: string): string {
const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16);
return path.join(agentDir, "harness-auth", "codex", digest);
}
function buildCodexAuthFile(credential: OAuthCredential): string {
return `${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
},
},
null,
2,
)}\n`;
}
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
@@ -51,29 +9,21 @@ export async function bridgeCodexAppServerStartOptions(params: {
authProfileId?: string;
}): Promise<CodexAppServerStartOptions> {
const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID;
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
const bridge = await prepareCodexAuthBridgeFromProfile({
agentDir: params.agentDir,
authProfileId: profileId,
bridgeRoot: "harness-auth",
});
const credential = store.profiles[profileId];
if (!isBridgeableCodexOAuthCredential(credential)) {
if (!bridge) {
return params.startOptions;
}
const codexHome = resolveCodexBridgeHome(params.agentDir, profileId);
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthFile(credential),
});
return {
...params.startOptions,
env: {
...params.startOptions.env,
CODEX_HOME: codexHome,
CODEX_HOME: bridge.codexHome,
},
clearEnv: Array.from(
new Set([...(params.startOptions.clearEnv ?? []), ...CODEX_AUTH_ENV_CLEAR_KEYS]),
),
clearEnv: Array.from(new Set([...(params.startOptions.clearEnv ?? []), ...bridge.clearEnv])),
};
}

View File

@@ -2,6 +2,7 @@ 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";
@@ -24,6 +25,22 @@ describe("prepareOpenAICodexCliExecution", () => {
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",
},
},
},
agentDir,
);
const result = await prepareOpenAICodexCliExecution({
config: undefined,
@@ -32,14 +49,6 @@ describe("prepareOpenAICodexCliExecution", () => {
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,
accountId: "acct-123",
},
});
expect(result).toMatchObject({
@@ -69,6 +78,19 @@ describe("prepareOpenAICodexCliExecution", () => {
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,
);
await expect(
prepareOpenAICodexCliExecution({
@@ -78,11 +100,6 @@ describe("prepareOpenAICodexCliExecution", () => {
provider: "codex-cli",
modelId: "gpt-5.4",
authProfileId: "openai-codex:default",
authCredential: {
type: "api_key",
provider: "openai-codex",
key: "sk-test",
},
}),
).resolves.toBeNull();
});
@@ -93,6 +110,21 @@ describe("prepareOpenAICodexCliExecution", () => {
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,
);
await expect(
prepareOpenAICodexCliExecution({
@@ -102,13 +134,6 @@ describe("prepareOpenAICodexCliExecution", () => {
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,75 +1,29 @@
import crypto from "node:crypto";
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;
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,
);
}
function resolveCodexBridgeHome(agentDir: string, profileId: string): string {
const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16);
return path.join(agentDir, "cli-auth", "codex", digest);
}
function buildCodexAuthFile(credential: OAuthCredential): string {
return `${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
},
},
null,
2,
)}\n`;
}
import { prepareCodexAuthBridgeFromProfile } from "openclaw/plugin-sdk/codex-auth-bridge-runtime";
export async function prepareOpenAICodexCliExecution(
ctx: CliBackendPrepareExecutionContext,
): Promise<CliBackendPreparedExecution | null> {
if (
!ctx.agentDir ||
!ctx.authProfileId ||
!isCodexBridgeableOAuthCredential(ctx.authCredential)
) {
if (!ctx.agentDir || !ctx.authProfileId) {
return null;
}
const codexHome = resolveCodexBridgeHome(ctx.agentDir, ctx.authProfileId);
await writePrivateSecretFileAtomic({
rootDir: ctx.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthFile(ctx.authCredential),
const bridge = await prepareCodexAuthBridgeFromProfile({
agentDir: ctx.agentDir,
authProfileId: ctx.authProfileId,
bridgeRoot: "cli-auth",
});
if (!bridge) {
return null;
}
return {
env: {
CODEX_HOME: codexHome,
CODEX_HOME: bridge.codexHome,
},
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
clearEnv: bridge.clearEnv,
};
}

View File

@@ -1,12 +1,25 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn());
const readOpenAICodexCliOAuthProfileMock = vi.hoisted(() => vi.fn());
vi.mock("./openai-codex-provider.runtime.js", () => ({
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
}));
vi.mock("./openai-codex-cli-auth.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./openai-codex-cli-auth.js")>();
return {
...actual,
readOpenAICodexCliOAuthProfile: readOpenAICodexCliOAuthProfileMock,
};
});
let buildOpenAICodexProviderPlugin: typeof import("./openai-codex-provider.js").buildOpenAICodexProviderPlugin;
const tempDirs: string[] = [];
describe("openai codex provider", () => {
beforeAll(async () => {
@@ -15,6 +28,13 @@ describe("openai codex provider", () => {
beforeEach(() => {
refreshOpenAICodexTokenMock.mockReset();
readOpenAICodexCliOAuthProfileMock.mockReset();
});
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("falls back to the cached credential when accountId extraction fails", async () => {
@@ -98,6 +118,54 @@ describe("openai codex provider", () => {
});
});
it("uses the provider auth context env when importing Codex CLI auth", async () => {
const provider = buildOpenAICodexProviderPlugin();
const importMethod = provider.auth?.find((method) => method.id === "import-codex-cli");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openai-codex-provider-"));
tempDirs.push(agentDir);
readOpenAICodexCliOAuthProfileMock.mockImplementationOnce(({ env }) => {
expect(env).toMatchObject({
CODEX_HOME: "/sandboxed/codex-home",
});
return {
profileId: "openai-codex:default",
credential: {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
email: "codex@example.com",
displayName: "Codex User",
accountId: "acct-123",
},
};
});
await expect(
importMethod?.run({
config: {},
env: { CODEX_HOME: "/sandboxed/codex-home" },
agentDir,
prompter: {} as never,
runtime: {} as never,
isRemote: false,
openUrl: async () => {},
oauth: { createVpsAwareHandlers: (() => ({})) as never },
}),
).resolves.toMatchObject({
profiles: [
{
profileId: "openai-codex:default",
credential: expect.objectContaining({
provider: "openai-codex",
access: "access-token",
}),
},
],
});
});
it("owns native reasoning output mode for Codex responses", () => {
const provider = buildOpenAICodexProviderPlugin();

View File

@@ -284,7 +284,7 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) {
async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) {
const profile = readOpenAICodexCliOAuthProfile({
env: process.env,
env: ctx.env ?? process.env,
store: ensureAuthProfileStore(ctx.agentDir, {
allowKeychainPrompt: false,
}),

View File

@@ -305,6 +305,10 @@
"types": "./dist/plugin-sdk/agent-runtime.d.ts",
"default": "./dist/plugin-sdk/agent-runtime.js"
},
"./plugin-sdk/codex-auth-bridge-runtime": {
"types": "./dist/plugin-sdk/codex-auth-bridge-runtime.d.ts",
"default": "./dist/plugin-sdk/codex-auth-bridge-runtime.js"
},
"./plugin-sdk/simple-completion-runtime": {
"types": "./dist/plugin-sdk/simple-completion-runtime.d.ts",
"default": "./dist/plugin-sdk/simple-completion-runtime.js"

View File

@@ -62,6 +62,7 @@
"text-runtime",
"text-chunking",
"agent-runtime",
"codex-auth-bridge-runtime",
"simple-completion-runtime",
"speech-core",
"plugin-runtime",

View File

@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
createOAuthManager,
isSafeToAdoptBootstrapOAuthIdentity,
isSafeToAdoptMainStoreOAuthIdentity,
isSafeToOverwriteStoredOAuthIdentity,
OAuthManagerRefreshError,
} from "./oauth-manager.js";
@@ -73,6 +74,33 @@ describe("isSafeToOverwriteStoredOAuthIdentity", () => {
});
});
describe("isSafeToAdoptMainStoreOAuthIdentity", () => {
it("requires positive identity binding before adopting from the main store", () => {
expect(
isSafeToAdoptMainStoreOAuthIdentity(
createCredential({
access: "sub-access",
refresh: "sub-refresh",
}),
createCredential({
access: "main-access",
refresh: "main-refresh",
accountId: "acct-main",
}),
),
).toBe(false);
});
it("accepts matching account identities", () => {
expect(
isSafeToAdoptMainStoreOAuthIdentity(
createCredential({ accountId: "acct-123" }),
createCredential({ access: "main-access", refresh: "main-refresh", accountId: "acct-123" }),
),
).toBe(true);
});
});
describe("OAuthManagerRefreshError", () => {
it("serializes without leaking credential or store secrets", () => {
const refreshedStore: AuthProfileStore = {
@@ -140,7 +168,6 @@ describe("createOAuthManager", () => {
expires: Date.now() - 30_000,
}),
isRefreshTokenReusedError: () => false,
isSafeToCopyOAuthIdentity: () => true,
});
const result = await manager.resolveOAuthAccess({

View File

@@ -10,6 +10,7 @@ import {
areOAuthCredentialsEquivalent,
hasUsableOAuthCredential,
isSafeToAdoptBootstrapOAuthIdentity,
isSafeToAdoptMainStoreOAuthIdentity,
isSafeToOverwriteStoredOAuthIdentity,
overlayRuntimeExternalOAuthProfiles,
shouldBootstrapFromExternalCliCredential,
@@ -34,10 +35,6 @@ export type OAuthManagerAdapter = {
credential: OAuthCredential;
}) => OAuthCredential | null;
isRefreshTokenReusedError: (error: unknown) => boolean;
isSafeToCopyOAuthIdentity: (
existing: Pick<OAuthCredential, "accountId" | "email">,
incoming: Pick<OAuthCredential, "accountId" | "email">,
) => boolean;
};
export type ResolvedOAuthAccess = {
@@ -90,6 +87,7 @@ export {
areOAuthCredentialsEquivalent,
hasUsableOAuthCredential,
isSafeToAdoptBootstrapOAuthIdentity,
isSafeToAdoptMainStoreOAuthIdentity,
isSafeToOverwriteStoredOAuthIdentity,
overlayRuntimeExternalOAuthProfiles,
shouldBootstrapFromExternalCliCredential,
@@ -199,7 +197,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
Number.isFinite(mainCred.expires) &&
(!Number.isFinite(params.credential.expires) ||
mainCred.expires > params.credential.expires) &&
adapter.isSafeToCopyOAuthIdentity(params.credential, mainCred)
isSafeToAdoptMainStoreOAuthIdentity(params.credential, mainCred)
) {
params.store.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(params.store, params.agentDir);
@@ -327,7 +325,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
mainCred?.type === "oauth" &&
mainCred.provider === cred.provider &&
hasUsableOAuthCredential(mainCred) &&
adapter.isSafeToCopyOAuthIdentity(cred, mainCred)
isSafeToAdoptMainStoreOAuthIdentity(cred, mainCred)
) {
store.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(store, params.agentDir);
@@ -344,7 +342,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
mainCred?.type === "oauth" &&
mainCred.provider === cred.provider &&
hasUsableOAuthCredential(mainCred) &&
!adapter.isSafeToCopyOAuthIdentity(cred, mainCred)
!isSafeToAdoptMainStoreOAuthIdentity(cred, mainCred)
) {
log.warn("refused to adopt fresh main-store OAuth credential: identity mismatch", {
profileId: params.profileId,
@@ -537,7 +535,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
mainCred?.type === "oauth" &&
mainCred.provider === params.credential.provider &&
hasUsableOAuthCredential(mainCred) &&
adapter.isSafeToCopyOAuthIdentity(params.credential, mainCred)
isSafeToAdoptMainStoreOAuthIdentity(params.credential, mainCred)
) {
refreshedStore.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(refreshedStore, params.agentDir);

View File

@@ -131,6 +131,19 @@ export function isSafeToAdoptBootstrapOAuthIdentity(
return hasMatchingOAuthIdentity(existing, incoming);
}
export function isSafeToAdoptMainStoreOAuthIdentity(
existing: OAuthCredential | undefined,
incoming: OAuthCredential,
): boolean {
if (!existing || existing.type !== "oauth") {
return false;
}
if (existing.provider !== incoming.provider) {
return false;
}
return hasMatchingOAuthIdentity(existing, incoming);
}
export function shouldBootstrapFromExternalCliCredential(params: {
existing: OAuthCredential | undefined;
imported: OAuthCredential;

View File

@@ -288,7 +288,6 @@ const oauthManager = createOAuthManager({
credential,
}),
isRefreshTokenReusedError,
isSafeToCopyOAuthIdentity,
});
export function resetOAuthRefreshQueuesForTest(): void {

View File

@@ -185,7 +185,6 @@ export async function prepareCliRunContext(
provider: params.provider,
modelId,
authProfileId: effectiveAuthProfileId,
authCredential,
});
const authEpoch = await resolveCliAuthEpoch({
provider: params.provider,

View File

@@ -0,0 +1,77 @@
import crypto from "node:crypto";
import path from "node:path";
import { writePrivateSecretFileAtomic } from "../infra/secret-file.js";
import { loadAuthProfileStoreForSecretsRuntime } from "./auth-profiles/store.js";
import type { OAuthCredential } from "./auth-profiles/types.js";
export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
export const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const;
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,
);
}
export function resolveCodexBridgeHome(
agentDir: string,
profileId: string,
bridgeRoot: "cli-auth" | "harness-auth",
): string {
const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16);
return path.join(agentDir, bridgeRoot, "codex", digest);
}
export function buildCodexAuthFile(credential: OAuthCredential): string {
return `${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
},
},
null,
2,
)}\n`;
}
export async function prepareCodexAuthBridgeFromProfile(params: {
agentDir: string;
authProfileId: string;
bridgeRoot: "cli-auth" | "harness-auth";
}): Promise<{ codexHome: string; clearEnv: string[] } | null> {
const store = loadAuthProfileStoreForSecretsRuntime(params.agentDir);
const credential = store.profiles[params.authProfileId];
if (!isCodexBridgeableOAuthCredential(credential)) {
return null;
}
const codexHome = resolveCodexBridgeHome(
params.agentDir,
params.authProfileId,
params.bridgeRoot,
);
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthFile(credential),
});
return {
codexHome,
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
};
}

View File

@@ -1,4 +1,4 @@
import { mkdir, stat, symlink, writeFile } from "node:fs/promises";
import * as fsPromises from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
@@ -47,7 +47,7 @@ describe("readSecretFileSync", () => {
it("reads and trims a regular secret file", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, " top-secret \n", "utf8");
await fsPromises.writeFile(file, " top-secret \n", "utf8");
expect(readSecretFileSync(file, "Gateway password")).toBe("top-secret");
expect(tryReadSecretFileSync(file, "Gateway password")).toBe("top-secret");
@@ -90,7 +90,7 @@ describe("readSecretFileSync", () => {
name: "rejects files larger than the secret-file limit",
setup: async (dir: string) => {
const file = path.join(dir, "secret.txt");
await writeFile(file, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8");
await fsPromises.writeFile(file, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8");
return file;
},
expectedMessage: (file: string) =>
@@ -100,7 +100,7 @@ describe("readSecretFileSync", () => {
name: "rejects non-regular files",
setup: async (dir: string) => {
const nestedDir = path.join(dir, "secret-dir");
await mkdir(nestedDir);
await fsPromises.mkdir(nestedDir);
return nestedDir;
},
expectedMessage: (file: string) => `Gateway password file at ${file} must be a regular file.`,
@@ -110,8 +110,8 @@ describe("readSecretFileSync", () => {
setup: async (dir: string) => {
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
await fsPromises.writeFile(target, "top-secret\n", "utf8");
await fsPromises.symlink(target, link);
return link;
},
options: { rejectSymlink: true },
@@ -121,7 +121,7 @@ describe("readSecretFileSync", () => {
name: "rejects empty secret files after trimming",
setup: async (dir: string) => {
const file = path.join(dir, "secret.txt");
await writeFile(file, " \n\t ", "utf8");
await fsPromises.writeFile(file, " \n\t ", "utf8");
return file;
},
expectedMessage: (file: string) => `Gateway password file at ${file} is empty.`,
@@ -136,7 +136,7 @@ describe("readSecretFileSync", () => {
pathValue: async () =>
createSecretPath(async (dir) => {
const file = path.join(dir, "secret.txt");
await writeFile(file, " \n\t ", "utf8");
await fsPromises.writeFile(file, " \n\t ", "utf8");
return file;
}),
label: "Gateway password",
@@ -154,8 +154,8 @@ describe("readSecretFileSync", () => {
createSecretPath(async (dir) => {
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
await fsPromises.writeFile(target, "top-secret\n", "utf8");
await fsPromises.symlink(target, link);
return link;
}),
label: "Telegram bot token",
@@ -207,8 +207,8 @@ describe("writePrivateSecretFileAtomic", () => {
secret: '{"ok":true}',
});
if (process.platform !== "win32") {
const dirStat = await stat(path.dirname(file));
const fileStat = await stat(file);
const dirStat = await fsPromises.stat(path.dirname(file));
const fileStat = await fsPromises.stat(file);
expect(dirStat.mode & 0o777).toBe(PRIVATE_SECRET_DIR_MODE);
expect(fileStat.mode & 0o777).toBe(PRIVATE_SECRET_FILE_MODE);
}
@@ -219,9 +219,9 @@ describe("writePrivateSecretFileAtomic", () => {
const nestedDir = path.join(dir, "nested");
const target = path.join(dir, "outside.txt");
const link = path.join(nestedDir, "auth.json");
await mkdir(nestedDir);
await writeFile(target, "outside", "utf8");
await symlink(target, link);
await fsPromises.mkdir(nestedDir);
await fsPromises.writeFile(target, "outside", "utf8");
await fsPromises.symlink(target, link);
await expect(
writePrivateSecretFileAtomic({
@@ -235,8 +235,8 @@ describe("writePrivateSecretFileAtomic", () => {
it("rejects symlinked path components", async () => {
const dir = await createTempDir();
const targetDir = path.join(dir, "outside-dir");
await mkdir(targetDir);
await symlink(targetDir, path.join(dir, "linked"));
await fsPromises.mkdir(targetDir);
await fsPromises.symlink(targetDir, path.join(dir, "linked"));
await expect(
writePrivateSecretFileAtomic({
@@ -246,4 +246,34 @@ describe("writePrivateSecretFileAtomic", () => {
}),
).rejects.toThrow("must not be a symlink");
});
it("tightens an existing world-readable directory before writing secrets", async () => {
const dir = await createTempDir();
const nestedDir = path.join(dir, "nested");
await fsPromises.mkdir(nestedDir, { mode: 0o777 });
if (process.platform !== "win32") {
await writePrivateSecretFileAtomic({
rootDir: dir,
filePath: path.join(nestedDir, "auth.json"),
content: '{"ok":true}\n',
});
const dirStat = await fsPromises.stat(nestedDir);
expect(dirStat.mode & 0o777).toBe(PRIVATE_SECRET_DIR_MODE);
}
});
it("rejects a parent directory symlink before it can escape the private root", async () => {
const dir = await createTempDir();
const targetDir = await createTempDir();
const aliasDir = path.join(dir, "nested");
await fsPromises.symlink(targetDir, aliasDir);
await expect(
writePrivateSecretFileAtomic({
rootDir: dir,
filePath: path.join(aliasDir, "auth.json"),
content: '{"ok":true}\n',
}),
).rejects.toThrow("must not be a symlink");
});
});

View File

@@ -151,6 +151,31 @@ function assertPathWithinRoot(rootDir: string, targetPath: string): void {
}
}
function assertRealPathWithinRoot(rootDir: string, targetPath: string): void {
const relative = path.relative(rootDir, targetPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Private secret path must stay under ${rootDir}.`);
}
}
async function enforcePrivatePathMode(
resolvedPath: string,
expectedMode: number,
kind: "directory" | "file",
): Promise<void> {
if (process.platform === "win32") {
return;
}
await fsp.chmod(resolvedPath, expectedMode);
const stat = await fsp.stat(resolvedPath);
const actualMode = stat.mode & 0o777;
if (actualMode !== expectedMode) {
throw new Error(
`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`,
);
}
}
async function ensurePrivateDirectory(rootDir: string, targetDir: string): Promise<void> {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(targetDir);
@@ -163,12 +188,13 @@ async function ensurePrivateDirectory(rootDir: string, targetDir: string): Promi
if (!rootStat.isDirectory()) {
throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
}
await fsp.chmod(resolvedRoot, PRIVATE_SECRET_DIR_MODE).catch(() => undefined);
await enforcePrivatePathMode(resolvedRoot, PRIVATE_SECRET_DIR_MODE, "directory");
return;
}
assertPathWithinRoot(resolvedRoot, resolvedTarget);
await ensurePrivateDirectory(resolvedRoot, resolvedRoot);
const resolvedRootReal = await fsp.realpath(resolvedRoot);
let current = resolvedRoot;
for (const segment of path
@@ -190,7 +216,9 @@ async function ensurePrivateDirectory(rootDir: string, targetDir: string): Promi
}
await fsp.mkdir(current, { mode: PRIVATE_SECRET_DIR_MODE });
}
await fsp.chmod(current, PRIVATE_SECRET_DIR_MODE).catch(() => undefined);
const currentReal = await fsp.realpath(current);
assertRealPathWithinRoot(resolvedRootReal, currentReal);
await enforcePrivatePathMode(currentReal, PRIVATE_SECRET_DIR_MODE, "directory");
}
}
@@ -202,16 +230,21 @@ export async function writePrivateSecretFileAtomic(params: {
const resolvedRoot = path.resolve(params.rootDir);
const resolvedFile = path.resolve(params.filePath);
assertPathWithinRoot(resolvedRoot, resolvedFile);
const parentDir = path.dirname(resolvedFile);
await ensurePrivateDirectory(resolvedRoot, parentDir);
const intendedParentDir = path.dirname(resolvedFile);
await ensurePrivateDirectory(resolvedRoot, intendedParentDir);
const resolvedRootReal = await fsp.realpath(resolvedRoot);
const parentDir = await fsp.realpath(intendedParentDir);
assertRealPathWithinRoot(resolvedRootReal, parentDir);
const fileName = path.basename(resolvedFile);
const finalFilePath = path.join(parentDir, fileName);
try {
const stat = await fsp.lstat(resolvedFile);
const stat = await fsp.lstat(finalFilePath);
if (stat.isSymbolicLink()) {
throw new Error(`Private secret file ${resolvedFile} must not be a symlink.`);
throw new Error(`Private secret file ${finalFilePath} must not be a symlink.`);
}
if (!stat.isFile()) {
throw new Error(`Private secret file ${resolvedFile} must be a regular file.`);
throw new Error(`Private secret file ${finalFilePath} must be a regular file.`);
}
} catch (error) {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
@@ -232,10 +265,14 @@ export async function writePrivateSecretFileAtomic(params: {
} finally {
await handle.close();
}
await fsp.chmod(tempPath, PRIVATE_SECRET_FILE_MODE).catch(() => undefined);
await fsp.rename(tempPath, resolvedFile);
await enforcePrivatePathMode(tempPath, PRIVATE_SECRET_FILE_MODE, "file");
const refreshedParentReal = await fsp.realpath(intendedParentDir);
if (refreshedParentReal !== parentDir) {
throw new Error(`Private secret parent directory changed during write for ${finalFilePath}.`);
}
await fsp.rename(tempPath, finalFilePath);
createdTemp = false;
await fsp.chmod(resolvedFile, PRIVATE_SECRET_FILE_MODE).catch(() => undefined);
await enforcePrivatePathMode(finalFilePath, PRIVATE_SECRET_FILE_MODE, "file");
} finally {
if (createdTemp) {
await fsp.unlink(tempPath).catch(() => undefined);

View File

@@ -0,0 +1,8 @@
export {
buildCodexAuthFile,
CODEX_AUTH_ENV_CLEAR_KEYS,
isCodexBridgeableOAuthCredential,
OPENAI_CODEX_PROVIDER_ID,
prepareCodexAuthBridgeFromProfile,
resolveCodexBridgeHome,
} from "../agents/codex-auth-bridge.js";

View File

@@ -25,7 +25,6 @@ export type CliBackendPrepareExecutionContext = {
provider: string;
modelId: string;
authProfileId?: string;
authCredential?: unknown;
};
export type CliBackendPreparedExecution = {