fix: stabilize codex auth ownership and ws fallback cache

This commit is contained in:
Peter Steinberger
2026-04-04 19:59:58 +09:00
parent fca889eea3
commit 0b1c9c7057
20 changed files with 869 additions and 107 deletions

View File

@@ -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)

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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 () => {

View File

@@ -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.`,
);

View File

@@ -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));
}
}

View File

@@ -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;
};

View File

@@ -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 });
}
});
});

View File

@@ -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,
}),

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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" };

View File

@@ -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,

View File

@@ -240,6 +240,7 @@ function buildEmbeddedRunnerConfig(
providers: {
[provider]: {
api: resolveEmbeddedModelApi(params.model),
auth: "api-key",
apiKey: params.apiKey,
baseUrl: providerBaseUrl,
models: [buildEmbeddedModelDefinition(params.model)],

View File

@@ -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(

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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). */

View File

@@ -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);
});
});

View File

@@ -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,