mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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 }));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user