Skip to content

Commit a57b4c5

Browse files
steipetevincentkoc
andauthored
fix(openai): guard post-hook tool payloads (#92928)
Guard OpenAI post-hook tool inspection and code-mode filtering against unreadable accessors and asynchronous payload replacements. Preserve valid official `exec` and `wait` function tools across Responses and Chat Completions paths. Supersedes #89703. Co-authored-by: Vincent Koc <[email protected]>
1 parent 333a93c commit a57b4c5

5 files changed

Lines changed: 278 additions & 21 deletions

File tree

src/agents/openai-tool-projection.live.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import OpenAI from "openai";
22
import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions.js";
33
import type { ResponseCreateParamsNonStreaming } from "openai/resources/responses/responses.js";
44
import { describe, expect, it } from "vitest";
5+
import { createCodexNativeWebSearchWrapper } from "../llm/providers/stream-wrappers/openai.js";
56
import type { Context, Model } from "../llm/types.js";
67
import { isLiveTestEnabled } from "./live-test-helpers.js";
78
import {
89
buildOpenAICompletionsParams,
910
buildOpenAIResponsesParams,
11+
createOpenAIResponsesTransportStreamFn,
1012
} from "./openai-transport-stream.js";
1113

1214
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
@@ -131,4 +133,95 @@ describeLive("OpenAI tool projection live", () => {
131133
value: "OPENAI_PROJECTION_OK",
132134
});
133135
}, 45_000);
136+
137+
it("keeps code-mode tools after a payload hook adds an unreadable sibling", async () => {
138+
const model = {
139+
id: modelId,
140+
name: modelId,
141+
api: "openai-responses",
142+
provider: "openai",
143+
baseUrl: "https://api.openai.com/v1",
144+
reasoning: true,
145+
input: ["text"],
146+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
147+
contextWindow: 200000,
148+
maxTokens: 256,
149+
} satisfies Model<"openai-responses">;
150+
const codeModeContext = {
151+
systemPrompt: "Call the requested function exactly once.",
152+
messages: [
153+
{
154+
role: "user",
155+
content: "Call exec with value exactly OPENAI_POST_HOOK_OK.",
156+
timestamp: 1,
157+
},
158+
],
159+
tools: [
160+
{
161+
name: "exec",
162+
description: "Return the requested probe value.",
163+
parameters: {
164+
type: "object",
165+
properties: { value: { type: "string" } },
166+
required: ["value"],
167+
additionalProperties: false,
168+
},
169+
},
170+
{
171+
name: "wait",
172+
description: "Wait without doing work.",
173+
parameters: {
174+
type: "object",
175+
properties: {},
176+
additionalProperties: false,
177+
},
178+
},
179+
],
180+
} satisfies Context;
181+
const streamFn = createCodexNativeWebSearchWrapper(createOpenAIResponsesTransportStreamFn(), {
182+
codeModeToolSurfaceEnabled: true,
183+
});
184+
const streamOptions = {
185+
apiKey: OPENAI_KEY,
186+
maxTokens: 128,
187+
reasoning: "low",
188+
toolChoice: { type: "function", name: "exec" },
189+
openclawCodeModeToolSurface: true,
190+
onPayload(payload: unknown) {
191+
const record = payload as Record<string, unknown>;
192+
const tools = record.tools;
193+
if (!Array.isArray(tools) || tools.length !== 2) {
194+
throw new Error("Expected projected exec and wait tools");
195+
}
196+
record.tools = [
197+
tools[0],
198+
{
199+
type: "function",
200+
get function(): { name: string } {
201+
throw new Error("live unreadable post-hook function getter");
202+
},
203+
},
204+
tools[1],
205+
];
206+
return record;
207+
},
208+
} satisfies Parameters<typeof streamFn>[2] & {
209+
reasoning: "low";
210+
toolChoice: { type: "function"; name: string };
211+
openclawCodeModeToolSurface: true;
212+
};
213+
const stream = await Promise.resolve(streamFn(model, codeModeContext, streamOptions));
214+
215+
const result = await stream.result();
216+
const toolCall = result.content.find(
217+
(block) => block.type === "toolCall" && block.name === "exec",
218+
);
219+
220+
expect(result.stopReason).toBe("toolUse");
221+
expect(toolCall).toMatchObject({
222+
type: "toolCall",
223+
name: "exec",
224+
arguments: { value: "OPENAI_POST_HOOK_OK" },
225+
});
226+
}, 45_000);
134227
});

src/agents/openai-transport-stream.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,38 @@ describe("openai transport stream", () => {
493493
}
494494
});
495495

496+
it("skips unreadable model payload tool names in debug summaries", () => {
497+
const previous = process.env.OPENCLAW_DEBUG_MODEL_PAYLOAD;
498+
process.env.OPENCLAW_DEBUG_MODEL_PAYLOAD = "tools";
499+
try {
500+
expect(
501+
testing.summarizeResponsesTools([
502+
{
503+
type: "function",
504+
get function(): { name: string } {
505+
throw new Error("responses debug tool function getter exploded");
506+
},
507+
},
508+
{
509+
type: "function",
510+
function: {
511+
get name(): string {
512+
throw new Error("responses debug nested name getter exploded");
513+
},
514+
},
515+
},
516+
{ type: "function", function: { name: "wait" } },
517+
]),
518+
).toBe("count=3 names=wait");
519+
} finally {
520+
if (previous === undefined) {
521+
delete process.env.OPENCLAW_DEBUG_MODEL_PAYLOAD;
522+
} else {
523+
process.env.OPENCLAW_DEBUG_MODEL_PAYLOAD = previous;
524+
}
525+
}
526+
});
527+
496528
it("redacts full model payload debug summaries", () => {
497529
const previous = process.env.OPENCLAW_DEBUG_MODEL_PAYLOAD;
498530
process.env.OPENCLAW_DEBUG_MODEL_PAYLOAD = "full-redacted";
@@ -530,6 +562,36 @@ describe("openai transport stream", () => {
530562
expect(payload.tools).toHaveLength(2);
531563
});
532564

565+
it("skips unreadable code mode response payload tool names", () => {
566+
const payload = {
567+
tools: [
568+
{ type: "function", name: "exec" },
569+
{
570+
type: "function",
571+
get function(): { name: string } {
572+
throw new Error("responses code mode function getter exploded");
573+
},
574+
},
575+
{
576+
type: "function",
577+
function: {
578+
get name(): string {
579+
throw new Error("responses code mode nested name getter exploded");
580+
},
581+
},
582+
},
583+
{ type: "function", function: { name: "wait" } },
584+
],
585+
};
586+
587+
testing.enforceCodeModeResponsesToolSurface(payload);
588+
testing.assertCodeModeResponsesToolSurface(payload);
589+
expect(payload.tools).toEqual([
590+
{ type: "function", name: "exec" },
591+
{ type: "function", function: { name: "wait" } },
592+
]);
593+
});
594+
533595
it("fails closed when the code mode final payload tool surface is not exec/wait", () => {
534596
expect(() =>
535597
testing.assertCodeModeResponsesToolSurface({

src/agents/openai-transport-stream.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -350,19 +350,32 @@ function responseInputRoles(input: unknown): string {
350350
return [...roles].toSorted().join(",");
351351
}
352352

353+
function readToolPayloadField(record: Record<string, unknown>, field: string): unknown {
354+
try {
355+
return record[field];
356+
} catch {
357+
return undefined;
358+
}
359+
}
360+
353361
function readResponsesToolDisplayName(tool: unknown): string {
354362
if (!tool || typeof tool !== "object") {
355363
return "";
356364
}
357365
const record = tool as Record<string, unknown>;
358-
if (typeof record.name === "string") {
359-
return record.name;
360-
}
361-
const fn = record.function;
362-
if (fn && typeof fn === "object" && typeof (fn as Record<string, unknown>).name === "string") {
363-
return (fn as Record<string, unknown>).name as string;
366+
const name = readToolPayloadField(record, "name");
367+
if (typeof name === "string") {
368+
return name;
369+
}
370+
const fn = readToolPayloadField(record, "function");
371+
if (fn && typeof fn === "object") {
372+
const fnName = readToolPayloadField(fn as Record<string, unknown>, "name");
373+
if (typeof fnName === "string") {
374+
return fnName;
375+
}
364376
}
365-
return typeof record.type === "string" ? record.type : "";
377+
const type = readToolPayloadField(record, "type");
378+
return typeof type === "string" && type !== "function" ? type : "";
366379
}
367380

368381
function summarizeResponsesTools(tools: unknown): string {
@@ -381,11 +394,16 @@ function responsesPayloadToolName(tool: unknown): string | undefined {
381394
if (!isRecord(tool)) {
382395
return undefined;
383396
}
384-
if (typeof tool.name === "string") {
385-
return tool.name;
397+
const name = readToolPayloadField(tool, "name");
398+
if (typeof name === "string") {
399+
return name;
400+
}
401+
const fn = readToolPayloadField(tool, "function");
402+
if (!isRecord(fn)) {
403+
return undefined;
386404
}
387-
const fn = tool.function;
388-
return isRecord(fn) && typeof fn.name === "string" ? fn.name : undefined;
405+
const fnName = readToolPayloadField(fn, "name");
406+
return typeof fnName === "string" ? fnName : undefined;
389407
}
390408

391409
function enforceCodeModeResponsesToolSurface(payload: unknown): void {

src/llm/providers/stream-wrappers/openai.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ describe("createCodexNativeWebSearchWrapper", () => {
147147
const payloadObj = payload as { tools?: unknown } | undefined;
148148
if (payloadObj && Array.isArray(payloadObj.tools)) {
149149
payloadObj.tools.push({ type: "function", name: "web_search" });
150+
payloadObj.tools.push({
151+
type: "function",
152+
get function(): { name: string } {
153+
throw new Error("code mode payload function getter exploded");
154+
},
155+
});
150156
}
151157
},
152158
},
@@ -158,6 +164,56 @@ describe("createCodexNativeWebSearchWrapper", () => {
158164
]);
159165
});
160166

167+
it("filters async replacement payloads when code mode owns the tool surface", async () => {
168+
let observedOptions: Parameters<StreamFn>[2];
169+
const baseStreamFn: StreamFn = (_model, _context, options) => {
170+
observedOptions = options;
171+
return createAssistantMessageEventStream();
172+
};
173+
const wrapped = createCodexNativeWebSearchWrapper(baseStreamFn, {
174+
codeModeToolSurfaceEnabled: true,
175+
});
176+
const model = {
177+
api: "openai-responses",
178+
provider: "openai",
179+
id: "gpt-5.5",
180+
} as Model<"openai-responses">;
181+
182+
void wrapped(
183+
model,
184+
{
185+
messages: [],
186+
tools: [
187+
{ name: "exec", description: "", parameters: {} },
188+
{ name: "wait", description: "", parameters: {} },
189+
],
190+
},
191+
{
192+
onPayload: async () => ({
193+
tools: [
194+
{ type: "function", name: "exec" },
195+
{
196+
type: "function",
197+
get function(): { name: string } {
198+
throw new Error("async code mode payload function getter exploded");
199+
},
200+
},
201+
{ type: "function", name: "wait" },
202+
{ type: "web_search" },
203+
],
204+
}),
205+
},
206+
);
207+
208+
const nextPayload = await observedOptions?.onPayload?.({ tools: [] }, model);
209+
expect(nextPayload).toEqual({
210+
tools: [
211+
{ type: "function", name: "exec" },
212+
{ type: "function", name: "wait" },
213+
],
214+
});
215+
});
216+
161217
it("does not enable code-mode transport enforcement when config is on but controls are inactive", () => {
162218
const observedOptions: Array<Record<string, unknown>> = [];
163219
const payloads: Array<Record<string, unknown>> = [];

src/llm/providers/stream-wrappers/openai.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,37 @@ function isCodeModeEnabled(config?: OpenClawConfig): boolean {
123123
);
124124
}
125125

126+
function readPayloadToolField(record: Record<string, unknown>, field: string): unknown {
127+
try {
128+
return record[field];
129+
} catch {
130+
return undefined;
131+
}
132+
}
133+
134+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
135+
return (
136+
value !== null &&
137+
(typeof value === "object" || typeof value === "function") &&
138+
typeof (value as { then?: unknown }).then === "function"
139+
);
140+
}
141+
126142
function readPayloadToolName(tool: unknown): string | undefined {
127143
if (!tool || typeof tool !== "object") {
128144
return undefined;
129145
}
130-
const record = tool as { name?: unknown; function?: { name?: unknown } };
131-
if (typeof record.name === "string") {
132-
return record.name;
146+
const record = tool as Record<string, unknown>;
147+
const name = readPayloadToolField(record, "name");
148+
if (typeof name === "string") {
149+
return name;
150+
}
151+
const fn = readPayloadToolField(record, "function");
152+
if (!fn || typeof fn !== "object") {
153+
return undefined;
133154
}
134-
return typeof record.function?.name === "string" ? record.function.name : undefined;
155+
const fnName = readPayloadToolField(fn as Record<string, unknown>, "name");
156+
return typeof fnName === "string" ? fnName : undefined;
135157
}
136158

137159
function isCodeModePayloadToolName(name: string | undefined): boolean {
@@ -154,7 +176,7 @@ function filterCodeModeGroupedToolDeclarations(tool: unknown): Record<string, un
154176
const record = tool as Record<string, unknown>;
155177
const filteredGroups: Record<string, unknown> = {};
156178
for (const key of ["functionDeclarations", "function_declarations"] as const) {
157-
const filtered = filterCodeModeToolDeclarations(record[key]);
179+
const filtered = filterCodeModeToolDeclarations(readPayloadToolField(record, key));
158180
if (filtered === undefined) {
159181
continue;
160182
}
@@ -183,6 +205,12 @@ function filterCodeModePayloadTools(payload: unknown): void {
183205
});
184206
}
185207

208+
function filterCodeModePayloadHookResult(payload: unknown, nextPayload: unknown): unknown {
209+
const finalPayload = nextPayload === undefined ? payload : nextPayload;
210+
filterCodeModePayloadTools(finalPayload);
211+
return nextPayload === undefined ? undefined : finalPayload;
212+
}
213+
186214
function hasCodeModeVisibleTools(context: { tools?: unknown }): boolean {
187215
if (!Array.isArray(context.tools)) {
188216
return false;
@@ -653,12 +681,12 @@ export function createCodexNativeWebSearchWrapper(
653681
onPayload: (payload) => {
654682
filterCodeModePayloadTools(payload);
655683
const nextPayload = originalOnPayload?.(payload, model);
656-
if (nextPayload !== undefined) {
657-
filterCodeModePayloadTools(nextPayload);
658-
return nextPayload;
684+
if (isPromiseLike(nextPayload)) {
685+
return Promise.resolve(nextPayload).then((resolvedPayload) =>
686+
filterCodeModePayloadHookResult(payload, resolvedPayload),
687+
);
659688
}
660-
filterCodeModePayloadTools(payload);
661-
return undefined;
689+
return filterCodeModePayloadHookResult(payload, nextPayload);
662690
},
663691
};
664692
return underlying(model, context, codeModeOptions);

0 commit comments

Comments
 (0)