mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-25 23:47:20 +00:00
fix: stabilize codex auth ownership and ws fallback cache
This commit is contained in:
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289)
|
||||
- ACP/agents: inherit the target agent workspace for cross-agent ACP spawns and fall back safely when the inherited workspace no longer exists. (#58438) Thanks @zssggle-rgb.
|
||||
- ACPX/Windows: preserve backslashes and absolute `.exe` paths in Claude CLI parsing, and fail fast on wrapper-script targets with guidance to use `cmd.exe /c`, `powershell.exe -File`, or `node <script>`. (#60689)
|
||||
- Providers/OpenAI Codex: treat Codex CLI auth as the canonical source, stop persisting copied Codex OAuth secrets into `auth-profiles.json`, refresh expired Codex-managed tokens back into Codex storage, and keep OpenAI WebSocket fallback/cache paths stable across transport changes.
|
||||
- Gateway/Windows scheduled tasks: preserve Task Scheduler settings on reinstall, fail loudly when `/Run` does not start, and report fast failed restarts accurately instead of pretending they timed out after 60 seconds. (#59335) Thanks @tmimmanuel.
|
||||
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345)
|
||||
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069)
|
||||
|
||||
@@ -5,31 +5,49 @@ export default definePluginEntry({
|
||||
name: "OpenAI Provider",
|
||||
description: "Bundled OpenAI provider plugins",
|
||||
async register(api) {
|
||||
const { buildOpenAICodexCliBackend } = await import("./cli-backend.js");
|
||||
const { buildOpenAICodexProviderPlugin } = await import("./openai-codex-provider.js");
|
||||
const { buildOpenAIProvider } = await import("./openai-provider.js");
|
||||
const {
|
||||
buildOpenAICodexCliBackend,
|
||||
buildOpenAICodexProviderPlugin,
|
||||
buildOpenAIImageGenerationProvider,
|
||||
buildOpenAIProvider,
|
||||
buildOpenAIRealtimeTranscriptionProvider,
|
||||
buildOpenAIRealtimeVoiceProvider,
|
||||
buildOpenAISpeechProvider,
|
||||
OPENAI_FRIENDLY_PROMPT_OVERLAY,
|
||||
openaiCodexMediaUnderstandingProvider,
|
||||
openaiMediaUnderstandingProvider,
|
||||
resolveOpenAIPromptOverlayMode,
|
||||
shouldApplyOpenAIPromptOverlay,
|
||||
} = await import("./register.runtime.js");
|
||||
} = await import("./prompt-overlay.js");
|
||||
const registerOptional = async (registerFn: () => Promise<void>) => {
|
||||
try {
|
||||
await registerFn();
|
||||
} catch {
|
||||
// Optional OpenAI surfaces must not block core provider registration.
|
||||
}
|
||||
};
|
||||
|
||||
const promptOverlayMode = resolveOpenAIPromptOverlayMode(api.pluginConfig);
|
||||
api.registerCliBackend(buildOpenAICodexCliBackend());
|
||||
api.registerProvider(buildOpenAIProvider());
|
||||
api.registerProvider(buildOpenAICodexProviderPlugin());
|
||||
api.registerSpeechProvider(buildOpenAISpeechProvider());
|
||||
api.registerRealtimeTranscriptionProvider(buildOpenAIRealtimeTranscriptionProvider());
|
||||
api.registerRealtimeVoiceProvider(buildOpenAIRealtimeVoiceProvider());
|
||||
api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider);
|
||||
api.registerMediaUnderstandingProvider(openaiCodexMediaUnderstandingProvider);
|
||||
api.registerImageGenerationProvider(buildOpenAIImageGenerationProvider());
|
||||
await registerOptional(async () => {
|
||||
const { buildOpenAIImageGenerationProvider } = await import("./image-generation-provider.js");
|
||||
api.registerImageGenerationProvider(buildOpenAIImageGenerationProvider());
|
||||
});
|
||||
await registerOptional(async () => {
|
||||
const { buildOpenAIRealtimeTranscriptionProvider } =
|
||||
await import("./realtime-transcription-provider.js");
|
||||
api.registerRealtimeTranscriptionProvider(buildOpenAIRealtimeTranscriptionProvider());
|
||||
});
|
||||
await registerOptional(async () => {
|
||||
const { buildOpenAIRealtimeVoiceProvider } = await import("./realtime-voice-provider.js");
|
||||
api.registerRealtimeVoiceProvider(buildOpenAIRealtimeVoiceProvider());
|
||||
});
|
||||
await registerOptional(async () => {
|
||||
const { buildOpenAISpeechProvider } = await import("./speech-provider.js");
|
||||
api.registerSpeechProvider(buildOpenAISpeechProvider());
|
||||
});
|
||||
await registerOptional(async () => {
|
||||
const { openaiMediaUnderstandingProvider, openaiCodexMediaUnderstandingProvider } =
|
||||
await import("./media-understanding-provider.js");
|
||||
api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider);
|
||||
api.registerMediaUnderstandingProvider(openaiCodexMediaUnderstandingProvider);
|
||||
});
|
||||
if (promptOverlayMode !== "off") {
|
||||
api.on("before_prompt_build", (_event, ctx) =>
|
||||
shouldApplyOpenAIPromptOverlay({
|
||||
|
||||
@@ -341,6 +341,70 @@ describe("ensureAuthProfileStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes Codex CLI auth without persisting copied tokens into auth-profiles.json", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-external-sync-"));
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
try {
|
||||
const agentDir = path.join(root, "agent");
|
||||
const codexHome = path.join(root, "codex-home");
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, "auth.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: "codex-access-token",
|
||||
refresh_token: "codex-refresh-token",
|
||||
account_id: "acct_123",
|
||||
},
|
||||
last_refresh: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(store.profiles["openai-codex:default"]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "codex-access-token",
|
||||
refresh: "codex-refresh-token",
|
||||
managedBy: "codex-cli",
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false);
|
||||
} finally {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("logs one warning with aggregated reasons for rejected auth-profiles entries", () => {
|
||||
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined);
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,10 @@ const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({
|
||||
readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
}));
|
||||
|
||||
const { writeCodexCliCredentialsMock } = vi.hoisted(() => ({
|
||||
writeCodexCliCredentialsMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
const {
|
||||
refreshProviderOAuthCredentialWithPluginMock,
|
||||
formatProviderAuthProfileApiKeyWithPluginMock,
|
||||
@@ -36,6 +40,7 @@ const {
|
||||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
|
||||
writeCodexCliCredentials: writeCodexCliCredentialsMock,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
}));
|
||||
@@ -104,6 +109,8 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
|
||||
getOAuthApiKeyMock.mockClear();
|
||||
readCodexCliCredentialsCachedMock.mockReset();
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(null);
|
||||
writeCodexCliCredentialsMock.mockReset();
|
||||
writeCodexCliCredentialsMock.mockReturnValue(true);
|
||||
refreshProviderOAuthCredentialWithPluginMock.mockReset();
|
||||
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined);
|
||||
formatProviderAuthProfileApiKeyWithPluginMock.mockReset();
|
||||
@@ -234,16 +241,69 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
|
||||
email: undefined,
|
||||
});
|
||||
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
|
||||
expect(writeCodexCliCredentialsMock).not.toHaveBeenCalled();
|
||||
|
||||
const persisted = await readPersistedStore(agentDir);
|
||||
expect(persisted.profiles[profileId]).toMatchObject({
|
||||
expect(persisted.profiles[profileId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refreshes expired Codex-managed credentials and writes them back to Codex storage", async () => {
|
||||
const profileId = "openai-codex:default";
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "expired-access-token",
|
||||
refresh: "expired-refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
managedBy: "codex-cli",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
readCodexCliCredentialsCachedMock.mockReturnValueOnce({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "fresh-cli-access-token",
|
||||
refresh: "fresh-cli-refresh-token",
|
||||
access: "still-expired-cli-access-token",
|
||||
refresh: "still-expired-cli-refresh-token",
|
||||
expires: Date.now() - 30_000,
|
||||
accountId: "acct-cli",
|
||||
managedBy: "codex-cli",
|
||||
});
|
||||
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValueOnce({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "rotated-cli-access-token",
|
||||
refresh: "rotated-cli-refresh-token",
|
||||
expires: Date.now() + 86_400_000,
|
||||
accountId: "acct-rotated",
|
||||
});
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
store: ensureAuthProfileStore(agentDir),
|
||||
profileId,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
apiKey: "rotated-cli-access-token",
|
||||
provider: "openai-codex",
|
||||
email: undefined,
|
||||
});
|
||||
expect(writeCodexCliCredentialsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
access: "rotated-cli-access-token",
|
||||
refresh: "rotated-cli-refresh-token",
|
||||
managedBy: "codex-cli",
|
||||
}),
|
||||
);
|
||||
|
||||
const persisted = await readPersistedStore(agentDir);
|
||||
expect(persisted.profiles[profileId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps throwing for non-codex providers on the same refresh error", async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../../plugins/provider-runtime.runtime.js";
|
||||
import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js";
|
||||
import { refreshChutesTokens } from "../chutes-oauth.js";
|
||||
import { writeCodexCliCredentials } from "../cli-credentials.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
||||
import { resolveTokenExpiryState } from "./credential-state.js";
|
||||
import { formatAuthDoctorHint } from "./doctor.js";
|
||||
@@ -199,6 +200,31 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
newCredentials: externallyManaged,
|
||||
};
|
||||
}
|
||||
if (externallyManaged.managedBy === "codex-cli") {
|
||||
const pluginRefreshed = await refreshProviderOAuthCredentialWithPlugin({
|
||||
provider: externallyManaged.provider,
|
||||
context: externallyManaged,
|
||||
});
|
||||
if (pluginRefreshed) {
|
||||
const refreshedCredentials: OAuthCredential = {
|
||||
...externallyManaged,
|
||||
...pluginRefreshed,
|
||||
type: "oauth",
|
||||
managedBy: "codex-cli",
|
||||
};
|
||||
if (!writeCodexCliCredentials(refreshedCredentials)) {
|
||||
log.warn("failed to persist refreshed codex credentials back to Codex storage", {
|
||||
profileId: params.profileId,
|
||||
});
|
||||
}
|
||||
store.profiles[params.profileId] = refreshedCredentials;
|
||||
saveAuthProfileStore(store, params.agentDir);
|
||||
return {
|
||||
apiKey: await buildOAuthApiKey(refreshedCredentials.provider, refreshedCredentials),
|
||||
newCredentials: refreshedCredentials,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${externallyManaged.managedBy} credential is expired; refresh it in the external CLI and retry.`,
|
||||
);
|
||||
|
||||
@@ -353,6 +353,35 @@ function mergeAuthProfileStores(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPersistedAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
|
||||
const profiles = Object.fromEntries(
|
||||
Object.entries(store.profiles).flatMap(([profileId, credential]) => {
|
||||
if (credential.type === "oauth" && credential.managedBy) {
|
||||
return [];
|
||||
}
|
||||
if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) {
|
||||
const sanitized = { ...credential } as Record<string, unknown>;
|
||||
delete sanitized.key;
|
||||
return [[profileId, sanitized]];
|
||||
}
|
||||
if (credential.type === "token" && credential.tokenRef && credential.token !== undefined) {
|
||||
const sanitized = { ...credential } as Record<string, unknown>;
|
||||
delete sanitized.token;
|
||||
return [[profileId, sanitized]];
|
||||
}
|
||||
return [[profileId, credential]];
|
||||
}),
|
||||
) as AuthProfileStore["profiles"];
|
||||
|
||||
return {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles,
|
||||
order: store.order ?? undefined,
|
||||
lastGood: store.lastGood ?? undefined,
|
||||
usageStats: store.usageStats ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
@@ -444,10 +473,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
const asStore = loadCoercedStore(authPath);
|
||||
if (asStore) {
|
||||
// Sync from external CLI tools on every load.
|
||||
const synced = syncExternalCliCredentialsTimed(asStore);
|
||||
if (synced) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
}
|
||||
syncExternalCliCredentialsTimed(asStore);
|
||||
return asStore;
|
||||
}
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
||||
@@ -483,10 +509,7 @@ function loadAuthProfileStoreForAgent(
|
||||
if (asStore) {
|
||||
// Runtime secret activation must remain read-only:
|
||||
// sync external CLI credentials in-memory, but never persist while readOnly.
|
||||
const synced = syncExternalCliCredentialsTimed(asStore, { log: !readOnly });
|
||||
if (synced && !readOnly) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
}
|
||||
syncExternalCliCredentialsTimed(asStore, { log: !readOnly });
|
||||
if (!readOnly) {
|
||||
writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), asStore);
|
||||
}
|
||||
@@ -519,11 +542,11 @@ function loadAuthProfileStoreForAgent(
|
||||
|
||||
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
||||
// Keep external CLI credentials visible in runtime even during read-only loads.
|
||||
const syncedCli = syncExternalCliCredentialsTimed(store, { log: !readOnly });
|
||||
syncExternalCliCredentialsTimed(store, { log: !readOnly });
|
||||
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
|
||||
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
|
||||
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth);
|
||||
if (shouldWrite) {
|
||||
saveJsonFile(authPath, store);
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
|
||||
@@ -593,31 +616,12 @@ export function ensureAuthProfileStore(
|
||||
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const runtimeKey = resolveRuntimeStoreKey(agentDir);
|
||||
const profiles = Object.fromEntries(
|
||||
Object.entries(store.profiles).map(([profileId, credential]) => {
|
||||
if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) {
|
||||
const sanitized = { ...credential } as Record<string, unknown>;
|
||||
delete sanitized.key;
|
||||
return [profileId, sanitized];
|
||||
}
|
||||
if (credential.type === "token" && credential.tokenRef && credential.token !== undefined) {
|
||||
const sanitized = { ...credential } as Record<string, unknown>;
|
||||
delete sanitized.token;
|
||||
return [profileId, sanitized];
|
||||
}
|
||||
return [profileId, credential];
|
||||
}),
|
||||
) as AuthProfileStore["profiles"];
|
||||
const payload = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles,
|
||||
order: store.order ?? undefined,
|
||||
lastGood: store.lastGood ?? undefined,
|
||||
usageStats: store.usageStats ?? undefined,
|
||||
} satisfies AuthProfileStore;
|
||||
const payload = buildPersistedAuthProfileStore(store);
|
||||
saveJsonFile(authPath, payload);
|
||||
writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), payload);
|
||||
const runtimeStore = cloneAuthProfileStore(store);
|
||||
syncExternalCliCredentialsTimed(runtimeStore, { log: false });
|
||||
writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), runtimeStore);
|
||||
if (runtimeAuthStoreSnapshots.has(runtimeKey)) {
|
||||
runtimeAuthStoreSnapshots.set(runtimeKey, cloneAuthProfileStore(payload));
|
||||
runtimeAuthStoreSnapshots.set(runtimeKey, cloneAuthProfileStore(runtimeStore));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export type OAuthCredential = OAuthCredentials & {
|
||||
displayName?: string;
|
||||
/**
|
||||
* When set, another CLI owns refresh-token rotation for this credential.
|
||||
* OpenClaw may re-read that external source, but must not refresh on its own.
|
||||
* OpenClaw should prefer that external source as canonical storage and avoid
|
||||
* persisting copied secrets into auth-profiles.json.
|
||||
*/
|
||||
managedBy?: ExternalOAuthManager;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").reset
|
||||
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
|
||||
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
|
||||
let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials;
|
||||
let writeCodexCliCredentials: typeof import("./cli-credentials.js").writeCodexCliCredentials;
|
||||
let writeCodexCliFileCredentials: typeof import("./cli-credentials.js").writeCodexCliFileCredentials;
|
||||
|
||||
function mockExistingClaudeKeychainItem() {
|
||||
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
|
||||
@@ -74,6 +76,8 @@ describe("cli credentials", () => {
|
||||
writeClaudeCliKeychainCredentials,
|
||||
writeClaudeCliCredentials,
|
||||
readCodexCliCredentials,
|
||||
writeCodexCliCredentials,
|
||||
writeCodexCliFileCredentials,
|
||||
} = await import("./cli-credentials.js"));
|
||||
});
|
||||
|
||||
@@ -358,4 +362,112 @@ describe("cli credentials", () => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates existing Codex auth.json in place", () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-write-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
fs.mkdirSync(tempHome, { recursive: true, mode: 0o700 });
|
||||
const authPath = path.join(tempHome, "auth.json");
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: "sk-existing",
|
||||
tokens: {
|
||||
id_token: "id-token",
|
||||
access_token: "old-access",
|
||||
refresh_token: "old-refresh",
|
||||
account_id: "acct-old",
|
||||
},
|
||||
last_refresh: "2026-03-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ok = writeCodexCliFileCredentials({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
const persisted = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record<string, unknown>;
|
||||
expect(persisted).toMatchObject({
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: "sk-existing",
|
||||
});
|
||||
expect(persisted.tokens).toMatchObject({
|
||||
id_token: "id-token",
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
account_id: "acct-new",
|
||||
});
|
||||
expect(typeof persisted.last_refresh).toBe("string");
|
||||
} finally {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the existing Codex keychain entry over auth.json on darwin writes", () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-keychain-write-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const expSeconds = Math.floor(Date.parse("2026-03-26T12:34:56Z") / 1000);
|
||||
execSyncMock.mockImplementation((command: unknown) => {
|
||||
const cmd = String(command);
|
||||
expect(cmd).toContain("Codex Auth");
|
||||
return JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
id_token: "id-token",
|
||||
access_token: createJwtWithExp(expSeconds),
|
||||
refresh_token: "old-refresh",
|
||||
account_id: "acct-old",
|
||||
},
|
||||
last_refresh: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
const ok = writeCodexCliCredentials(
|
||||
{
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "acct-new",
|
||||
},
|
||||
{
|
||||
platform: "darwin",
|
||||
execSync: execSyncMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(execFileSyncMock).toHaveBeenCalledTimes(1);
|
||||
const addCall = getAddGenericPasswordCall();
|
||||
expect(addCall?.[0]).toBe("security");
|
||||
const payload = (() => {
|
||||
const args = (addCall?.[1] as string[] | undefined) ?? [];
|
||||
const valueIndex = args.indexOf("-w");
|
||||
return valueIndex >= 0 ? args[valueIndex + 1] : undefined;
|
||||
})();
|
||||
expect(payload).toBeDefined();
|
||||
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
|
||||
expect(parsed.tokens).toMatchObject({
|
||||
id_token: "id-token",
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
account_id: "acct-new",
|
||||
});
|
||||
expect(parsed.auth_mode).toBe("chatgpt");
|
||||
} finally {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,26 @@ type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
|
||||
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
|
||||
};
|
||||
|
||||
type CodexCliFileOptions = {
|
||||
codexHome?: string;
|
||||
};
|
||||
|
||||
type CodexCliWriteOptions = CodexCliFileOptions & {
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
execFileSync?: ExecFileSyncFn;
|
||||
writeKeychain?: (
|
||||
credentials: OAuthCredentials,
|
||||
options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
execFileSync?: ExecFileSyncFn;
|
||||
},
|
||||
) => boolean;
|
||||
writeFile?: (credentials: OAuthCredentials, options?: CodexCliFileOptions) => boolean;
|
||||
};
|
||||
|
||||
type ExecSyncFn = typeof execSync;
|
||||
type ExecFileSyncFn = typeof execFileSync;
|
||||
|
||||
@@ -114,12 +134,8 @@ function parseClaudeCliOauthCredential(claudeOauth: unknown): ClaudeCliCredentia
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexCliAuthPath() {
|
||||
return path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME);
|
||||
}
|
||||
|
||||
function resolveCodexHomePath() {
|
||||
const configured = process.env.CODEX_HOME;
|
||||
function resolveCodexHomePath(codexHome?: string) {
|
||||
const configured = codexHome ?? process.env.CODEX_HOME;
|
||||
const home = configured ? resolveUserPath(configured) : resolveUserPath("~/.codex");
|
||||
try {
|
||||
return fs.realpathSync.native(home);
|
||||
@@ -185,6 +201,18 @@ function computeCodexKeychainAccount(codexHome: string) {
|
||||
return `cli|${hash.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function resolveCodexKeychainParams(options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}) {
|
||||
return {
|
||||
platform: options?.platform ?? process.platform,
|
||||
execSyncImpl: options?.execSync ?? execSync,
|
||||
codexHome: resolveCodexHomePath(options?.codexHome),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeJwtExpiryMs(token: string): number | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) {
|
||||
@@ -201,17 +229,15 @@ function decodeJwtExpiryMs(token: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readCodexKeychainCredentials(options?: {
|
||||
function readCodexKeychainAuthRecord(options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
}): Record<string, unknown> | null {
|
||||
const { platform, execSyncImpl, codexHome } = resolveCodexKeychainParams(options);
|
||||
if (platform !== "darwin") {
|
||||
return null;
|
||||
}
|
||||
const execSyncImpl = options?.execSync ?? execSync;
|
||||
|
||||
const codexHome = resolveCodexHomePath();
|
||||
const account = computeCodexKeychainAccount(codexHome);
|
||||
|
||||
try {
|
||||
@@ -225,7 +251,23 @@ function readCodexKeychainCredentials(options?: {
|
||||
).trim();
|
||||
|
||||
const parsed = JSON.parse(secret) as Record<string, unknown>;
|
||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCodexKeychainCredentials(options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const parsed = readCodexKeychainAuthRecord(options);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||
try {
|
||||
const accessToken = tokens?.access_token;
|
||||
const refreshToken = tokens?.refresh_token;
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
@@ -487,11 +529,129 @@ export function writeClaudeCliCredentials(
|
||||
return writeFile(newCredentials, { homeDir: options?.homeDir });
|
||||
}
|
||||
|
||||
function buildUpdatedCodexAuthRecord(
|
||||
existing: Record<string, unknown> | null,
|
||||
newCredentials: OAuthCredentials,
|
||||
): Record<string, unknown> {
|
||||
const next = existing ? { ...existing } : {};
|
||||
const existingTokens =
|
||||
next.tokens && typeof next.tokens === "object" ? (next.tokens as Record<string, unknown>) : {};
|
||||
next.auth_mode = next.auth_mode ?? "chatgpt";
|
||||
next.tokens = {
|
||||
...existingTokens,
|
||||
access_token: newCredentials.access,
|
||||
refresh_token: newCredentials.refresh,
|
||||
...(typeof newCredentials.accountId === "string" && newCredentials.accountId.trim().length > 0
|
||||
? { account_id: newCredentials.accountId }
|
||||
: {}),
|
||||
};
|
||||
next.last_refresh = new Date().toISOString();
|
||||
return next;
|
||||
}
|
||||
|
||||
export function writeCodexCliKeychainCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
execFileSync?: ExecFileSyncFn;
|
||||
},
|
||||
): boolean {
|
||||
const { platform, codexHome } = resolveCodexKeychainParams(options);
|
||||
if (platform !== "darwin") {
|
||||
return false;
|
||||
}
|
||||
const existing = readCodexKeychainAuthRecord(options);
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const execFileSyncImpl = options?.execFileSync ?? execFileSync;
|
||||
const account = computeCodexKeychainAccount(codexHome);
|
||||
const next = buildUpdatedCodexAuthRecord(existing, newCredentials);
|
||||
|
||||
try {
|
||||
execFileSyncImpl(
|
||||
"security",
|
||||
["add-generic-password", "-U", "-s", "Codex Auth", "-a", account, "-w", JSON.stringify(next)],
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
codexCliCache = null;
|
||||
log.info("wrote refreshed credentials to codex cli keychain", {
|
||||
expires: new Date(newCredentials.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn("failed to write credentials to codex cli keychain", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCodexCliFileCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
options?: CodexCliFileOptions,
|
||||
): boolean {
|
||||
const codexHome = resolveCodexHomePath(options?.codexHome);
|
||||
const authPath = path.join(codexHome, CODEX_CLI_AUTH_FILENAME);
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = loadJsonFile(authPath);
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return false;
|
||||
}
|
||||
const next = buildUpdatedCodexAuthRecord(raw as Record<string, unknown>, newCredentials);
|
||||
saveJsonFile(authPath, next);
|
||||
codexCliCache = null;
|
||||
log.info("wrote refreshed credentials to codex cli file", {
|
||||
expires: new Date(newCredentials.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn("failed to write credentials to codex cli file", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCodexCliCredentials(
|
||||
newCredentials: OAuthCredentials,
|
||||
options?: CodexCliWriteOptions,
|
||||
): boolean {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const writeKeychain = options?.writeKeychain ?? writeCodexCliKeychainCredentials;
|
||||
const writeFile =
|
||||
options?.writeFile ??
|
||||
((credentials, fileOptions) => writeCodexCliFileCredentials(credentials, fileOptions));
|
||||
|
||||
if (
|
||||
platform === "darwin" &&
|
||||
writeKeychain(newCredentials, {
|
||||
codexHome: options?.codexHome,
|
||||
platform,
|
||||
execSync: options?.execSync,
|
||||
execFileSync: options?.execFileSync,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return writeFile(newCredentials, { codexHome: options?.codexHome });
|
||||
}
|
||||
|
||||
export function readCodexCliCredentials(options?: {
|
||||
codexHome?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const keychain = readCodexKeychainCredentials({
|
||||
codexHome: options?.codexHome,
|
||||
platform: options?.platform,
|
||||
execSync: options?.execSync,
|
||||
});
|
||||
@@ -499,7 +659,7 @@ export function readCodexCliCredentials(options?: {
|
||||
return keychain;
|
||||
}
|
||||
|
||||
const authPath = resolveCodexCliAuthPath();
|
||||
const authPath = path.join(resolveCodexHomePath(options?.codexHome), CODEX_CLI_AUTH_FILENAME);
|
||||
const raw = loadJsonFile(authPath);
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
@@ -541,17 +701,19 @@ export function readCodexCliCredentials(options?: {
|
||||
}
|
||||
|
||||
export function readCodexCliCredentialsCached(options?: {
|
||||
codexHome?: string;
|
||||
ttlMs?: number;
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const authPath = resolveCodexCliAuthPath();
|
||||
const authPath = path.join(resolveCodexHomePath(options?.codexHome), CODEX_CLI_AUTH_FILENAME);
|
||||
return readCachedCliCredential({
|
||||
ttlMs: options?.ttlMs ?? 0,
|
||||
cache: codexCliCache,
|
||||
cacheKey: `${options?.platform ?? process.platform}|${authPath}`,
|
||||
read: () =>
|
||||
readCodexCliCredentials({
|
||||
codexHome: options?.codexHome,
|
||||
platform: options?.platform,
|
||||
execSync: options?.execSync,
|
||||
}),
|
||||
|
||||
@@ -485,6 +485,41 @@ describe("resolveApiKeyForProvider", () => {
|
||||
),
|
||||
).rejects.toThrow('No API key found for provider "xai"');
|
||||
});
|
||||
|
||||
it("prefers explicit api-key provider config over ambient auth profiles", async () => {
|
||||
const resolved = await resolveApiKeyForProvider({
|
||||
provider: "openai",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
auth: "api-key",
|
||||
apiKey: "sk-config-live", // pragma: allowlist secret
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "sk-profile-stale", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
apiKey: "sk-config-live",
|
||||
source: "models.json",
|
||||
mode: "api-key",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveApiKeyForProvider – synthetic local auth for custom providers", () => {
|
||||
|
||||
@@ -113,6 +113,18 @@ export function hasUsableCustomProviderApiKey(
|
||||
return Boolean(resolveUsableCustomProviderApiKey({ cfg, provider, env }));
|
||||
}
|
||||
|
||||
export function shouldPreferExplicitConfigApiKeyAuth(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
provider: string,
|
||||
): boolean {
|
||||
const providerConfig = resolveProviderConfig(cfg, provider);
|
||||
return (
|
||||
resolveProviderAuthOverride(cfg, provider) === "api-key" &&
|
||||
providerConfig !== undefined &&
|
||||
hasExplicitProviderApiKeyConfig(providerConfig)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProviderAuthOverride(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
provider: string,
|
||||
@@ -379,7 +391,18 @@ export async function resolveApiKeyForProvider(params: {
|
||||
if (authOverride === "aws-sdk") {
|
||||
return resolveAwsSdkAuthInfo();
|
||||
}
|
||||
if (shouldPreferExplicitConfigApiKeyAuth(cfg, provider)) {
|
||||
const customKey = resolveUsableCustomProviderApiKey({ cfg, provider });
|
||||
if (customKey) {
|
||||
return {
|
||||
apiKey: customKey.apiKey,
|
||||
source: customKey.source,
|
||||
mode: "api-key",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const providerConfig = resolveProviderConfig(cfg, provider);
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
@@ -455,7 +478,6 @@ export async function resolveApiKeyForProvider(params: {
|
||||
return resolveAwsSdkAuthInfo();
|
||||
}
|
||||
|
||||
const providerConfig = resolveProviderConfig(cfg, provider);
|
||||
const hasInlineConfiguredModels =
|
||||
Array.isArray(providerConfig?.models) && providerConfig.models.length > 0;
|
||||
const owningPluginIds = !hasInlineConfiguredModels
|
||||
|
||||
@@ -2260,6 +2260,42 @@ describe("createOpenAIWebSocketStreamFn", () => {
|
||||
expect(sent.text).toEqual({ verbosity: "low" });
|
||||
expect(sent.service_tier).toBe("priority");
|
||||
});
|
||||
|
||||
it("awaits async onPayload mutations before sending response.create", async () => {
|
||||
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-onpayload-async");
|
||||
const stream = streamFn(
|
||||
modelStub as Parameters<typeof streamFn>[0],
|
||||
contextStub as Parameters<typeof streamFn>[1],
|
||||
{
|
||||
onPayload: async (payload: unknown) => {
|
||||
const request = payload as Record<string, unknown>;
|
||||
await Promise.resolve();
|
||||
request.metadata = { async_hook: "applied" };
|
||||
return undefined;
|
||||
},
|
||||
} as unknown as Parameters<typeof streamFn>[2],
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
await new Promise((r) => setImmediate(r));
|
||||
MockManager.lastInstance!.simulateEvent({
|
||||
type: "response.completed",
|
||||
response: makeResponseObject("resp-onpayload-async", "Done"),
|
||||
});
|
||||
for await (const _ of await resolveStream(stream)) {
|
||||
/* consume */
|
||||
}
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
const sent = MockManager.lastInstance!.sentEvents[0] as Record<string, unknown>;
|
||||
expect(sent.type).toBe("response.create");
|
||||
expect(sent.metadata).toMatchObject({ async_hook: "applied" });
|
||||
});
|
||||
it("forwards topP and toolChoice to response.create", async () => {
|
||||
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-topp");
|
||||
const opts = { topP: 0.9, toolChoice: "auto" };
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "./openai-ws-message-conversion.js";
|
||||
import { buildOpenAIWebSocketResponseCreatePayload } from "./openai-ws-request.js";
|
||||
import { log } from "./pi-embedded-runner/logger.js";
|
||||
import { normalizeProviderId } from "./provider-id.js";
|
||||
import { createBoundaryAwareStreamFnForModel } from "./provider-transport-stream.js";
|
||||
import {
|
||||
buildAssistantMessageWithZeroUsage,
|
||||
@@ -314,6 +315,119 @@ function createWsManager(
|
||||
});
|
||||
}
|
||||
|
||||
const AZURE_OPENAI_PROVIDER_IDS = new Set(["azure-openai", "azure-openai-responses"]);
|
||||
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
||||
|
||||
function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname.toLowerCase() === "api.openai.com" &&
|
||||
/^\/v1\/?$/u.test(url.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenAICodexBaseUrl(baseUrl?: string): boolean {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return /^https?:\/\/chatgpt\.com\/backend-api\/?$/iu.test(trimmed);
|
||||
}
|
||||
|
||||
function isAzureOpenAIBaseUrl(baseUrl?: string): boolean {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(trimmed).hostname.toLowerCase().endsWith(".openai.azure.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTransportIdentityValue(value: string, maxLength = 160): string {
|
||||
const trimmed = value.trim().replace(/[\r\n]+/gu, " ");
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
||||
}
|
||||
|
||||
function usesNativeOpenAIRoute(provider: string, baseUrl?: string): boolean {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
if (!normalizedProvider) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedProvider === "openai") {
|
||||
return !baseUrl || isOpenAIApiBaseUrl(baseUrl);
|
||||
}
|
||||
if (AZURE_OPENAI_PROVIDER_IDS.has(normalizedProvider)) {
|
||||
return !baseUrl || isAzureOpenAIBaseUrl(baseUrl);
|
||||
}
|
||||
if (normalizedProvider === OPENAI_CODEX_PROVIDER_ID) {
|
||||
return !baseUrl || isOpenAIApiBaseUrl(baseUrl) || isOpenAICodexBaseUrl(baseUrl);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveNativeOpenAISessionHeaders(params: {
|
||||
provider: string;
|
||||
baseUrl?: string;
|
||||
sessionId?: string;
|
||||
}): Record<string, string> | undefined {
|
||||
if (!params.sessionId || !usesNativeOpenAIRoute(params.provider, params.baseUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
const sessionId = normalizeTransportIdentityValue(params.sessionId);
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
"x-client-request-id": sessionId,
|
||||
"x-openclaw-session-id": sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNativeOpenAITransportTurnState(params: {
|
||||
provider: string;
|
||||
baseUrl?: string;
|
||||
sessionId?: string;
|
||||
turnId: string;
|
||||
attempt: number;
|
||||
transport: "stream" | "websocket";
|
||||
}): ProviderTransportTurnState | undefined {
|
||||
const sessionHeaders = resolveNativeOpenAISessionHeaders({
|
||||
provider: params.provider,
|
||||
baseUrl: params.baseUrl,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
if (!sessionHeaders) {
|
||||
return undefined;
|
||||
}
|
||||
const turnId = normalizeTransportIdentityValue(params.turnId);
|
||||
const attempt = String(Math.max(1, params.attempt));
|
||||
return {
|
||||
headers: {
|
||||
...sessionHeaders,
|
||||
"x-openclaw-turn-id": turnId,
|
||||
"x-openclaw-turn-attempt": attempt,
|
||||
},
|
||||
metadata: {
|
||||
openclaw_session_id: sessionHeaders["x-openclaw-session-id"] ?? "",
|
||||
openclaw_turn_id: turnId,
|
||||
openclaw_turn_attempt: attempt,
|
||||
openclaw_transport: params.transport,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderTransportTurnState(
|
||||
model: Parameters<StreamFn>[0],
|
||||
params: {
|
||||
@@ -323,24 +437,46 @@ function resolveProviderTransportTurnState(
|
||||
transport: "stream" | "websocket";
|
||||
},
|
||||
): ProviderTransportTurnState | undefined {
|
||||
return resolveProviderTransportTurnStateWithPlugin({
|
||||
provider: model.provider,
|
||||
context: {
|
||||
if (usesNativeOpenAIRoute(model.provider, (model as { baseUrl?: string }).baseUrl)) {
|
||||
return resolveNativeOpenAITransportTurnState({
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
model: model as ProviderRuntimeModel,
|
||||
baseUrl: (model as { baseUrl?: string }).baseUrl,
|
||||
sessionId: params.sessionId,
|
||||
turnId: params.turnId,
|
||||
attempt: params.attempt,
|
||||
transport: params.transport,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
return (
|
||||
resolveProviderTransportTurnStateWithPlugin({
|
||||
provider: model.provider,
|
||||
context: {
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
model: model as ProviderRuntimeModel,
|
||||
sessionId: params.sessionId,
|
||||
turnId: params.turnId,
|
||||
attempt: params.attempt,
|
||||
transport: params.transport,
|
||||
},
|
||||
}) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWebSocketSessionPolicy(
|
||||
model: Parameters<StreamFn>[0],
|
||||
sessionId: string,
|
||||
): { headers?: Record<string, string>; degradeCooldownMs: number } {
|
||||
if (usesNativeOpenAIRoute(model.provider, (model as { baseUrl?: string }).baseUrl)) {
|
||||
return {
|
||||
headers: resolveNativeOpenAISessionHeaders({
|
||||
provider: model.provider,
|
||||
baseUrl: (model as { baseUrl?: string }).baseUrl,
|
||||
sessionId,
|
||||
}),
|
||||
degradeCooldownMs: Math.max(0, wsDegradeCooldownMsOverride ?? DEFAULT_WS_DEGRADE_COOLDOWN_MS),
|
||||
};
|
||||
}
|
||||
const policy = resolveProviderWebSocketSessionPolicyWithPlugin({
|
||||
provider: model.provider,
|
||||
context: {
|
||||
@@ -691,7 +827,7 @@ export function createOpenAIWebSocketStreamFn(
|
||||
tools: convertTools(context.tools),
|
||||
metadata: turnState?.metadata,
|
||||
}) as Record<string, unknown>;
|
||||
const nextPayload = options?.onPayload?.(payload, model);
|
||||
const nextPayload = await options?.onPayload?.(payload, model);
|
||||
payload = mergeTransportMetadata(
|
||||
(nextPayload ?? payload) as Record<string, unknown>,
|
||||
turnState?.metadata,
|
||||
|
||||
@@ -240,6 +240,7 @@ function buildEmbeddedRunnerConfig(
|
||||
providers: {
|
||||
[provider]: {
|
||||
api: resolveEmbeddedModelApi(params.model),
|
||||
auth: "api-key",
|
||||
apiKey: params.apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
models: [buildEmbeddedModelDefinition(params.model)],
|
||||
|
||||
@@ -113,6 +113,7 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager
|
||||
import { truncateSessionAfterCompaction } from "./session-truncation.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js";
|
||||
import {
|
||||
resolveEmbeddedAgentApiKey,
|
||||
resolveEmbeddedAgentBaseStreamFn,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "./stream-resolution.js";
|
||||
@@ -762,7 +763,11 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
modelApi: effectiveModel.api,
|
||||
});
|
||||
const wsApiKey = shouldUseWebSocketTransport
|
||||
? await authStorage.getApiKey(provider)
|
||||
? await resolveEmbeddedAgentApiKey({
|
||||
provider,
|
||||
resolvedApiKey: hasRuntimeAuthExchange ? undefined : apiKeyInfo?.apiKey,
|
||||
authStorage,
|
||||
})
|
||||
: undefined;
|
||||
if (shouldUseWebSocketTransport && !wsApiKey) {
|
||||
log.warn(
|
||||
@@ -779,6 +784,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
sessionId: params.sessionId,
|
||||
signal: runAbortController.signal,
|
||||
model: effectiveModel,
|
||||
resolvedApiKey: hasRuntimeAuthExchange ? undefined : apiKeyInfo?.apiKey,
|
||||
authStorage,
|
||||
});
|
||||
const { effectiveExtraParams } = applyExtraParamsToAgent(
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ensureAuthProfileStore,
|
||||
type ResolvedProviderAuth,
|
||||
resolveAuthProfileOrder,
|
||||
shouldPreferExplicitConfigApiKeyAuth,
|
||||
} from "../model-auth.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||
@@ -212,12 +213,14 @@ export async function runEmbeddedPiAgent(
|
||||
lockedProfileId = undefined;
|
||||
}
|
||||
}
|
||||
const profileOrder = resolveAuthProfileOrder({
|
||||
cfg: params.config,
|
||||
store: authStore,
|
||||
provider,
|
||||
preferredProfile: preferredProfileId,
|
||||
});
|
||||
const profileOrder = shouldPreferExplicitConfigApiKeyAuth(params.config, provider)
|
||||
? []
|
||||
: resolveAuthProfileOrder({
|
||||
cfg: params.config,
|
||||
store: authStore,
|
||||
provider,
|
||||
preferredProfile: preferredProfileId,
|
||||
});
|
||||
if (lockedProfileId && !profileOrder.includes(lockedProfileId)) {
|
||||
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
|
||||
}
|
||||
@@ -476,6 +479,10 @@ export async function runEmbeddedPiAgent(
|
||||
|
||||
const prompt =
|
||||
provider === "anthropic" ? scrubAnthropicRefusalMagic(params.prompt) : params.prompt;
|
||||
let resolvedStreamApiKey: string | undefined;
|
||||
if (!runtimeAuthState && apiKeyInfo) {
|
||||
resolvedStreamApiKey = (apiKeyInfo as ApiKeyInfo).apiKey;
|
||||
}
|
||||
|
||||
const attempt = await runEmbeddedAttempt({
|
||||
sessionId: params.sessionId,
|
||||
@@ -525,6 +532,7 @@ export async function runEmbeddedPiAgent(
|
||||
runtimeAuthState ? null : apiKeyInfo,
|
||||
params.config,
|
||||
),
|
||||
resolvedApiKey: resolvedStreamApiKey,
|
||||
authProfileId: lastProfileId,
|
||||
authProfileIdSource: lockedProfileId ? "user" : "auto",
|
||||
authStorage,
|
||||
|
||||
@@ -94,15 +94,21 @@ import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js";
|
||||
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
|
||||
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
|
||||
import { isRunnerAbortError } from "../abort.js";
|
||||
import { resolveCacheRetention } from "../anthropic-cache-retention.js";
|
||||
import { isCacheTtlEligibleProvider } from "../cache-ttl.js";
|
||||
import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js";
|
||||
import { runContextEngineMaintenance } from "../context-engine-maintenance.js";
|
||||
import { buildEmbeddedExtensionFactories } from "../extensions.js";
|
||||
import { resolveCacheRetention } from "../anthropic-cache-retention.js";
|
||||
import { applyExtraParamsToAgent, resolveAgentTransportOverride } from "../extra-params.js";
|
||||
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
|
||||
import { log } from "../logger.js";
|
||||
import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js";
|
||||
import {
|
||||
collectPromptCacheToolNames,
|
||||
beginPromptCacheObservation,
|
||||
completePromptCacheObservation,
|
||||
type PromptCacheChange,
|
||||
} from "../prompt-cache-observability.js";
|
||||
import { sanitizeSessionHistory, validateReplayTurns } from "../replay-history.js";
|
||||
import {
|
||||
clearActiveEmbeddedRun,
|
||||
@@ -114,23 +120,18 @@ import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
|
||||
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
|
||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "../skills-runtime.js";
|
||||
import {
|
||||
describeEmbeddedAgentStreamStrategy,
|
||||
resetEmbeddedAgentBaseStreamFnCacheForTest,
|
||||
resolveEmbeddedAgentApiKey,
|
||||
resolveEmbeddedAgentBaseStreamFn,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "../stream-resolution.js";
|
||||
import {
|
||||
applySystemPromptOverrideToSession,
|
||||
buildEmbeddedSystemPrompt,
|
||||
createSystemPromptOverride,
|
||||
} from "../system-prompt.js";
|
||||
import {
|
||||
collectPromptCacheToolNames,
|
||||
beginPromptCacheObservation,
|
||||
completePromptCacheObservation,
|
||||
type PromptCacheChange,
|
||||
} from "../prompt-cache-observability.js";
|
||||
import {
|
||||
describeEmbeddedAgentStreamStrategy,
|
||||
resetEmbeddedAgentBaseStreamFnCacheForTest,
|
||||
resolveEmbeddedAgentBaseStreamFn,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "../stream-resolution.js";
|
||||
import { dropThinkingBlocks } from "../thinking.js";
|
||||
import { collectAllowedToolNames } from "../tool-name-allowlist.js";
|
||||
import { installToolResultContextGuard } from "../tool-result-context-guard.js";
|
||||
@@ -925,7 +926,11 @@ export async function runEmbeddedAttempt(
|
||||
modelApi: params.model.api,
|
||||
});
|
||||
const wsApiKey = shouldUseWebSocketTransport
|
||||
? await params.authStorage.getApiKey(params.provider)
|
||||
? await resolveEmbeddedAgentApiKey({
|
||||
provider: params.provider,
|
||||
resolvedApiKey: params.resolvedApiKey,
|
||||
authStorage: params.authStorage,
|
||||
})
|
||||
: undefined;
|
||||
if (shouldUseWebSocketTransport && !wsApiKey) {
|
||||
log.warn(
|
||||
@@ -947,6 +952,7 @@ export async function runEmbeddedAttempt(
|
||||
sessionId: params.sessionId,
|
||||
signal: runAbortController.signal,
|
||||
model: params.model,
|
||||
resolvedApiKey: params.resolvedApiKey,
|
||||
authStorage: params.authStorage,
|
||||
});
|
||||
|
||||
@@ -978,9 +984,10 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
|
||||
const cacheObservabilityEnabled = Boolean(cacheTrace) || log.isEnabled("debug");
|
||||
const promptCacheToolNames = collectPromptCacheToolNames(
|
||||
[...builtInTools, ...allCustomTools] as Array<{ name?: string }>,
|
||||
);
|
||||
const promptCacheToolNames = collectPromptCacheToolNames([
|
||||
...builtInTools,
|
||||
...allCustomTools,
|
||||
] as Array<{ name?: string }>);
|
||||
let promptCacheChangesForTurn: PromptCacheChange[] | null = null;
|
||||
|
||||
if (cacheTrace) {
|
||||
|
||||
@@ -20,6 +20,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
|
||||
contextEngine?: ContextEngine;
|
||||
/** Resolved model context window in tokens for assemble/compact budgeting. */
|
||||
contextTokenBudget?: number;
|
||||
/** Resolved API key for this run when runtime auth did not replace it. */
|
||||
resolvedApiKey?: string;
|
||||
/** Auth profile resolved for this attempt's provider/model call. */
|
||||
authProfileId?: string;
|
||||
/** Source for the resolved auth profile (user-locked or automatic). */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
describeEmbeddedAgentStreamStrategy,
|
||||
resolveEmbeddedAgentApiKey,
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "./stream-resolution.js";
|
||||
|
||||
@@ -65,6 +66,21 @@ describe("describeEmbeddedAgentStreamStrategy", () => {
|
||||
});
|
||||
|
||||
describe("resolveEmbeddedAgentStreamFn", () => {
|
||||
it("prefers the resolved run api key over a later authStorage lookup", async () => {
|
||||
const authStorage = {
|
||||
getApiKey: vi.fn(async () => "storage-key"),
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveEmbeddedAgentApiKey({
|
||||
provider: "openai",
|
||||
resolvedApiKey: "resolved-key",
|
||||
authStorage,
|
||||
}),
|
||||
).resolves.toBe("resolved-key");
|
||||
expect(authStorage.getApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still routes supported streamSimple fallbacks through boundary-aware transports", () => {
|
||||
const streamFn = resolveEmbeddedAgentStreamFn({
|
||||
currentStreamFn: undefined,
|
||||
@@ -94,4 +110,32 @@ describe("resolveEmbeddedAgentStreamFn", () => {
|
||||
|
||||
expect(streamFn).not.toBe(streamSimple);
|
||||
});
|
||||
|
||||
it("injects the resolved run api key into provider-owned stream functions", async () => {
|
||||
const providerStreamFn = vi.fn(async (_model, _context, options) => options);
|
||||
const authStorage = {
|
||||
getApiKey: vi.fn(async () => "storage-key"),
|
||||
};
|
||||
const streamFn = resolveEmbeddedAgentStreamFn({
|
||||
currentStreamFn: undefined,
|
||||
providerStreamFn,
|
||||
shouldUseWebSocketTransport: false,
|
||||
sessionId: "session-1",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
} as never,
|
||||
resolvedApiKey: "resolved-key",
|
||||
authStorage,
|
||||
});
|
||||
|
||||
await expect(
|
||||
streamFn({ provider: "openai", id: "gpt-5.4" } as never, {} as never, {}),
|
||||
).resolves.toMatchObject({
|
||||
apiKey: "resolved-key",
|
||||
});
|
||||
expect(authStorage.getApiKey).not.toHaveBeenCalled();
|
||||
expect(providerStreamFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,18 @@ export function describeEmbeddedAgentStreamStrategy(params: {
|
||||
return "session-custom";
|
||||
}
|
||||
|
||||
export async function resolveEmbeddedAgentApiKey(params: {
|
||||
provider: string;
|
||||
resolvedApiKey?: string;
|
||||
authStorage?: { getApiKey(provider: string): Promise<string | undefined> };
|
||||
}): Promise<string | undefined> {
|
||||
const resolvedApiKey = params.resolvedApiKey?.trim();
|
||||
if (resolvedApiKey) {
|
||||
return resolvedApiKey;
|
||||
}
|
||||
return params.authStorage ? await params.authStorage.getApiKey(params.provider) : undefined;
|
||||
}
|
||||
|
||||
export function resolveEmbeddedAgentStreamFn(params: {
|
||||
currentStreamFn: StreamFn | undefined;
|
||||
providerStreamFn?: StreamFn;
|
||||
@@ -56,6 +68,7 @@ export function resolveEmbeddedAgentStreamFn(params: {
|
||||
sessionId: string;
|
||||
signal?: AbortSignal;
|
||||
model: EmbeddedRunAttemptParams["model"];
|
||||
resolvedApiKey?: string;
|
||||
authStorage?: { getApiKey(provider: string): Promise<string | undefined> };
|
||||
}): StreamFn {
|
||||
if (params.providerStreamFn) {
|
||||
@@ -70,10 +83,14 @@ export function resolveEmbeddedAgentStreamFn(params: {
|
||||
// Provider-owned transports bypass pi-coding-agent's default auth lookup,
|
||||
// so keep injecting the resolved runtime apiKey for streamSimple-compatible
|
||||
// transports that still read credentials from options.apiKey.
|
||||
if (params.authStorage) {
|
||||
const { authStorage, model } = params;
|
||||
if (params.authStorage || params.resolvedApiKey) {
|
||||
const { authStorage, model, resolvedApiKey } = params;
|
||||
return async (m, context, options) => {
|
||||
const apiKey = await authStorage.getApiKey(model.provider);
|
||||
const apiKey = await resolveEmbeddedAgentApiKey({
|
||||
provider: model.provider,
|
||||
resolvedApiKey,
|
||||
authStorage,
|
||||
});
|
||||
return inner(m, normalizeContext(context), {
|
||||
...options,
|
||||
apiKey: apiKey ?? options?.apiKey,
|
||||
|
||||
Reference in New Issue
Block a user