mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
891 lines
36 KiB
TypeScript
891 lines
36 KiB
TypeScript
/**
|
|
* Unit tests for the Codex app-server WS transport (CodexAppServerExecutor).
|
|
*
|
|
* Everything is exercised against a MOCK ws transport (no live connection):
|
|
* - gating: isCodexAppServerRequired selects the app-server path only when
|
|
* codexTransport==="app-server" (+ config + flag on)
|
|
* - lifecycle: the turn emits initialize → thread/start → turn/start in order
|
|
* - stall-guard: an inbound server approval request is auto-approved
|
|
* - mapping: notifications map to the correct AdapterEvents
|
|
* - bridge: streaming output is a valid SSE Response
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { isCodexAppServerRequired } from "../../open-sse/executors/codex.ts";
|
|
import { CodexAppServerExecutor } from "../../open-sse/executors/codex-app-server.ts";
|
|
import {
|
|
CodexAppServerClient,
|
|
type CodexWreqWebSocket,
|
|
} from "../../open-sse/executors/codex/appServerClient.ts";
|
|
import {
|
|
translateNotification,
|
|
translateToolCall,
|
|
dynamicToolWireName,
|
|
mapUsage,
|
|
} from "../../open-sse/executors/codex/appServerEvents.ts";
|
|
import { resolveAppServerConfig } from "../../open-sse/executors/codex/appServerConfig.ts";
|
|
import { probeCodexAppServerAuth } from "../../open-sse/executors/codex/appServerAuthProbe.ts";
|
|
import type { AdapterEvent } from "../../open-sse/vendor/codex-chatgpt-web/types.ts";
|
|
import type { ExecuteInput } from "../../open-sse/executors/base.ts";
|
|
|
|
// ── A scriptable fake wreq WebSocket ────────────────────────────────────────
|
|
// Records every frame the client sends, and lets the test drive server frames in.
|
|
interface FakeSocketController {
|
|
socket: CodexWreqWebSocket;
|
|
sent: Array<Record<string, unknown>>;
|
|
emit: (frame: Record<string, unknown>) => void;
|
|
emitError: (message: string) => void;
|
|
emitClose: () => void;
|
|
closed: boolean;
|
|
}
|
|
|
|
function makeFakeSocket(): FakeSocketController {
|
|
const sent: Array<Record<string, unknown>> = [];
|
|
const ctrl: FakeSocketController = {
|
|
sent,
|
|
closed: false,
|
|
socket: null as unknown as CodexWreqWebSocket,
|
|
emit: () => {},
|
|
emitError: () => {},
|
|
emitClose: () => {},
|
|
};
|
|
const socket: CodexWreqWebSocket = {
|
|
send: (data: string) => {
|
|
sent.push(JSON.parse(data));
|
|
},
|
|
close: () => {
|
|
ctrl.closed = true;
|
|
},
|
|
onmessage: null,
|
|
onerror: null,
|
|
onclose: null,
|
|
};
|
|
ctrl.socket = socket;
|
|
ctrl.emit = (frame) => socket.onmessage?.({ data: JSON.stringify(frame) });
|
|
ctrl.emitError = (message) => socket.onerror?.({ message });
|
|
ctrl.emitClose = () => socket.onclose?.();
|
|
return ctrl;
|
|
}
|
|
|
|
/** A websocketFn that hands out a pre-made fake socket and records the connect opts. */
|
|
function fakeTransport(ctrl: FakeSocketController) {
|
|
const calls: Array<{ url: string; opts?: Record<string, unknown> }> = [];
|
|
const fn = async (url: string, opts?: Record<string, unknown>) => {
|
|
calls.push({ url, opts });
|
|
return ctrl.socket;
|
|
};
|
|
return { fn, calls };
|
|
}
|
|
|
|
const APP_SERVER_PSD = {
|
|
codexTransport: "app-server",
|
|
codexAppServerUrl: "ws://ts-egress:1456",
|
|
codexAppServerToken: "deadbeef",
|
|
codexAppServerCwd: "/tmp",
|
|
};
|
|
|
|
function makeExecuteInput(overrides: Partial<ExecuteInput> = {}): ExecuteInput {
|
|
return {
|
|
model: "gpt-5.5",
|
|
body: { input: "hello there" },
|
|
stream: true,
|
|
credentials: { providerSpecificData: { ...APP_SERVER_PSD } },
|
|
...overrides,
|
|
} as ExecuteInput;
|
|
}
|
|
|
|
// ── Gating ──────────────────────────────────────────────────────────────────
|
|
|
|
test("isCodexAppServerRequired: true only when codexTransport==='app-server' + configured", () => {
|
|
assert.equal(
|
|
isCodexAppServerRequired({ providerSpecificData: { ...APP_SERVER_PSD } }),
|
|
true
|
|
);
|
|
// wrong transport
|
|
assert.equal(
|
|
isCodexAppServerRequired({
|
|
providerSpecificData: { ...APP_SERVER_PSD, codexTransport: "websocket" },
|
|
}),
|
|
false
|
|
);
|
|
// no providerSpecificData
|
|
assert.equal(isCodexAppServerRequired({}), false);
|
|
// transport set but not configured (no url/token, no env)
|
|
const prevUrl = process.env.OMNIROUTE_CODEX_APPSERVER_WS;
|
|
const prevTok = process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN;
|
|
const prevTokFile = process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE;
|
|
delete process.env.OMNIROUTE_CODEX_APPSERVER_WS;
|
|
delete process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN;
|
|
delete process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE;
|
|
try {
|
|
assert.equal(
|
|
isCodexAppServerRequired({ providerSpecificData: { codexTransport: "app-server" } }),
|
|
false
|
|
);
|
|
} finally {
|
|
if (prevUrl !== undefined) process.env.OMNIROUTE_CODEX_APPSERVER_WS = prevUrl;
|
|
if (prevTok !== undefined) process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN = prevTok;
|
|
if (prevTokFile !== undefined)
|
|
process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE = prevTokFile;
|
|
}
|
|
});
|
|
|
|
test("isCodexAppServerRequired: false when OMNIROUTE_CODEX_APP_SERVER_ENABLED=false", () => {
|
|
const prev = process.env.OMNIROUTE_CODEX_APP_SERVER_ENABLED;
|
|
process.env.OMNIROUTE_CODEX_APP_SERVER_ENABLED = "false";
|
|
try {
|
|
assert.equal(
|
|
isCodexAppServerRequired({ providerSpecificData: { ...APP_SERVER_PSD } }),
|
|
false
|
|
);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.OMNIROUTE_CODEX_APP_SERVER_ENABLED;
|
|
else process.env.OMNIROUTE_CODEX_APP_SERVER_ENABLED = prev;
|
|
}
|
|
});
|
|
|
|
test("resolveAppServerConfig: env fallback + token-file, ws-scheme validation", () => {
|
|
assert.equal(resolveAppServerConfig({ codexAppServerUrl: "http://x", codexAppServerToken: "t" }), null);
|
|
const cfg = resolveAppServerConfig({ ...APP_SERVER_PSD });
|
|
assert.deepEqual(cfg, { url: "ws://ts-egress:1456", token: "deadbeef", cwd: "/tmp" });
|
|
});
|
|
|
|
// ── Notification → AdapterEvent mapping ─────────────────────────────────────
|
|
|
|
test("translateNotification: maps deltas, done and error to AdapterEvents", () => {
|
|
const events: AdapterEvent[] = [];
|
|
const push = (e: AdapterEvent) => events.push(e);
|
|
|
|
assert.equal(
|
|
translateNotification("item/agentMessage/delta", { delta: "Hel" }, push),
|
|
false
|
|
);
|
|
assert.equal(
|
|
translateNotification("item/reasoning/textDelta", { delta: "think" }, push),
|
|
false
|
|
);
|
|
// terminal → returns true
|
|
assert.equal(
|
|
translateNotification(
|
|
"turn/completed",
|
|
{ turn: { usage: { input_tokens: 10, output_tokens: 5 } } },
|
|
push
|
|
),
|
|
true
|
|
);
|
|
|
|
assert.deepEqual(events[0], { type: "text_delta", text: "Hel" });
|
|
assert.deepEqual(events[1], { type: "thinking_delta", thinking: "think" });
|
|
assert.equal(events[2].type, "done");
|
|
const done = events[2] as Extract<AdapterEvent, { type: "done" }>;
|
|
assert.equal(done.endTurn, true);
|
|
assert.equal(done.usage?.inputTokens, 10);
|
|
assert.equal(done.usage?.outputTokens, 5);
|
|
});
|
|
|
|
test("translateNotification: error notification maps to error event (terminal)", () => {
|
|
const events: AdapterEvent[] = [];
|
|
const isTerminal = translateNotification(
|
|
"error",
|
|
{ error: { message: "boom" } },
|
|
(e) => events.push(e)
|
|
);
|
|
assert.equal(isTerminal, true);
|
|
assert.equal(events[0].type, "error");
|
|
const err = events[0] as Extract<AdapterEvent, { type: "error" }>;
|
|
assert.equal(err.message, "boom");
|
|
assert.equal(err.status, 502);
|
|
});
|
|
|
|
test("mapUsage: converts snake_case token counts", () => {
|
|
const usage = mapUsage({
|
|
input_tokens: 100,
|
|
cached_input_tokens: 20,
|
|
output_tokens: 40,
|
|
reasoning_output_tokens: 8,
|
|
});
|
|
assert.equal(usage?.inputTokens, 100);
|
|
assert.equal(usage?.cachedInputTokens, 20);
|
|
assert.equal(usage?.cacheReadInputTokens, 20);
|
|
assert.equal(usage?.outputTokens, 40);
|
|
assert.equal(usage?.reasoningOutputTokens, 8);
|
|
assert.equal(mapUsage(undefined), undefined);
|
|
});
|
|
|
|
// ── Client: approval stall-guard (deny-by-default, opt-in approve) ─────────
|
|
|
|
test("CodexAppServerClient: server approval request is auto-DENIED by default (#11205 hardening)", async () => {
|
|
const ctrl = makeFakeSocket();
|
|
const { fn } = fakeTransport(ctrl);
|
|
const client = new CodexAppServerClient({ websocketFn: fn });
|
|
await client.connect("ws://x", "tok");
|
|
|
|
// Auth header attached on connect
|
|
// (the fake records opts on connect via fakeTransport calls; verified in lifecycle test)
|
|
|
|
// Server sends an exec approval request with id=99.
|
|
ctrl.emit({
|
|
jsonrpc: "2.0",
|
|
id: 99,
|
|
method: "execCommandApproval",
|
|
params: { command: ["ls", "-la"], cwd: "/tmp" },
|
|
});
|
|
|
|
const reply = ctrl.sent.find((f) => f.id === 99);
|
|
assert.ok(reply, "client must reply to the server approval request");
|
|
// Security contract (post-#11205 review): codex's OWN command/file/permission
|
|
// executions are denied by default — approval prompts are NOT the harness
|
|
// tool-call passthrough (that path is item/tool/call, handled separately), so
|
|
// denying never sabotages harness tools. Blanket auto-approve + a permissive
|
|
// sandbox is a confused-deputy for prompt-injected turns.
|
|
assert.equal((reply!.result as Record<string, unknown>).decision, "denied");
|
|
});
|
|
|
|
test("CodexAppServerClient: approval request is auto-approved only with explicit opt-in", async () => {
|
|
const ctrl = makeFakeSocket();
|
|
const { fn } = fakeTransport(ctrl);
|
|
const client = new CodexAppServerClient({ websocketFn: fn, autoApproveApprovals: true });
|
|
await client.connect("ws://x", "tok");
|
|
|
|
ctrl.emit({
|
|
jsonrpc: "2.0",
|
|
id: 100,
|
|
method: "item/fileChange/requestApproval",
|
|
params: { changes: [] },
|
|
});
|
|
|
|
const reply = ctrl.sent.find((f) => f.id === 100);
|
|
assert.ok(reply, "client must reply to the server approval request");
|
|
assert.equal((reply!.result as Record<string, unknown>).decision, "approved");
|
|
});
|
|
|
|
test("CodexAppServerClient: non-approval server request gets a JSON-RPC error", async () => {
|
|
const ctrl = makeFakeSocket();
|
|
const { fn } = fakeTransport(ctrl);
|
|
const client = new CodexAppServerClient({ websocketFn: fn });
|
|
await client.connect("ws://x", "tok");
|
|
|
|
ctrl.emit({ jsonrpc: "2.0", id: 7, method: "item/tool/call", params: {} });
|
|
const reply = ctrl.sent.find((f) => f.id === 7);
|
|
assert.ok(reply);
|
|
assert.equal((reply!.error as { code: number }).code, -32601);
|
|
});
|
|
|
|
test("CodexAppServerClient: notifications reach the handler; responses settle requests", async () => {
|
|
const ctrl = makeFakeSocket();
|
|
const { fn } = fakeTransport(ctrl);
|
|
const client = new CodexAppServerClient({ websocketFn: fn });
|
|
await client.connect("ws://x", "tok");
|
|
|
|
const seen: string[] = [];
|
|
client.onNotification((method) => seen.push(method));
|
|
|
|
// Fire a request; the fake echoes an id-matched response.
|
|
const reqPromise = client.request("initialize", { clientInfo: {} });
|
|
const sentInit = ctrl.sent.find((f) => f.method === "initialize");
|
|
assert.ok(sentInit);
|
|
ctrl.emit({ jsonrpc: "2.0", id: sentInit!.id, result: { ok: true } });
|
|
const result = (await reqPromise) as { ok: boolean };
|
|
assert.equal(result.ok, true);
|
|
|
|
// A method-only frame is a notification.
|
|
ctrl.emit({ jsonrpc: "2.0", method: "item/agentMessage/delta", params: { delta: "x" } });
|
|
assert.ok(seen.includes("item/agentMessage/delta"));
|
|
});
|
|
|
|
// ── Executor: lifecycle order + streaming SSE Response ───────────────────────
|
|
|
|
/** Drive a full streaming turn against a fake transport and return the SSE text. */
|
|
async function runStreamingTurn(): Promise<{
|
|
sent: Array<Record<string, unknown>>;
|
|
sseText: string;
|
|
}> {
|
|
const ctrl = makeFakeSocket();
|
|
const { fn } = fakeTransport(ctrl);
|
|
const executor = new CodexAppServerExecutor({ websocketFn: fn });
|
|
|
|
// Auto-responder: as soon as the client sends a request, emit its response and,
|
|
// for turn/start, stream a couple of notifications + turn/completed.
|
|
const originalSend = ctrl.socket.send;
|
|
ctrl.socket.send = (data: string) => {
|
|
originalSend(data);
|
|
const frame = JSON.parse(data) as Record<string, unknown>;
|
|
if (frame.id == null || !frame.method) return;
|
|
queueMicrotask(() => {
|
|
if (frame.method === "thread/start") {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: { threadId: "thr_1" } });
|
|
} else if (frame.method === "turn/start") {
|
|
ctrl.emit({
|
|
jsonrpc: "2.0",
|
|
method: "item/agentMessage/delta",
|
|
params: { delta: "Hello" },
|
|
});
|
|
ctrl.emit({
|
|
jsonrpc: "2.0",
|
|
method: "turn/completed",
|
|
params: { turn: { usage: { input_tokens: 3, output_tokens: 2 } } },
|
|
});
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: {} });
|
|
} else {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: {} });
|
|
}
|
|
});
|
|
};
|
|
|
|
const result = await executor.execute(makeExecuteInput());
|
|
const response = "response" in result ? result.response : result;
|
|
assert.equal(response.status, 200);
|
|
assert.match(response.headers.get("Content-Type") ?? "", /text\/event-stream/);
|
|
const sseText = await response.text();
|
|
return { sent: ctrl.sent, sseText };
|
|
}
|
|
|
|
test("CodexAppServerExecutor: streaming turn emits initialize → thread/start → turn/start in order", async () => {
|
|
const { sent } = await runStreamingTurn();
|
|
const methods = sent.filter((f) => typeof f.method === "string" && f.id != null).map((f) => f.method);
|
|
const lifecycle = methods.filter(
|
|
(m) => m === "initialize" || m === "thread/start" || m === "turn/start"
|
|
);
|
|
assert.deepEqual(lifecycle, ["initialize", "thread/start", "turn/start"]);
|
|
|
|
// thread/start carried the router defaults: approvalPolicy:"never" (codex
|
|
// never blocks on its own approval) + sandbox:"workspace-write" — hardened
|
|
// default post-#11205 security review (was "danger-full-access"): codex's own
|
|
// sandbox now confines writes to the turn's cwd tree unless the operator
|
|
// explicitly opts back into a wider sandbox via providerSpecificData/env.
|
|
const threadStart = sent.find((f) => f.method === "thread/start");
|
|
assert.equal((threadStart!.params as Record<string, unknown>).approvalPolicy, "never");
|
|
assert.equal((threadStart!.params as Record<string, unknown>).sandbox, "workspace-write");
|
|
|
|
// turn/start carried the text input with text_elements:[]
|
|
const turnStart = sent.find((f) => f.method === "turn/start");
|
|
const turnParams = turnStart!.params as Record<string, unknown>;
|
|
assert.equal(turnParams.threadId, "thr_1");
|
|
assert.deepEqual(turnParams.input, [{ type: "text", text: "hello there", text_elements: [] }]);
|
|
});
|
|
|
|
test("CodexAppServerExecutor: streaming output is a valid Responses SSE stream", async () => {
|
|
const { sseText } = await runStreamingTurn();
|
|
assert.match(sseText, /event: response\.created/);
|
|
assert.match(sseText, /response\.output_text\.delta/);
|
|
assert.ok(sseText.includes("Hello"));
|
|
assert.match(sseText, /event: response\.completed/);
|
|
assert.ok(sseText.includes("[DONE]"));
|
|
});
|
|
|
|
test("CodexAppServerExecutor: unconfigured connection returns an in-band error Response", async () => {
|
|
const executor = new CodexAppServerExecutor({ websocketFn: async () => makeFakeSocket().socket });
|
|
const prevUrl = process.env.OMNIROUTE_CODEX_APPSERVER_WS;
|
|
const prevTok = process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN;
|
|
const prevTokFile = process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE;
|
|
delete process.env.OMNIROUTE_CODEX_APPSERVER_WS;
|
|
delete process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN;
|
|
delete process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE;
|
|
try {
|
|
const result = await executor.execute(
|
|
makeExecuteInput({ credentials: { providerSpecificData: { codexTransport: "app-server" } } })
|
|
);
|
|
const response = "response" in result ? result.response : result;
|
|
assert.equal(response.status, 503);
|
|
} finally {
|
|
if (prevUrl !== undefined) process.env.OMNIROUTE_CODEX_APPSERVER_WS = prevUrl;
|
|
if (prevTok !== undefined) process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN = prevTok;
|
|
if (prevTokFile !== undefined)
|
|
process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE = prevTokFile;
|
|
}
|
|
});
|
|
|
|
test("CodexAppServerExecutor: non-streaming turn returns a JSON Response", async () => {
|
|
const ctrl = makeFakeSocket();
|
|
const { fn } = fakeTransport(ctrl);
|
|
const executor = new CodexAppServerExecutor({ websocketFn: fn });
|
|
|
|
const originalSend = ctrl.socket.send;
|
|
ctrl.socket.send = (data: string) => {
|
|
originalSend(data);
|
|
const frame = JSON.parse(data) as Record<string, unknown>;
|
|
if (frame.id == null || !frame.method) return;
|
|
queueMicrotask(() => {
|
|
if (frame.method === "thread/start") {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: { threadId: "thr_1" } });
|
|
} else if (frame.method === "turn/start") {
|
|
ctrl.emit({
|
|
jsonrpc: "2.0",
|
|
method: "item/agentMessage/delta",
|
|
params: { delta: "Hi" },
|
|
});
|
|
ctrl.emit({ jsonrpc: "2.0", method: "turn/completed", params: { turn: {} } });
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: {} });
|
|
} else {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: {} });
|
|
}
|
|
});
|
|
};
|
|
|
|
const result = await executor.execute(makeExecuteInput({ stream: false }));
|
|
const response = "response" in result ? result.response : result;
|
|
assert.equal(response.status, 200);
|
|
assert.match(response.headers.get("Content-Type") ?? "", /application\/json/);
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
assert.ok(Array.isArray(body.output));
|
|
});
|
|
|
|
// ── Tool path: INBOUND advertise + OUTBOUND passthrough ──────────────────────
|
|
|
|
test("dynamicToolWireName: flattens namespaced tools, passes plain ones through", () => {
|
|
assert.equal(dynamicToolWireName("mcp__ctx7", "get_docs"), "mcp__ctx7__get_docs");
|
|
assert.equal(dynamicToolWireName(null, "read_file"), "read_file");
|
|
assert.equal(dynamicToolWireName(undefined, "read_file"), "read_file");
|
|
});
|
|
|
|
test("translateToolCall: emits tool_call_start/delta/end with callId, wire name, JSON args", () => {
|
|
const events: AdapterEvent[] = [];
|
|
translateToolCall(
|
|
{ callId: "call_42", namespace: null, tool: "get_weather", arguments: { city: "SF" } },
|
|
(e) => events.push(e)
|
|
);
|
|
assert.equal(events.length, 3);
|
|
assert.deepEqual(events[0], { type: "tool_call_start", id: "call_42", name: "get_weather" });
|
|
assert.deepEqual(events[1], { type: "tool_call_delta", arguments: '{"city":"SF"}' });
|
|
assert.deepEqual(events[2], { type: "tool_call_end" });
|
|
});
|
|
|
|
test("translateToolCall: restores MCP namespace into the wire name for the round-trip", () => {
|
|
const events: AdapterEvent[] = [];
|
|
translateToolCall(
|
|
{ callId: "call_9", namespace: "mcp__ctx7", tool: "get_docs", arguments: "{}" },
|
|
(e) => events.push(e)
|
|
);
|
|
const start = events[0] as Extract<AdapterEvent, { type: "tool_call_start" }>;
|
|
assert.equal(start.name, "mcp__ctx7__get_docs");
|
|
});
|
|
|
|
/**
|
|
* Drive a streaming turn where the harness advertises a function tool and codex
|
|
* responds by invoking it via the `item/tool/call` ServerRequest. Assert (a) the
|
|
* tool is advertised on thread/start via `dynamicTools`, (b) the app-server request
|
|
* is settled, and (c) the SSE stream carries a Responses function_call for the tool.
|
|
*/
|
|
async function runToolTurn(): Promise<{
|
|
sent: Array<Record<string, unknown>>;
|
|
sseText: string;
|
|
}> {
|
|
const ctrl = makeFakeSocket();
|
|
const { fn } = fakeTransport(ctrl);
|
|
const executor = new CodexAppServerExecutor({ websocketFn: fn });
|
|
|
|
const originalSend = ctrl.socket.send;
|
|
ctrl.socket.send = (data: string) => {
|
|
originalSend(data);
|
|
const frame = JSON.parse(data) as Record<string, unknown>;
|
|
if (frame.id == null || !frame.method) return;
|
|
queueMicrotask(() => {
|
|
if (frame.method === "thread/start") {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: { threadId: "thr_1" } });
|
|
} else if (frame.method === "turn/start") {
|
|
// codex invokes the harness tool via a server → client ServerRequest.
|
|
ctrl.emit({
|
|
jsonrpc: "2.0",
|
|
id: 5000,
|
|
method: "item/tool/call",
|
|
params: {
|
|
threadId: "thr_1",
|
|
turnId: "turn_1",
|
|
callId: "call_abc",
|
|
namespace: null,
|
|
tool: "get_weather",
|
|
arguments: { city: "SF" },
|
|
},
|
|
});
|
|
// Settle turn/start too (codex would eventually complete; the passthrough
|
|
// already ended the turn on our side).
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: {} });
|
|
} else {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: {} });
|
|
}
|
|
});
|
|
};
|
|
|
|
const input = makeExecuteInput({
|
|
body: {
|
|
input: "what's the weather?",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
name: "get_weather",
|
|
description: "Get the weather for a city",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { city: { type: "string" } },
|
|
required: ["city"],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await executor.execute(input);
|
|
const response = "response" in result ? result.response : result;
|
|
const sseText = await response.text();
|
|
return { sent: ctrl.sent, sseText };
|
|
}
|
|
|
|
test("CodexAppServerExecutor: advertises harness tools to codex via thread/start dynamicTools", async () => {
|
|
const { sent } = await runToolTurn();
|
|
const threadStart = sent.find((f) => f.method === "thread/start");
|
|
assert.ok(threadStart, "thread/start must be sent");
|
|
const params = threadStart!.params as Record<string, unknown>;
|
|
const dynamicTools = params.dynamicTools as Array<Record<string, unknown>> | undefined;
|
|
assert.ok(Array.isArray(dynamicTools), "dynamicTools must be advertised");
|
|
assert.equal(dynamicTools!.length, 1);
|
|
assert.equal(dynamicTools![0].type, "function");
|
|
assert.equal(dynamicTools![0].name, "get_weather");
|
|
assert.ok(dynamicTools![0].inputSchema, "spec carries the inputSchema");
|
|
|
|
// experimentalApi capability opted in on initialize (dynamicTools is experimental)
|
|
const init = sent.find((f) => f.method === "initialize");
|
|
const caps = (init!.params as Record<string, unknown>).capabilities as Record<string, unknown>;
|
|
assert.equal(caps.experimentalApi, true);
|
|
});
|
|
|
|
test("CodexAppServerExecutor: item/tool/call is settled and surfaced as a Responses function_call", async () => {
|
|
const { sent, sseText } = await runToolTurn();
|
|
|
|
// The app-server request (id 5000) must be settled so the socket never stalls.
|
|
const toolReply = sent.find((f) => f.id === 5000);
|
|
assert.ok(toolReply, "the item/tool/call request id must be settled");
|
|
const replyResult = toolReply!.result as Record<string, unknown>;
|
|
assert.ok(replyResult, "settled with a DynamicToolCallResponse result");
|
|
assert.equal(replyResult.success, false);
|
|
assert.ok(Array.isArray(replyResult.contentItems));
|
|
|
|
// The SSE stream carries the harness function_call for get_weather with its args.
|
|
assert.match(sseText, /function_call/);
|
|
assert.ok(sseText.includes("get_weather"));
|
|
assert.ok(sseText.includes("call_abc"), "the codex callId is relayed as the call_id");
|
|
assert.ok(sseText.includes("SF"), "the tool arguments are relayed");
|
|
assert.match(sseText, /event: response\.completed/);
|
|
assert.ok(sseText.includes("[DONE]"));
|
|
});
|
|
|
|
// REGRESSION (live BUG#3, 2026-08-22): the real codex app-server ACCEPTS a turn
|
|
// on turn/start (returns status:"inProgress") and delivers the model output +
|
|
// terminal turn/completed LATER as async notifications. The original run() closed
|
|
// the WS in its finally-block as soon as `await turn/start` resolved, tearing the
|
|
// socket down BEFORE those notifications arrived, so the event queue never closed
|
|
// and the request hung until the caller's timeout. The pre-existing mocks hid this
|
|
// because they emitted turn/completed in the SAME microtask as the turn/start
|
|
// response (completion raced ahead of request-resolution). This test reproduces
|
|
// the real ordering: turn/start resolves FIRST, then agentMessage/delta +
|
|
// turn/completed fire on a later macrotask. It must still complete (not hang).
|
|
test("CodexAppServerExecutor: async post-turn/start completion does not close the socket early (BUG#3)", async () => {
|
|
const ctrl = makeFakeSocket();
|
|
const { fn } = fakeTransport(ctrl);
|
|
const executor = new CodexAppServerExecutor({ websocketFn: fn });
|
|
|
|
// Model a REAL socket: once closed, it delivers no more frames. The shared
|
|
// makeFakeSocket keeps emitting after close (fine for the other tests), but
|
|
// this regression turns specifically on the fact that a prematurely-closed
|
|
// socket DROPS the later turn/completed — so guard emits on ctrl.closed here.
|
|
const emitLive = (frame: Record<string, unknown>) => {
|
|
if (ctrl.closed) return; // socket torn down → frame never arrives (real behavior)
|
|
ctrl.emit(frame);
|
|
};
|
|
|
|
const originalSend = ctrl.socket.send;
|
|
ctrl.socket.send = (data: string) => {
|
|
originalSend(data);
|
|
const frame = JSON.parse(data) as Record<string, unknown>;
|
|
if (frame.id == null || !frame.method) return;
|
|
if (frame.method === "thread/start") {
|
|
queueMicrotask(() =>
|
|
emitLive({ jsonrpc: "2.0", id: frame.id, result: { thread: { id: "thr_async" } } })
|
|
);
|
|
} else if (frame.method === "turn/start") {
|
|
// Resolve turn/start FIRST (status inProgress) …
|
|
queueMicrotask(() =>
|
|
emitLive({
|
|
jsonrpc: "2.0",
|
|
id: frame.id,
|
|
result: { turn: { id: "t1", status: "inProgress" } },
|
|
})
|
|
);
|
|
// … then, on a LATER macrotask, stream the output + terminal completion.
|
|
// Under the OLD code the finally-block closes the socket right after
|
|
// turn/start resolves, so ctrl.closed is true here and these frames are
|
|
// DROPPED → the queue never closes → execute() hangs (test times out).
|
|
setTimeout(() => {
|
|
emitLive({
|
|
jsonrpc: "2.0",
|
|
method: "item/agentMessage/delta",
|
|
params: { delta: "ASYNC-OK" },
|
|
});
|
|
emitLive({
|
|
jsonrpc: "2.0",
|
|
method: "turn/completed",
|
|
params: { turn: { usage: { input_tokens: 1, output_tokens: 1 } } },
|
|
});
|
|
}, 15);
|
|
} else {
|
|
queueMicrotask(() => emitLive({ jsonrpc: "2.0", id: frame.id, result: {} }));
|
|
}
|
|
};
|
|
|
|
// Non-streaming: execute() awaits events.collect(), which only returns once the
|
|
// queue closes on the terminal notification. Under the old (buggy) code the
|
|
// socket closed early, the terminal frame was dropped, and this promise never
|
|
// resolved. Guard with a timeout so a regression fails loudly, not by hanging.
|
|
const result = await Promise.race([
|
|
executor.execute(makeExecuteInput({ stream: false })),
|
|
new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new Error("execute() hung: socket closed before async completion (BUG#3 regressed)")), 5000)
|
|
),
|
|
]);
|
|
const response = "response" in result ? result.response : (result as Response);
|
|
assert.equal(response.status, 200);
|
|
const body = JSON.parse(await response.text()) as {
|
|
status?: string;
|
|
output?: Array<{ content?: Array<{ text?: string }> }>;
|
|
};
|
|
assert.equal(body.status, "completed", "the turn completed after the async terminal notification");
|
|
const text = body.output?.[0]?.content?.[0]?.text ?? "";
|
|
assert.equal(text, "ASYNC-OK", "the model output that arrived AFTER turn/start is present");
|
|
});
|
|
|
|
// ── Layer-2 auth-status probe (probeCodexAppServerAuth) ─────────────────────
|
|
// /readyz proves the server PROCESS is up but NOT that its Codex CLI is signed
|
|
// in. probeCodexAppServerAuth opens the JSON-RPC WS and reads account/read:
|
|
// authenticated → { account: {...} } ; logged out → no account (or auth error).
|
|
// Verified against codex 0.149.0: account/read returns
|
|
// { account: { type, email, planType }, requiresOpenaiAuth }.
|
|
|
|
/** A fake websocketFn that answers initialize + account/read with a scripted result. */
|
|
function fakeAuthTransport(accountReadResponse: {
|
|
result?: Record<string, unknown>;
|
|
error?: { code: number; message: string };
|
|
}) {
|
|
const ctrl = makeFakeSocket();
|
|
const originalSend = ctrl.socket.send;
|
|
ctrl.socket.send = (data: string) => {
|
|
originalSend(data);
|
|
const frame = JSON.parse(data) as Record<string, unknown>;
|
|
if (frame.id == null || !frame.method) return;
|
|
queueMicrotask(() => {
|
|
if (frame.method === "initialize") {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: { ok: true } });
|
|
} else if (frame.method === "account/read") {
|
|
if (accountReadResponse.error) {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, error: accountReadResponse.error });
|
|
} else {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: accountReadResponse.result ?? {} });
|
|
}
|
|
} else {
|
|
ctrl.emit({ jsonrpc: "2.0", id: frame.id, result: {} });
|
|
}
|
|
});
|
|
};
|
|
const fn = async () => ctrl.socket;
|
|
return fn;
|
|
}
|
|
|
|
const AUTH_CONFIG = { url: "ws://ts-egress:1456", token: "deadbeef", cwd: "/tmp" };
|
|
|
|
test("probeCodexAppServerAuth: account with email → authenticated", async () => {
|
|
const fn = fakeAuthTransport({
|
|
result: { account: { type: "chatgpt", email: "user@example.com", planType: "pro" }, requiresOpenaiAuth: true },
|
|
});
|
|
const status = await probeCodexAppServerAuth(AUTH_CONFIG, fn, 3000);
|
|
assert.equal(status.state, "authenticated");
|
|
if (status.state === "authenticated") {
|
|
assert.equal(status.account.email, "user@example.com");
|
|
assert.equal(status.account.planType, "pro");
|
|
}
|
|
});
|
|
|
|
test("probeCodexAppServerAuth: no account → logged_out", async () => {
|
|
const fn = fakeAuthTransport({ result: { requiresOpenaiAuth: true } }); // no `account`
|
|
const status = await probeCodexAppServerAuth(AUTH_CONFIG, fn, 3000);
|
|
assert.equal(status.state, "logged_out");
|
|
});
|
|
|
|
test("probeCodexAppServerAuth: auth-error on account/read → logged_out", async () => {
|
|
const fn = fakeAuthTransport({ error: { code: -32000, message: "AuthRequiredError: please login" } });
|
|
const status = await probeCodexAppServerAuth(AUTH_CONFIG, fn, 3000);
|
|
assert.equal(status.state, "logged_out");
|
|
});
|
|
|
|
test("probeCodexAppServerAuth: no transport → unknown (does not throw)", async () => {
|
|
const status = await probeCodexAppServerAuth(AUTH_CONFIG, null, 3000);
|
|
assert.equal(status.state, "unknown");
|
|
});
|
|
// ── Security hardening (#11205 post-merge review) ───────────────────────────
|
|
// Two findings from the automated push review on the original #11205 merge:
|
|
// (1) the readyz health probe sent the bearer token to any URL a connection
|
|
// config pointed at, following redirects (SSRF / credential exfil);
|
|
// (2) env-sourced credentials were happily paired with a
|
|
// providerSpecificData-sourced URL, so anyone able to write a connection
|
|
// could harvest the operator's env token.
|
|
// The binding rule: env-sourced tokens are only sent to env-sourced URLs or to
|
|
// operator-local hosts (loopback / RFC1918 / link-local / ULA / localhost /
|
|
// single-label LAN names / *.local / *.ts.net / *.internal). A psd-sourced
|
|
// token may go anywhere — whoever wrote the psd already knows it.
|
|
|
|
function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
|
|
const prev: Record<string, string | undefined> = {};
|
|
for (const k of Object.keys(vars)) {
|
|
prev[k] = process.env[k];
|
|
if (vars[k] === undefined) delete process.env[k];
|
|
else process.env[k] = vars[k];
|
|
}
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
for (const k of Object.keys(vars)) {
|
|
if (prev[k] === undefined) delete process.env[k];
|
|
else process.env[k] = prev[k];
|
|
}
|
|
}
|
|
}
|
|
|
|
const BINDING_ENV_KEYS = {
|
|
OMNIROUTE_CODEX_APPSERVER_WS: undefined,
|
|
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN: "env-token-hex",
|
|
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE: undefined,
|
|
} as const;
|
|
|
|
test("resolveAppServerConfig: refuses env token → remote psd URL (SSRF binding)", () => {
|
|
withEnv({ ...BINDING_ENV_KEYS }, () => {
|
|
// attacker/lower-priv connection config points the URL at an outside host;
|
|
// the env token must NOT be attached → unconfigured (null), feature off.
|
|
assert.equal(
|
|
resolveAppServerConfig({
|
|
codexTransport: "app-server",
|
|
codexAppServerUrl: "wss://evil.example.com:8443",
|
|
}),
|
|
null
|
|
);
|
|
// dotted hostnames are not local even when they look benign
|
|
assert.equal(
|
|
resolveAppServerConfig({
|
|
codexTransport: "app-server",
|
|
codexAppServerUrl: "ws://appserver.evil-corp.io:1456",
|
|
}),
|
|
null
|
|
);
|
|
});
|
|
});
|
|
|
|
test("resolveAppServerConfig: env token allowed to operator-local psd URLs", () => {
|
|
withEnv({ ...BINDING_ENV_KEYS }, () => {
|
|
const localUrls = [
|
|
"ws://127.0.0.1:1456",
|
|
"ws://localhost:1456",
|
|
"ws://[::1]:1456",
|
|
"ws://10.0.0.5:1456",
|
|
"ws://172.16.3.4:1456",
|
|
"ws://192.168.0.15:1456",
|
|
"ws://169.254.1.1:1456",
|
|
"ws://ts-egress:1456", // single-label LAN/hosts-file name
|
|
"ws://codex.local:1456",
|
|
"ws://node1.ts.net:1456",
|
|
"ws://sidecar.internal:1456",
|
|
];
|
|
for (const url of localUrls) {
|
|
const cfg = resolveAppServerConfig({ codexTransport: "app-server", codexAppServerUrl: url });
|
|
assert.ok(cfg, `expected env token to bind to local URL ${url}`);
|
|
assert.equal(cfg!.token, "env-token-hex");
|
|
}
|
|
});
|
|
});
|
|
|
|
test("resolveAppServerConfig: psd-sourced token may pair with any psd URL", () => {
|
|
withEnv(
|
|
{
|
|
OMNIROUTE_CODEX_APPSERVER_WS: undefined,
|
|
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN: undefined,
|
|
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE: undefined,
|
|
},
|
|
() => {
|
|
const cfg = resolveAppServerConfig({
|
|
codexTransport: "app-server",
|
|
codexAppServerUrl: "wss://codex.remote.example.com:443",
|
|
codexAppServerToken: "psd-token",
|
|
});
|
|
assert.ok(cfg, "psd token + psd URL is self-consistent, allowed");
|
|
assert.equal(cfg!.token, "psd-token");
|
|
}
|
|
);
|
|
});
|
|
|
|
test("resolveAppServerConfig: env URL + env token pairs regardless of host", () => {
|
|
withEnv(
|
|
{
|
|
OMNIROUTE_CODEX_APPSERVER_WS: "wss://codex-remote.example.com:8443",
|
|
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN: "env-token-hex",
|
|
OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE: undefined,
|
|
},
|
|
() => {
|
|
const cfg = resolveAppServerConfig({ codexTransport: "app-server" });
|
|
assert.ok(cfg, "operator's own env pair is self-consistent, allowed");
|
|
assert.equal(cfg!.url, "wss://codex-remote.example.com:8443");
|
|
}
|
|
);
|
|
});
|
|
|
|
// ── Health probe: redirect pinning + binding inheritance ────────────────────
|
|
|
|
test("testCodexAppServerConnection: readyz probe pins redirects (no token leak via 30x)", async () => {
|
|
const { testCodexAppServerConnection } = await import(
|
|
"../../src/app/api/providers/[id]/test/codexAppServerHealth.ts"
|
|
);
|
|
const originalFetch = globalThis.fetch;
|
|
const seen: Array<{ url: string; init?: RequestInit }> = [];
|
|
globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
|
|
seen.push({ url: String(url), init });
|
|
return new Response("not ready", { status: 503 });
|
|
}) as typeof fetch;
|
|
try {
|
|
const result = await testCodexAppServerConnection({
|
|
provider: "codex-app-server",
|
|
providerSpecificData: {
|
|
codexAppServerUrl: "ws://127.0.0.1:1456",
|
|
codexAppServerToken: "deadbeef",
|
|
},
|
|
});
|
|
assert.ok(result, "app-server provider must take the readyz path");
|
|
assert.equal(result!.valid, false);
|
|
assert.equal(seen.length, 1);
|
|
assert.equal(seen[0].url, "http://127.0.0.1:1456/readyz");
|
|
assert.equal(seen[0].init?.redirect, "manual", "bearer token must never follow a redirect");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("testCodexAppServerConnection: env token + remote psd URL reports unconfigured, no network", async () => {
|
|
const { testCodexAppServerConnection } = await import(
|
|
"../../src/app/api/providers/[id]/test/codexAppServerHealth.ts"
|
|
);
|
|
const originalFetch = globalThis.fetch;
|
|
let fetched = false;
|
|
globalThis.fetch = (async () => {
|
|
fetched = true;
|
|
return new Response("ok", { status: 200 });
|
|
}) as typeof fetch;
|
|
try {
|
|
await withEnv({ ...BINDING_ENV_KEYS }, async () => {
|
|
const result = await testCodexAppServerConnection({
|
|
provider: "codex-app-server",
|
|
providerSpecificData: { codexAppServerUrl: "wss://evil.example.com:8443" },
|
|
});
|
|
assert.ok(result);
|
|
assert.equal(result!.valid, false);
|
|
assert.match(String((result!.diagnosis as { code?: string })?.code), /app_server_unconfigured/);
|
|
});
|
|
assert.equal(fetched, false, "binding refusal must happen before any network call");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|