Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/server/responses/native-injection.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -44,6 +50,10 @@ export class NativeInjectionChannel implements NativeResponseControl {
private ackTimer?: ReturnType<typeof setTimeout>;
private idleTimer?: ReturnType<typeof setTimeout>;
private readonly settings = new Map<string, string>();
private declaredToolNames?: ReadonlySet<string>;
private declaredBareToolNames: ReadonlySet<string> = new Set();
private declaredNamelessCallTypes: ReadonlySet<string> = new Set();
private providerExecutedCallTypes: ReadonlySet<ProviderExecutedCallType> = new Set();
private readonly lane: unknown;

/** Pin the original settings and lane; construction never opens a connection. */
Expand All @@ -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<string>, bareNames: ReadonlySet<string>, namelessCallTypes: ReadonlySet<string>, providerExecuted: ReadonlySet<ProviderExecutedCallType>): 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.");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.");
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/native-response-control.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -10,6 +11,7 @@ export interface NativeResponseControl {
relayActive: boolean;
normalizeContinuation?: (frame: Record<string, unknown>) => Record<string, unknown>;
replayFactory?: () => NativeSteeringReplayObserver;
configureToolAuthorization?: (active: boolean, names: ReadonlySet<string>, bareNames: ReadonlySet<string>, namelessCallTypes: ReadonlySet<string>, providerExecuted: ReadonlySet<ProviderExecutedCallType>) => void;
readonly attached: boolean;
readonly ended: boolean;
attach(send: (frame: Record<string, unknown>) => void, fail: (error: Error) => void): () => void;
Expand Down
7 changes: 7 additions & 0 deletions src/server/responses/passthrough-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/server/ws-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,22 @@ function sendProtocolError(ws: ServerWebSocket<WsData>, 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<WsData>, 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<WsData>,
sseStream: ReadableStream<Uint8Array>,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion structure/transports/streaming-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions tests/responses/ws-native-injection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions tests/responses/ws-native-result-continuations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading