fix: keep codex oauth bridge extension-owned (#68284) (thanks @vincentkoc)

This commit is contained in:
Peter Steinberger
2026-04-18 20:56:38 +01:00
parent f1cc8f0cfc
commit 2fc429dfbf
27 changed files with 363 additions and 198 deletions

View File

@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
- Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198)
- Telegram/streaming: fence same-session stale preview and finalization work after aborts so Telegram no longer replays an older reply or flushes a hidden short preview after the abort confirmation lands. (#68100) Thanks @rubencu.
- OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc.
- OpenAI Codex/OAuth: keep Codex-specific auth bridging inside the owning plugins, preserve canonical imported CLI profiles, and allow legacy identity-less main-store OAuth sessions to upgrade during refresh mirroring. (#68284) Thanks @vincentkoc.
- Config/redact: add `browser.cdpUrl` and `browser.profiles.*.cdpUrl` to sensitive URL config paths so embedded credentials (query tokens and HTTP Basic auth) are properly redacted in `config.get` API responses and availability error messages. (#67679) Thanks @Ziy1-Tan.
- Agents/TTS: report failed speech synthesis as a real tool error so unconfigured providers no longer feed successful TTS failure output back into agent loops. (#67980) Thanks @lawrence3699.
- Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent.

View File

@@ -1,2 +1,2 @@
3447b4257e1eeaf3388b2c148e0086c534ba43147004100e9baa7cb662835c79 plugin-sdk-api-baseline.json
78590addd53ec7db1ef5a56eecc93c39303344ccfad8349a14e8f97480fe64b2 plugin-sdk-api-baseline.jsonl
f2002dd0661ca25eee382e1ab14c4655cfa3f4d616eaf4ac98db307df8453687 plugin-sdk-api-baseline.json
34ad0f68b93b49eed4bce4f5e56511e3c555917c0b8743d399534d9ec3e8a8e2 plugin-sdk-api-baseline.jsonl

View File

@@ -41,10 +41,12 @@ describe("bridgeCodexAppServerStartOptions", () => {
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct-123",
idToken: "id-token",
},
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const result = await bridgeCodexAppServerStartOptions({
@@ -73,6 +75,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
expect(authFile).toEqual({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
@@ -93,7 +96,9 @@ describe("bridgeCodexAppServerStartOptions", () => {
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
};
saveAuthProfileStore({ version: 1, profiles: {} }, agentDir);
saveAuthProfileStore({ version: 1, profiles: {} }, agentDir, {
filterExternalAuthProfiles: false,
});
await expect(
bridgeCodexAppServerStartOptions({
@@ -121,6 +126,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const codexHome = resolveHashedCodexHome(agentDir, "openai-codex:default");

View File

@@ -1,7 +1,54 @@
import { prepareCodexAuthBridgeFromProfile } from "openclaw/plugin-sdk/codex-auth-bridge-runtime";
import crypto from "node:crypto";
import path from "node:path";
import {
ensureAuthProfileStoreForLocalUpdate,
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";
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, "harness-auth", "codex", digest);
}
function buildCodexAuthFile(credential: OAuthCredential): string {
return `${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
...(credential.idToken ? { id_token: credential.idToken } : {}),
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
},
},
null,
2,
)}\n`;
}
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
@@ -9,21 +56,27 @@ export async function bridgeCodexAppServerStartOptions(params: {
authProfileId?: string;
}): Promise<CodexAppServerStartOptions> {
const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID;
const bridge = await prepareCodexAuthBridgeFromProfile({
agentDir: params.agentDir,
authProfileId: profileId,
bridgeRoot: "harness-auth",
});
if (!bridge) {
const store = ensureAuthProfileStoreForLocalUpdate(params.agentDir);
const credential = store.profiles[profileId];
if (!isCodexBridgeableOAuthCredential(credential)) {
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: bridge.codexHome,
CODEX_HOME: codexHome,
},
clearEnv: Array.from(new Set([...(params.startOptions.clearEnv ?? []), ...bridge.clearEnv])),
clearEnv: Array.from(
new Set([...(params.startOptions.clearEnv ?? []), ...CODEX_AUTH_ENV_CLEAR_KEYS]),
),
};
}

View File

@@ -532,7 +532,7 @@ describe("runCodexAppServerAttempt", () => {
const binding = await startOrResumeThread({
client: {
request: async (method) => {
request: async (method: string) => {
if (method === "thread/resume") {
return { thread: { id: "thread-existing" }, modelProvider: "openai" };
}

View File

@@ -42,6 +42,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
@@ -61,6 +62,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
idToken: "id-token",
email: "codex@example.com",
},
});
@@ -172,6 +174,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
@@ -201,6 +204,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
idToken: "id-token",
email: "codex@example.com",
},
});
@@ -217,6 +221,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
@@ -254,6 +259,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
@@ -281,6 +287,7 @@ describe("readOpenAICodexCliOAuthProfile", () => {
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
idToken: "id-token",
email: "codex@example.com",
},
});

View File

@@ -22,6 +22,7 @@ export const OPENAI_CODEX_DEFAULT_PROFILE_ID = `${PROVIDER_ID}:default`;
type CodexCliAuthFile = {
auth_mode?: unknown;
tokens?: {
id_token?: unknown;
access_token?: unknown;
refresh_token?: unknown;
account_id?: unknown;
@@ -76,7 +77,8 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean
a.displayName === b.displayName &&
a.enterpriseUrl === b.enterpriseUrl &&
a.projectId === b.projectId &&
a.accountId === b.accountId
a.accountId === b.accountId &&
a.idToken === b.idToken
);
}
@@ -131,6 +133,7 @@ export function readOpenAICodexCliOAuthProfile(params: {
}
const accountId = trimNonEmptyString(authFile.tokens?.account_id);
const idToken = trimNonEmptyString(authFile.tokens?.id_token);
const identity = resolveCodexAuthIdentity({ accessToken: access });
const credential: OAuthCredential = {
type: "oauth",
@@ -139,6 +142,7 @@ export function readOpenAICodexCliOAuthProfile(params: {
refresh,
expires: resolveCodexAccessTokenExpiry(access) ?? 0,
...(accountId ? { accountId } : {}),
...(idToken ? { idToken } : {}),
...(identity.email ? { email: identity.email } : {}),
...(identity.profileName ? { displayName: identity.profileName } : {}),
};

View File

@@ -36,10 +36,12 @@ describe("prepareOpenAICodexCliExecution", () => {
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct-123",
idToken: "id-token",
},
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const result = await prepareOpenAICodexCliExecution({
@@ -64,6 +66,7 @@ describe("prepareOpenAICodexCliExecution", () => {
expect(authFile).toEqual({
auth_mode: "chatgpt",
tokens: {
id_token: "id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
@@ -90,6 +93,7 @@ describe("prepareOpenAICodexCliExecution", () => {
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
await expect(
@@ -124,6 +128,7 @@ describe("prepareOpenAICodexCliExecution", () => {
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
await expect(

View File

@@ -1,8 +1,56 @@
import crypto from "node:crypto";
import path from "node:path";
import type {
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
} from "openclaw/plugin-sdk/cli-backend";
import { prepareCodexAuthBridgeFromProfile } from "openclaw/plugin-sdk/codex-auth-bridge-runtime";
import {
ensureAuthProfileStoreForLocalUpdate,
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: {
...(credential.idToken ? { id_token: credential.idToken } : {}),
access_token: credential.access,
refresh_token: credential.refresh,
...(credential.accountId ? { account_id: credential.accountId } : {}),
},
},
null,
2,
)}\n`;
}
export async function prepareOpenAICodexCliExecution(
ctx: CliBackendPrepareExecutionContext,
@@ -11,19 +59,23 @@ export async function prepareOpenAICodexCliExecution(
return null;
}
const bridge = await prepareCodexAuthBridgeFromProfile({
agentDir: ctx.agentDir,
authProfileId: ctx.authProfileId,
bridgeRoot: "cli-auth",
});
if (!bridge) {
const store = ensureAuthProfileStoreForLocalUpdate(ctx.agentDir);
const credential = store.profiles[ctx.authProfileId];
if (!isCodexBridgeableOAuthCredential(credential)) {
return null;
}
const codexHome = resolveCodexBridgeHome(ctx.agentDir, ctx.authProfileId);
await writePrivateSecretFileAtomic({
rootDir: ctx.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: buildCodexAuthFile(credential),
});
return {
env: {
CODEX_HOME: bridge.codexHome,
CODEX_HOME: codexHome,
},
clearEnv: bridge.clearEnv,
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
};
}

View File

@@ -118,6 +118,41 @@ describe("openai codex provider", () => {
});
});
it("exposes Codex CLI auth as a runtime-only external profile", () => {
const provider = buildOpenAICodexProviderPlugin();
const credential = {
type: "oauth" as const,
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct-123",
};
readOpenAICodexCliOAuthProfileMock.mockReturnValueOnce({
profileId: "openai-codex:default",
credential,
});
expect(
provider.resolveExternalAuthProfiles?.({
env: { CODEX_HOME: "/sandboxed/codex-home" } as NodeJS.ProcessEnv,
store: { version: 1, profiles: {} },
}),
).toEqual([
{
profileId: "openai-codex:default",
credential,
persistence: "runtime-only",
},
]);
expect(readOpenAICodexCliOAuthProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({ CODEX_HOME: "/sandboxed/codex-home" }),
store: { version: 1, profiles: {} },
}),
);
});
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");
@@ -156,7 +191,7 @@ describe("openai codex provider", () => {
).resolves.toMatchObject({
profiles: [
{
profileId: "default:codex@example.com",
profileId: "openai-codex:default",
credential: expect.objectContaining({
provider: "openai-codex",
access: "access-token",

View File

@@ -5,9 +5,10 @@ import type {
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
ensureAuthProfileStore,
ensureAuthProfileStoreForLocalUpdate,
listProfilesForProvider,
type OAuthCredential,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
import { loginOpenAICodexOAuth } from "openclaw/plugin-sdk/provider-auth-login";
@@ -285,9 +286,7 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) {
async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) {
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env ?? process.env,
store: ensureAuthProfileStore(ctx.agentDir, {
allowKeychainPrompt: false,
}),
store: ensureAuthProfileStoreForLocalUpdate(ctx.agentDir),
});
if (!profile) {
throw new Error(
@@ -295,20 +294,38 @@ async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) {
);
}
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
return {
profiles: [{ profileId: profile.profileId, credential: profile.credential }],
configPatch: {
agents: {
defaults: {
models: {
[OPENAI_CODEX_DEFAULT_MODEL]: {},
},
},
},
},
defaultModel: OPENAI_CODEX_DEFAULT_MODEL,
access: profile.credential.access,
refresh: profile.credential.refresh,
expires: profile.credential.expires,
email: profile.credential.email,
displayName: profile.credential.displayName,
profilePrefix: "default",
credentialExtra: profile.credential.accountId
? { accountId: profile.credential.accountId }
: {},
notes: ["Imported existing Codex CLI login into OpenClaw canonical auth."],
} satisfies ProviderAuthResult;
}
function ensureOpenAICodexCatalogAuthStore(ctx: { agentDir?: string; env?: NodeJS.ProcessEnv }) {
const store = ensureAuthProfileStoreForLocalUpdate(ctx.agentDir);
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env ?? process.env,
store,
});
if (!profile) {
return store;
}
return {
...store,
profiles: {
...store.profiles,
[profile.profileId]: profile.credential,
},
};
}
function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) {
@@ -350,9 +367,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
catalog: {
order: "profile",
run: async (ctx) => {
const authStore = ensureAuthProfileStore(ctx.agentDir, {
allowKeychainPrompt: false,
});
const authStore = ensureOpenAICodexCatalogAuthStore(ctx);
if (listProfilesForProvider(authStore, PROVIDER_ID).length === 0) {
return null;
}
@@ -395,6 +410,13 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
fetchUsageSnapshot: async (ctx) =>
await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn),
refreshOAuth: async (cred) => await refreshOpenAICodexOAuthCredential(cred),
resolveExternalAuthProfiles: (ctx) => {
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env,
store: ctx.store,
});
return profile ? [{ ...profile, persistence: "runtime-only" }] : [];
},
augmentModelCatalog: (ctx) => {
const gpt54Template = findCatalogTemplate({
entries: ctx.entries,

View File

@@ -305,10 +305,6 @@
"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,7 +62,6 @@
"text-runtime",
"text-chunking",
"agent-runtime",
"codex-auth-bridge-runtime",
"simple-completion-runtime",
"speech-core",
"plugin-runtime",

View File

@@ -1,7 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ProviderExternalAuthProfile } from "../plugins/provider-external-auth.types.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
@@ -10,8 +11,12 @@ import {
import { AUTH_STORE_VERSION, log } from "./auth-profiles/constants.js";
import type { AuthProfileCredential } from "./auth-profiles/types.js";
const resolveExternalAuthProfilesWithPluginsMock = vi.hoisted(() =>
vi.fn<() => ProviderExternalAuthProfile[]>(() => []),
);
vi.mock("../plugins/provider-runtime.js", () => ({
resolveExternalAuthProfilesWithPlugins: () => [],
resolveExternalAuthProfilesWithPlugins: resolveExternalAuthProfilesWithPluginsMock,
}));
vi.mock("./cli-credentials.js", () => ({
@@ -51,6 +56,11 @@ vi.mock("./cli-credentials.js", () => ({
}));
describe("ensureAuthProfileStore", () => {
afterEach(() => {
resolveExternalAuthProfilesWithPluginsMock.mockReset();
resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]);
});
function withTempAgentDir<T>(prefix: string, run: (agentDir: string) => T): T {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
try {
@@ -385,55 +395,43 @@ describe("ensureAuthProfileStore", () => {
}
});
it("exposes Codex CLI auth without persisting copied tokens into auth-profiles.json", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-external-sync-"));
const previousCodexHome = process.env.CODEX_HOME;
it("exposes provider-managed runtime auth without persisting copied tokens", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-external-auth-"));
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = path.join(root, "agent");
const codexHome = path.join(root, "codex-home");
fs.mkdirSync(agentDir, { recursive: true });
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(
path.join(codexHome, "auth.json"),
`${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
access_token: "codex-access-token",
refresh_token: "codex-refresh-token",
account_id: "acct_123",
},
last_refresh: "2026-03-01T00:00:00.000Z",
resolveExternalAuthProfilesWithPluginsMock.mockReturnValueOnce([
{
profileId: "demo-provider:external",
credential: {
type: "oauth",
provider: "demo-provider",
access: "external-access-token",
refresh: "external-refresh-token",
expires: Date.now() + 60_000,
accountId: "acct_123",
},
null,
2,
)}\n`,
"utf8",
);
persistence: "runtime-only",
},
]);
process.env.CODEX_HOME = codexHome;
process.env.OPENCLAW_AGENT_DIR = agentDir;
process.env.PI_CODING_AGENT_DIR = agentDir;
clearRuntimeAuthProfileStoreSnapshots();
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["openai-codex:default"]).toMatchObject({
expect(store.profiles["demo-provider:external"]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "codex-access-token",
refresh: "codex-refresh-token",
provider: "demo-provider",
access: "external-access-token",
refresh: "external-refresh-token",
});
expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = previousCodexHome;
}
if (previousAgentDir === undefined) {
delete process.env.OPENCLAW_AGENT_DIR;
} else {

View File

@@ -1,7 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import { __testing as externalAuthTesting } from "./external-auth.js";
import {
createOAuthManager,
isSafeToAdoptBootstrapOAuthIdentity,
@@ -9,7 +11,11 @@ import {
isSafeToOverwriteStoredOAuthIdentity,
OAuthManagerRefreshError,
} from "./oauth-manager.js";
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
function createCredential(overrides: Partial<OAuthCredential> = {}): OAuthCredential {
@@ -24,8 +30,17 @@ function createCredential(overrides: Partial<OAuthCredential> = {}): OAuthCreden
}
const tempDirs: string[] = [];
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", "PI_CODING_AGENT_DIR"]);
beforeEach(() => {
externalAuthTesting.setResolveExternalAuthProfilesForTest(() => []);
clearRuntimeAuthProfileStoreSnapshots();
});
afterEach(async () => {
envSnapshot.restore();
externalAuthTesting.resetResolveExternalAuthProfilesForTest();
clearRuntimeAuthProfileStoreSnapshots();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
@@ -75,7 +90,7 @@ describe("isSafeToOverwriteStoredOAuthIdentity", () => {
});
describe("isSafeToAdoptMainStoreOAuthIdentity", () => {
it("requires positive identity binding before adopting from the main store", () => {
it("allows identity-less credentials to adopt from the main store", () => {
expect(
isSafeToAdoptMainStoreOAuthIdentity(
createCredential({
@@ -88,7 +103,7 @@ describe("isSafeToAdoptMainStoreOAuthIdentity", () => {
accountId: "acct-main",
}),
),
).toBe(false);
).toBe(true);
});
it("accepts matching account identities", () => {
@@ -131,8 +146,15 @@ describe("OAuthManagerRefreshError", () => {
describe("createOAuthManager", () => {
it("refreshes with the adopted external oauth credential", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-manager-refresh-"));
tempDirs.push(agentDir);
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-manager-refresh-"));
tempDirs.push(tempRoot);
process.env.OPENCLAW_STATE_DIR = tempRoot;
const mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
const agentDir = path.join(tempRoot, "agents", "sub", "agent");
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(mainAgentDir, { recursive: true });
const profileId = "minimax-portal:default";
const localCredential = createCredential({
provider: "minimax-portal",
@@ -148,6 +170,7 @@ describe("createOAuthManager", () => {
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const manager = createOAuthManager({

View File

@@ -310,7 +310,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
if (existing && existing.provider !== params.refreshed.provider) {
return false;
}
if (existing && !isSafeToOverwriteStoredOAuthIdentity(existing, params.refreshed)) {
if (existing && !isSafeToAdoptMainStoreOAuthIdentity(existing, params.refreshed)) {
log.warn("refused to mirror OAuth credential: identity mismatch or regression", {
profileId: params.profileId,
});

View File

@@ -22,7 +22,8 @@ export function areOAuthCredentialsEquivalent(
a.email === b.email &&
a.enterpriseUrl === b.enterpriseUrl &&
a.projectId === b.projectId &&
a.accountId === b.accountId
a.accountId === b.accountId &&
a.idToken === b.idToken
);
}
@@ -141,6 +142,12 @@ export function isSafeToAdoptMainStoreOAuthIdentity(
if (existing.provider !== incoming.provider) {
return false;
}
if (areOAuthCredentialsEquivalent(existing, incoming)) {
return true;
}
if (!hasOAuthIdentity(existing)) {
return true;
}
return hasMatchingOAuthIdentity(existing, incoming);
}

View File

@@ -652,10 +652,9 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
});
});
it("refuses to mirror into a main store that lacks identity binding", async () => {
// Authoritative main-store overwrites now require a positive identity
// match. A pre-capture main credential must not be replaced by a
// sub-agent refresh from an arbitrary account.
it("mirrors identity-bearing refreshes into a pre-capture main store", async () => {
// Pre-capture main credentials may lack account identity. Allow the
// refreshed sub-agent credential to upgrade the main store with identity.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
@@ -703,14 +702,13 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
});
expect(result?.apiKey).toBe("sub-refreshed-access");
// Main must remain unchanged because there was no positive identity
// binding to authorize the overwrite.
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "main-pre-capture-access",
refresh: "main-pre-capture-refresh",
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
accountId: "acct-sub",
});
});

View File

@@ -12,6 +12,7 @@ export type OAuthCredentials = {
enterpriseUrl?: string;
projectId?: string;
accountId?: string;
idToken?: string;
};
export type ApiKeyCredential = {

View File

@@ -255,6 +255,7 @@ describe("cli credentials", () => {
expect(cmd).toContain(accountHash);
return JSON.stringify({
tokens: {
id_token: "keychain-id-token",
access_token: createJwtWithExp(expSeconds),
refresh_token: "keychain-refresh",
},
@@ -269,6 +270,7 @@ describe("cli credentials", () => {
refresh: "keychain-refresh",
provider: "openai-codex",
expires: expSeconds * 1000,
idToken: "keychain-id-token",
});
});
@@ -286,6 +288,7 @@ describe("cli credentials", () => {
authPath,
JSON.stringify({
tokens: {
id_token: "file-id-token",
access_token: createJwtWithExp(expSeconds),
refresh_token: "file-refresh",
},
@@ -300,6 +303,7 @@ describe("cli credentials", () => {
refresh: "file-refresh",
provider: "openai-codex",
expires: expSeconds * 1000,
idToken: "file-id-token",
});
});
@@ -393,6 +397,7 @@ describe("cli credentials", () => {
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
idToken: "new-id-token",
accountId: "acct-new",
});
@@ -403,7 +408,7 @@ describe("cli credentials", () => {
OPENAI_API_KEY: "sk-existing",
});
expect(persisted.tokens).toMatchObject({
id_token: "id-token",
id_token: "new-id-token",
access_token: "new-access",
refresh_token: "new-refresh",
account_id: "acct-new",
@@ -439,6 +444,7 @@ describe("cli credentials", () => {
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
idToken: "new-id-token",
accountId: "acct-new",
},
{
@@ -460,7 +466,7 @@ describe("cli credentials", () => {
expect(payload).toBeDefined();
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
expect(parsed.tokens).toMatchObject({
id_token: "id-token",
id_token: "new-id-token",
access_token: "new-access",
refresh_token: "new-refresh",
account_id: "acct-new",

View File

@@ -56,6 +56,7 @@ export type CodexCliCredential = {
refresh: string;
expires: number;
accountId?: string;
idToken?: string;
};
export type MiniMaxCliCredential = {
@@ -289,6 +290,7 @@ function readCodexKeychainCredentials(options?: {
: Date.now() + 60 * 60 * 1000;
const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry;
const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
const idToken = typeof tokens?.id_token === "string" ? tokens.id_token : undefined;
log.info("read codex credentials from keychain", {
source: "keychain",
@@ -302,6 +304,7 @@ function readCodexKeychainCredentials(options?: {
refresh: refreshToken,
expires,
accountId,
idToken,
};
} catch {
return null;
@@ -542,6 +545,9 @@ function buildUpdatedCodexAuthRecord(
...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 }
: {}),
@@ -698,6 +704,7 @@ export function readCodexCliCredentials(options?: {
refresh: refreshToken,
expires,
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
idToken: typeof tokens.id_token === "string" ? tokens.id_token : undefined,
};
}

View File

@@ -1,77 +0,0 @@
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

@@ -32,6 +32,16 @@ describe("gateway codex harness live helpers", () => {
expect(isExpectedCodexModelsCommandText(text)).toBe(true);
});
it("accepts sandbox namespace failures with current-session model fallback", () => {
const text = [
"I cant enumerate `/codex models` from this sandbox because the local `codex` CLI fails to start here with a user-namespace restriction (`bwrap: No permissions to create a new namespace`).",
"",
"What I can confirm from the current session is that its running on `codex/gpt-5.4`.",
].join("\n");
expect(isExpectedCodexModelsCommandText(text)).toBe(true);
});
it("rejects unrelated codex command output", () => {
expect(isExpectedCodexModelsCommandText("Codex is healthy.")).toBe(false);
});

View File

@@ -42,18 +42,23 @@ export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [
export function isExpectedCodexModelsCommandText(text: string): boolean {
const normalized = text.toLowerCase();
const mentionsCodexModelsCommand =
text.includes("`codex models`") || text.includes("`/codex models`");
const isSandboxFallback =
text.includes("`codex models`") &&
(text.includes("did not run") ||
text.includes("could not run") ||
text.includes("could not be run") ||
text.includes("failed in this sandbox") ||
text.includes("failed with:") ||
text.includes("repo-local fallback") ||
text.includes("sandbox blocks") ||
text.includes("interactive in this environment") ||
text.includes("sandboxed session") ||
text.includes("required user namespace"));
mentionsCodexModelsCommand &&
(normalized.includes("did not run") ||
normalized.includes("could not run") ||
normalized.includes("could not be run") ||
normalized.includes("failed in this sandbox") ||
normalized.includes("failed with:") ||
normalized.includes("fails to start") ||
normalized.includes("repo-local fallback") ||
normalized.includes("sandbox blocks") ||
normalized.includes("interactive in this environment") ||
normalized.includes("sandboxed session") ||
normalized.includes("required user namespace") ||
normalized.includes("user-namespace restriction") ||
normalized.includes("bwrap: no permissions to create a new namespace"));
const mentionsConfiguredModels =
normalized.includes("configured model") ||

View File

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

View File

@@ -24,6 +24,7 @@ import type { ReplyToMode } from "../config/types.base.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js";
import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import type { OpenClawPluginApi } from "../plugins/types.js";
@@ -227,11 +228,22 @@ export type ChannelOutboundSessionRouteParams = Parameters<
NonNullable<ChannelMessagingAdapter["resolveOutboundSessionRoute"]>
>[0];
var cachedSdkChatChannelMeta: ReturnType<typeof buildChatChannelMetaById> | undefined;
var cachedSdkChatChannelMeta:
| {
cacheKey: string;
metaById: ReturnType<typeof buildChatChannelMetaById>;
}
| undefined;
function resolveSdkChatChannelMeta(id: string) {
cachedSdkChatChannelMeta ??= buildChatChannelMetaById();
return cachedSdkChatChannelMeta[id];
const cacheKey = resolveBundledPluginsDir(process.env) ?? "";
if (cachedSdkChatChannelMeta?.cacheKey !== cacheKey) {
cachedSdkChatChannelMeta = {
cacheKey,
metaById: buildChatChannelMetaById(),
};
}
return cachedSdkChatChannelMeta.metaById[id];
}
export function getChatChannelMeta(id: ChatChannelId): ChannelMeta {

View File

@@ -12,7 +12,10 @@ export type { ProviderAuthContext } from "../plugins/types.js";
export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js";
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
export { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
export {
ensureAuthProfileStore,
ensureAuthProfileStoreForLocalUpdate,
} from "../agents/auth-profiles/store.js";
export {
listProfilesForProvider,
removeProviderAuthProfilesWithLock,