mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:40:43 +00:00
refactor(auth): route codex runtimes through canonical oauth
This commit is contained in:
committed by
Peter Steinberger
parent
f98e98ab66
commit
859eb06662
@@ -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
|
||||
|
||||
100
extensions/codex/src/app-server/auth-bridge.test.ts
Normal file
100
extensions/codex/src/app-server/auth-bridge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
76
extensions/codex/src/app-server/auth-bridge.ts
Normal file
76
extensions/codex/src/app-server/auth-bridge.ts
Normal 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]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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(): {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 });
|
||||
})(),
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
77
extensions/openai/openai-codex-cli-bridge.test.ts
Normal file
77
extensions/openai/openai-codex-cli-bridge.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
extensions/openai/openai-codex-cli-bridge.ts
Normal file
72
extensions/openai/openai-codex-cli-bridge.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -54,6 +54,7 @@ export type CliReusableSession = {
|
||||
|
||||
export type PreparedCliRunContext = {
|
||||
params: RunCliAgentParams;
|
||||
effectiveAuthProfileId?: string;
|
||||
started: number;
|
||||
workspaceDir: string;
|
||||
backendResolved: ResolvedCliBackend;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user