refactor(auth): route codex runtimes through canonical oauth

This commit is contained in:
Vincent Koc
2026-04-18 06:48:11 -07:00
committed by Peter Steinberger
parent f98e98ab66
commit 859eb06662
36 changed files with 830 additions and 108 deletions

View File

@@ -1,2 +1,2 @@
0b0cf2ecc30501bb6381671e3704570f405655b026a0b8b6437c3a5677450b9b plugin-sdk-api-baseline.json
cb72d7b5f73005280854654b51501ec82f5a2f23b7ccb915b63c6354300559d5 plugin-sdk-api-baseline.jsonl
445130135f0037ca2f0877428d58deedf7a7f50e588af5505c1ba09d346663ae plugin-sdk-api-baseline.json
147f6f63b835a92e24d6c93b91b0e2adbe1b8fb381d3bd45ef1ae63fd9b3386e plugin-sdk-api-baseline.jsonl

View File

@@ -0,0 +1,100 @@
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,
}));
let bridgeCodexAppServerStartOptions: typeof import("./auth-bridge.js").bridgeCodexAppServerStartOptions;
describe("bridgeCodexAppServerStartOptions", () => {
const tempDirs: string[] = [];
beforeAll(async () => {
({ bridgeCodexAppServerStartOptions } = await import("./auth-bridge.js"));
});
afterEach(async () => {
mocks.ensureAuthProfileStore.mockReset();
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 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",
},
},
});
const result = await bridgeCodexAppServerStartOptions({
startOptions: {
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: {
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
});
});
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 = {
command: "codex",
args: ["app-server"],
headers: { authorization: "Bearer dev-token" },
};
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {},
});
await expect(
bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
authProfileId: "openai-codex:missing",
}),
).resolves.toEqual(startOptions);
});
});

View File

@@ -0,0 +1,76 @@
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 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;
agentDir: string;
authProfileId?: string;
}): Promise<CodexAppServerStartOptions> {
const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID;
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const credential = store.profiles[profileId];
if (!isBridgeableCodexOAuthCredential(credential)) {
return params.startOptions;
}
const codexHome = resolveCodexBridgeHome(params.agentDir, profileId);
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(path.join(codexHome, "auth.json"), buildCodexAuthFile(credential));
return {
...params.startOptions,
env: {
...params.startOptions.env,
CODEX_HOME: codexHome,
},
clearEnv: Array.from(
new Set([...(params.startOptions.clearEnv ?? []), ...CODEX_AUTH_ENV_CLEAR_KEYS]),
),
};
}

View File

@@ -105,6 +105,38 @@ describe("maybeCompactCodexAppServerSession", () => {
},
});
});
it("reuses the bound auth profile for native compaction", async () => {
const fake = createFakeCodexClient();
let seenAuthProfileId: string | undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileId = authProfileId;
return fake.client;
});
const sessionFile = path.join(tempDir, "session.jsonl");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
authProfileId: "openai-codex:work",
});
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
expect(seenAuthProfileId).toBe("openai-codex:work");
});
});
function createFakeCodexClient(): {

View File

@@ -11,6 +11,7 @@ import { getSharedCodexAppServerClient } from "./shared-client.js";
type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
) => Promise<CodexAppServerClient>;
type CodexNativeCompactionCompletion = {
signal: "thread/compacted" | "item/completed";
@@ -25,8 +26,8 @@ type CodexNativeCompactionWaiter = {
const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
getSharedCodexAppServerClient({ startOptions });
let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedPiSessionParams,
@@ -38,7 +39,7 @@ export async function maybeCompactCodexAppServerSession(
return { ok: false, compacted: false, reason: "no codex app-server thread binding" };
}
const client = await clientFactory(appServer.start);
const client = await clientFactory(appServer.start, binding.authProfileId);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
let completion: CodexNativeCompactionCompletion;
try {
@@ -212,6 +213,7 @@ export const __testing = {
clientFactory = factory;
},
resetCodexAppServerClientFactoryForTests(): void {
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
clientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
},
} as const;

View File

@@ -12,6 +12,8 @@ export type CodexAppServerStartOptions = {
url?: string;
authToken?: string;
headers: Record<string, string>;
env?: Record<string, string>;
clearEnv?: string[];
};
export type CodexAppServerRuntimeOptions = {
@@ -158,6 +160,8 @@ export function codexAppServerStartOptionsKey(options: CodexAppServerStartOption
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
left.localeCompare(right),
),
env: Object.entries(options.env ?? {}).toSorted(([left], [right]) => left.localeCompare(right)),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
});
}

View File

@@ -28,6 +28,7 @@ export type CodexAppServerListModelsOptions = {
includeHidden?: boolean;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
sharedClient?: boolean;
};
@@ -40,10 +41,12 @@ export async function listCodexAppServerModels(
? await getSharedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
});
try {
const response = await client.request<JsonObject>(

View File

@@ -8,6 +8,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
requestParams?: JsonValue;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
}): Promise<T> {
const timeoutMs = params.timeoutMs ?? 60_000;
return await withTimeout(
@@ -15,6 +16,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
const client = await getSharedCodexAppServerClient({
startOptions: params.startOptions,
timeoutMs,
authProfileId: params.authProfileId,
});
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
})(),

View File

@@ -313,6 +313,53 @@ describe("runCodexAppServerAttempt", () => {
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
});
it("passes the selected auth profile into app-server startup", async () => {
const seenAuthProfileIds: Array<string | undefined> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
seenAuthProfileIds.push(authProfileId);
return {
request: async (method: string) => {
if (method === "thread/start") {
return {
thread: { id: "thread-1" },
model: "gpt-5.4-codex",
modelProvider: "openai",
};
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
return {};
},
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
} as never;
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.authProfileId = "openai-codex:work";
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:work"]));
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
await run;
expect(seenAuthProfileIds).toEqual(["openai-codex:work"]);
});
it("times out turn start before the active run handle is installed", async () => {
const request = vi.fn(
async (method: string, _params?: unknown, options?: { timeoutMs?: number }) => {

View File

@@ -37,10 +37,11 @@ import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
) => Promise<CodexAppServerClient>;
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
getSharedCodexAppServerClient({ startOptions });
let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
export async function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
@@ -101,7 +102,7 @@ export async function runCodexAppServerAttempt(
timeoutMs: params.timeoutMs,
signal: runAbortController.signal,
operation: async () => {
const startupClient = await clientFactory(appServer.start);
const startupClient = await clientFactory(appServer.start, params.authProfileId);
const startupThread = await startOrResumeThread({
client: startupClient,
params,
@@ -487,6 +488,7 @@ export const __testing = {
clientFactory = factory;
},
resetCodexAppServerClientFactoryForTests(): void {
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
clientFactory = (startOptions, authProfileId) =>
getSharedCodexAppServerClient({ startOptions, authProfileId });
},
} as const;

View File

@@ -6,6 +6,7 @@ export type CodexAppServerThreadBinding = {
threadId: string;
sessionFile: string;
cwd: string;
authProfileId?: string;
model?: string;
modelProvider?: string;
dynamicToolsFingerprint?: string;
@@ -41,6 +42,7 @@ export async function readCodexAppServerBinding(
threadId: parsed.threadId,
sessionFile,
cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined,
model: typeof parsed.model === "string" ? parsed.model : undefined,
modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined,
dynamicToolsFingerprint:
@@ -71,6 +73,7 @@ export async function writeCodexAppServerBinding(
sessionFile,
threadId: binding.threadId,
cwd: binding.cwd,
authProfileId: binding.authProfileId,
model: binding.model,
modelProvider: binding.modelProvider,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,

View File

@@ -1,14 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js";
import { listCodexAppServerModels } from "./models.js";
import { resetSharedCodexAppServerClientForTests } from "./shared-client.js";
import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
resolveOpenClawAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
}));
vi.mock("./auth-bridge.js", () => ({
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir,
}));
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
describe("shared Codex app-server client", () => {
beforeAll(async () => {
({ listCodexAppServerModels } = await import("./models.js"));
({ resetSharedCodexAppServerClientForTests } = await import("./shared-client.js"));
});
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.resolveOpenClawAgentDir.mockClear();
});
it("closes the shared app-server when the version gate fails", async () => {
@@ -18,6 +39,7 @@ describe("shared Codex app-server client", () => {
// Model discovery uses the shared-client path, which owns child teardown
// when initialize discovers an unsupported app-server.
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
@@ -45,6 +67,7 @@ describe("shared Codex app-server client", () => {
expect(first.process.kill).toHaveBeenCalledTimes(1);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(second.writes[0] ?? "{}") as { id?: number };
second.send({
id: initialize.id,
@@ -57,4 +80,30 @@ describe("shared Codex app-server client", () => {
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
});
it("passes the selected auth profile through the bridge helper", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const listPromise = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: "openai-codex:work",
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: initialize.id,
result: { userAgent: "openclaw/0.118.0 (macOS; test)" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const modelList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: modelList.id, result: { data: [] } });
await expect(listPromise).resolves.toEqual({ models: [] });
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId: "openai-codex:work",
}),
);
});
});

View File

@@ -1,3 +1,5 @@
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
import {
codexAppServerStartOptionsKey,
@@ -25,9 +27,14 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
export async function getSharedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
}): Promise<CodexAppServerClient> {
const state = getSharedCodexAppServerClientState();
const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start,
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
const key = codexAppServerStartOptionsKey(startOptions);
if (state.key && state.key !== key) {
clearSharedCodexAppServerClient();
@@ -62,8 +69,13 @@ export async function getSharedCodexAppServerClient(options?: {
export async function createIsolatedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
}): Promise<CodexAppServerClient> {
const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start,
agentDir: resolveOpenClawAgentDir(),
authProfileId: options?.authProfileId,
});
const client = CodexAppServerClient.start(startOptions);
const initialize = client.initialize();
try {

View File

@@ -53,6 +53,7 @@ export async function startOrResumeThread(params: {
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
@@ -62,6 +63,7 @@ export async function startOrResumeThread(params: {
...binding,
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
@@ -93,6 +95,7 @@ export async function startOrResumeThread(params: {
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,
@@ -103,6 +106,7 @@ export async function startOrResumeThread(params: {
threadId: response.thread.id,
sessionFile: params.params.sessionFile,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider),
dynamicToolsFingerprint,

View File

@@ -3,8 +3,15 @@ import type { CodexAppServerStartOptions } from "./config.js";
import type { CodexAppServerTransport } from "./transport.js";
export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
const env = {
...process.env,
...options.env,
};
for (const key of options.clearEnv ?? []) {
delete env[key];
}
return spawn(options.command, options.args, {
env: process.env,
env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
});

View File

@@ -3,6 +3,8 @@ import {
CLI_FRESH_WATCHDOG_DEFAULTS,
CLI_RESUME_WATCHDOG_DEFAULTS,
} from "openclaw/plugin-sdk/cli-backend";
import { OPENAI_CODEX_DEFAULT_PROFILE_ID } from "./openai-codex-cli-auth.js";
import { prepareOpenAICodexCliExecution } from "./openai-codex-cli-bridge.js";
const CODEX_CLI_DEFAULT_MODEL_REF = "codex-cli/gpt-5.4";
@@ -20,6 +22,9 @@ 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: [

View File

@@ -0,0 +1,77 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { prepareOpenAICodexCliExecution } from "./openai-codex-cli-bridge.js";
describe("prepareOpenAICodexCliExecution", () => {
const tempDirs: string[] = [];
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);
const result = await 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,
accountId: "acct-123",
},
});
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: {
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
});
});
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);
await expect(
prepareOpenAICodexCliExecution({
config: undefined,
workspaceDir: agentDir,
agentDir,
provider: "codex-cli",
modelId: "gpt-5.4",
authProfileId: "openai-codex:default",
authCredential: {
type: "api_key",
provider: "openai-codex",
key: "sk-test",
},
}),
).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,72 @@
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";
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`;
}
export async function prepareOpenAICodexCliExecution(
ctx: CliBackendPrepareExecutionContext,
): Promise<CliBackendPreparedExecution | null> {
if (
!ctx.agentDir ||
!ctx.authProfileId ||
!isCodexBridgeableOAuthCredential(ctx.authCredential)
) {
return null;
}
const codexHome = resolveCodexBridgeHome(ctx.agentDir, ctx.authProfileId);
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(path.join(codexHome, "auth.json"), buildCodexAuthFile(ctx.authCredential));
return {
env: {
CODEX_HOME: codexHome,
},
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
};
}

View File

@@ -87,6 +87,17 @@ describe("openai codex provider", () => {
);
});
it("offers explicit browser and one-time Codex CLI import auth methods", () => {
const provider = buildOpenAICodexProviderPlugin();
expect(provider.auth?.map((method) => method.id)).toEqual(["oauth", "import-codex-cli"]);
expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({
label: "Import Codex CLI login",
hint: "Use existing .codex auth once",
kind: "oauth",
});
});
it("owns native reasoning output mode for Codex responses", () => {
const provider = buildOpenAICodexProviderPlugin();

View File

@@ -282,6 +282,35 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) {
});
}
async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) {
const profile = readOpenAICodexCliOAuthProfile({
env: process.env,
store: ensureAuthProfileStore(ctx.agentDir, {
allowKeychainPrompt: false,
}),
});
if (!profile) {
throw new Error(
"No compatible Codex CLI OAuth login found. Sign in with `codex` first or use ChatGPT OAuth instead.",
);
}
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
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."],
});
}
function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) {
if (ctx.profileId !== CODEX_CLI_PROFILE_ID) {
return undefined;
@@ -302,6 +331,13 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
kind: "oauth",
run: async (ctx) => await runOpenAICodexOAuth(ctx),
},
{
id: "import-codex-cli",
label: "Import Codex CLI login",
hint: "Use existing .codex auth once",
kind: "oauth",
run: async (ctx) => await runImportOpenAICodexCliAuth(ctx),
},
],
wizard: {
setup: {
@@ -327,13 +363,6 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
},
resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx),
buildAuthDoctorHint: (ctx) => buildOpenAICodexAuthDoctorHint(ctx),
resolveExternalAuthProfiles: (ctx) => {
const profile = readOpenAICodexCliOAuthProfile({
env: ctx.env,
store: ctx.store,
});
return profile ? [{ ...profile, persistence: "runtime-only" }] : undefined;
},
supportsXHighThinking: ({ modelId }) =>
matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS),
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS),

View File

@@ -108,7 +108,7 @@ describe("buildAuthHealthSummary", () => {
expect(statuses["google:no-refresh"]).toBe("expired");
});
it("prefers fresher imported external OAuth credentials when reporting health", () => {
it("does not let fresh .codex state override expired canonical health", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
readCodexCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
@@ -138,7 +138,7 @@ describe("buildAuthHealthSummary", () => {
});
const statuses = profileStatuses(summary);
expect(statuses["openai-codex:default"]).toBe("ok");
expect(statuses["openai-codex:default"]).toBe("expired");
});
it("keeps healthy local oauth over fresher imported Codex CLI credentials in health status", () => {
@@ -198,7 +198,7 @@ describe("buildAuthHealthSummary", () => {
expect(profile?.status).toBe("expiring");
});
it("prefers fresher imported external OAuth when the local credential is near expiry", () => {
it("does not let fresh .codex state override near-expiry canonical health", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
readCodexCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
@@ -227,8 +227,8 @@ describe("buildAuthHealthSummary", () => {
});
const profile = summary.profiles.find((entry) => entry.profileId === "openai-codex:default");
expect(profile?.status).toBe("ok");
expect(profile?.expiresAt).toBe(now + DEFAULT_OAUTH_WARN_MS + 60_000);
expect(profile?.status).toBe("expiring");
expect(profile?.expiresAt).toBe(now + 2 * 60_000);
});
it("marks token profiles with invalid expires as missing with reason code", () => {

View File

@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential;
let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles;
let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential;
let isSafeToUseExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").isSafeToUseExternalCliCredential;
let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential;
let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential;
let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID;
@@ -48,6 +49,7 @@ describe("external cli oauth resolution", () => {
mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
({
hasUsableOAuthCredential,
isSafeToUseExternalCliCredential,
readManagedExternalCliCredential,
resolveExternalCliAuthProfiles,
shouldBootstrapFromExternalCliCredential,
@@ -193,7 +195,7 @@ describe("external cli oauth resolution", () => {
).toBe(true);
});
it("does not bootstrap across different known oauth identities", () => {
it("refuses external oauth usage across different known identities", () => {
const imported = makeOAuthCredential({
provider: "openai-codex",
access: "fresh-cli-access",
@@ -203,8 +205,8 @@ describe("external cli oauth resolution", () => {
});
expect(
shouldBootstrapFromExternalCliCredential({
existing: makeOAuthCredential({
isSafeToUseExternalCliCredential(
makeOAuthCredential({
provider: "openai-codex",
access: "expired-local-access",
refresh: "expired-local-refresh",
@@ -212,12 +214,12 @@ describe("external cli oauth resolution", () => {
accountId: "acct-local",
}),
imported,
}),
),
).toBe(false);
});
});
it("reads codex external cli credentials by profile id", () => {
it("does not use codex as a runtime bootstrap source anymore", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
@@ -231,10 +233,7 @@ describe("external cli oauth resolution", () => {
credential: makeOAuthCredential({ provider: "openai-codex" }),
});
expect(credential).toMatchObject({
access: "codex-access-token",
refresh: "codex-refresh-token",
});
expect(credential).toBeNull();
});
it("returns null when the profile id/provider do not map to the same external source", () => {
@@ -250,16 +249,7 @@ describe("external cli oauth resolution", () => {
expect(credential).toBeNull();
});
it("resolves fresher codex and minimax external oauth profiles as runtime overlays", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
access: "codex-fresh-access",
refresh: "codex-fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
it("resolves fresher minimax external oauth profiles as runtime overlays", () => {
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "minimax-portal",
@@ -273,13 +263,6 @@ describe("external cli oauth resolution", () => {
const profiles = resolveExternalCliAuthProfiles({
version: 1,
profiles: {
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: makeOAuthCredential({
provider: "openai-codex",
access: "codex-stale-access",
refresh: "codex-stale-refresh",
expires: Date.now() - 5_000,
accountId: "acct-codex",
}),
[MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({
provider: "minimax-portal",
access: "minimax-stale-access",
@@ -293,20 +276,16 @@ describe("external cli oauth resolution", () => {
const profilesById = new Map(
profiles.map((profile) => [profile.profileId, profile.credential]),
);
expect(profilesById.get(OPENAI_CODEX_DEFAULT_PROFILE_ID)).toMatchObject({
access: "codex-fresh-access",
refresh: "codex-fresh-refresh",
});
expect(profilesById.get(MINIMAX_CLI_PROFILE_ID)).toMatchObject({
access: "minimax-fresh-access",
refresh: "minimax-fresh-refresh",
});
});
it("does not emit runtime overlays when the stored credential is newer", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
it("does not emit runtime overlays when the stored minimax credential is newer", () => {
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
provider: "minimax-portal",
access: "stale-external-access",
refresh: "stale-external-refresh",
expires: Date.now() - 5_000,
@@ -315,9 +294,9 @@ describe("external cli oauth resolution", () => {
const profiles = resolveExternalCliAuthProfiles(
makeStore(
OPENAI_CODEX_DEFAULT_PROFILE_ID,
MINIMAX_CLI_PROFILE_ID,
makeOAuthCredential({
provider: "openai-codex",
provider: "minimax-portal",
access: "fresh-store-access",
refresh: "fresh-store-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
@@ -328,10 +307,10 @@ describe("external cli oauth resolution", () => {
expect(profiles).toEqual([]);
});
it("does not overlay fresh external cli oauth over a still-usable local credential", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
it("does not overlay fresh minimax oauth over a still-usable local credential", () => {
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
provider: "minimax-portal",
access: "fresh-cli-access",
refresh: "fresh-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
@@ -340,9 +319,9 @@ describe("external cli oauth resolution", () => {
const profiles = resolveExternalCliAuthProfiles(
makeStore(
OPENAI_CODEX_DEFAULT_PROFILE_ID,
MINIMAX_CLI_PROFILE_ID,
makeOAuthCredential({
provider: "openai-codex",
provider: "minimax-portal",
access: "healthy-local-access",
refresh: "healthy-local-refresh",
expires: Date.now() + 10 * 60_000,

View File

@@ -1,12 +1,5 @@
import {
readCodexCliCredentialsCached,
readMiniMaxCliCredentialsCached,
} from "../cli-credentials.js";
import {
EXTERNAL_CLI_SYNC_TTL_MS,
MINIMAX_CLI_PROFILE_ID,
OPENAI_CODEX_DEFAULT_PROFILE_ID,
} from "./constants.js";
import { readMiniMaxCliCredentialsCached } from "../cli-credentials.js";
import { EXTERNAL_CLI_SYNC_TTL_MS, MINIMAX_CLI_PROFILE_ID } from "./constants.js";
import { log } from "./constants.js";
import {
areOAuthCredentialsEquivalent,
@@ -82,11 +75,6 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
provider: "minimax-portal",
readCredentials: () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
},
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
provider: "openai-codex",
readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
},
];
function resolveExternalCliSyncProvider(params: {

View File

@@ -6,7 +6,7 @@ import {
OAUTH_REFRESH_LOCK_OPTIONS,
log,
} from "./constants.js";
import { resolveTokenExpiryState } from "./credential-state.js";
import { hasUsableOAuthCredential as hasUsableStoredOAuthCredential } from "./credential-state.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js";
import {
ensureAuthProfileStore,
@@ -130,13 +130,7 @@ export function hasUsableOAuthCredential(
credential: OAuthCredential | undefined,
now = Date.now(),
): boolean {
if (!credential || credential.type !== "oauth") {
return false;
}
if (typeof credential.access !== "string" || credential.access.trim().length === 0) {
return false;
}
return resolveTokenExpiryState(credential.expires, now) === "valid";
return hasUsableStoredOAuthCredential(credential, { now });
}
function normalizeAuthIdentityToken(value: string | undefined): string | undefined {

View File

@@ -259,7 +259,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
});
});
it("prefers fresh Codex CLI credentials when the stored default profile is expired", async () => {
it("keeps runtime refresh on canonical auth even when .codex has a fresher token", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(
{
@@ -285,6 +285,23 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
expires: Date.now() + 86_400_000,
accountId: "acct-cli",
});
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async (params?: { context?: unknown }) => {
expect(params?.context).toMatchObject({
access: "expired-access-token",
refresh: "expired-refresh-token",
accountId: "acct-cli",
});
return {
type: "oauth",
provider: "openai-codex",
access: "rotated-local-access-token",
refresh: "rotated-local-refresh-token",
expires: Date.now() + 86_400_000,
accountId: "acct-cli",
};
},
);
await expect(
resolveApiKeyForProfile({
@@ -293,12 +310,12 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
agentDir,
}),
).resolves.toEqual({
apiKey: "fresh-cli-access-token",
apiKey: "rotated-local-access-token",
provider: "openai-codex",
email: undefined,
});
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
expect(writeCodexCliCredentialsMock).not.toHaveBeenCalled();
});
@@ -588,7 +605,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
);
});
it("adopts a fresher imported refresh token even when its access token is already expired", async () => {
it("keeps the canonical refresh token even when .codex has a fresher but expired refresh token", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(
{
@@ -616,8 +633,8 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async (params?: { context?: unknown }) => {
expect(params?.context).toMatchObject({
access: "newer-but-expired-cli-access-token",
refresh: "fresh-cli-refresh-token",
access: "expired-local-access-token",
refresh: "stale-local-refresh-token",
});
return {
type: "oauth",

View File

@@ -141,4 +141,55 @@ describe("resolveCliAuthEpoch", () => {
expect(second).not.toBe(first);
expect(third).not.toBe(second);
});
it("can ignore local codex state when the backend is profile-owned", async () => {
let localAccess = "local-access-a";
let profileRefresh = "profile-refresh-a";
setCliAuthEpochTestDeps({
readCodexCliCredentialsCached: () => ({
type: "oauth",
provider: "openai-codex",
access: localAccess,
refresh: "local-refresh",
expires: 1,
accountId: "acct-1",
}),
loadAuthProfileStoreForRuntime: () => ({
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "profile-access",
refresh: profileRefresh,
expires: 1,
accountId: "acct-1",
},
},
}),
});
const first = await resolveCliAuthEpoch({
provider: "codex-cli",
authProfileId: "openai-codex:default",
skipLocalCredential: true,
});
localAccess = "local-access-b";
const second = await resolveCliAuthEpoch({
provider: "codex-cli",
authProfileId: "openai-codex:default",
skipLocalCredential: true,
});
profileRefresh = "profile-refresh-b";
const third = await resolveCliAuthEpoch({
provider: "codex-cli",
authProfileId: "openai-codex:default",
skipLocalCredential: true,
});
expect(first).toBeDefined();
expect(second).toBe(first);
expect(third).toBeDefined();
expect(third).not.toBe(second);
});
});

View File

@@ -136,14 +136,17 @@ function getAuthProfileCredential(
export async function resolveCliAuthEpoch(params: {
provider: string;
authProfileId?: string;
skipLocalCredential?: boolean;
}): Promise<string | undefined> {
const provider = params.provider.trim();
const authProfileId = normalizeOptionalString(params.authProfileId);
const parts: string[] = [];
const localFingerprint = getLocalCliCredentialFingerprint(provider);
if (localFingerprint) {
parts.push(`local:${provider}:${localFingerprint}`);
if (params.skipLocalCredential !== true) {
const localFingerprint = getLocalCliCredentialFingerprint(provider);
if (localFingerprint) {
parts.push(`local:${provider}:${localFingerprint}`);
}
}
if (authProfileId) {

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import type { CliBundleMcpMode } from "../plugins/types.js";
import type { CliBackendAuthEpochMode, CliBundleMcpMode } from "../plugins/types.js";
import {
__testing as cliBackendsTesting,
resolveCliBackendConfig,
@@ -24,6 +24,9 @@ function createBackendEntry(params: {
config: CliBackendConfig;
bundleMcp?: boolean;
bundleMcpMode?: CliBundleMcpMode;
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
prepareExecution?: () => Promise<null>;
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
}) {
return {
@@ -34,6 +37,9 @@ function createBackendEntry(params: {
config: params.config,
...(params.bundleMcp ? { bundleMcp: params.bundleMcp } : {}),
...(params.bundleMcpMode ? { bundleMcpMode: params.bundleMcpMode } : {}),
...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}),
...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}),
...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}),
...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}),
liveTest: {
defaultModelRef:
@@ -233,6 +239,9 @@ beforeEach(() => {
id: "codex-cli",
bundleMcp: true,
bundleMcpMode: "codex-config-overrides",
defaultAuthProfileId: "openai-codex:default",
authEpochMode: "profile-only",
prepareExecution: async () => null,
config: {
command: "codex",
args: [
@@ -764,6 +773,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?.config.systemPromptFileConfigArg).toBe("-c");
expect(resolved?.config.systemPromptFileConfigKey).toBe("model_instructions_file");
expect(resolved?.config.systemPromptWhen).toBe("first");

View File

@@ -3,7 +3,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js";
import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js";
import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js";
import type { CliBundleMcpMode, CliBackendPlugin, PluginTextTransforms } from "../plugins/types.js";
import type {
CliBackendAuthEpochMode,
CliBundleMcpMode,
CliBackendPlugin,
PluginTextTransforms,
} from "../plugins/types.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { normalizeProviderId } from "./model-selection.js";
import { mergePluginTextTransforms } from "./plugin-text-transforms.js";
@@ -28,6 +33,9 @@ export type ResolvedCliBackend = {
pluginId?: string;
transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"];
textTransforms?: PluginTextTransforms;
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
prepareExecution?: CliBackendPlugin["prepareExecution"];
};
export type ResolvedCliBackendLiveTest = {
@@ -45,6 +53,9 @@ type FallbackCliBackendPolicy = {
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"];
textTransforms?: PluginTextTransforms;
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
prepareExecution?: CliBackendPlugin["prepareExecution"];
};
const FALLBACK_CLI_BACKEND_POLICIES: Record<string, FallbackCliBackendPolicy> = {};
@@ -78,6 +89,9 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic
normalizeConfig: entry.backend.normalizeConfig,
transformSystemPrompt: entry.backend.transformSystemPrompt,
textTransforms: entry.backend.textTransforms,
defaultAuthProfileId: entry.backend.defaultAuthProfileId,
authEpochMode: entry.backend.authEpochMode,
prepareExecution: entry.backend.prepareExecution,
};
}
@@ -198,6 +212,9 @@ export function resolveCliBackendConfig(
pluginId: registered.pluginId,
transformSystemPrompt: registered.transformSystemPrompt,
textTransforms: mergePluginTextTransforms(runtimeTextTransforms, registered.textTransforms),
defaultAuthProfileId: registered.defaultAuthProfileId,
authEpochMode: registered.authEpochMode,
prepareExecution: registered.prepareExecution,
};
}
@@ -223,6 +240,9 @@ export function resolveCliBackendConfig(
runtimeTextTransforms,
fallbackPolicy.textTransforms,
),
defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId,
authEpochMode: fallbackPolicy.authEpochMode,
prepareExecution: fallbackPolicy.prepareExecution,
};
}
const mergedFallback = fallbackPolicy?.baseConfig
@@ -245,6 +265,9 @@ export function resolveCliBackendConfig(
runtimeTextTransforms,
fallbackPolicy?.textTransforms,
),
defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId,
authEpochMode: fallbackPolicy?.authEpochMode,
prepareExecution: fallbackPolicy?.prepareExecution,
};
}

View File

@@ -52,7 +52,7 @@ export async function runPreparedCliAgent(
},
requestShaping: {
...(params.thinkLevel ? { thinking: params.thinkLevel } : {}),
...(params.authProfileId ? { authMode: "auth-profile" } : {}),
...(context.effectiveAuthProfileId ? { authMode: "auth-profile" } : {}),
},
completion: {
finishReason: "stop",
@@ -68,7 +68,9 @@ export async function runPreparedCliAgent(
? {
cliSessionBinding: {
sessionId: resultParams.effectiveCliSessionId,
...(params.authProfileId ? { authProfileId: params.authProfileId } : {}),
...(context.effectiveAuthProfileId
? { authProfileId: context.effectiveAuthProfileId }
: {}),
...(context.authEpoch ? { authEpoch: context.authEpoch } : {}),
...(context.extraSystemPromptHash
? { extraSystemPromptHash: context.extraSystemPromptHash }

View File

@@ -3,7 +3,10 @@ import {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { resolveOpenClawAgentDir } from "../agent-paths.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import { loadAuthProfileStoreForRuntime } from "../auth-profiles/store.js";
import type { AuthProfileCredential } from "../auth-profiles/types.js";
import {
buildBootstrapInjectionStats,
buildBootstrapPromptWarning,
@@ -73,9 +76,23 @@ export async function prepareCliRunContext(
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}
const agentDir = resolveOpenClawAgentDir();
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
const effectiveAuthProfileId =
requestedAuthProfileId ?? backendResolved.defaultAuthProfileId?.trim() ?? undefined;
let authCredential: AuthProfileCredential | undefined;
if (effectiveAuthProfileId) {
const authStore = loadAuthProfileStoreForRuntime(agentDir, {
readOnly: true,
allowKeychainPrompt: false,
});
authCredential = authStore.profiles[effectiveAuthProfileId];
}
const authEpoch = await resolveCliAuthEpoch({
provider: params.provider,
authProfileId: params.authProfileId,
authProfileId: effectiveAuthProfileId,
skipLocalCredential:
backendResolved.authEpochMode === "profile-only" && Boolean(effectiveAuthProfileId),
});
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
const extraSystemPromptHash = hashCliSessionText(extraSystemPrompt);
@@ -149,13 +166,51 @@ export async function prepareCliRunContext(
: undefined,
warn: (message) => cliBackendLog.warn(message),
});
const preparedExecution = await backendResolved.prepareExecution?.({
config: params.config,
workspaceDir,
agentDir,
provider: params.provider,
modelId,
authProfileId: effectiveAuthProfileId,
authCredential,
});
const preparedBackendEnv =
preparedExecution?.env && Object.keys(preparedExecution.env).length > 0
? { ...preparedBackend.env, ...preparedExecution.env }
: preparedBackend.env;
const preparedBackendCleanup =
preparedBackend.cleanup || preparedExecution?.cleanup
? async () => {
try {
await preparedExecution?.cleanup?.();
} finally {
await preparedBackend.cleanup?.();
}
}
: undefined;
const preparedBackendClearEnv = [
...(preparedBackend.backend.clearEnv ?? []),
...(preparedExecution?.clearEnv ?? []),
];
const preparedBackendFinal = {
...preparedBackend,
backend: {
...preparedBackend.backend,
...(preparedBackendClearEnv.length > 0
? { clearEnv: Array.from(new Set(preparedBackendClearEnv)) }
: {}),
},
...(preparedBackendEnv ? { env: preparedBackendEnv } : {}),
...(preparedBackendCleanup ? { cleanup: preparedBackendCleanup } : {}),
};
const reusableCliSession = params.cliSessionBinding
? resolveCliSessionReuse({
binding: params.cliSessionBinding,
authProfileId: params.authProfileId,
authProfileId: effectiveAuthProfileId,
authEpoch,
extraSystemPromptHash,
mcpConfigHash: preparedBackend.mcpConfigHash,
mcpConfigHash: preparedBackendFinal.mcpConfigHash,
})
: params.cliSessionId
? { sessionId: params.cliSessionId }
@@ -240,10 +295,11 @@ export async function prepareCliRunContext(
return {
params,
effectiveAuthProfileId,
started,
workspaceDir,
backendResolved,
preparedBackend,
preparedBackend: preparedBackendFinal,
reusableCliSession,
modelId,
normalizedModel,

View File

@@ -54,6 +54,7 @@ export type CliReusableSession = {
export type PreparedCliRunContext = {
params: RunCliAgentParams;
effectiveAuthProfileId?: string;
started: number;
workspaceDir: string;
backendResolved: ResolvedCliBackend;

View File

@@ -1,5 +1,10 @@
export type { CliBackendConfig } from "../config/types.js";
export type { CliBackendPlugin } from "../plugins/types.js";
export type {
CliBackendAuthEpochMode,
CliBackendPlugin,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
} from "../plugins/types.js";
export {
CLI_FRESH_WATCHDOG_DEFAULTS,
CLI_RESUME_WATCHDOG_DEFAULTS,

View File

@@ -59,6 +59,7 @@ export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth
export { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js";
export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
export { resolveRequiredHomeDir } from "../infra/home-dir.js";
export { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
export {
normalizeOptionalSecretInput,
normalizeSecretInput,

View File

@@ -18,6 +18,24 @@ export type CliBundleMcpMode =
| "codex-config-overrides"
| "gemini-system-settings";
export type CliBackendPrepareExecutionContext = {
config?: OpenClawConfig;
workspaceDir: string;
agentDir?: string;
provider: string;
modelId: string;
authProfileId?: string;
authCredential?: unknown;
};
export type CliBackendPreparedExecution = {
env?: Record<string, string>;
clearEnv?: string[];
cleanup?: () => Promise<void>;
};
export type CliBackendAuthEpochMode = "combined" | "profile-only";
/** Plugin-owned CLI backend defaults used by the text-only CLI runner. */
export type CliBackendPlugin = {
/** Provider id used in model refs, for example `claude-cli/opus`. */
@@ -84,4 +102,33 @@ export type CliBackendPlugin = {
* `output` applies to parsed/streamed assistant text from the CLI.
*/
textTransforms?: PluginTextTransforms;
/**
* Preferred auth-profile id when the caller did not explicitly lock one.
*
* Use this when the backend should consume a canonical OpenClaw auth profile
* rather than ambient host auth by default.
*/
defaultAuthProfileId?: string;
/**
* Session/auth epoch source policy.
*
* `combined` keeps the legacy "host credential + auth profile" fingerprint.
* `profile-only` treats the selected OpenClaw auth profile as the sole auth
* owner for session invalidation when one is present.
*/
authEpochMode?: CliBackendAuthEpochMode;
/**
* Backend-owned execution bridge.
*
* Use this on async run paths when the backend needs a generated auth/config
* bridge (for example a private CLI home directory) without teaching the core
* runner about provider-specific file formats.
*/
prepareExecution?: (
ctx: CliBackendPrepareExecutionContext,
) =>
| Promise<CliBackendPreparedExecution | null | undefined>
| CliBackendPreparedExecution
| null
| undefined;
};

View File

@@ -67,6 +67,9 @@ import type {
import type { VideoGenerationProvider } from "../video-generation/types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type {
CliBackendAuthEpochMode,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
CliBackendPlugin,
CliBundleMcpMode,
PluginTextReplacement,
@@ -141,6 +144,9 @@ export type {
PluginConversationBindingResolutionDecision,
} from "./conversation-binding.types.js";
export type {
CliBackendAuthEpochMode,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
CliBackendPlugin,
CliBundleMcpMode,
PluginTextReplacement,