fix(media): route OAuth image defaults through Codex (#92824)

Route implicit OpenAI image understanding through the Codex app-server for eligible OpenAI OAuth profiles. Preserve scoped and persisted credential ownership plus the rotating-token refresh lifecycle for isolated clients.

Fixes #87168

Thanks @bek91.
This commit is contained in:
Bek
2026-06-14 05:11:18 -04:00
committed by GitHub
parent af091174db
commit 46a5a5ee5f
19 changed files with 2160 additions and 93 deletions
@@ -5,6 +5,14 @@ import { buildCodexMediaUnderstandingProvider } from "./media-understanding-prov
import type { CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
const sharedClientMocks = vi.hoisted(() => ({
createIsolatedCodexAppServerClient: vi.fn(),
}));
vi.mock("./src/app-server/shared-client.js", () => ({
createIsolatedCodexAppServerClient: sharedClientMocks.createIsolatedCodexAppServerClient,
}));
function codexModel(inputModalities: string[] = ["text", "image"]) {
return {
id: "gpt-5.4",
@@ -169,6 +177,7 @@ function createFakeClient(options?: {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
close: vi.fn(),
} as unknown as CodexAppServerClient;
return { client, requests, approvalResponses };
@@ -178,13 +187,24 @@ describe("codex media understanding provider", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
sharedClientMocks.createIsolatedCodexAppServerClient.mockReset();
});
it("runs image understanding through a bounded Codex app-server turn", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(
async (_startOptions, _authProfileId, _agentDir, _config) => client,
);
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,
clientFactory,
});
const cfg = {
auth: {
order: {
openai: ["openai:work"],
},
},
};
const result = await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
@@ -194,7 +214,7 @@ describe("codex media understanding provider", () => {
model: "gpt-5.4",
prompt: "Describe briefly.",
timeoutMs: 30_000,
cfg: {},
cfg,
agentDir: "/tmp/openclaw-agent",
});
@@ -204,6 +224,12 @@ describe("codex media understanding provider", () => {
"thread/start",
"turn/start",
]);
expect(clientFactory).toHaveBeenCalledWith(
expect.any(Object),
undefined,
"/tmp/openclaw-agent",
cfg,
);
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
modelProvider: "openai",
@@ -236,6 +262,62 @@ describe("codex media understanding provider", () => {
});
});
it("treats a blank agent directory as absent when starting the app-server", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const provider = buildCodexMediaUnderstandingProvider({ clientFactory });
const cfg = {};
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg,
agentDir: " ",
});
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg);
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
});
it("passes the scoped auth store into isolated app-server startup", async () => {
const { client } = createFakeClient();
sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue(client);
const provider = buildCodexMediaUnderstandingProvider();
const authStore = {
version: 1,
profiles: {
"openai:scoped": {
type: "oauth" as const,
provider: "openai",
access: "scoped-access",
refresh: "scoped-refresh",
expires: Date.now() + 60_000,
},
},
};
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
authStore,
agentDir: "/tmp/openclaw-agent",
});
expect(sharedClientMocks.createIsolatedCodexAppServerClient).toHaveBeenCalledWith(
expect.objectContaining({ authProfileStore: authStore }),
);
});
it("clamps oversized image understanding turn timeouts", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
@@ -102,6 +102,8 @@ async function describeCodexImages(
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authStore: req.authStore,
cfg: req.cfg,
options,
taskLabel: "image understanding",
developerInstructions:
@@ -123,6 +125,8 @@ type BoundedCodexVisionTurnParams = {
profile?: string;
timeoutMs: number;
agentDir?: string;
authStore?: ImagesDescriptionRequest["authStore"];
cfg: ImagesDescriptionRequest["cfg"];
options: CodexMediaUnderstandingProviderOptions;
taskLabel: string;
developerInstructions: string;
@@ -135,17 +139,22 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams):
pluginConfig: params.options.pluginConfig,
});
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 100, 100);
const agentDir = params.agentDir?.trim() || undefined;
const cwd = agentDir ?? process.cwd();
const ownsClient = !params.options.clientFactory;
// Tests inject a client factory; production creates an isolated app-server
// client so media tasks cannot reuse the interactive attempt session.
const client = params.options.clientFactory
? await params.options.clientFactory(appServer.start, params.profile)
? await params.options.clientFactory(appServer.start, params.profile, agentDir, params.cfg)
: await import("./src/app-server/shared-client.js").then(
({ createIsolatedCodexAppServerClient }) =>
createIsolatedCodexAppServerClient({
startOptions: appServer.start,
timeoutMs,
authProfileId: params.profile,
agentDir,
authProfileStore: params.authStore,
config: params.cfg,
}),
);
const abortController = new AbortController();
@@ -166,7 +175,7 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams):
{
model: params.model,
modelProvider: "openai",
cwd: params.agentDir || process.cwd(),
cwd,
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
@@ -193,7 +202,7 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams):
{
threadId: thread.thread.id,
input: params.input,
cwd: params.agentDir || process.cwd(),
cwd,
approvalPolicy: "on-request",
model: params.model,
effort: "low",
@@ -242,6 +251,8 @@ async function extractCodexStructured(
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authStore: req.authStore,
cfg: req.cfg,
options,
taskLabel: "structured extraction",
developerInstructions:
@@ -5,6 +5,7 @@ import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForSecretsRuntime,
replaceRuntimeAuthProfileStoreSnapshots,
} from "openclaw/plugin-sdk/agent-runtime";
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -14,6 +15,7 @@ import {
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerAuthProfileId,
resolveCodexAppServerAuthProfileStore,
resolveCodexAppServerFallbackApiKeyCacheKey,
resolveCodexAppServerHomeDir,
resolveCodexAppServerNativeHomeDir,
@@ -179,6 +181,39 @@ async function writeCodexCliApiKeyAuthFile(codexHome: string): Promise<void> {
}
describe("bridgeCodexAppServerStartOptions", () => {
it("preserves persisted provenance when preparing a supplied base store", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const authProfileStore = { version: 1, profiles: {} };
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 60_000,
},
});
const prepared = resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(prepared).not.toBe(authProfileStore);
expect(prepared.runtimePersistedProfileIds).toContain("openai:work");
expect(prepared.profiles["openai:work"]).toMatchObject({
access: "persisted-access",
refresh: "persisted-refresh",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("sets agent-owned CODEX_HOME without overriding HOME for local app-server launches", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions();
@@ -576,6 +611,603 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("applies a supplied scoped OAuth profile instead of persisted credentials", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "persisted-account",
},
});
const authProfileStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-access",
refresh: "scoped-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "scoped-account",
},
},
};
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-access",
chatgptAccountId: "scoped-account",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it.each([
{ name: "without persisted same-id credentials", persistSameId: false },
{ name: "with persisted same-id credentials", persistSameId: true },
])("refreshes an expired scoped OAuth profile $name", async ({ persistSameId }) => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "scoped-refreshed-access",
refresh: "scoped-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "scoped-refreshed-account",
});
try {
if (persistSameId) {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "persisted-account",
},
});
}
const authProfileStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-expired-access",
refresh: "scoped-refresh",
expires: Date.now() - 60_000,
accountId: "scoped-account",
},
},
};
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "scoped-refreshed-account",
chatgptPlanType: null,
});
expect(authProfileStore.profiles["openai:work"]).toMatchObject({
access: "scoped-refreshed-access",
accountId: "scoped-refreshed-account",
});
if (persistSameId) {
expect(
loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"],
).toMatchObject({
access: "persisted-access",
accountId: "persisted-account",
});
}
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("routes a supplied persisted OAuth clone through canonical refresh", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "persisted-refreshed-access",
refresh: "persisted-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "persisted-account",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-expired-access",
refresh: "persisted-refresh",
expires: Date.now() - 60_000,
accountId: "persisted-account",
},
});
const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir);
expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work");
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("persisted-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "persisted-refreshed-access",
chatgptAccountId: "persisted-account",
chatgptPlanType: null,
});
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject(
{
access: "persisted-refreshed-access",
refresh: "persisted-refreshed-refresh",
accountId: "persisted-account",
},
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps a prepared persisted store aligned across rotating refresh tokens", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken
.mockResolvedValueOnce({
access: "first-rotated-access",
refresh: "first-rotated-refresh",
expires: Date.now() + 60_000,
})
.mockResolvedValueOnce({
access: "second-rotated-access",
refresh: "second-rotated-refresh",
expires: Date.now() + 60_000,
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "initial-access",
refresh: "initial-refresh",
expires: Date.now() + 60_000,
},
});
const authProfileStore = resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: "openai:work",
authProfileStore: { version: 1, profiles: {} },
});
await refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
await refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken.mock.calls).toEqual([
["initial-refresh"],
["first-rotated-refresh"],
]);
expect(authProfileStore.profiles["openai:work"]).toMatchObject({
access: "second-rotated-access",
refresh: "second-rotated-refresh",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("does not replace a prepared persisted store changed during refresh", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
let resolveRefresh:
| ((value: { access: string; refresh: string; expires: number }) => void)
| undefined;
oauthMocks.refreshOpenAICodexToken.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "initial-access",
refresh: "initial-refresh",
expires: Date.now() + 60_000,
},
});
const authProfileStore = resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: "openai:work",
authProfileStore: { version: 1, profiles: {} },
});
const refresh = refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
await vi.waitFor(() => expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1));
authProfileStore.profiles["openai:work"] = {
type: "oauth",
provider: "openai",
access: "replacement-access",
refresh: "replacement-refresh",
expires: Date.now() + 60_000,
accountId: "replacement-account",
};
resolveRefresh?.({
access: "rotated-access",
refresh: "rotated-refresh",
expires: Date.now() + 60_000,
});
await refresh;
expect(authProfileStore.profiles["openai:work"]).toMatchObject({
access: "replacement-access",
refresh: "replacement-refresh",
accountId: "replacement-account",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps a runtime-external same-account OAuth profile scoped", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "scoped-refreshed-access",
refresh: "scoped-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "shared-account",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "shared-account",
},
});
const authProfileStore: AuthProfileStore = {
version: 1,
runtimeExternalProfileIds: ["openai:work"],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-expired-access",
refresh: "scoped-refresh",
expires: Date.now() - 60_000,
accountId: "shared-account",
},
},
};
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "shared-account",
chatgptPlanType: null,
});
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject(
{
access: "persisted-access",
refresh: "persisted-refresh",
accountId: "shared-account",
},
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps an ambiguous supplied OAuth identity scoped", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "scoped-refreshed-access",
refresh: "scoped-refreshed-refresh",
expires: Date.now() + 60_000,
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "persisted-account",
},
});
const authProfileStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-expired-access",
refresh: "scoped-refresh",
expires: Date.now() - 60_000,
},
},
};
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "openai:work",
chatgptPlanType: null,
});
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject(
{
access: "persisted-access",
refresh: "persisted-refresh",
accountId: "persisted-account",
},
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("routes a same-identity stale persisted clone through canonical persisted auth", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "stale-access",
refresh: "stale-refresh",
expires: Date.now() - 60_000,
accountId: "persisted-account",
},
});
const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir);
expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work");
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "current-access",
refresh: "current-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "persisted-account",
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "current-access",
chatgptAccountId: "persisted-account",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps a changed-identity persisted clone scoped", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "account-a-refreshed-access",
refresh: "account-a-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "account-a",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "account-a-expired-access",
refresh: "account-a-refresh",
expires: Date.now() - 60_000,
accountId: "account-a",
},
});
const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir);
expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work");
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "account-b-access",
refresh: "account-b-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "account-b",
},
});
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: authProfileStore }]);
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("account-a-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "account-a-refreshed-access",
chatgptAccountId: "account-a",
chatgptPlanType: null,
});
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject(
{
access: "account-b-access",
refresh: "account-b-refresh",
accountId: "account-b",
},
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("serializes concurrent refreshes of the same scoped OAuth profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
let resolveRefresh:
| ((value: { access: string; refresh: string; expires: number; accountId: string }) => void)
| undefined;
oauthMocks.refreshOpenAICodexToken.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);
const authProfileStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-expired-access",
refresh: "scoped-refresh",
expires: Date.now() - 60_000,
accountId: "scoped-account",
},
},
};
try {
const first = applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
const second = applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
await vi.waitFor(() => expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1));
resolveRefresh?.({
access: "scoped-refreshed-access",
refresh: "scoped-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "scoped-refreshed-account",
});
await Promise.all([first, second]);
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenNthCalledWith(1, "account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "scoped-refreshed-account",
chatgptPlanType: null,
});
expect(request).toHaveBeenNthCalledWith(2, "account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "scoped-refreshed-account",
chatgptPlanType: null,
});
} finally {
resolveRefresh?.({
access: "cleanup-access",
refresh: "cleanup-refresh",
expires: Date.now() + 60_000,
accountId: "cleanup-account",
});
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("leaves native app-server auth untouched when auth bridging is disabled", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ requiresOpenaiAuth: true }));
+204 -27
View File
@@ -4,9 +4,10 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
findPersistedAuthProfileCredential,
loadAuthProfileStoreForSecretsRuntime,
refreshOAuthCredentialForRuntime,
resolveAuthProfileOrder,
@@ -18,6 +19,7 @@ import {
type AuthProfileStore,
type OAuthCredential,
} from "openclaw/plugin-sdk/agent-runtime";
import { hasUsableOAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type {
@@ -48,11 +50,16 @@ const CODEX_AUTH_JSON_FILENAME = "auth.json";
const CODEX_HOME_DIRNAME = ".codex";
type AuthProfileOrderConfig = Parameters<typeof resolveAuthProfileOrder>[0]["cfg"];
const scopedOAuthRefreshQueues = new WeakMap<
AuthProfileStore,
Map<string, Promise<OAuthCredential>>
>();
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId?: string | null;
authProfileStore?: AuthProfileStore;
config?: AuthProfileOrderConfig;
}): Promise<CodexAppServerStartOptions> {
if (params.startOptions.transport !== "stdio") {
@@ -65,9 +72,10 @@ export async function bridgeCodexAppServerStartOptions(params: {
if (params.authProfileId === null) {
return isolatedStartOptions;
}
const store = ensureCodexAppServerAuthProfileStore({
const store = resolveCodexAppServerAuthProfileStore({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
config: params.config,
});
const authProfileId = resolveCodexAppServerAuthProfileId({
@@ -103,13 +111,15 @@ export function resolveCodexAppServerAuthProfileId(params: {
export function resolveCodexAppServerAuthProfileIdForAgent(params: {
authProfileId?: string;
authProfileStore?: AuthProfileStore;
agentDir?: string;
config?: AuthProfileOrderConfig;
}): string | undefined {
const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {});
const store = ensureCodexAppServerAuthProfileStore({
const store = resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
config: params.config,
});
return resolveCodexAppServerAuthProfileId({
@@ -132,7 +142,7 @@ function ensureCodexAppServerAuthProfileStore(params: {
});
}
function resolveCodexAppServerAuthProfileStore(params: {
export function resolveCodexAppServerAuthProfileStore(params: {
agentDir?: string;
authProfileId?: string;
authProfileStore?: AuthProfileStore;
@@ -163,13 +173,41 @@ function resolveCodexAppServerAuthProfileStore(params: {
...params.authProfileStore.order,
}
: undefined;
const profiles = {
...overlaidStore.profiles,
...params.authProfileStore.profiles,
};
const suppliedProfileIds = new Set(Object.keys(params.authProfileStore.profiles));
const mergeRuntimeProfileIds = (overlaidIds?: string[], suppliedIds?: string[]) => [
...(overlaidIds ?? []).filter((profileId) => !suppliedProfileIds.has(profileId)),
...(suppliedIds ?? []),
];
const runtimePersistedProfileIds = mergeRuntimeProfileIds(
overlaidStore.runtimePersistedProfileIds,
params.authProfileStore.runtimePersistedProfileIds,
).filter((profileId) => profiles[profileId]);
const runtimeExternalProfileIds = mergeRuntimeProfileIds(
overlaidStore.runtimeExternalProfileIds,
params.authProfileStore.runtimeExternalProfileIds,
).filter((profileId) => profiles[profileId]);
const runtimeExternalProfileIdsAuthoritative =
overlaidStore.runtimeExternalProfileIdsAuthoritative === true ||
params.authProfileStore.runtimeExternalProfileIdsAuthoritative === true;
return {
...params.authProfileStore,
...(order ? { order } : {}),
profiles: {
...overlaidStore.profiles,
...params.authProfileStore.profiles,
},
profiles,
...(runtimePersistedProfileIds.length > 0
? { runtimePersistedProfileIds: [...new Set(runtimePersistedProfileIds)] }
: {}),
...(runtimeExternalProfileIds.length > 0 || runtimeExternalProfileIdsAuthoritative
? {
runtimeExternalProfileIds: [...new Set(runtimeExternalProfileIds)],
...(runtimeExternalProfileIdsAuthoritative
? { runtimeExternalProfileIdsAuthoritative: true }
: {}),
}
: {}),
};
}
@@ -339,6 +377,7 @@ export async function applyCodexAppServerAuthProfile(params: {
client: CodexAppServerClient;
agentDir: string;
authProfileId?: string | null;
authProfileStore?: AuthProfileStore;
startOptions?: CodexAppServerStartOptions;
config?: AuthProfileOrderConfig;
}): Promise<void> {
@@ -348,6 +387,7 @@ export async function applyCodexAppServerAuthProfile(params: {
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
config: params.config,
});
if (!loginParams) {
@@ -371,6 +411,7 @@ export async function applyCodexAppServerAuthProfile(params: {
function resolveCodexAppServerAuthProfileLoginParams(params: {
agentDir: string;
authProfileId?: string;
authProfileStore?: AuthProfileStore;
config?: AuthProfileOrderConfig;
}): Promise<CodexLoginAccountParams | undefined> {
return resolveCodexAppServerAuthProfileLoginParamsInternal(params);
@@ -379,6 +420,7 @@ function resolveCodexAppServerAuthProfileLoginParams(params: {
export async function refreshCodexAppServerAuthTokens(params: {
agentDir: string;
authProfileId?: string;
authProfileStore?: AuthProfileStore;
config?: AuthProfileOrderConfig;
}): Promise<CodexChatgptAuthTokensRefreshResponse> {
const loginParams = await resolveCodexAppServerAuthProfileLoginParamsInternal({
@@ -398,12 +440,14 @@ export async function refreshCodexAppServerAuthTokens(params: {
async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
agentDir: string;
authProfileId?: string;
authProfileStore?: AuthProfileStore;
forceOAuthRefresh?: boolean;
config?: AuthProfileOrderConfig;
}): Promise<CodexLoginAccountParams | undefined> {
const store = ensureCodexAppServerAuthProfileStore({
const store = resolveCodexAppServerAuthProfileStore({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
config: params.config,
});
const profileId = resolveCodexAppServerAuthProfileId({
@@ -425,6 +469,8 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
}
const loginParams = await resolveLoginParamsForCredential(profileId, credential, {
agentDir: params.agentDir,
store,
preferStoreCredential: Boolean(params.authProfileStore?.profiles[profileId]),
forceOAuthRefresh: params.forceOAuthRefresh === true,
config: params.config,
});
@@ -509,14 +555,22 @@ function resolveCodexCliAuthFileApiKeyCacheKey(env: NodeJS.ProcessEnv): string |
async function resolveLoginParamsForCredential(
profileId: string,
credential: AuthProfileCredential,
params: { agentDir: string; forceOAuthRefresh: boolean; config?: AuthProfileOrderConfig },
params: {
agentDir: string;
store: AuthProfileStore;
preferStoreCredential: boolean;
forceOAuthRefresh: boolean;
config?: AuthProfileOrderConfig;
},
): Promise<CodexLoginAccountParams | undefined> {
// Runtime honors the persisted auth profile type. Shape-based remediation
// belongs at credential entry time so request handling does not preemptively
// reject opaque provider credentials.
if (credential.type === "api_key") {
const resolved = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
store: params.preferStoreCredential
? params.store
: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
profileId,
agentDir: params.agentDir,
});
@@ -525,7 +579,9 @@ async function resolveLoginParamsForCredential(
}
if (credential.type === "token") {
const resolved = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
store: params.preferStoreCredential
? params.store
: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
profileId,
agentDir: params.agentDir,
});
@@ -539,6 +595,8 @@ async function resolveLoginParamsForCredential(
}
const resolvedCredential = await resolveOAuthCredentialForCodexAppServer(profileId, credential, {
agentDir: params.agentDir,
store: params.store,
preferStoreCredential: params.preferStoreCredential,
forceRefresh: params.forceOAuthRefresh,
config: params.config,
});
@@ -551,22 +609,40 @@ async function resolveLoginParamsForCredential(
async function resolveOAuthCredentialForCodexAppServer(
profileId: string,
credential: OAuthCredential,
params: { agentDir: string; forceRefresh: boolean; config?: AuthProfileOrderConfig },
params: {
agentDir: string;
store: AuthProfileStore;
preferStoreCredential: boolean;
forceRefresh: boolean;
config?: AuthProfileOrderConfig;
},
): Promise<OAuthCredential> {
const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({
agentDir: params.agentDir,
profileId,
});
const store = ensureCodexAppServerAuthProfileStore({
const persistedCredential = findPersistedAuthProfileCredential({
agentDir: ownerAgentDir,
authProfileId: profileId,
config: params.config,
profileId,
});
const persistedStore = ensureAuthProfileStoreWithoutExternalProfiles(ownerAgentDir, {
allowKeychainPrompt: false,
});
const persistedCredential = persistedStore.profiles[profileId];
const useScopedCredential =
params.preferStoreCredential &&
shouldUseScopedOAuthCredential({
store: params.store,
profileId,
persistedCredential,
suppliedCredential: credential,
config: params.config,
});
const store = useScopedCredential
? params.store
: ensureCodexAppServerAuthProfileStore({
agentDir: ownerAgentDir,
authProfileId: profileId,
config: params.config,
});
const persistedOAuthCredential =
!useScopedCredential &&
persistedCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(persistedCredential.provider, params.config)
? persistedCredential
@@ -577,6 +653,14 @@ async function resolveOAuthCredentialForCodexAppServer(
isCodexAppServerAuthProvider(ownerCredential.provider, params.config)
? ownerCredential
: undefined;
if (useScopedCredential && overlaidOAuthCredential) {
return await resolveScopedOAuthCredential({
store,
profileId,
credential: overlaidOAuthCredential,
forceRefresh: params.forceRefresh,
});
}
if (params.forceRefresh && !persistedOAuthCredential && overlaidOAuthCredential) {
const refreshedRuntimeCredential = await refreshOAuthCredentialForRuntime({
credential: overlaidOAuthCredential,
@@ -593,18 +677,111 @@ async function resolveOAuthCredentialForCodexAppServer(
agentDir: ownerAgentDir,
forceRefresh: params.forceRefresh && Boolean(persistedOAuthCredential),
});
const refreshed = loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId];
const storedCredential = store.profiles[profileId];
const candidate =
const refreshed = useScopedCredential
? undefined
: loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId];
const refreshedOAuthCredential =
refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider, params.config)
? refreshed
: storedCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(storedCredential.provider, params.config)
? storedCredential
: credential;
: undefined;
if (refreshedOAuthCredential && isDeepStrictEqual(params.store.profiles[profileId], credential)) {
// Persisted refreshes rotate refresh tokens. Keep an isolated prepared
// store aligned without reverting a concurrent caller-owned replacement.
params.store.profiles[profileId] = refreshedOAuthCredential;
}
const storedCredential = store.profiles[profileId];
const candidate = refreshedOAuthCredential
? refreshedOAuthCredential
: storedCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(storedCredential.provider, params.config)
? storedCredential
: credential;
return resolved?.apiKey ? { ...candidate, access: resolved.apiKey } : candidate;
}
function shouldUseScopedOAuthCredential(params: {
store: AuthProfileStore;
profileId: string;
persistedCredential: AuthProfileCredential | undefined;
suppliedCredential: OAuthCredential;
config?: AuthProfileOrderConfig;
}): boolean {
if (!params.store.runtimePersistedProfileIds?.includes(params.profileId)) {
return true;
}
const persisted = params.persistedCredential;
if (persisted?.type !== "oauth") {
return true;
}
if (
resolveProviderIdForAuth(persisted.provider, { config: params.config }) !==
resolveProviderIdForAuth(params.suppliedCredential.provider, { config: params.config })
) {
return true;
}
return (
!isDeepStrictEqual(persisted, params.suppliedCredential) &&
!hasMatchingOAuthIdentity(persisted, params.suppliedCredential)
);
}
function hasMatchingOAuthIdentity(persisted: OAuthCredential, supplied: OAuthCredential): boolean {
const persistedAccountId = persisted.accountId?.trim();
const suppliedAccountId = supplied.accountId?.trim();
if (persistedAccountId && suppliedAccountId) {
return persistedAccountId === suppliedAccountId;
}
const persistedEmail = persisted.email?.trim().toLowerCase();
const suppliedEmail = supplied.email?.trim().toLowerCase();
return Boolean(persistedEmail && suppliedEmail && persistedEmail === suppliedEmail);
}
async function resolveScopedOAuthCredential(params: {
store: AuthProfileStore;
profileId: string;
credential: OAuthCredential;
forceRefresh: boolean;
}): Promise<OAuthCredential> {
const existingRefresh = scopedOAuthRefreshQueues.get(params.store)?.get(params.profileId);
if (existingRefresh) {
return await existingRefresh;
}
if (!params.forceRefresh && hasUsableOAuthCredential(params.credential)) {
return params.credential;
}
const storeRefreshes = scopedOAuthRefreshQueues.get(params.store) ?? new Map();
scopedOAuthRefreshQueues.set(params.store, storeRefreshes);
const refresh = (async () => {
const current = params.store.profiles[params.profileId];
const credential = current?.type === "oauth" ? current : params.credential;
if (!params.forceRefresh && hasUsableOAuthCredential(credential)) {
return credential;
}
const refreshed = await refreshOAuthCredentialForRuntime({ credential });
if (!refreshed?.access?.trim()) {
throw new Error(`Codex app-server auth profile "${params.profileId}" could not refresh.`);
}
if (!isDeepStrictEqual(params.store.profiles[params.profileId], credential)) {
throw new Error(
`Codex app-server auth profile "${params.profileId}" changed while refreshing.`,
);
}
params.store.profiles[params.profileId] = refreshed;
return refreshed;
})();
storeRefreshes.set(params.profileId, refresh);
try {
return await refresh;
} finally {
// Scoped stores are process-local; serialize their rotating refresh token
// and release the queue entry with the refresh that owns it.
if (storeRefreshes.get(params.profileId) === refresh) {
storeRefreshes.delete(params.profileId);
}
}
}
function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrderConfig): boolean {
const resolvedProvider = resolveProviderIdForAuth(provider, { config });
return (
@@ -13,6 +13,14 @@ const mocks = vi.hoisted(() => ({
resolveCodexAppServerAuthProfileIdForAgent: vi.fn(
(params?: { authProfileId?: string }) => params?.authProfileId,
),
resolveCodexAppServerAuthProfileStore: vi.fn(
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
),
refreshCodexAppServerAuthTokens: vi.fn(async () => ({
accessToken: "refreshed-access",
chatgptAccountId: "refreshed-account",
chatgptPlanType: null,
})),
resolveCodexAppServerFallbackApiKeyCacheKey: vi.fn(() => undefined as string | undefined),
resolveManagedCodexAppServerStartOptions: vi.fn(async (startOptions) => startOptions),
embeddedAgentLog: { debug: vi.fn(), warn: vi.fn() },
@@ -23,6 +31,8 @@ vi.mock("./auth-bridge.js", () => ({
applyCodexAppServerAuthProfile: mocks.applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent,
resolveCodexAppServerAuthProfileStore: mocks.resolveCodexAppServerAuthProfileStore,
refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens,
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey,
}));
@@ -79,6 +89,7 @@ function bridgeStartOptionsCall() {
return firstMockArg(mocks.bridgeCodexAppServerStartOptions, "bridge start options") as {
agentDir?: string;
authProfileId?: string;
authProfileStore?: unknown;
config?: unknown;
startOptions: { command?: string; commandSource?: string };
};
@@ -88,6 +99,7 @@ function applyAuthProfileCall() {
return firstMockArg(mocks.applyCodexAppServerAuthProfile, "apply auth profile") as {
agentDir?: string;
authProfileId?: string;
authProfileStore?: unknown;
config?: unknown;
};
}
@@ -96,6 +108,7 @@ function resolveAuthProfileCall() {
return firstMockArg(mocks.resolveCodexAppServerAuthProfileIdForAgent, "resolve auth profile") as {
agentDir?: string;
authProfileId?: string;
authProfileStore?: unknown;
config?: unknown;
};
}
@@ -142,6 +155,11 @@ describe("shared Codex app-server client", () => {
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
(params?: { authProfileId?: string }) => params?.authProfileId,
);
mocks.resolveCodexAppServerAuthProfileStore.mockClear();
mocks.resolveCodexAppServerAuthProfileStore.mockImplementation(
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
);
mocks.refreshCodexAppServerAuthTokens.mockClear();
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockClear();
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockReturnValue(undefined);
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
@@ -240,6 +258,95 @@ describe("shared Codex app-server client", () => {
expect(applyCall?.authProfileId).toBe("openai:work");
});
it("carries a scoped auth store through isolated app-server startup", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const authProfileStore = { version: 1, profiles: {} };
const preparedAuthProfileStore = {
version: 1,
profiles: {
"openai:scoped": { type: "token", provider: "openai", token: "prepared-token" },
},
};
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockReturnValue("openai:scoped");
mocks.resolveCodexAppServerAuthProfileStore.mockReturnValue(preparedAuthProfileStore);
const clientPromise = createIsolatedCodexAppServerClient({
timeoutMs: 1000,
authProfileStore,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledWith({
agentDir: "/tmp/openclaw-agent",
authProfileId: undefined,
authProfileStore,
config: undefined,
});
expect(resolveAuthProfileCall().authProfileStore).toBe(preparedAuthProfileStore);
expect(bridgeStartOptionsCall().authProfileStore).toBe(preparedAuthProfileStore);
expect(applyAuthProfileCall().authProfileStore).toBe(preparedAuthProfileStore);
const priorWriteCount = harness.writes.length;
harness.send({
id: "refresh-1",
method: "account/chatgptAuthTokens/refresh",
params: { reason: "unauthorized", previousAccountId: "scoped-account" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(priorWriteCount));
expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledWith({
agentDir: "/tmp/openclaw-agent",
authProfileId: "openai:scoped",
authProfileStore: preparedAuthProfileStore,
config: undefined,
});
expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toEqual({
id: "refresh-1",
result: {
accessToken: "refreshed-access",
chatgptAccountId: "refreshed-account",
chatgptPlanType: null,
},
});
});
it("registers persisted profile refresh for isolated app-server startup", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const clientPromise = createIsolatedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:persisted",
agentDir: "/tmp/openclaw-persisted-agent",
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
const priorWriteCount = harness.writes.length;
harness.send({
id: "refresh-persisted",
method: "account/chatgptAuthTokens/refresh",
params: { reason: "unauthorized", previousAccountId: "persisted-account" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(priorWriteCount));
expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledWith({
agentDir: "/tmp/openclaw-persisted-agent",
authProfileId: "openai:persisted",
config: undefined,
});
expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toEqual({
id: "refresh-persisted",
result: {
accessToken: "refreshed-access",
chatgptAccountId: "refreshed-account",
chatgptPlanType: null,
},
});
});
it("skips target auth resolution when native source auth is requested", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
@@ -2,11 +2,13 @@
* Owns shared and isolated Codex app-server client startup, auth application,
* lease tracking, and teardown.
*/
import { resolveDefaultAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { resolveDefaultAgentDir, type AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
import {
applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions,
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthProfileIdForAgent,
resolveCodexAppServerAuthProfileStore,
resolveCodexAppServerFallbackApiKeyCacheKey,
} from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
@@ -113,26 +115,41 @@ type CodexAppServerClientOptions = {
abandonSignal?: AbortSignal;
};
type IsolatedCodexAppServerClientOptions = CodexAppServerClientOptions & {
authProfileStore?: AuthProfileStore;
};
type ResolvedCodexAppServerClientStartContext = {
agentDir: string;
usesNativeAuth: boolean;
authProfileId: string | undefined;
authProfileStore: AuthProfileStore | undefined;
startOptions: CodexAppServerStartOptions;
};
async function resolveCodexAppServerClientStartContext(
options?: CodexAppServerClientOptions,
options?: IsolatedCodexAppServerClientOptions,
): Promise<ResolvedCodexAppServerClientStartContext> {
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
const usesNativeAuth = options?.authProfileId === null;
const requestedAuthProfileId =
options?.authProfileId === null ? undefined : options?.authProfileId;
const authProfileStore =
!usesNativeAuth && options?.authProfileStore
? resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: requestedAuthProfileId,
authProfileStore: options.authProfileStore,
config: options.config,
})
: options?.authProfileStore;
const authProfileId = usesNativeAuth
? undefined
: resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: requestedAuthProfileId,
agentDir,
config: options?.config,
...(authProfileStore ? { authProfileStore } : {}),
});
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
@@ -142,8 +159,9 @@ async function resolveCodexAppServerClientStartContext(
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
config: options?.config,
...(authProfileStore ? { authProfileStore } : {}),
});
return { agentDir, usesNativeAuth, authProfileId, startOptions };
return { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions };
}
/** Gets or starts a shared Codex app-server client without retaining a lease. */
@@ -269,11 +287,26 @@ async function acquireSharedCodexAppServerClient(
/** Starts a non-shared Codex app-server client owned entirely by the caller. */
export async function createIsolatedCodexAppServerClient(
options?: CodexAppServerClientOptions,
options?: IsolatedCodexAppServerClientOptions,
): Promise<CodexAppServerClient> {
const { agentDir, usesNativeAuth, authProfileId, startOptions } =
const { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions } =
await resolveCodexAppServerClientStartContext(options);
const client = CodexAppServerClient.start(startOptions);
if (authProfileId) {
// Profile-backed Codex auth is ephemeral. Keep the host refresh callback
// available whether the profile came from a scoped store or persisted state.
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
}
return await refreshCodexAppServerAuthTokens({
agentDir,
authProfileId,
...(authProfileStore ? { authProfileStore } : {}),
config: options?.config,
});
});
}
const initialize = client.initialize();
try {
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
@@ -283,6 +316,7 @@ export async function createIsolatedCodexAppServerClient(
authProfileId: usesNativeAuth ? null : authProfileId,
startOptions,
config: options?.config,
...(authProfileStore ? { authProfileStore } : {}),
});
return client;
} catch (error) {
@@ -126,6 +126,38 @@ describe("overlayRuntimeExternalOAuthProfiles", () => {
expect(overlaid.runtimeExternalProfileIdsAuthoritative).toBe(true);
});
it("removes persisted provenance for every externally overlaid profile", () => {
const store: AuthProfileStore = {
version: 1,
runtimePersistedProfileIds: ["openai:default"],
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: 1,
},
},
};
const overlaid = overlayRuntimeExternalOAuthProfiles(store, [
{
profileId: "openai:default",
persistence: "persisted",
credential: {
type: "oauth",
provider: "openai",
access: "external-access",
refresh: "external-refresh",
expires: 2,
},
},
]);
expect(overlaid.runtimePersistedProfileIds).toBeUndefined();
});
it("replaces an existing OAuth credential with an out-of-range expiry", () => {
const existing: OAuthCredential = {
type: "oauth",
+7
View File
@@ -196,9 +196,16 @@ export function overlayRuntimeExternalOAuthProfiles(
): AuthProfileStore {
const externalProfiles = Array.from(profiles);
const next = cloneAuthProfileStore(store);
const overlaidProfileIds = new Set(externalProfiles.map((profile) => profile.profileId));
for (const profile of externalProfiles) {
next.profiles[profile.profileId] = profile.credential;
}
next.runtimePersistedProfileIds = store.runtimePersistedProfileIds
?.filter((profileId) => next.profiles[profileId] && !overlaidProfileIds.has(profileId))
.toSorted();
if (next.runtimePersistedProfileIds?.length === 0) {
next.runtimePersistedProfileIds = undefined;
}
const runtimeOnlyProfileIds = new Set(
externalProfiles
.filter((profile) => profile.persistence !== "persisted")
@@ -220,6 +220,45 @@ describe("persisted auth profile boundary", () => {
expect(merged.lastGood?.anthropic).toBe(profileId);
});
it("tracks persisted profile provenance with override precedence", () => {
const merged = mergeAuthProfileStores(
{
version: AUTH_STORE_VERSION,
runtimePersistedProfileIds: ["openai:base", "openai:overridden"],
profiles: {
"openai:base": {
type: "api_key",
provider: "openai",
key: "base-key",
},
"openai:overridden": {
type: "api_key",
provider: "openai",
key: "old-key",
},
},
},
{
version: AUTH_STORE_VERSION,
runtimePersistedProfileIds: ["openai:added"],
profiles: {
"openai:overridden": {
type: "api_key",
provider: "openai",
key: "scoped-key",
},
"openai:added": {
type: "api_key",
provider: "openai",
key: "added-key",
},
},
},
);
expect(merged.runtimePersistedProfileIds).toEqual(["openai:added", "openai:base"]);
});
it("preserves config-only order fallbacks during agent-store merges", () => {
const merged = mergeAuthProfileStores(
{
+12
View File
@@ -592,6 +592,7 @@ export function mergeAuthProfileStores(
!override.order &&
!override.lastGood &&
!override.usageStats &&
override.runtimePersistedProfileIds === undefined &&
override.runtimeExternalProfileIds === undefined &&
override.runtimeExternalProfileIdsAuthoritative !== true
) {
@@ -651,6 +652,14 @@ export function mergeAuthProfileStores(
lastGood,
usageStats,
};
const runtimePersistedProfileIds = [
...(base.runtimePersistedProfileIds ?? []).filter(
(profileId) => !overrideProfileIds.has(profileId),
),
...(override.runtimePersistedProfileIds ?? []),
]
.filter((profileId) => merged.profiles[profileId])
.toSorted();
const baseRuntimeExternalProfileIds =
override.runtimeExternalProfileIdsAuthoritative === true &&
options?.preserveBaseRuntimeExternalProfiles !== true
@@ -681,6 +690,9 @@ export function mergeAuthProfileStores(
override,
merged: {
...merged,
...(runtimePersistedProfileIds.length > 0
? { runtimePersistedProfileIds: [...new Set(runtimePersistedProfileIds)] }
: {}),
...runtimeExternalProfileMetadata,
},
});
+50 -1
View File
@@ -20,8 +20,10 @@ import {
} from "./profiles.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
getRuntimeAuthProfileStoreSnapshot,
loadAuthProfileStoreForRuntime,
loadAuthProfileStoreWithoutExternalProfiles,
replaceRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore } from "./types.js";
@@ -97,6 +99,49 @@ function expectOAuthCredentialFields(
}
describe("promoteAuthProfileInOrder", () => {
it("marks newly saved runtime snapshot profiles as persisted", async () => {
await withAuthProfileTestState(
"openclaw-auth-profile-runtime-persisted-",
async ({ agentDir }) => {
fs.mkdirSync(agentDir, { recursive: true });
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: AUTH_STORE_VERSION,
profiles: {},
},
},
]);
try {
saveAuthProfileStore(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "account-123",
},
},
},
agentDir,
);
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.runtimePersistedProfileIds).toEqual([
"openai:work",
]);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
}
},
{ clearOAuthDir: true },
);
});
it("normalizes copied secrets when using the locked upsert path", async () => {
await withAuthProfileTestState(
"openclaw-auth-profile-upsert-",
@@ -122,7 +167,11 @@ describe("promoteAuthProfileInOrder", () => {
agentDir,
});
const profiles = loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles;
const store = loadAuthProfileStoreWithoutExternalProfiles(agentDir);
expect(store.runtimePersistedProfileIds).toEqual(["anthropic:key", "openai:manual"]);
expect(store.runtimeExternalProfileIds).toBeUndefined();
expect(store.runtimeExternalProfileIdsAuthoritative).toBeUndefined();
const profiles = store.profiles;
expect(profiles["openai:manual"]).toMatchObject({
type: "token",
provider: "openai",
+50 -19
View File
@@ -435,6 +435,12 @@ function pruneAuthProfileStoreReferences(
Object.entries(store.usageStats).filter(([profileId]) => keptProfileIds.has(profileId)),
)
: undefined;
store.runtimePersistedProfileIds = store.runtimePersistedProfileIds
?.filter((profileId) => keptProfileIds.has(profileId))
.toSorted();
if (store.runtimePersistedProfileIds?.length === 0) {
store.runtimePersistedProfileIds = undefined;
}
store.runtimeExternalProfileIds = store.runtimeExternalProfileIds
?.filter((profileId) => keptProfileIds.has(profileId))
.toSorted();
@@ -521,22 +527,40 @@ function buildAuthProfileStoreWithoutExternalProfiles(params: {
const runtimeExternalProfileIds = new Set(params.store.runtimeExternalProfileIds ?? []);
const localStore = cloneAuthProfileStore(params.store);
if (runtimeExternalProfileIds.size === 0) {
localStore.runtimeExternalProfileIds = undefined;
localStore.runtimeExternalProfileIdsAuthoritative = undefined;
return localStore;
return stripRuntimeExternalProfileMetadata(localStore);
}
for (const profileId of runtimeExternalProfileIds) {
delete localStore.profiles[profileId];
}
const keptProfileIds = new Set(Object.keys(localStore.profiles));
pruneAuthProfileStoreReferences(localStore, keptProfileIds);
localStore.runtimeExternalProfileIds = undefined;
localStore.runtimeExternalProfileIdsAuthoritative = undefined;
const persistedStore = loadAuthProfileStoreWithoutExternalProfiles(
params.agentDir,
params.options,
);
return mergeAuthProfileStores(persistedStore, localStore);
return stripRuntimeExternalProfileMetadata(mergeAuthProfileStores(persistedStore, localStore));
}
function stripRuntimeExternalProfileMetadata(store: AuthProfileStore): AuthProfileStore {
const stripped = { ...store };
delete stripped.runtimeExternalProfileIds;
delete stripped.runtimeExternalProfileIdsAuthoritative;
return stripped;
}
function markRuntimePersistedProfiles(
store: AuthProfileStore,
persistedStore: AuthProfileStore = store,
): AuthProfileStore {
const profileIds = Object.entries(persistedStore.profiles)
.flatMap(([profileId, credential]) =>
isDeepStrictEqual(store.profiles[profileId], credential) ? [profileId] : [],
)
.toSorted();
return {
...store,
runtimePersistedProfileIds: profileIds.length > 0 ? profileIds : undefined,
};
}
function buildRuntimeAuthProfileStoreForSave(params: {
@@ -743,11 +767,11 @@ export async function updateAuthProfileStoreWithLock(params: {
export function loadAuthProfileStore(): AuthProfileStore {
const asStore = loadPersistedAuthProfileStore();
if (asStore) {
return overlayExternalAuthProfiles(asStore);
return overlayExternalAuthProfiles(markRuntimePersistedProfiles(asStore));
}
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
return overlayExternalAuthProfiles(store);
return overlayExternalAuthProfiles(markRuntimePersistedProfiles(store));
}
function loadAuthProfileStoreForAgent(
@@ -762,7 +786,7 @@ function loadAuthProfileStoreForAgent(
agentDir,
options,
});
return synced.store;
return markRuntimePersistedProfiles(synced.store);
}
const store: AuthProfileStore = {
@@ -782,7 +806,7 @@ function loadAuthProfileStoreForAgent(
agentDir,
options,
});
return synced.store;
return markRuntimePersistedProfiles(synced.store);
}
/** Loads the effective runtime store for an agent, including inherited main profiles. */
@@ -841,13 +865,15 @@ export function loadAuthProfileStoreWithoutExternalProfiles(
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
return stripRuntimeExternalProfileMetadata(store);
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
});
return stripRuntimeExternalProfileMetadata(
mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
}),
);
}
/** Ensure an auth store is available, including runtime/external profile overlays. */
@@ -905,13 +931,15 @@ export function ensureAuthProfileStoreWithoutExternalProfiles(
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
return stripRuntimeExternalProfileMetadata(store);
}
const mainStore = loadAuthProfileStoreForAgent(undefined, effectiveOptions);
return mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
});
return stripRuntimeExternalProfileMetadata(
mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
}),
);
}
/** Find a persisted credential in the scoped store, falling back to the main store. */
@@ -1030,7 +1058,10 @@ export function saveAuthProfileStore(
}
if (hasRuntimeAuthProfileStoreSnapshot(agentDir)) {
const existingRuntimeStore = getRuntimeAuthProfileStoreSnapshot(agentDir);
const nextRuntimeStore = buildRuntimeAuthProfileStoreForSave({ store, agentDir, options });
const nextRuntimeStore = markRuntimePersistedProfiles(
buildRuntimeAuthProfileStoreForSave({ store, agentDir, options }),
localStore,
);
setRuntimeAuthProfileStoreSnapshot(
existingRuntimeStore
? mergeRuntimeExternalProfileReferences({
+2
View File
@@ -139,6 +139,8 @@ export type AuthProfileStateStore = {
/** Effective in-memory auth store combining credentials, state, and overlays. */
export type AuthProfileStore = AuthProfileSecretsStore &
AuthProfileState & {
/** Runtime-only provenance for credentials cloned from persisted auth stores. */
runtimePersistedProfileIds?: string[];
/** Runtime-only provenance for external OAuth profiles overlaid onto this store. */
runtimeExternalProfileIds?: string[];
/** True when the runtime external profile set was freshly resolved, even if empty. */
@@ -1030,6 +1030,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped"));
const codexAuthStore = {
version: 1,
runtimePersistedProfileIds: ["anthropic:work", "openai:other", "openai:work", "xai:work"],
profiles: {
"openai:work": {
type: "oauth" as const,
@@ -1109,6 +1110,9 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
const forwardedAuthStore = expectRecordFields(harnessParams.authProfileStore, {});
const authProfiles = expectRecordFields(forwardedAuthStore.profiles, {});
expect(Object.keys(authProfiles)).toEqual(["openai:work"]);
expect(forwardedAuthStore.runtimePersistedProfileIds).toEqual(["openai:work"]);
expect(forwardedAuthStore.runtimeExternalProfileIds).toBeUndefined();
expect(forwardedAuthStore.runtimeExternalProfileIdsAuthoritative).toBeUndefined();
expectRecordFields(authProfiles["openai:work"], {
provider: "openai",
});
@@ -1216,6 +1220,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
});
const codexAuthStore = {
version: 1 as const,
runtimePersistedProfileIds: ["openai:work"],
profiles: {
"openai:work": {
type: "oauth" as const,
@@ -1311,6 +1316,8 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
});
const codexAuthStore = {
version: 1 as const,
runtimePersistedProfileIds: ["xai:work"],
runtimeExternalProfileIds: ["openai:default"],
profiles: {
"openai:default": {
type: "oauth" as const,
@@ -1431,6 +1438,9 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
const forwardedAuthStore = expectRecordFields(harnessParams.authProfileStore, {});
const authProfiles = expectRecordFields(forwardedAuthStore.profiles, {});
expect(Object.keys(authProfiles)).toEqual(["openai:default"]);
expect(forwardedAuthStore.runtimePersistedProfileIds).toBeUndefined();
expect(forwardedAuthStore.runtimeExternalProfileIds).toEqual(["openai:default"]);
expect(forwardedAuthStore.runtimeExternalProfileIdsAuthoritative).toBeUndefined();
expectRecordFields(authProfiles["openai:default"], {
provider: "openai",
});
+16
View File
@@ -421,10 +421,26 @@ function createScopedAuthProfileStore(
return credential ? [[profileId, credential] as const] : [];
}),
);
const scopedRuntimeExternalProfileIds = (store.runtimeExternalProfileIds ?? []).filter(
(profileId) => scopedProfiles[profileId],
);
const scopedRuntimePersistedProfileIds = (store.runtimePersistedProfileIds ?? []).filter(
(profileId) => scopedProfiles[profileId],
);
return Object.keys(scopedProfiles).length > 0
? {
version: store.version,
profiles: scopedProfiles,
...(scopedRuntimePersistedProfileIds.length > 0
? { runtimePersistedProfileIds: scopedRuntimePersistedProfileIds }
: {}),
...(scopedRuntimeExternalProfileIds.length > 0 ||
store.runtimeExternalProfileIdsAuthoritative === true
? { runtimeExternalProfileIds: scopedRuntimeExternalProfileIds }
: {}),
...(store.runtimeExternalProfileIdsAuthoritative === true
? { runtimeExternalProfileIdsAuthoritative: true }
: {}),
}
: createEmptyAuthProfileStore();
}
+364 -15
View File
@@ -17,6 +17,7 @@ import type {
import { withEnvAsync } from "../../test-utils/env.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createOpenClawCodingTools } from "../agent-tools.js";
import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
import { minimaxUnderstandImage } from "../minimax-vlm.js";
import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js";
@@ -161,22 +162,41 @@ vi.mock("../../media/channel-inbound-roots.js", () => ({
},
}));
function readMockAuthProfileStore(agentDir?: string): {
version: number;
profiles: Record<string, { provider?: string; type?: string }>;
} {
const fallback = {
version: 1,
profiles: {} as Record<string, { provider?: string; type?: string }>,
};
if (!agentDir) {
return fallback;
}
try {
return JSON.parse(fsSync.readFileSync(path.join(agentDir, "auth-profiles.json"), "utf8")) as {
version: number;
profiles: Record<string, { provider?: string; type?: string }>;
};
} catch {
return fallback;
}
}
vi.mock("../auth-profiles.js", () => ({
externalCliDiscoveryForProviderAuth: () => undefined,
externalCliDiscoveryForProviderAuth: (params: { provider: string }) => params,
ensureAuthProfileStore: (agentDir?: string) => {
if (!agentDir) {
return { version: 1, profiles: {} };
}
const pathname = path.join(agentDir, "auth-profiles.json");
try {
return JSON.parse(fsSync.readFileSync(pathname, "utf8")) as {
version?: number;
profiles?: Record<string, { provider?: string }>;
const store = readMockAuthProfileStore(agentDir);
if (process.env.OPENCLAW_TEST_CODEX_CLI_OAUTH === "1") {
store.profiles["openai:default"] = {
provider: "openai",
type: "oauth",
};
} catch {
return { version: 1, profiles: {} };
}
return store;
},
ensureAuthProfileStoreWithoutExternalProfiles: (agentDir?: string) =>
readMockAuthProfileStore(agentDir),
hasAnyAuthProfileStoreSource: (agentDir?: string) => {
if (!agentDir) {
return false;
@@ -186,10 +206,82 @@ vi.mock("../auth-profiles.js", () => ({
listProfilesForProvider: (
store: { profiles?: Record<string, { provider?: string }> },
provider: string,
) => Object.values(store.profiles ?? {}).filter((profile) => profile?.provider === provider),
) =>
Object.entries(store.profiles ?? {})
.filter(([, profile]) => profile?.provider === provider)
.map(([profileId]) => profileId),
resolveAuthProfileOrder: (params: {
cfg?: OpenClawConfig;
store: { profiles?: Record<string, { provider?: string }> };
provider: string;
}) => {
const profiles = Object.entries(params.store.profiles ?? {})
.filter(([, profile]) => profile?.provider === params.provider)
.map(([profileId]) => profileId);
const configured = params.cfg?.auth?.order?.[params.provider];
return configured ? configured.filter((profileId) => profiles.includes(profileId)) : profiles;
},
}));
vi.mock("../auth-profiles/external-cli-sync.js", () => ({
resolveExternalCliAuthProfiles: (
_store: unknown,
options?: { providerIds?: Iterable<string> },
) => {
const providerIds = new Set(
Array.from(options?.providerIds ?? []).map((providerId) => providerId.toLowerCase()),
);
if (
process.env.OPENCLAW_TEST_CODEX_CLI_OAUTH !== "1" ||
(!providerIds.has("openai") && !providerIds.has("codex"))
) {
return [];
}
return [
{
profileId: "openai:default",
credential: {
provider: "openai",
type: "oauth",
access: "oauth-test",
refresh: "refresh-test",
expires: Date.now() + 60_000,
},
},
];
},
}));
vi.mock("../model-auth.js", () => ({
resolveProviderEntryApiKeyProfileReference: (params: {
cfg?: OpenClawConfig;
provider: string;
store: { profiles?: Record<string, { provider?: string; type?: string }> };
}) => {
const apiKey = params.cfg?.models?.providers?.[params.provider]?.apiKey;
if (typeof apiKey !== "string" || !apiKey.trim()) {
return { kind: "none" };
}
const profile = params.store.profiles?.[apiKey.trim()];
if (!profile) {
return { kind: "literal", apiKey: apiKey.trim(), source: "models.json" };
}
return { kind: "profile", profileId: apiKey.trim(), credential: profile };
},
hasRuntimeAvailableProviderAuth: (params: {
provider: string;
cfg?: OpenClawConfig;
modelApi?: string;
}) => {
const providerConfig = params.cfg?.models?.providers?.[params.provider];
if (params.provider === "codex") {
return process.env.OPENCLAW_TEST_CODEX_ROUTE === "1";
}
if (params.provider === "openai" && params.modelApi === "openai-responses") {
return Boolean(process.env.OPENAI_API_KEY || providerConfig?.apiKey);
}
return Boolean(providerConfig?.apiKey);
},
hasUsableCustomProviderApiKey: (cfg?: OpenClawConfig, provider?: string) => {
const providerConfig = cfg?.models?.providers?.[provider ?? ""];
const apiKey = providerConfig?.apiKey;
@@ -261,6 +353,7 @@ async function createOpenClawCodingToolsWithFreshModules(options?: CreateOpenCla
["minimax-cn", "MiniMax-VL-01"],
["minimax-portal", "MiniMax-VL-01"],
["minimax-portal-cn", "MiniMax-VL-01"],
["codex", "gpt-5.5"],
["openai", "gpt-5.4-mini"],
["opencode", "gpt-5-nano"],
["opencode-go", "kimi-k2.6"],
@@ -631,6 +724,7 @@ function installImageUnderstandingProviderDeps(
["minimax-cn", "MiniMax-VL-01"],
["minimax-portal", "MiniMax-VL-01"],
["minimax-portal-cn", "MiniMax-VL-01"],
["codex", "gpt-5.5"],
["openai", "gpt-5.4-mini"],
["opencode", "gpt-5-nano"],
["opencode-go", "kimi-k2.6"],
@@ -827,6 +921,43 @@ function findSchemaUnionKeywords(schema: unknown, pathLocal = "root"): string[]
}
describe("image tool implicit imageModel config", () => {
type Profiles = AuthProfileStore["profiles"];
type ImplicitImageRoutingCase = {
name: string;
cfg: OpenClawConfig;
profiles?: Profiles;
codexRoute?: boolean;
openAiApiKey?: boolean;
expected: ReturnType<typeof resolveImageModelConfigForTool>;
};
const openAiPrimaryCfg = {
agents: { defaults: { model: { primary: "openai/gpt-5.4" } } },
} satisfies OpenClawConfig;
const anthropicPrimaryCfg = {
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } },
} satisfies OpenClawConfig;
const codexImageModel = { primary: "codex/gpt-5.5" };
const openAiDefaultImageModel = { primary: "openai/gpt-5.4-mini" };
const openAiOAuthProfile = (provider = "openai"): AuthProfileCredential => ({
provider,
type: "oauth" as const,
access: "oauth-test",
refresh: "refresh-test",
expires: Date.now() + 60_000,
});
const openAiTokenProfile = (provider = "openai"): AuthProfileCredential => ({
provider,
type: "token" as const,
token: "token-test",
});
const makeAuthStore = (profiles: Profiles): AuthProfileStore => ({ version: 1, profiles });
const writeProfiles = (agentDir: string, profiles: Profiles) =>
writeAuthProfiles(agentDir, makeAuthStore(profiles));
const priorFetch = global.fetch;
registerImageToolEnvReset(priorFetch, [
"OPENAI_API_KEY",
@@ -840,6 +971,8 @@ describe("image tool implicit imageModel config", () => {
"DASHSCOPE_API_KEY",
"ZAI_API_KEY",
"Z_AI_API_KEY",
"OPENCLAW_TEST_CODEX_CLI_OAUTH",
"OPENCLAW_TEST_CODEX_ROUTE",
// Avoid implicit Copilot provider discovery hitting the network in tests.
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
@@ -855,13 +988,200 @@ describe("image tool implicit imageModel config", () => {
testing.setProviderDepsForTest();
});
const implicitImageRoutingCases: ImplicitImageRoutingCase[] = [
{
name: "uses Codex media for implicit OpenAI image defaults on canonical OAuth-only auth",
cfg: openAiPrimaryCfg,
profiles: { "openai:chatgpt": openAiOAuthProfile() },
codexRoute: true,
expected: codexImageModel,
},
{
name: "uses Codex media for implicit OpenAI image defaults on canonical token-only auth",
cfg: openAiPrimaryCfg,
profiles: { "openai:token": openAiTokenProfile() },
codexRoute: true,
expected: codexImageModel,
},
{
name: "uses Codex media for implicit OpenAI image auto candidates on OAuth-only auth",
cfg: anthropicPrimaryCfg,
profiles: { "openai:chatgpt": openAiOAuthProfile() },
codexRoute: true,
expected: codexImageModel,
},
{
name: "drops implicit OpenAI image auto candidates on OAuth-only auth without Codex route",
cfg: anthropicPrimaryCfg,
profiles: { "openai:chatgpt": openAiOAuthProfile() },
expected: null,
},
{
name: "keeps implicit OpenAI image auto candidates when direct OpenAI API key auth exists",
cfg: anthropicPrimaryCfg,
openAiApiKey: true,
expected: openAiDefaultImageModel,
},
{
name: "keeps implicit OpenAI image defaults when direct OpenAI API key auth exists",
cfg: openAiPrimaryCfg,
openAiApiKey: true,
expected: openAiDefaultImageModel,
},
{
name: "does not treat legacy openai-codex profiles as canonical Codex OAuth",
cfg: openAiPrimaryCfg,
profiles: { "openai-codex:default": openAiOAuthProfile("openai-codex") },
codexRoute: true,
expected: null,
},
];
it("stays disabled without auth when no pairing is possible", async () => {
await withTempAgentDir(async (agentDir) => {
expect(resolveImageModelConfigForTool({ cfg: openAiPrimaryCfg, agentDir })).toBeNull();
expect(createImageTool({ config: openAiPrimaryCfg, agentDir })).toBeNull();
});
});
it.each(implicitImageRoutingCases)(
"$name",
async ({ cfg, profiles, codexRoute, openAiApiKey, expected }) => {
if (codexRoute) {
vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1");
}
if (openAiApiKey) {
vi.stubEnv("OPENAI_API_KEY", "openai-test");
}
await withTempAgentDir(async (agentDir) => {
if (profiles) {
await writeProfiles(agentDir, profiles);
}
const actual = resolveImageModelConfigForTool({ cfg, agentDir });
if (expected === null) {
expect(actual).toBeNull();
} else {
expect(actual).toEqual(expected);
}
});
},
);
it("uses Codex media when OAuth-only OpenAI has configured vision model metadata", async () => {
await withTempAgentDir(async (agentDir) => {
await writeProfiles(agentDir, { "openai:chatgpt": openAiOAuthProfile() });
vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1");
const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.4" } } },
...openAiPrimaryCfg,
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [makeModelDefinition("gpt-5.5", ["text", "image"])],
},
},
},
};
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toBeNull();
expect(createImageTool({ config: cfg, agentDir })).toBeNull();
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
primary: codexImageModel.primary,
});
});
});
it("keeps configured OpenAI vision metadata when direct OpenAI API key auth exists", async () => {
vi.stubEnv("OPENAI_API_KEY", "openai-test");
await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = {
...openAiPrimaryCfg,
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [makeModelDefinition("gpt-5.5", ["text", "image"])],
},
},
},
};
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
primary: "openai/gpt-5.5",
fallbacks: [openAiDefaultImageModel.primary],
});
});
});
it("preserves explicit OpenAI image model config without direct auth", async () => {
await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.4" },
imageModel: { primary: "openai/gpt-5.5" },
},
},
};
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
primary: "openai/gpt-5.5",
});
});
});
it("preserves explicit Codex image model config", async () => {
await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.4" },
imageModel: codexImageModel,
},
},
};
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual(codexImageModel);
});
});
it("lets external CLI Codex OAuth survive the candidate auth filter", async () => {
await withTempAgentDir(async (agentDir) => {
vi.stubEnv("OPENCLAW_TEST_CODEX_CLI_OAUTH", "1");
vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1");
expect(resolveImageModelConfigForTool({ cfg: openAiPrimaryCfg, agentDir })).toEqual(
codexImageModel,
);
});
});
it("lets external CLI Codex OAuth survive a supplied scoped auth store", async () => {
await withTempAgentDir(async (agentDir) => {
vi.stubEnv("OPENCLAW_TEST_CODEX_CLI_OAUTH", "1");
vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1");
expect(
resolveImageModelConfigForTool({
cfg: openAiPrimaryCfg,
agentDir,
authStore: makeAuthStore({}),
}),
).toEqual(codexImageModel);
});
});
it("does not re-import persisted OpenAI OAuth when a scoped auth store is supplied", async () => {
await withTempAgentDir(async (agentDir) => {
await writeProfiles(agentDir, { "openai:chatgpt": openAiOAuthProfile() });
vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1");
expect(
resolveImageModelConfigForTool({
cfg: openAiPrimaryCfg,
agentDir,
authStore: makeAuthStore({}),
}),
).toBeNull();
});
});
@@ -930,6 +1250,35 @@ describe("image tool implicit imageModel config", () => {
});
});
it("carries the scoped auth store into image provider execution", async () => {
await withTempAgentDir(async (agentDir) => {
const describeImage = vi.fn(async (params: ImageDescriptionRequest) => ({
text: "ok",
model: params.model,
}));
installImageUnderstandingProviderStubs({
id: "codex",
capabilities: ["image"],
describeImage,
});
const authProfileStore = makeAuthStore({
"openai:scoped": openAiOAuthProfile(),
});
const tool = createRequiredImageTool({
config: { agents: { defaults: { imageModel: { primary: "codex/gpt-5.5" } } } },
agentDir,
authProfileStore,
});
await tool.execute("t1", {
prompt: "Describe this image.",
image: `data:image/png;base64,${ONE_PIXEL_PNG_B64}`,
});
expect(firstImageRequest(describeImage).authStore).toBe(authProfileStore);
});
});
it("pairs minimax primary with MiniMax-VL-01 (and fallbacks) when auth exists", async () => {
await withTempAgentDir(async (agentDir) => {
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
+48 -1
View File
@@ -74,6 +74,7 @@ import {
buildToolModelConfigFromCandidates,
hasToolModelConfig,
resolveDefaultModelRef,
resolveOpenAiImageMediaCandidate,
} from "./model-config.helpers.js";
import {
createSandboxBridgeReadFile,
@@ -246,6 +247,30 @@ export function resolveImageModelConfigForTool(params: {
}
const primary = resolveDefaultModelRef(params.cfg);
let verifiedSubstituteProvider: string | undefined;
const resolveCodexImageModel = () =>
imageToolProviderDeps.resolveDefaultMediaModel({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
providerId: "codex",
capability: "image",
includeConfiguredImageModels: false,
});
const resolveImplicitOpenAiImageCandidate = (openAiModel: string): string | null => {
const decision = resolveOpenAiImageMediaCandidate({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
openAiModel,
codexModel: resolveCodexImageModel(),
});
if (decision.kind === "substitute") {
verifiedSubstituteProvider = decision.provider;
return decision.ref;
}
return decision.kind === "keep" ? decision.ref : null;
};
const providerVisionFromConfig = resolveProviderVisionModelFromConfig({
cfg: params.cfg,
@@ -253,6 +278,13 @@ export function resolveImageModelConfigForTool(params: {
});
const primaryCandidates = (() => {
if (providerVisionFromConfig) {
if (primary.provider === "openai") {
return [
resolveImplicitOpenAiImageCandidate(
providerVisionFromConfig.slice(providerVisionFromConfig.indexOf("/") + 1),
),
];
}
return [providerVisionFromConfig];
}
const providerDefault = imageToolProviderDeps.resolveDefaultMediaModel({
@@ -263,6 +295,9 @@ export function resolveImageModelConfigForTool(params: {
includeConfiguredImageModels: !isMinimaxVlmProvider(primary.provider),
});
if (providerDefault) {
if (primary.provider === "openai") {
return [resolveImplicitOpenAiImageCandidate(providerDefault)];
}
return [`${primary.provider}/${providerDefault}`];
}
if (isMinimaxVlmProvider(primary.provider)) {
@@ -285,7 +320,12 @@ export function resolveImageModelConfigForTool(params: {
capability: "image",
includeConfiguredImageModels: !isMinimaxVlmProvider(providerId),
});
return modelId ? `${providerId}/${modelId}` : null;
if (!modelId) {
return null;
}
return providerId === "openai"
? resolveImplicitOpenAiImageCandidate(modelId)
: `${providerId}/${modelId}`;
});
const autoCandidates = rawAutoCandidates.filter(
(candidate) =>
@@ -312,6 +352,8 @@ export function resolveImageModelConfigForTool(params: {
agentDir: params.agentDir,
authStore: params.authStore,
candidates: [...primaryAliasCandidates, ...primaryCandidates, ...remainingAutoCandidates],
isProviderConfigured: (provider) =>
verifiedSubstituteProvider && provider === verifiedSubstituteProvider ? true : undefined,
});
}
@@ -594,6 +636,7 @@ type ImageSandboxConfig = {
async function runImagePrompt(params: {
cfg?: OpenClawConfig;
agentDir: string;
authStore?: AuthProfileStore;
imageModelConfig: ImageModelConfig;
modelOverride?: string;
prompt: string;
@@ -642,6 +685,7 @@ async function runImagePrompt(params: {
timeoutMs,
cfg: providerCfg,
agentDir: params.agentDir,
authStore: params.authStore,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
return { text: described.text, provider, model: described.model ?? modelId };
@@ -661,6 +705,7 @@ async function runImagePrompt(params: {
timeoutMs,
cfg: providerCfg,
agentDir: params.agentDir,
authStore: params.authStore,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
return { text: described.text, provider, model: described.model ?? modelId };
@@ -679,6 +724,7 @@ async function runImagePrompt(params: {
timeoutMs,
cfg: providerCfg,
agentDir: params.agentDir,
authStore: params.authStore,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
parts.push(`Image ${index + 1}:\n${described.text.trim()}`);
@@ -1010,6 +1056,7 @@ export function createImageTool(options?: {
const result = await runImagePrompt({
cfg: options?.config,
agentDir,
authStore: options?.authProfileStore,
imageModelConfig,
modelOverride,
prompt: promptRaw,
+195 -18
View File
@@ -2,13 +2,92 @@
// stored agent auth profiles for reusable media tools.
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { hasProviderAuthForTool } from "./model-config.helpers.js";
import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
import {
hasDirectProviderApiKeyAuthForTool,
hasProviderAuthForTool,
resolveOpenAiImageMediaCandidate,
} from "./model-config.helpers.js";
describe("hasProviderAuthForTool", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.mock("../auth-profiles/external-cli-sync.js", () => ({
resolveExternalCliAuthProfiles: () => [],
}));
const AGENT_DIR = "/tmp/openclaw-model-config-helper";
const MODEL = "gpt-5.5";
type Decision = ReturnType<typeof resolveOpenAiImageMediaCandidate>;
type Profiles = AuthProfileStore["profiles"];
const codexSubstitute = {
kind: "substitute",
provider: "codex",
ref: `codex/${MODEL}`,
} satisfies Decision;
const openAiKeep = { kind: "keep", ref: `openai/${MODEL}` } satisfies Decision;
const drop = { kind: "drop" } satisfies Decision;
const openAiRefCfg: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "openai:default",
models: [],
},
},
},
};
const store = (profiles: Profiles): AuthProfileStore => ({ version: 1, profiles });
const oauth = (provider: string): AuthProfileCredential => ({
provider,
type: "oauth",
access: "oauth-test",
refresh: "refresh-test",
expires: Date.now() + 60_000,
});
const token = (provider: string): AuthProfileCredential => ({
provider,
type: "token",
token: "token-test",
});
const apiKey = (provider: string, key = "direct-openai-key"): AuthProfileCredential => ({
provider,
type: "api_key",
key,
});
const resolveMedia = (
overrides: Partial<Parameters<typeof resolveOpenAiImageMediaCandidate>[0]> = {},
) =>
resolveOpenAiImageMediaCandidate({
agentDir: AGENT_DIR,
authStore: store({}),
openAiModel: MODEL,
codexModel: MODEL,
...overrides,
});
const hasDirectOpenAiKey = (
overrides: Partial<Parameters<typeof hasDirectProviderApiKeyAuthForTool>[0]> = {},
) =>
hasDirectProviderApiKeyAuthForTool({
provider: "openai",
agentDir: AGENT_DIR,
authStore: store({}),
modelApi: "openai-responses",
...overrides,
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("hasProviderAuthForTool", () => {
it("accepts config-backed custom provider auth", () => {
const cfg = {
models: {
@@ -28,24 +107,122 @@ describe("hasProviderAuthForTool", () => {
it("keeps auth-store profiles as valid tool auth", () => {
// Tool-specific model selection should honor the same stored profile shape
// used by agent sessions, not only process env/config keys.
expect(
hasProviderAuthForTool({
const authStore = store({
"hatchery:default": {
provider: "hatchery",
authStore: {
version: 1,
profiles: {
"hatchery:default": {
provider: "hatchery",
type: "api_key",
key: "sk-profile", // pragma: allowlist secret
},
},
},
}),
).toBe(true);
type: "api_key",
key: "sk-profile", // pragma: allowlist secret
},
});
expect(hasProviderAuthForTool({ provider: "hatchery", authStore })).toBe(true);
});
it("rejects providers without config, env, or profile auth", () => {
expect(hasProviderAuthForTool({ provider: "unconfigured-provider" })).toBe(false);
});
});
describe("resolveOpenAiImageMediaCandidate", () => {
const cases: Array<[string, AuthProfileStore, Decision]> = [
[
"canonical OpenAI OAuth-only media auth",
store({ "openai:chatgpt": oauth("openai") }),
codexSubstitute,
],
[
"canonical OpenAI token-only media auth",
store({ "openai:token": token("openai") }),
codexSubstitute,
],
[
"legacy openai-codex OAuth profiles",
store({ "openai-codex:default": oauth("openai-codex") }),
drop,
],
[
"legacy openai-codex token profiles",
store({ "openai-codex:token": token("openai-codex") }),
drop,
],
["no direct auth or verified Codex route", store({}), drop],
];
it.each(cases)("resolves %s", (_label, authStore, expected) => {
expect(resolveMedia({ authStore })).toEqual(expected);
});
it("keeps OpenAI media when a direct API key profile exists", () => {
const authStore = store({ "openai:api-key": apiKey("openai") });
expect(hasDirectOpenAiKey({ authStore })).toBe(true);
expect(resolveMedia({ authStore })).toEqual(openAiKeep);
});
it("uses Codex when an ineligible direct API key profile is stale", () => {
const authStore = store({
"openai:api-key": { provider: "openai", type: "api_key" },
"openai:chatgpt": oauth("openai"),
});
expect(hasDirectOpenAiKey({ authStore })).toBe(false);
expect(resolveMedia({ authStore })).toEqual(codexSubstitute);
});
it("honors auth order when choosing between direct OpenAI and Codex media", () => {
const cfg: OpenClawConfig = {
auth: {
order: {
openai: ["openai:chatgpt"],
},
},
};
const authStore = store({
"openai:api-key": apiKey("openai"),
"openai:chatgpt": oauth("openai"),
});
expect(hasDirectOpenAiKey({ cfg, authStore })).toBe(false);
expect(resolveMedia({ cfg, authStore })).toEqual(codexSubstitute);
});
it("drops Codex media when auth order excludes subscription-style auth", () => {
const cfg: OpenClawConfig = {
auth: {
order: {
openai: ["openai:api-key"],
},
},
};
const authStore = store({
"openai:api-key": { provider: "openai", type: "api_key" },
"openai:chatgpt": oauth("openai"),
});
expect(resolveMedia({ cfg, authStore })).toEqual(drop);
});
it("does not treat provider apiKey OAuth profile references as direct OpenAI media auth", () => {
const authStore = store({ "openai:default": oauth("openai") });
expect(hasDirectOpenAiKey({ cfg: openAiRefCfg, authStore })).toBe(false);
expect(resolveMedia({ cfg: openAiRefCfg, authStore })).toEqual(codexSubstitute);
});
it("treats provider apiKey API-key profile references as direct OpenAI media auth", () => {
const authStore = store({ "openai:default": apiKey("openai") });
expect(hasDirectOpenAiKey({ cfg: openAiRefCfg, authStore })).toBe(true);
expect(resolveMedia({ cfg: openAiRefCfg, authStore })).toEqual(openAiKeep);
});
it("does not treat unresolved provider apiKey profile references as direct auth", () => {
const authStore = store({
"openai:default": { provider: "openai", type: "api_key" },
"openai:chatgpt": oauth("openai"),
});
expect(hasDirectOpenAiKey({ cfg: openAiRefCfg, authStore })).toBe(false);
expect(resolveMedia({ cfg: openAiRefCfg, authStore })).toEqual(codexSubstitute);
});
});
+255 -2
View File
@@ -17,14 +17,32 @@ import {
ensureAuthProfileStoreWithoutExternalProfiles,
hasAnyAuthProfileStoreSource,
listProfilesForProvider,
resolveAuthProfileOrder,
} from "../auth-profiles.js";
import { evaluateStoredCredentialEligibility } from "../auth-profiles/credential-state.js";
import { resolveExternalCliAuthProfiles } from "../auth-profiles/external-cli-sync.js";
import { overlayRuntimeExternalOAuthProfiles } from "../auth-profiles/oauth-shared.js";
import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../model-auth.js";
import {
hasRuntimeAvailableProviderAuth,
hasUsableCustomProviderApiKey,
resolveProviderEntryApiKeyProfileReference,
resolveEnvApiKey,
} from "../model-auth.js";
import { resolveConfiguredModelRef } from "../model-selection.js";
export type ToolModelConfig = { primary?: string; fallbacks?: string[]; timeoutMs?: number };
const OPENAI_PROVIDER_ID = "openai";
const CODEX_MEDIA_PROVIDER_ID = "codex";
const OPENAI_RESPONSES_MODEL_API = "openai-responses";
export type OpenAiImageMediaCandidateDecision =
| { kind: "keep"; ref: string }
| { kind: "substitute"; ref: string; provider: string }
| { kind: "drop" };
/** Returns whether a tool model config contains a primary or fallback model ref. */
export function hasToolModelConfig(model: ToolModelConfig | undefined): boolean {
return Boolean(
@@ -111,6 +129,241 @@ export function hasProviderAuthForTool(params: {
return hasUsableCustomProviderApiKey(params.cfg, params.provider);
}
function formatProviderModelRef(provider: string, model: string): string {
return `${provider}/${model}`;
}
function loadAuthStoreForProvider(params: {
provider: string;
cfg?: OpenClawConfig;
agentDir?: string;
authStore?: AuthProfileStore;
includeExternalCli?: boolean;
}): AuthProfileStore | undefined {
if (params.authStore) {
return params.authStore;
}
const agentDir = params.agentDir?.trim();
if (!agentDir) {
return undefined;
}
return params.includeExternalCli
? ensureAuthProfileStore(agentDir, {
externalCli: externalCliDiscoveryForProviderAuth({
provider: params.provider,
cfg: params.cfg,
}),
})
: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
});
}
function overlayExternalCliAuthStoreForProvider(params: {
provider: string;
authStore: AuthProfileStore;
}): AuthProfileStore {
const profiles = resolveExternalCliAuthProfiles(params.authStore, {
allowKeychainPrompt: false,
providerIds: [params.provider],
});
if (profiles.length === 0) {
return params.authStore;
}
return overlayRuntimeExternalOAuthProfiles(params.authStore, profiles);
}
function hasAuthProfileTypeInStore(params: {
provider: string;
cfg?: OpenClawConfig;
store: AuthProfileStore;
type: AuthProfileCredential["type"] | readonly AuthProfileCredential["type"][];
}): boolean {
const types = Array.isArray(params.type) ? params.type : [params.type];
return resolveAuthProfileOrder({
cfg: params.cfg,
store: params.store,
provider: params.provider,
}).some((profileId) => types.includes(params.store.profiles[profileId]?.type));
}
function hasAuthProfileTypeForProvider(params: {
provider: string;
cfg?: OpenClawConfig;
agentDir?: string;
authStore?: AuthProfileStore;
includeExternalCli?: boolean;
type: AuthProfileCredential["type"] | readonly AuthProfileCredential["type"][];
}): boolean {
const store = loadAuthStoreForProvider(params);
if (store && hasAuthProfileTypeInStore({ ...params, store })) {
return true;
}
// Codex-harness tool construction can pass a scoped store with external CLI
// profiles stripped. Keep that store authoritative, but still honor explicit
// includeExternalCli lookups so Codex OAuth-only image routing remains visible.
if (params.includeExternalCli && params.authStore) {
const externalStore = overlayExternalCliAuthStoreForProvider({
provider: params.provider,
authStore: params.authStore,
});
return hasAuthProfileTypeInStore({ ...params, store: externalStore });
}
return false;
}
/** Returns whether a provider has direct API-key-capable auth for model-backed tools. */
export function hasDirectProviderApiKeyAuthForTool(params: {
provider: string;
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
authStore?: AuthProfileStore;
modelApi?: string;
}): boolean {
const providerEntryProfileAuth = resolveDirectProviderEntryAuthFromProfileReference(params);
if (providerEntryProfileAuth !== undefined) {
return providerEntryProfileAuth;
}
if (
hasRuntimeAvailableProviderAuth({
provider: params.provider,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
modelApi: params.modelApi,
allowPluginSyntheticAuth: false,
})
) {
return true;
}
return hasAuthProfileTypeForProvider({
provider: params.provider,
cfg: params.cfg,
agentDir: params.agentDir,
authStore: params.authStore,
type: "api_key",
});
}
function hasCanonicalOpenAiCodexAuthSignal(params: {
cfg?: OpenClawConfig;
agentDir?: string;
authStore?: AuthProfileStore;
}): boolean {
return hasAuthProfileTypeForProvider({
provider: OPENAI_PROVIDER_ID,
cfg: params.cfg,
agentDir: params.agentDir,
authStore: params.authStore,
includeExternalCli: true,
type: ["oauth", "token"],
});
}
function resolveDirectProviderEntryAuthFromProfileReference(params: {
provider: string;
cfg?: OpenClawConfig;
agentDir?: string;
authStore?: AuthProfileStore;
}): boolean | undefined {
const resolveFromStore = (store: AuthProfileStore): boolean | undefined => {
const reference = resolveProviderEntryApiKeyProfileReference({
cfg: params.cfg,
provider: params.provider,
store,
});
if (reference.kind === "profile") {
return (
reference.credential.type === "api_key" &&
evaluateStoredCredentialEligibility({ credential: reference.credential }).eligible
);
}
if (reference.kind === "profile-incompatible") {
return false;
}
return undefined;
};
const store = loadAuthStoreForProvider({
provider: params.provider,
cfg: params.cfg,
agentDir: params.agentDir,
authStore: params.authStore,
includeExternalCli: true,
});
const storeResult = store ? resolveFromStore(store) : undefined;
if (storeResult !== undefined) {
return storeResult;
}
if (params.authStore) {
const externalStore = overlayExternalCliAuthStoreForProvider({
provider: params.provider,
authStore: params.authStore,
});
return resolveFromStore(externalStore);
}
return undefined;
}
function hasCodexSyntheticMediaRoute(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
}): boolean {
return hasRuntimeAvailableProviderAuth({
provider: CODEX_MEDIA_PROVIDER_ID,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
});
}
/** Resolves the implicit OpenAI image slot without letting OAuth-only auth pick direct OpenAI. */
export function resolveOpenAiImageMediaCandidate(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
agentDir: string;
authStore?: AuthProfileStore;
openAiModel: string;
codexModel?: string;
}): OpenAiImageMediaCandidateDecision {
const openAiModel = params.openAiModel.trim();
if (!openAiModel) {
return { kind: "drop" };
}
if (
hasDirectProviderApiKeyAuthForTool({
provider: OPENAI_PROVIDER_ID,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
authStore: params.authStore,
modelApi: OPENAI_RESPONSES_MODEL_API,
})
) {
return {
kind: "keep",
ref: formatProviderModelRef(OPENAI_PROVIDER_ID, openAiModel),
};
}
const codexModel = params.codexModel?.trim();
// Codex's bundled synthetic marker only proves the app-server route exists.
// Require canonical OpenAI subscription-style auth too so fresh installs do
// not route to Codex media just because the bundled plugin is present.
if (
codexModel &&
hasCanonicalOpenAiCodexAuthSignal(params) &&
hasCodexSyntheticMediaRoute(params)
) {
return {
kind: "substitute",
provider: CODEX_MEDIA_PROVIDER_ID,
ref: formatProviderModelRef(CODEX_MEDIA_PROVIDER_ID, codexModel),
};
}
return { kind: "drop" };
}
/** Normalizes agent tool model config into a compact runtime shape. */
export function coerceToolModelConfig(model?: AgentToolModelConfig): ToolModelConfig {
const primary = resolveAgentModelPrimaryValue(model);
@@ -131,7 +384,7 @@ export function buildToolModelConfigFromCandidates(params: {
agentDir?: string;
authStore?: AuthProfileStore;
candidates: Array<string | null | undefined>;
isProviderConfigured?: (provider: string) => boolean;
isProviderConfigured?: (provider: string) => boolean | undefined;
}): ToolModelConfig | null {
if (hasToolModelConfig(params.explicit)) {
return params.explicit;