diff --git a/src/server/responses/native-injection.ts b/src/server/responses/native-injection.ts index c2f4a1c8117..bbe44fbafe4 100644 --- a/src/server/responses/native-injection.ts +++ b/src/server/responses/native-injection.ts @@ -1,6 +1,12 @@ import { CodexWsCorrelation } from "./codex-ws-correlation"; import type { NativeResponseControl } from "./native-response-control"; import type { NativeSteeringReplayObserver } from "./native-steering-replay"; +import { + UNDECLARED_TOOL_CALL_ERROR_CODE, + undeclaredToolCallMessage, + undeclaredToolCallNameInResponse, +} from "../responses-undeclared-tool-guard"; +import type { ProviderExecutedCallType } from "../responses-undeclared-tool-guard"; import { injectionError, injectionFingerprint, injectionId, injectionRecord as record, injectionResults, isInjectionRequest, MAX_NATIVE_INJECTIONS, MAX_NATIVE_INJECTION_BYTES, MAX_NATIVE_INJECTION_CALLS, @@ -44,6 +50,10 @@ export class NativeInjectionChannel implements NativeResponseControl { private ackTimer?: ReturnType; private idleTimer?: ReturnType; private readonly settings = new Map(); + private declaredToolNames?: ReadonlySet; + private declaredBareToolNames: ReadonlySet = new Set(); + private declaredNamelessCallTypes: ReadonlySet = new Set(); + private providerExecutedCallTypes: ReadonlySet = new Set(); private readonly lane: unknown; /** Pin the original settings and lane; construction never opens a connection. */ @@ -58,6 +68,14 @@ export class NativeInjectionChannel implements NativeResponseControl { /** A terminal is not final until submitted results have acknowledgements. */ get ended(): boolean { return this.finished; } + /** Mirror the ordinary response guard for native events that bypass its SSE rewrite. */ + configureToolAuthorization(active: boolean, names: ReadonlySet, bareNames: ReadonlySet, namelessCallTypes: ReadonlySet, providerExecuted: ReadonlySet): void { + this.declaredToolNames = active ? new Set(names) : undefined; + this.declaredBareToolNames = active ? new Set(bareNames) : new Set(); + this.declaredNamelessCallTypes = active ? new Set(namelessCallTypes) : new Set(); + this.providerExecutedCallTypes = active ? new Set(providerExecuted) : new Set(); + } + /** Attach once, after routing/auth/admission, retaining no global response-ID lookup. */ attach(send: (frame: Frame) => void, fail: (error: Error) => void): () => void { if (this.everAttached) throw new Error("Native injection transport is already owned."); @@ -86,8 +104,19 @@ export class NativeInjectionChannel implements NativeResponseControl { private live(): void { if (!this.send || this.finished) injectionError("injection_not_supported", "No live native injection transport is available on this route."); } + private authorize(item: unknown): void { + if (!this.declaredToolNames) return; + const undeclared = undeclaredToolCallNameInResponse( + { output: [item] }, this.declaredToolNames, this.declaredNamelessCallTypes, + this.providerExecutedCallTypes, this.declaredBareToolNames, + ); + if (undeclared !== undefined) { + injectionError(UNDECLARED_TOOL_CALL_ERROR_CODE, undeclaredToolCallMessage(undeclared)); + } + } /** Advertise client-owned function/custom calls and approvals, never hosted execution. */ private advertise(item: unknown): void { + this.authorize(item); const requirement = nativeToolRequirement(item); if (!requirement) return; const old = this.calls.get(requirement.key); @@ -225,6 +254,7 @@ export class NativeInjectionChannel implements NativeResponseControl { this.correlation?.finish(); this.correlation = new CodexWsCorrelation(true, () => false); } else if (!this.currentId || this.terminal) throw new Error("Unexpected native injection event outside an active response."); this.correlation?.accept({ ...event, stream_id: undefined }); + if (type === "response.output_item.added") this.authorize(event.item); if (type === "response.output_item.done") this.advertise(event.item); if (["response.completed", "response.failed", "response.incomplete"].includes(String(type))) { if (!this.currentId || response?.id !== this.currentId) throw new Error("Native injection terminal identity mismatch."); diff --git a/src/server/responses/native-response-control.ts b/src/server/responses/native-response-control.ts index afd1558d7f7..3a44464434e 100644 --- a/src/server/responses/native-response-control.ts +++ b/src/server/responses/native-response-control.ts @@ -1,6 +1,7 @@ import type { OcxProviderConfig } from "../../types"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import type { NativeSteeringReplayObserver } from "./native-steering-replay"; +import type { ProviderExecutedCallType } from "../responses-undeclared-tool-guard"; import { isInjectionRequest } from "./native-injection-protocol"; @@ -10,6 +11,7 @@ export interface NativeResponseControl { relayActive: boolean; normalizeContinuation?: (frame: Record) => Record; replayFactory?: () => NativeSteeringReplayObserver; + configureToolAuthorization?: (active: boolean, names: ReadonlySet, bareNames: ReadonlySet, namelessCallTypes: ReadonlySet, providerExecuted: ReadonlySet) => void; readonly attached: boolean; readonly ended: boolean; attach(send: (frame: Record) => void, fail: (error: Error) => void): () => void; diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 7bd9b64253c..cebc5e9fb9c 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -480,6 +480,13 @@ export async function preparePassthroughExchange( || clientDeclaredNamelessCallTypes.size > 0 || clientExplicitWireToolCatalog ) && route.provider.authMode !== "forward"; + options.nativeControl?.configureToolAuthorization?.( + undeclaredToolGuardActive, + declaredWireToolNames, + declaredBareWireToolNames, + declaredNamelessClientCallTypes, + providerExecutedCallTypes, + ); }; refreshUndeclaredToolGuard(request); // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index e6782bc2355..4a5c28c567d 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -228,6 +228,22 @@ function sendProtocolError(ws: ServerWebSocket, status: number, message: sendJsonFrame(ws, buildWsErrorFrame(status, protocolError(message))); } +/** + * Report an upstream-pump failure to the client. Errors that carry a structured + * code (for example the undeclared-tool guard's undeclared_tool_call) keep it so + * clients see the same rejection identity as the SSE path; everything else stays + * a generic protocol error. + */ +function sendUpstreamError(ws: ServerWebSocket, status: number, err: unknown): void { + const code = err != null && typeof (err as { code?: unknown }).code === "string" + ? (err as { code: string }).code + : undefined; + const message = err instanceof Error ? err.message : String(err); + sendJsonFrame(ws, buildWsErrorFrame(status, code + ? { type: "upstream_error", code, message } + : protocolError(message))); +} + export async function pumpResponsesSseToWebSocket( ws: ServerWebSocket, sseStream: ReadableStream, @@ -319,7 +335,7 @@ export async function pumpResponsesSseToWebSocket( && !(err instanceof WsSendDroppedError)) { reportTerminal("incomplete"); try { - sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err)); + sendUpstreamError(ws, 502, err); } catch (sendErr) { // If delivery is already dropped, there is no useful error frame left // to send. Swallow only that expected transport signal; other failures diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 9246b1e9695..69eb1c1637b 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -373,7 +373,9 @@ only string-valued developer `function_call_output` items for completed calls advertised by that response and lane. IDs are never global lookup keys. One physical injection awaits acknowledgement at a time because success carries a response ID, not an injection ID; further submissions remain in a bounded FIFO. Repeated call -results, mismatched/repeated acknowledgements and unsupported shapes fail closed. +results, mismatched/repeated acknowledgements and unsupported shapes fail closed. On +non-forward routes, native events also enforce the current request's explicit tool +catalog before advertising or relaying a client-executed call. A response terminal is relayed immediately, but pending acknowledgements and unreturned advertised calls retain the socket. Late tool results still reach that diff --git a/tests/responses/ws-native-injection.test.ts b/tests/responses/ws-native-injection.test.ts index c992cfaf813..f654f3fc72d 100644 --- a/tests/responses/ws-native-injection.test.ts +++ b/tests/responses/ws-native-injection.test.ts @@ -44,6 +44,65 @@ test.each([false, true])("real handler sends saved results over the same connect expect(getRequestLogEntries().at(-1)?.usage).toMatchObject({ inputTokens: 10, outputTokens: 5 }); }); +test("public API native injection rejects a function omitted from the request catalog", async () => { + const { socket, sent, ws } = await beginInjection({}, injectionConfig(true)); + socket.emit({ + type: "response.output_item.added", + output_index: 0, + item: { id: "item-omitted", type: "function_call", call_id: "call-omitted", name: "dangerous_local_tool", arguments: "{}" }, + }); + await waitForInjection(() => !ws.data.nativeControl); + expect(sent.some(event => event.type === "response.output_item.added")).toBe(false); + expect(sent.some(event => event.type === "error")).toBe(true); + expect(socket.readyState).toBe(3); +}); + +test("public API native injection rejects an undeclared call that only appears in the terminal snapshot", async () => { + const { socket, sent, ws } = await beginInjection({}, injectionConfig(true)); + completeInjection(socket, { + output: [ + { id: "item-late", type: "function_call", call_id: "call-late", name: "dangerous_local_tool", arguments: "{}" }, + ], + }); + await waitForInjection(() => !ws.data.nativeControl); + expect(sent.some(event => event.type === "response.completed")).toBe(false); + expect(sent.some(event => event.type === "error")).toBe(true); + expect(JSON.stringify(sent)).toContain("undeclared_tool_call"); + expect(socket.readyState).toBe(3); +}); + +test("public API native injection rejects an undeclared call arriving only in output_item.done", async () => { + const { socket, sent, ws } = await beginInjection({}, injectionConfig(true)); + // Establish the item as declared so the added event passes the guard, then let + // the done frame swap in an undeclared name for the same call. + socket.emit({ + type: "response.output_item.added", + output_index: 0, + item: { id: "item-done", type: "function_call", call_id: "call-done", name: "get_value", arguments: "{}" }, + }); + socket.emit({ + type: "response.output_item.done", + output_index: 0, + item: { id: "item-done", type: "function_call", call_id: "call-done", name: "dangerous_local_tool", arguments: "{}" }, + }); + await waitForInjection(() => !ws.data.nativeControl); + expect(sent.some(event => event.type === "response.output_item.done")).toBe(false); + expect(sent.some(event => event.type === "error")).toBe(true); + expect(JSON.stringify(sent)).toContain("undeclared_tool_call"); + expect(socket.readyState).toBe(3); +}); + +test("public API native injection forwards a declared function call on the guarded path", async () => { + const { socket, sent } = await beginInjection({}, injectionConfig(true)); + socket.emit({ + type: "response.output_item.added", + output_index: 0, + item: { id: "item-ok", type: "function_call", call_id: "call-ok", name: "get_value", arguments: "{}" }, + }); + await waitForInjection(() => sent.some(event => event.type === "response.output_item.added")); + expect(sent.some(event => event.type === "error")).toBe(false); +}); + test("terminal before acknowledgement is relayed without dropping the late successful acknowledgement", async () => { const { socket, send, sent, ws, id } = await beginInjection(); const call = advertiseInjection(socket); diff --git a/tests/responses/ws-native-result-continuations.test.ts b/tests/responses/ws-native-result-continuations.test.ts index 9073ce06301..955c5bcb0d4 100644 --- a/tests/responses/ws-native-result-continuations.test.ts +++ b/tests/responses/ws-native-result-continuations.test.ts @@ -81,14 +81,19 @@ test("semantic comparison ignores object-key order but retains content-array ord }); test.each([false, true])("rich/custom/approval continuation uses one original socket; API=%s", async api => { - const { socket, send, sent, ws, id } = await beginInjection({}, injectionConfig(api)); + // The catalog authorizes by wire name; a function spec keeps the adapter wire shape verbatim. + const tools = [ + { type: "function", name: "get_value", parameters: { type: "object", properties: {} } }, + { type: "function", name: "custom", parameters: { type: "object", properties: {} } }, + ]; + const { socket, send, sent, ws, id } = await beginInjection({ tools }, injectionConfig(api)); const func = advertiseInjection(socket); const custom = customCall(); const approval = approvalCall(); emitItem(socket, custom, 1); emitItem(socket, approval, 2); completeInjection(socket, { output: [func, custom, approval] }); await waitForInjection(() => sent.some(frame => frame.type === "response.completed")); expect(ws.data.nativeControl).toBeDefined(); - const frame = continuationFrame({ type: "response.create", previous_response_id: id, + const frame = continuationFrame({ type: "response.create", previous_response_id: id, tools, input: [savedResult("call-1", "text"), customResult(), approvalResult(false)] }, api); send(frame); await waitForInjection(() => socket.frames.length === 2);