Skip to content

Commit 40be5ad

Browse files
authored
fix: harden GPT-5 runtime paths
Co-authored-by: EVA <100yenadmin@users.noreply.github.com>
1 parent 4630ce3 commit 40be5ad

52 files changed

Lines changed: 2334 additions & 204 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
3636

3737
- Plugins/onboarding: record local plugin install source metadata without duplicating raw absolute local paths in persisted `plugins.installs`, while preserving linked load-path cleanup. (#70970) Thanks @vincentkoc.
3838
- Browser/tool: tell agents not to pass per-call `timeoutMs` on existing-session type, evaluate, and other Chrome MCP actions that reject timeout overrides.
39+
- Codex/GPT-5.4: harden fallback, auth-profile, tool-schema, and replay edge cases across native and embedded runtime paths. (#70743) Thanks @100yenadmin.
3940
- Voice-call/Telnyx: preserve inbound/outbound callback metadata and read transcription text from Telnyx's current `transcription_data` payload.
4041
- Codex harness: send verbose tool progress to chat channels for native app-server runs, matching the Pi harness `/verbose on` and `/verbose full` behavior. (#70966) Thanks @jalehman.
4142
- Codex models: fetch paginated Codex app-server model catalogs, mark truncated `/codex models` output, and keep ChatGPT OAuth defaults on the `openai-codex/gpt-5.5` route instead of the OpenAI API-key route.

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
7070
import { createCodexUserInputBridge } from "./user-input-bridge.js";
7171
import { filterToolsForVisionInputs } from "./vision-tools.js";
7272

73+
type OpenClawCodingToolsOptions = NonNullable<
74+
Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0]
75+
>;
76+
7377
let clientFactory = defaultCodexAppServerClientFactory;
7478

7579
function emitCodexAppServerEvent(
@@ -709,7 +713,10 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
709713
abortSignal: input.runAbortController.signal,
710714
modelProvider: params.model.provider,
711715
modelId: params.modelId,
712-
modelCompat: params.model.compat,
716+
modelCompat:
717+
params.model.compat && typeof params.model.compat === "object"
718+
? (params.model.compat as OpenClawCodingToolsOptions["modelCompat"])
719+
: undefined,
713720
modelApi: params.model.api,
714721
modelContextWindowTokens: params.model.contextWindow,
715722
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),

extensions/matrix/src/actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ function createMatrixExposedActions(params: {
9999

100100
function buildMatrixProfileToolSchema(): NonNullable<ChannelMessageToolDiscovery["schema"]> {
101101
return {
102+
actions: ["set-profile"],
102103
properties: {
103104
displayName: Type.Optional(
104105
Type.String({

extensions/msteams/src/actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export function describeMSTeamsMessageTool({
274274
capabilities: enabled ? ["presentation"] : [],
275275
schema: enabled
276276
? {
277+
actions: ["unpin"],
277278
properties: {
278279
pinnedMessageId: Type.Optional(
279280
Type.String({

extensions/msteams/src/channel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ function describeMSTeamsMessageTool({
388388
capabilities: enabled ? ["presentation"] : [],
389389
schema: enabled
390390
? {
391+
actions: ["unpin"],
391392
properties: {
392393
pinnedMessageId: Type.Optional(
393394
Type.String({

extensions/openai/speech-provider.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
1212
response: await globalThis.fetch(url, init),
1313
release: vi.fn(async () => {}),
1414
}),
15+
ssrfPolicyFromHttpBaseUrlAllowedHostname: () => undefined,
1516
}));
1617

1718
function isSpeechRequestBody(value: unknown): value is { response_format?: string } {

extensions/openai/tts.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
2424
response: await globalThis.fetch(url, init),
2525
release: vi.fn(async () => {}),
2626
}),
27+
ssrfPolicyFromHttpBaseUrlAllowedHostname: () => undefined,
2728
}));
2829

2930
describe("openai tts", () => {

extensions/openai/tts.ts

Lines changed: 57 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import {
33
isDebugProxyGlobalFetchPatchInstalled,
44
} from "openclaw/plugin-sdk/proxy-capture";
55
import { extractProviderErrorDetail, trimToUndefined } from "openclaw/plugin-sdk/speech";
6-
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
6+
import {
7+
fetchWithSsrFGuard,
8+
ssrfPolicyFromHttpBaseUrlAllowedHostname,
9+
} from "openclaw/plugin-sdk/ssrf-runtime";
710

811
export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
912

@@ -91,72 +94,63 @@ export async function openaiTTS(params: {
9194
throw new Error(`Invalid voice: ${voice}`);
9295
}
9396

94-
const controller = new AbortController();
95-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
96-
97+
const requestHeaders = {
98+
Authorization: `Bearer ${apiKey}`,
99+
"Content-Type": "application/json",
100+
};
101+
const requestBody = JSON.stringify({
102+
model,
103+
input: text,
104+
voice,
105+
response_format: responseFormat,
106+
...(speed != null && { speed }),
107+
...(effectiveInstructions != null && { instructions: effectiveInstructions }),
108+
});
109+
const requestUrl = `${baseUrl}/audio/speech`;
110+
const debugProxyFetchPatchInstalled = isDebugProxyGlobalFetchPatchInstalled();
111+
const { response, release } = await fetchWithSsrFGuard({
112+
url: requestUrl,
113+
init: {
114+
method: "POST",
115+
headers: requestHeaders,
116+
body: requestBody,
117+
},
118+
timeoutMs,
119+
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
120+
capture: false,
121+
pinDns: debugProxyFetchPatchInstalled ? false : undefined,
122+
auditContext: "openai-tts",
123+
});
97124
try {
98-
const requestHeaders = {
99-
Authorization: `Bearer ${apiKey}`,
100-
"Content-Type": "application/json",
101-
};
102-
const requestBody = JSON.stringify({
103-
model,
104-
input: text,
105-
voice,
106-
response_format: responseFormat,
107-
...(speed != null && { speed }),
108-
...(effectiveInstructions != null && { instructions: effectiveInstructions }),
109-
});
110-
const requestUrl = `${baseUrl}/audio/speech`;
111-
const isGlobalFetchPatchInstalled = isDebugProxyGlobalFetchPatchInstalled();
112-
const guardedFetchImpl = isGlobalFetchPatchInstalled
113-
? globalThis.fetch.bind(globalThis)
114-
: undefined;
115-
const { response, release } = await fetchWithSsrFGuard({
116-
url: requestUrl,
117-
init: {
125+
if (!debugProxyFetchPatchInstalled) {
126+
captureHttpExchange({
127+
url: requestUrl,
118128
method: "POST",
119-
headers: requestHeaders,
120-
body: requestBody,
121-
signal: controller.signal,
122-
},
123-
...(guardedFetchImpl ? { fetchImpl: guardedFetchImpl } : {}),
124-
capture: false,
125-
auditContext: "openai-tts",
126-
});
127-
try {
128-
if (!isGlobalFetchPatchInstalled) {
129-
captureHttpExchange({
130-
url: requestUrl,
131-
method: "POST",
132-
requestHeaders,
133-
requestBody,
134-
response,
135-
transport: "http",
136-
meta: {
137-
provider: "openai",
138-
capability: "tts",
139-
},
140-
});
141-
}
142-
143-
if (!response.ok) {
144-
const detail = await extractOpenAiErrorDetail(response);
145-
const requestId =
146-
trimToUndefined(response.headers.get("x-request-id")) ??
147-
trimToUndefined(response.headers.get("request-id"));
148-
throw new Error(
149-
`OpenAI TTS API error (${response.status})` +
150-
(detail ? `: ${detail}` : "") +
151-
(requestId ? ` [request_id=${requestId}]` : ""),
152-
);
153-
}
129+
requestHeaders,
130+
requestBody,
131+
response,
132+
transport: "http",
133+
meta: {
134+
provider: "openai",
135+
capability: "tts",
136+
},
137+
});
138+
}
154139

155-
return Buffer.from(await response.arrayBuffer());
156-
} finally {
157-
await release();
140+
if (!response.ok) {
141+
const detail = await extractOpenAiErrorDetail(response);
142+
const requestId =
143+
trimToUndefined(response.headers.get("x-request-id")) ??
144+
trimToUndefined(response.headers.get("request-id"));
145+
throw new Error(
146+
`OpenAI TTS API error (${response.status})` +
147+
(detail ? `: ${detail}` : "") +
148+
(requestId ? ` [request_id=${requestId}]` : ""),
149+
);
158150
}
151+
152+
return Buffer.from(await response.arrayBuffer());
159153
} finally {
160-
clearTimeout(timeout);
154+
await release();
161155
}
162156
}

src/agents/agent-command.live-model-switch.test.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const state = vi.hoisted(() => ({
1010
clearAgentRunContextMock: vi.fn(),
1111
updateSessionStoreAfterAgentRunMock: vi.fn(),
1212
deliverAgentCommandResultMock: vi.fn(),
13+
clearSessionAuthProfileOverrideMock: vi.fn(),
14+
authProfileStoreMock: { profiles: {} } as { profiles: Record<string, unknown> },
15+
sessionEntryMock: undefined as unknown,
16+
sessionStoreMock: undefined as unknown,
1317
}));
1418

1519
vi.mock("./model-fallback.js", () => ({
@@ -57,12 +61,12 @@ vi.mock("./command/session.js", () => ({
5761
resolveSession: () => ({
5862
sessionId: "session-1",
5963
sessionKey: "agent:main",
60-
sessionEntry: {
64+
sessionEntry: state.sessionEntryMock ?? {
6165
sessionId: "session-1",
6266
updatedAt: Date.now(),
6367
skillsSnapshot: { prompt: "", skills: [], version: 0 },
6468
},
65-
sessionStore: undefined,
69+
sessionStore: state.sessionStoreMock,
6670
storePath: undefined,
6771
isNewSession: false,
6872
persistedThinking: undefined,
@@ -250,8 +254,13 @@ vi.mock("./auth-profiles.js", () => ({
250254
ensureAuthProfileStore: () => ({ profiles: {} }),
251255
}));
252256

257+
vi.mock("./auth-profiles/store.js", () => ({
258+
ensureAuthProfileStore: () => state.authProfileStoreMock,
259+
}));
260+
253261
vi.mock("./auth-profiles/session-override.js", () => ({
254-
clearSessionAuthProfileOverride: vi.fn(),
262+
clearSessionAuthProfileOverride: (...args: unknown[]) =>
263+
state.clearSessionAuthProfileOverrideMock(...args),
255264
}));
256265

257266
vi.mock("./defaults.js", () => ({
@@ -269,7 +278,12 @@ vi.mock("./model-catalog.js", () => ({
269278

270279
vi.mock("./model-selection.js", () => ({
271280
buildAllowedModelSet: () => ({
272-
allowedKeys: new Set<string>(["anthropic/claude", "openai/claude", "openai/gpt-5.4"]),
281+
allowedKeys: new Set<string>([
282+
"anthropic/claude",
283+
"codex-cli/gpt-5.4",
284+
"openai/claude",
285+
"openai/gpt-5.4",
286+
]),
273287
allowedCatalog: [],
274288
allowAny: false,
275289
}),
@@ -281,6 +295,12 @@ vi.mock("./model-selection.js", () => ({
281295
resolveThinkingDefault: () => "low",
282296
}));
283297

298+
vi.mock("./provider-auth-aliases.js", () => ({
299+
resolveProviderAuthAliasMap: () => ({}),
300+
resolveProviderIdForAuth: (provider: string) =>
301+
provider.trim().toLowerCase() === "codex-cli" ? "openai-codex" : provider.trim().toLowerCase(),
302+
}));
303+
284304
vi.mock("./skills.js", () => ({
285305
buildWorkspaceSkillSnapshot: () => ({}),
286306
}));
@@ -376,6 +396,9 @@ function expectFallbackOverrideCalls(first: boolean, second: boolean) {
376396
describe("agentCommand – LiveSessionModelSwitchError retry", () => {
377397
beforeEach(() => {
378398
vi.clearAllMocks();
399+
state.authProfileStoreMock = { profiles: {} };
400+
state.sessionEntryMock = undefined;
401+
state.sessionStoreMock = undefined;
379402
state.deliverAgentCommandResultMock.mockResolvedValue(undefined);
380403
state.updateSessionStoreAfterAgentRunMock.mockResolvedValue(undefined);
381404
});
@@ -450,6 +473,48 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
450473
expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(2);
451474
});
452475

476+
it("keeps aliased session auth profiles for codex-cli runs", async () => {
477+
let capturedAuthProfileProvider: string | undefined;
478+
const sessionEntry = {
479+
sessionId: "session-1",
480+
updatedAt: Date.now(),
481+
providerOverride: "codex-cli",
482+
modelOverride: "gpt-5.4",
483+
authProfileOverride: "openai-codex:work",
484+
authProfileOverrideSource: "user",
485+
skillsSnapshot: { prompt: "", skills: [], version: 0 },
486+
};
487+
state.sessionEntryMock = sessionEntry;
488+
state.authProfileStoreMock = {
489+
profiles: {
490+
"openai-codex:work": {
491+
type: "api_key",
492+
provider: "openai-codex",
493+
key: "sk-test",
494+
},
495+
},
496+
};
497+
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
498+
const result = await params.run(params.provider, params.model);
499+
return {
500+
result,
501+
provider: params.provider,
502+
model: params.model,
503+
attempts: [],
504+
};
505+
});
506+
state.runAgentAttemptMock.mockImplementation(async (...args: unknown[]) => {
507+
const attemptParams = args[0] as { authProfileProvider?: string } | undefined;
508+
capturedAuthProfileProvider = attemptParams?.authProfileProvider;
509+
return makeSuccessResult("codex-cli", "gpt-5.4");
510+
});
511+
512+
await runBasicAgentCommand();
513+
514+
expect(capturedAuthProfileProvider).toBe("codex-cli");
515+
expect(state.clearSessionAuthProfileOverrideMock).not.toHaveBeenCalled();
516+
});
517+
453518
it("updates hasSessionModelOverride for fallback resolution after switch", async () => {
454519
setupModelSwitchRetry({
455520
provider: "openai",

src/agents/agent-command.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
resolveDefaultModelForAgent,
5959
resolveThinkingDefault,
6060
} from "./model-selection.js";
61+
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
6162
import { normalizeSpawnedRunMetadata } from "./spawned-context.js";
6263
import { resolveAgentTimeoutMs } from "./timeout.js";
6364
import { ensureAgentWorkspace } from "./workspace.js";
@@ -756,7 +757,14 @@ async function agentCommandInternal(
756757
const entry = sessionEntry;
757758
const store = ensureAuthProfileStore();
758759
const profile = store.profiles[authProfileId];
759-
if (!profile || profile.provider !== providerForAuthProfileValidation) {
760+
const profileAuthProvider = profile
761+
? resolveProviderIdForAuth(profile.provider, { config: cfg, workspaceDir })
762+
: undefined;
763+
const validationAuthProvider = resolveProviderIdForAuth(providerForAuthProfileValidation, {
764+
config: cfg,
765+
workspaceDir,
766+
});
767+
if (!profile || profileAuthProvider !== validationAuthProvider) {
760768
if (sessionStore && sessionKey) {
761769
await clearSessionAuthProfileOverride({
762770
sessionEntry: entry,

0 commit comments

Comments
 (0)