Route web through local Codex gateway

This commit is contained in:
matamune 2026-05-16 00:46:48 +00:00
parent b549004678
commit c96668ec76
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
28 changed files with 1567 additions and 40 deletions

25
apps/gateway/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "codex-gateway-local",
"version": "0.1.0",
"description": "Local Codex gateway server for browser and transport clients.",
"type": "module",
"private": true,
"license": "Apache-2.0",
"bin": {
"codex-gateway-local": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"check:types": "tsc --noEmit",
"start": "bun ./src/index.ts serve",
"test": "bun test test/*.test.ts"
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

99
apps/gateway/src/args.ts Normal file
View file

@ -0,0 +1,99 @@
export type GatewayCliArgs =
| {
type: "serve";
port: number;
hostname: string;
appServerUrl?: string;
localAppServer: boolean;
}
| {
type: "help";
text: string;
};
export function parseArgs(
argv: string[],
env: Record<string, string | undefined> = process.env,
): GatewayCliArgs {
if (argv.includes("--help") || argv.includes("-h")) {
return { type: "help", text: helpText() };
}
const command = argv.find((value) => !value.startsWith("--")) ?? "serve";
if (command !== "serve") {
throw new Error(`Unknown command: ${command}`);
}
const appServerUrl =
stringFlag(argv, "app-server-url") ?? env.CODEX_GATEWAY_APP_SERVER_URL;
const localAppServer = booleanFlag(argv, "local-app-server") ||
booleanEnv(env.CODEX_GATEWAY_LOCAL_APP_SERVER);
if (appServerUrl && localAppServer) {
throw new Error("Cannot set both --local-app-server and --app-server-url.");
}
return {
type: "serve",
port: integerFlag(argv, "port") ??
integerEnv(env.CODEX_GATEWAY_PORT) ??
3586,
hostname: stringFlag(argv, "host") ?? env.CODEX_GATEWAY_HOST ?? "127.0.0.1",
appServerUrl,
localAppServer,
};
}
function stringFlag(args: string[], name: string): string | undefined {
const prefix = `--${name}=`;
const inline = args.find((arg) => arg.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length) || undefined;
}
const index = args.indexOf(`--${name}`);
if (index >= 0) {
return args[index + 1]?.trim() || undefined;
}
return undefined;
}
function integerFlag(args: string[], name: string): number | undefined {
const value = stringFlag(args, name);
return value ? parsePositiveInteger(value) : undefined;
}
function integerEnv(value: string | undefined): number | undefined {
return value ? parsePositiveInteger(value) : undefined;
}
function parsePositiveInteger(value: string): number | undefined {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function booleanFlag(args: string[], name: string): boolean {
return args.includes(`--${name}`);
}
function booleanEnv(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" ||
normalized === "on";
}
function helpText(): string {
return `codex-gateway-local serves the local Codex gateway protocol.
Usage:
codex-gateway-local serve [options]
Options:
--host <host> Host to bind. Defaults to 127.0.0.1.
--port <port> Port to bind. Defaults to 3586.
--app-server-url <url> Existing app-server WebSocket URL.
--local-app-server Start a local app-server over stdio.
--help, -h Show this help.
Environment:
CODEX_GATEWAY_HOST
CODEX_GATEWAY_PORT
CODEX_GATEWAY_APP_SERVER_URL
CODEX_GATEWAY_LOCAL_APP_SERVER
`;
}

152
apps/gateway/src/index.ts Normal file
View file

@ -0,0 +1,152 @@
#!/usr/bin/env bun
import {
CodexAppServerClient,
CodexStdioTransport,
} from "@peezy.tech/codex-flows";
import {
CodexGatewayProtocolServer,
type CodexGatewayPeer,
} from "@peezy.tech/codex-flows/gateway";
import { parseArgs, type GatewayCliArgs } from "./args.ts";
const defaultAppServerUrl = "ws://127.0.0.1:3585";
async function main(): Promise<void> {
const parsed = parseArgs(Bun.argv.slice(2), process.env);
if (parsed.type === "help") {
process.stdout.write(parsed.text);
return;
}
const client = createAppServerClient(parsed);
client.on("stderr", (line) => process.stderr.write(`${line}\n`));
await client.connect();
const gateway = new CodexGatewayProtocolServer({
appServer: client,
serverName: "codex-gateway-local",
serverVersion: "0.1.0",
});
const peers = new WeakMap<Bun.ServerWebSocket<unknown>, CodexGatewayPeer>();
const server = Bun.serve({
hostname: parsed.hostname,
port: parsed.port,
fetch(request, bunServer) {
if (bunServer.upgrade(request)) {
return undefined;
}
return new Response("Codex gateway WebSocket server\n", {
status: 426,
headers: { "content-type": "text/plain; charset=utf-8" },
});
},
websocket: {
open(socket) {
const peer: CodexGatewayPeer = {
send: (message) => socket.send(message),
};
peers.set(socket, peer);
gateway.addPeer(peer);
},
message(socket, message) {
const peer = peers.get(socket);
if (!peer) {
return;
}
void gateway.handleMessage(peer, websocketMessageToString(message))
.catch((error: unknown) => {
gateway.sendGatewayEvent(peer, {
type: "appServer.error",
at: new Date().toISOString(),
message: errorMessage(error),
});
});
},
close(socket) {
const peer = peers.get(socket);
if (peer) {
gateway.removePeer(peer);
peers.delete(socket);
}
},
},
});
process.stdout.write(
`codex-gateway-local listening on ws://${server.hostname}:${server.port}\n`,
);
process.stdout.write(
`codex-gateway-local app-server ${
parsed.localAppServer
? "local stdio"
: parsed.appServerUrl ??
process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ??
defaultAppServerUrl
}\n`,
);
await waitForShutdown(server, client);
}
function createAppServerClient(
args: Extract<GatewayCliArgs, { type: "serve" }>,
): CodexAppServerClient {
const appServerUrl =
args.appServerUrl ??
process.env.CODEX_WORKSPACE_APP_SERVER_WS_URL ??
defaultAppServerUrl;
return new CodexAppServerClient({
transport: args.localAppServer
? new CodexStdioTransport({
args: localAppServerArgs(),
requestTimeoutMs: 90_000,
})
: undefined,
webSocketTransportOptions: args.localAppServer
? undefined
: { url: appServerUrl, requestTimeoutMs: 90_000 },
clientName: "codex-gateway-local",
clientTitle: "Codex Gateway Local",
clientVersion: "0.1.0",
});
}
function localAppServerArgs(): string[] {
return [
"app-server",
"--listen",
"stdio://",
"--enable",
"apps",
"--enable",
"hooks",
];
}
function websocketMessageToString(message: string | Buffer): string {
return typeof message === "string" ? message : message.toString("utf8");
}
function waitForShutdown(
server: Bun.Server<unknown>,
client: CodexAppServerClient,
): Promise<void> {
return new Promise((resolve) => {
const shutdown = () => {
process.off("SIGINT", shutdown);
process.off("SIGTERM", shutdown);
server.stop(true);
client.close();
resolve();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
await main();

View file

@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import { parseArgs } from "../src/args.ts";
describe("parseArgs", () => {
test("defaults to serving the local gateway port", () => {
expect(parseArgs([], {})).toEqual({
type: "serve",
hostname: "127.0.0.1",
port: 3586,
appServerUrl: undefined,
localAppServer: false,
});
});
test("accepts host, port, and app-server URL flags", () => {
expect(
parseArgs([
"serve",
"--host",
"0.0.0.0",
"--port=4599",
"--app-server-url",
"ws://127.0.0.1:3585",
], {}),
).toEqual({
type: "serve",
hostname: "0.0.0.0",
port: 4599,
appServerUrl: "ws://127.0.0.1:3585",
localAppServer: false,
});
});
test("rejects local stdio and explicit WebSocket app-server together", () => {
expect(() =>
parseArgs([
"serve",
"--local-app-server",
"--app-server-url",
"ws://127.0.0.1:3585",
], {}),
).toThrow("Cannot set both --local-app-server and --app-server-url.");
});
test("reads environment overrides", () => {
expect(
parseArgs([], {
CODEX_GATEWAY_HOST: "0.0.0.0",
CODEX_GATEWAY_PORT: "4599",
CODEX_GATEWAY_LOCAL_APP_SERVER: "yes",
}),
).toEqual({
type: "serve",
hostname: "0.0.0.0",
port: 4599,
appServerUrl: undefined,
localAppServer: true,
});
});
});

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"types": ["node", "bun"],
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"]
}
},
"include": ["src", "test"]
}

View file

@ -8,7 +8,8 @@
"build": "tsc -b && vite build",
"check:types": "tsc --noEmit",
"dev": "vite --host 127.0.0.1",
"preview": "vite preview --host 127.0.0.1"
"preview": "vite preview --host 127.0.0.1",
"test": "bun test test/*.test.ts"
},
"dependencies": {
"@workspace/ui": "workspace:*",

View file

@ -24,7 +24,6 @@ import {
} from "react";
import {
CodexAppServerClient,
JsonRpcError,
createCodexAuthClient,
type CodexAuthClient,
@ -33,8 +32,13 @@ import {
type JsonRpcRequest,
type v2,
} from "@peezy.tech/codex-flows/browser";
import {
CodexGatewayClient,
type GatewayEvent,
} from "@peezy.tech/codex-flows/gateway";
import { ThemeProvider } from "./components/theme-provider.tsx";
import { gatewayStorageKey, initialGatewayWsUrl } from "./gateway-url.ts";
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
@ -46,9 +50,6 @@ type EventLogEntry = {
body?: string;
};
const defaultWsUrl =
import.meta.env.VITE_CODEX_APP_SERVER_WS_URL ?? defaultProxiedWsUrl();
export function App() {
return (
<ThemeProvider>
@ -58,9 +59,15 @@ export function App() {
}
function BareCodexApp() {
const clientRef = useRef<CodexAppServerClient | null>(null);
const clientRef = useRef<CodexGatewayClient | null>(null);
const authRef = useRef<CodexAuthClient | null>(null);
const [wsUrl, setWsUrl] = useState(initialWsUrl);
const [wsUrl, setWsUrl] = useState(() =>
initialGatewayWsUrl({
envUrl: import.meta.env.VITE_CODEX_GATEWAY_WS_URL,
location: window.location,
storage: window.localStorage,
})
);
const [connectedUrl, setConnectedUrl] = useState<string>();
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const [error, setError] = useState<string>();
@ -199,7 +206,7 @@ function BareCodexApp() {
}
clientRef.current?.close();
const client = new CodexAppServerClient({
const client = new CodexGatewayClient({
webSocketTransportOptions: { url, requestTimeoutMs: 90_000 },
clientName: "bare-web",
clientTitle: "Codex Bare Web",
@ -216,10 +223,17 @@ function BareCodexApp() {
body: previewJson(message.params, 900),
});
});
client.on("gatewayEvent", (event: GatewayEvent) => {
appendEvent({
kind: "control",
title: `gateway ${event.type}`,
body: previewJson(event, 900),
});
});
client.on("error", (eventError: unknown) => {
appendEvent({
kind: "error",
title: "transport error",
title: "gateway transport error",
body: errorMessage(eventError),
});
setError(errorMessage(eventError));
@ -241,7 +255,7 @@ function BareCodexApp() {
setError(undefined);
try {
await client.connect();
window.localStorage.setItem("codex-bare.ws-url", url);
window.localStorage.setItem(gatewayStorageKey, url);
setConnectedUrl(url);
setStatus("connected");
appendEvent({ kind: "control", title: "connected", body: url });
@ -492,7 +506,7 @@ function BareCodexApp() {
<h1 className="truncate text-base font-semibold">Codex Bare</h1>
</div>
<p className="truncate text-xs text-muted-foreground">
{connectedUrl ?? "No app-server connection"}
{connectedUrl ?? "No gateway connection"}
</p>
</div>
<form
@ -505,7 +519,7 @@ function BareCodexApp() {
<input
className="h-9 min-w-0 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 md:flex-1"
onChange={(event) => setWsUrl(event.target.value)}
placeholder="ws://127.0.0.1:3585"
placeholder="ws://127.0.0.1:3586"
value={wsUrl}
/>
<div className="flex gap-2">
@ -1116,15 +1130,6 @@ function stringValue(value: unknown) {
return typeof value === "string" && value ? value : undefined;
}
function initialWsUrl() {
return window.localStorage.getItem("codex-bare.ws-url") ?? defaultWsUrl;
}
function defaultProxiedWsUrl() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/__codex-app-server`;
}
function cx(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}

View file

@ -0,0 +1,20 @@
export const gatewayStorageKey = "codex-bare.gateway-url";
export type GatewayUrlOptions = {
envUrl?: string;
location: Pick<Location, "host" | "protocol">;
storage?: Pick<Storage, "getItem">;
};
export function initialGatewayWsUrl(options: GatewayUrlOptions): string {
return options.storage?.getItem(gatewayStorageKey) ??
options.envUrl ??
proxiedGatewayWsUrl(options.location);
}
export function proxiedGatewayWsUrl(
location: Pick<Location, "host" | "protocol">,
): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${location.host}/__codex-gateway`;
}

View file

@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import {
gatewayStorageKey,
initialGatewayWsUrl,
proxiedGatewayWsUrl,
} from "../src/gateway-url.ts";
describe("gateway URLs", () => {
test("uses the proxied gateway path on http origins", () => {
expect(proxiedGatewayWsUrl({ protocol: "http:", host: "localhost:5173" }))
.toBe("ws://localhost:5173/__codex-gateway");
});
test("uses wss for https origins", () => {
expect(proxiedGatewayWsUrl({ protocol: "https:", host: "flows.peezy.tech" }))
.toBe("wss://flows.peezy.tech/__codex-gateway");
});
test("prefers stored gateway URLs over env defaults", () => {
const values = new Map<string, string>([
[gatewayStorageKey, "ws://127.0.0.1:4599"],
]);
expect(
initialGatewayWsUrl({
envUrl: "ws://127.0.0.1:3586",
location: { protocol: "http:", host: "localhost:5173" },
storage: { getItem: (key) => values.get(key) ?? null },
}),
).toBe("ws://127.0.0.1:4599");
});
test("uses env defaults before deriving the proxied URL", () => {
expect(
initialGatewayWsUrl({
envUrl: "ws://127.0.0.1:3586",
location: { protocol: "http:", host: "localhost:5173" },
}),
).toBe("ws://127.0.0.1:3586");
});
});

View file

@ -25,6 +25,7 @@
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/browser": ["../../packages/codex-client/src/browser.ts"],
"@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.ts"],
"@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"],
"@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"],
"@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"],
"@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"],

View file

@ -11,6 +11,7 @@
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/browser": ["../../packages/codex-client/src/browser.ts"],
"@peezy.tech/codex-flows/flows": ["../../packages/codex-client/src/app-server/flows.ts"],
"@peezy.tech/codex-flows/gateway": ["../../packages/codex-client/src/gateway/index.ts"],
"@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"],
"@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"],
"@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"],

View file

@ -7,8 +7,8 @@ const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
const codexAppServerTarget =
process.env.VITE_CODEX_APP_SERVER_PROXY_TARGET ?? "ws://127.0.0.1:3585";
const codexGatewayTarget =
process.env.VITE_CODEX_GATEWAY_PROXY_TARGET ?? "ws://127.0.0.1:3586";
export default defineConfig({
base: process.env.VITE_BASE_PATH ?? "/",
@ -20,6 +20,10 @@ export default defineConfig({
__dirname,
"../../packages/codex-client/src/browser.ts",
),
"@peezy.tech/codex-flows/gateway": path.resolve(
__dirname,
"../../packages/codex-client/src/gateway/index.ts",
),
"@peezy.tech/codex-flows": path.resolve(
__dirname,
"../../packages/codex-client/src/index.ts",
@ -42,8 +46,8 @@ export default defineConfig({
server: {
allowedHosts: allowedHosts.length > 0 ? allowedHosts : undefined,
proxy: {
"/__codex-app-server": {
target: codexAppServerTarget,
"/__codex-gateway": {
target: codexGatewayTarget,
ws: true,
rewrite: () => "/",
configure: (proxy) => {

View file

@ -67,6 +67,21 @@
"typescript": "catalog:",
},
},
"apps/gateway": {
"name": "codex-gateway-local",
"version": "0.1.0",
"bin": {
"codex-gateway-local": "./src/index.ts",
},
"dependencies": {
"@peezy.tech/codex-flows": "workspace:*",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
},
},
"apps/web": {
"name": "web",
"version": "0.0.1",
@ -722,6 +737,8 @@
"codex-flow-systemd-local": ["codex-flow-systemd-local@workspace:apps/flow-backend-systemd-local"],
"codex-gateway-local": ["codex-gateway-local@workspace:apps/gateway"],
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],

View file

@ -31,21 +31,45 @@ backend with:
The backend may connect to an existing app-server WebSocket or to a local stdio
app-server started by the CLI.
## Future remote backend
## Browser gateway process
The browser UI talks to the standalone local gateway server instead of talking
directly to the Codex app-server. In development, Vite proxies
`/__codex-gateway` to `codex-gateway-local` on port `3586`.
The browser gateway protocol has two lanes:
| Lane | Methods | Owner |
|------|---------|-------|
| app-server pass-through | `appServer.call`, `appServer.notify`, `appServer.respond`, `appServer.respondError` | Codex app-server |
| gateway-owned | `gateway.*` methods and `gateway.event` notifications | Codex gateway backend |
Native app-server methods stay native. For example, `thread/list`,
`thread/read`, `thread/start`, `turn/start`, `turn/interrupt`,
`account/read`, and app-server-native goal APIs are wrapped in
`appServer.call` and forwarded to the app-server. The gateway may observe,
route, and correlate those calls, but it should not duplicate their semantics.
Gateway-owned methods are for orchestration that the app-server does not own:
delegations, return modes, group wakes, workbench/workspace routing,
hook-spool observed-thread wake behavior, persisted gateway/session state, and
optional read-only flow backend inspection.
## Remote backend
A remote backend can implement the same `CodexGatewayBackend` shape behind HTTP
or WebSocket. The transport-facing protocol should stay small:
| Direction | Shape | Purpose |
|-----------|-------|---------|
| transport to backend | `start`, `stop`, `handleInbound`, `commandRegistration` | lifecycle and event delivery |
| backend to transport | `CodexGatewayPresenter` operations | UI output and presentation updates |
| backend to app-server | Codex app-server client calls | thread, turn, goal, and tool orchestration |
| transport to backend | transport-specific inbound events or browser gateway JSON-RPC | lifecycle, commands, and event delivery |
| backend to transport | `CodexGatewayPresenter` operations or `gateway.event` notifications | UI output and presentation updates |
| backend to app-server | Codex app-server client calls | app-server-native thread, turn, auth, goal, and tool behavior |
| backend to flow backend | `@peezy.tech/flow-runtime` backend client calls | optional read-only inspection |
Inbound events are still transport-shaped today because Discord is the only UI.
If another UI lands, the next boundary is a transport-neutral gateway event
model plus a presenter adapter per UI.
Discord inbound events are still transport-shaped. The browser gateway protocol
is the first transport-neutral client lane; future surfaces should share that
shape where practical and add a presenter adapter only for UI output.
## Flow backend boundary

View file

@ -6,19 +6,31 @@ description: How Codex gateway surfaces differ from generic flow backends.
# Gateway backends
A Codex gateway backend is the runtime behind an operator surface such as
Discord. It owns Codex app-server orchestration and exposes a small UI-facing
contract to the transport.
Discord or the browser UI. It owns Codex app-server orchestration and exposes a
small UI-facing contract to the transport.
The Discord bridge is the first transport using this split:
- Discord owns bot login, commands, interactions, Discord channels, and message
delivery.
- The gateway backend owns app-server connection, Codex thread start/resume,
turns, goals, delegations, workbench state, hook-spool draining, persisted
bridge state, and optional flow-run inspection.
- The gateway backend owns app-server connection, delegations, workbench state,
hook-spool draining, persisted bridge/session state, and optional flow-run
inspection.
- The local backend is the first implementation. It can connect to an existing
app-server WebSocket or start a local stdio app-server.
The browser UI uses the same split. Its gateway client sends native app-server
methods through `appServer.call`, `appServer.notify`, `appServer.respond`, and
`appServer.respondError`. The gateway forwards those calls instead of
reimplementing app-server behavior for thread list/read, thread start/resume,
turn start/steer/interrupt, auth, account state, and app-server-native goal
APIs.
Gateway-owned commands are reserved for behavior that combines app-server state
with gateway policy or gateway state: delegation, return modes, group wakes,
workbench/workspace routing, hook-spool observed-thread wake behavior, persisted
gateway sessions, and read-only flow backend inspection.
This is separate from codex-flow backends. A flow backend accepts `FlowEvent`,
matches `flow.toml`, executes steps, records `FLOW_RESULT`, and exposes run and
event views. A gateway backend may read those run and event views, but it does

View file

@ -0,0 +1,52 @@
---
title: Run web over the local gateway
description: Start the browser UI through codex-gateway-local instead of a direct app-server WebSocket.
---
# Run web over the local gateway
Use the local gateway when the browser UI should share the same backend boundary
as Discord: the UI is a presenter/client, the gateway owns orchestration, and
the Codex app-server remains the source of truth for native app-server methods.
## Start the gateway
Connect the gateway to an existing app-server WebSocket:
```sh
bun apps/gateway/src/index.ts serve --app-server-url ws://127.0.0.1:3585
```
Or let the gateway start a local stdio app-server:
```sh
bun apps/gateway/src/index.ts serve --local-app-server
```
The gateway listens on `ws://127.0.0.1:3586` by default. Override it with
`--host`, `--port`, `CODEX_GATEWAY_HOST`, or `CODEX_GATEWAY_PORT`.
## Start the browser UI
```sh
bun run dev:web
```
The Vite dev server proxies `ws://<web-host>/__codex-gateway` to
`ws://127.0.0.1:3586`. Set `VITE_CODEX_GATEWAY_PROXY_TARGET` if the gateway is
on another host or port.
For a browser that should connect directly to a gateway WebSocket instead of
using the dev proxy, set `VITE_CODEX_GATEWAY_WS_URL`.
## Boundary
The web client uses `CodexGatewayClient`. Native app-server operations such as
thread listing, thread reads, thread starts, turn starts, turn interrupts, auth,
and account reads are sent through `appServer.call` and forwarded by the
gateway.
Do not reimplement app-server behavior in the gateway just to serve the web UI.
Add gateway-owned methods only for behavior that combines app-server state with
gateway state or policy, such as delegations, workbench routing, hook-spool
wakes, persisted gateway sessions, or read-only flow backend inspection.

View file

@ -10,6 +10,7 @@ description: Public and workspace packages in the codex-flow stack.
Low-level Codex app-server client package. It exports:
- app-server JSON-RPC client and stdio/WebSocket transports
- browser-safe gateway client and gateway protocol server primitives
- browser-safe WebSocket transport
- framework-agnostic app-server flow helpers
- auth helpers for account login/status/usage
@ -38,5 +39,7 @@ attempts, leases, output chunks, and final result payloads.
- `codex-flow-systemd-local`: local durable HTTP backend and CLI.
- [`codex-discord-bridge`](discord-bridge): Discord-to-Codex bridge with
gateway delegation and read-only flow inspection tools.
- `web`: browser UI for Codex app-server threads.
- `codex-gateway-local`: local WebSocket gateway that forwards native
app-server calls and exposes gateway-owned events/commands.
- `web`: browser UI for Codex threads through the local gateway.
- `codex-app-cli`: JSON-RPC CLI for app-server actions.

View file

@ -27,6 +27,7 @@ export default {
"guides/enable-code-mode",
"guides/operate-codex-release-flows",
"guides/run-discord-local-backend",
"guides/run-web-over-local-gateway",
],
},
{

View file

@ -37,7 +37,7 @@
}
},
"scripts": {
"build": "bun run --filter @peezy.tech/codex-flows build && bun run --filter @peezy.tech/flow-runtime build && bun run --filter @peezy.tech/flow-backend-convex build && bun run --filter @peezy.tech/codex-opencode-go-router build && bun run --filter @workspace/ui build && bun run --filter codex-app-cli build && bun run --filter codex-discord-bridge build && bun run --filter codex-flow-systemd-local build && bun run --filter codex-flow-runner build && bun run --filter web build && bun run --filter @peezy.tech/codex-flow-docs build",
"build": "bun run --filter @peezy.tech/codex-flows build && bun run --filter @peezy.tech/flow-runtime build && bun run --filter @peezy.tech/flow-backend-convex build && bun run --filter @peezy.tech/codex-opencode-go-router build && bun run --filter @workspace/ui build && bun run --filter codex-app-cli build && bun run --filter codex-discord-bridge build && bun run --filter codex-gateway-local build && bun run --filter codex-flow-systemd-local build && bun run --filter codex-flow-runner build && bun run --filter web build && bun run --filter @peezy.tech/codex-flow-docs build",
"check:types": "bun run --workspaces check:types",
"codex:update": "bun scripts/run-codex-release-update-thread.ts",
"dev": "bun run --filter web dev",
@ -47,10 +47,11 @@
"dev:web": "bun run --filter web dev",
"flow": "bun apps/flow-runner/src/index.ts",
"flow:backend": "bun apps/flow-backend-systemd-local/src/index.ts",
"gateway": "bun apps/gateway/src/index.ts serve",
"replay:thread": "bun scripts/run-code-mode-in-new-thread.ts",
"start": "bun run --filter web preview",
"start:discord:debug:commentary": "bun run --filter codex-discord-bridge start:debug:commentary",
"test": "bun run --filter @peezy.tech/codex-flows test && bun run --filter @peezy.tech/flow-runtime test && bun run --filter @peezy.tech/flow-backend-convex test && bun run --filter @peezy.tech/codex-opencode-go-router test && bun run --filter codex-flow-systemd-local test && bun run --filter codex-flow-runner test && bun run --filter codex-app-cli test && bun run --filter codex-discord-bridge test",
"test": "bun run --filter @peezy.tech/codex-flows test && bun run --filter @peezy.tech/flow-runtime test && bun run --filter @peezy.tech/flow-backend-convex test && bun run --filter @peezy.tech/codex-opencode-go-router test && bun run --filter codex-flow-systemd-local test && bun run --filter codex-flow-runner test && bun run --filter codex-app-cli test && bun run --filter codex-discord-bridge test && bun run --filter codex-gateway-local test && bun run --filter web test",
"release:check": "bun run --filter @peezy.tech/codex-flows release:check && bun run --filter @peezy.tech/flow-runtime release:check && bun run --filter @peezy.tech/flow-backend-convex release:check"
}
}

View file

@ -45,6 +45,10 @@
"types": "./dist/workbench.d.ts",
"import": "./dist/workbench.js"
},
"./gateway": {
"types": "./dist/gateway/index.d.ts",
"import": "./dist/gateway/index.js"
},
"./rpc": {
"types": "./dist/app-server/rpc.d.ts",
"import": "./dist/app-server/rpc.js"

View file

@ -83,6 +83,10 @@ export class CodexAppServerClient extends CodexEventEmitter {
return this.transport.request<T>(method, params);
}
notify(method: string, params?: unknown): void {
this.transport.notify(method, params);
}
respond(id: JsonRpcId, result: unknown): void {
this.transport.respond(id, result);
}

View file

@ -3,6 +3,12 @@ export {
type CodexBrowserAppServerClientOptions as CodexAppServerClientOptions,
type CodexBrowserAppServerTransport as CodexAppServerTransport,
} from "./app-server/browser-client.ts";
export {
CodexGatewayClient,
type CodexGatewayClientOptions,
type CodexGatewayTransport,
type GatewayEvent,
} from "./gateway/client.ts";
export {
CodexWebSocketTransport,
type CodexWebSocketTransportOptions,

View file

@ -0,0 +1,190 @@
import type { v2 } from "../app-server/generated/index.ts";
import { CodexEventEmitter } from "../app-server/events.ts";
import type { JsonRpcId } from "../app-server/rpc.ts";
import {
CodexWebSocketTransport,
type CodexWebSocketTransportOptions,
} from "../app-server/websocket-transport.ts";
import {
APP_SERVER_CALL_METHOD,
APP_SERVER_NOTIFICATION_METHOD,
APP_SERVER_NOTIFY_METHOD,
APP_SERVER_REQUEST_METHOD,
APP_SERVER_RESPOND_ERROR_METHOD,
APP_SERVER_RESPOND_METHOD,
GATEWAY_EVENT_METHOD,
GATEWAY_INITIALIZE_METHOD,
appServerNotificationParams,
appServerRequestParams,
gatewayEventParams,
type GatewayEvent,
type GatewayInitializeResponse,
} from "./protocol.ts";
export type CodexGatewayTransport = CodexEventEmitter & {
readonly requestTimeoutMs: number;
start(): void;
close(): void;
request<T = unknown>(method: string, params?: unknown): Promise<T>;
notify(method: string, params?: unknown): void;
};
export type CodexGatewayClientOptions = {
transport?: CodexGatewayTransport;
webSocketTransportOptions?: CodexWebSocketTransportOptions;
clientName?: string;
clientTitle?: string;
clientVersion?: string;
};
export class CodexGatewayClient extends CodexEventEmitter {
readonly transport: CodexGatewayTransport;
#clientName: string;
#clientTitle: string | null;
#clientVersion: string;
#connected = false;
constructor(options: CodexGatewayClientOptions = {}) {
super();
const url = options.webSocketTransportOptions?.url;
if (!options.transport && !url) {
throw new Error("A Codex gateway WebSocket URL is required");
}
this.transport =
options.transport ??
new CodexWebSocketTransport({
url: url!,
requestTimeoutMs: options.webSocketTransportOptions?.requestTimeoutMs,
});
this.#clientName = options.clientName ?? "codex-gateway-client";
this.#clientTitle = options.clientTitle ?? "Codex Gateway Client";
this.#clientVersion = options.clientVersion ?? "0.1.0";
this.transport.on("notification", (message) => {
if (message.method === APP_SERVER_NOTIFICATION_METHOD) {
const params = appServerNotificationParams(message.params);
if (params) {
this.emit("notification", params.message);
}
return;
}
if (message.method === APP_SERVER_REQUEST_METHOD) {
const params = appServerRequestParams(message.params);
if (params) {
this.emit("request", params.message);
}
return;
}
if (message.method === GATEWAY_EVENT_METHOD) {
const params = gatewayEventParams(message.params);
if (params) {
this.emit("gatewayEvent", params.event);
}
return;
}
this.emit("notification", message);
});
this.transport.on("close", (code, reason) => this.emit("close", code, reason));
this.transport.on("error", (error) => this.emit("error", error));
}
async connect(): Promise<void> {
if (this.#connected) {
return;
}
this.transport.start();
await this.transport.request<GatewayInitializeResponse>(
GATEWAY_INITIALIZE_METHOD,
{
clientInfo: {
name: this.#clientName,
title: this.#clientTitle,
version: this.#clientVersion,
},
capabilities: {
appServerPassThrough: true,
},
},
);
this.#connected = true;
}
close(): void {
this.#connected = false;
this.transport.close();
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
return this.transport.request<T>(APP_SERVER_CALL_METHOD, { method, params });
}
notify(method: string, params?: unknown): void {
this.transport.notify(APP_SERVER_NOTIFY_METHOD, { method, params });
}
respond(id: JsonRpcId, result: unknown): void {
void this.transport.request(APP_SERVER_RESPOND_METHOD, { id, result })
.catch((error: unknown) => this.emit("error", error));
}
respondError(id: JsonRpcId, code: number, message: string, data?: unknown): void {
void this.transport.request(APP_SERVER_RESPOND_ERROR_METHOD, {
id,
code,
message,
data,
}).catch((error: unknown) => this.emit("error", error));
}
gatewayRequest<T = unknown>(method: string, params?: unknown): Promise<T> {
return this.transport.request<T>(method, params);
}
startThread(
params: v2.ThreadStartParams,
): Promise<v2.ThreadStartResponse> {
return this.request<v2.ThreadStartResponse>("thread/start", params);
}
resumeThread(
params: v2.ThreadResumeParams,
): Promise<v2.ThreadResumeResponse> {
return this.request<v2.ThreadResumeResponse>("thread/resume", params);
}
listThreads(params: v2.ThreadListParams): Promise<v2.ThreadListResponse> {
return this.request<v2.ThreadListResponse>("thread/list", params);
}
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse> {
return this.request<v2.ThreadReadResponse>("thread/read", params);
}
injectThreadItems(
params: v2.ThreadInjectItemsParams,
): Promise<v2.ThreadInjectItemsResponse> {
return this.request<v2.ThreadInjectItemsResponse>("thread/inject_items", params);
}
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse> {
return this.request<v2.TurnStartResponse>("turn/start", params);
}
steerTurn(params: v2.TurnSteerParams): Promise<v2.TurnSteerResponse> {
return this.request<v2.TurnSteerResponse>("turn/steer", params);
}
interruptTurn(
params: v2.TurnInterruptParams,
): Promise<v2.TurnInterruptResponse> {
return this.request<v2.TurnInterruptResponse>("turn/interrupt", params);
}
getAccount(
params: v2.GetAccountParams = { refreshToken: false },
): Promise<v2.GetAccountResponse> {
return this.request<v2.GetAccountResponse>("account/read", params);
}
}
export type { GatewayEvent };

View file

@ -0,0 +1,40 @@
export {
CodexGatewayClient,
type CodexGatewayClientOptions,
type CodexGatewayTransport,
type GatewayEvent,
} from "./client.ts";
export {
CodexGatewayProtocolServer,
type CodexGatewayAppServer,
type CodexGatewayPeer,
type CodexGatewayProtocolServerOptions,
} from "./server.ts";
export {
APP_SERVER_CALL_METHOD,
APP_SERVER_NOTIFICATION_METHOD,
APP_SERVER_NOTIFY_METHOD,
APP_SERVER_REQUEST_METHOD,
APP_SERVER_RESPOND_ERROR_METHOD,
APP_SERVER_RESPOND_METHOD,
GATEWAY_EVENT_METHOD,
GATEWAY_INITIALIZE_METHOD,
appServerCallParams,
appServerNotificationParams,
appServerNotifyParams,
appServerRequestParams,
appServerRespondErrorParams,
appServerRespondParams,
gatewayEventParams,
gatewayOwnedMethodPrefixes,
isGatewayOwnedMethod,
type AppServerCallParams,
type AppServerNotificationParams,
type AppServerNotifyParams,
type AppServerRequestParams,
type AppServerRespondErrorParams,
type AppServerRespondParams,
type GatewayEventParams,
type GatewayInitializeParams,
type GatewayInitializeResponse,
} from "./protocol.ts";

View file

@ -0,0 +1,213 @@
import type {
JsonRpcId,
JsonRpcNotification,
JsonRpcRequest,
} from "../app-server/rpc.ts";
export const GATEWAY_INITIALIZE_METHOD = "gateway.initialize";
export const GATEWAY_EVENT_METHOD = "gateway.event";
export const APP_SERVER_CALL_METHOD = "appServer.call";
export const APP_SERVER_NOTIFY_METHOD = "appServer.notify";
export const APP_SERVER_RESPOND_METHOD = "appServer.respond";
export const APP_SERVER_RESPOND_ERROR_METHOD = "appServer.respondError";
export const APP_SERVER_NOTIFICATION_METHOD = "appServer.notification";
export const APP_SERVER_REQUEST_METHOD = "appServer.request";
export type GatewayInitializeParams = {
clientInfo?: {
name?: string;
title?: string | null;
version?: string;
};
capabilities?: Record<string, unknown>;
};
export type GatewayInitializeResponse = {
ok: true;
serverInfo: {
name: string;
version: string;
};
capabilities: {
appServerPassThrough: true;
gatewayCommands: string[];
flowInspection: boolean;
};
};
export type AppServerCallParams = {
method: string;
params?: unknown;
};
export type AppServerNotifyParams = {
method: string;
params?: unknown;
};
export type AppServerRespondParams = {
id: JsonRpcId;
result: unknown;
};
export type AppServerRespondErrorParams = {
id: JsonRpcId;
code: number;
message: string;
data?: unknown;
};
export type AppServerNotificationParams = {
message: JsonRpcNotification;
};
export type AppServerRequestParams = {
message: JsonRpcRequest;
};
export type GatewayEvent =
| {
type: "connected";
at: string;
}
| {
type: "appServer.connected";
at: string;
}
| {
type: "appServer.closed";
at: string;
code?: number | null;
reason?: string | null;
}
| {
type: "appServer.error";
at: string;
message: string;
}
| {
type: "unsupportedGatewayCommand";
at: string;
method: string;
};
export type GatewayEventParams = {
event: GatewayEvent;
};
export const gatewayOwnedMethodPrefixes = [
"gateway.delegation.",
"gateway.workbench.",
"gateway.flow.",
] as const;
export function isGatewayOwnedMethod(method: string): boolean {
return gatewayOwnedMethodPrefixes.some((prefix) => method.startsWith(prefix));
}
export function appServerCallParams(
value: unknown,
): AppServerCallParams | undefined {
const input = record(value);
const method = stringValue(input.method);
if (!method) {
return undefined;
}
return { method, params: input.params };
}
export function appServerNotifyParams(
value: unknown,
): AppServerNotifyParams | undefined {
const input = record(value);
const method = stringValue(input.method);
if (!method) {
return undefined;
}
return { method, params: input.params };
}
export function appServerRespondParams(
value: unknown,
): AppServerRespondParams | undefined {
const input = record(value);
const id = jsonRpcIdValue(input.id);
if (id === undefined || !("result" in input)) {
return undefined;
}
return { id, result: input.result };
}
export function appServerRespondErrorParams(
value: unknown,
): AppServerRespondErrorParams | undefined {
const input = record(value);
const id = jsonRpcIdValue(input.id);
const code = typeof input.code === "number" ? input.code : undefined;
const message = stringValue(input.message);
if (id === undefined || code === undefined || !message) {
return undefined;
}
return { id, code, message, data: input.data };
}
export function appServerNotificationParams(
value: unknown,
): AppServerNotificationParams | undefined {
const input = record(value);
const message = jsonRpcNotification(input.message);
return message ? { message } : undefined;
}
export function appServerRequestParams(
value: unknown,
): AppServerRequestParams | undefined {
const input = record(value);
const message = jsonRpcRequest(input.message);
return message ? { message } : undefined;
}
export function gatewayEventParams(
value: unknown,
): GatewayEventParams | undefined {
const input = record(value);
const event = record(input.event);
const type = stringValue(event.type);
if (!type) {
return undefined;
}
return { event: event as unknown as GatewayEvent };
}
function jsonRpcNotification(value: unknown): JsonRpcNotification | undefined {
const input = record(value);
const method = stringValue(input.method);
if (!method || "id" in input) {
return undefined;
}
return { jsonrpc: "2.0", method, params: input.params };
}
function jsonRpcRequest(value: unknown): JsonRpcRequest | undefined {
const input = record(value);
const method = stringValue(input.method);
const id = jsonRpcIdValue(input.id);
if (!method || id === undefined) {
return undefined;
}
return { jsonrpc: "2.0", id, method, params: input.params };
}
function jsonRpcIdValue(value: unknown): JsonRpcId | undefined {
return typeof value === "string" || typeof value === "number" ? value : undefined;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

View file

@ -0,0 +1,271 @@
import { CodexEventEmitter } from "../app-server/events.ts";
import {
isJsonRpcNotification,
isJsonRpcRequest,
type JsonRpcId,
type JsonRpcMessage,
type JsonRpcNotification,
type JsonRpcRequest,
type JsonRpcResponse,
} from "../app-server/rpc.ts";
import {
APP_SERVER_CALL_METHOD,
APP_SERVER_NOTIFICATION_METHOD,
APP_SERVER_NOTIFY_METHOD,
APP_SERVER_REQUEST_METHOD,
APP_SERVER_RESPOND_ERROR_METHOD,
APP_SERVER_RESPOND_METHOD,
GATEWAY_EVENT_METHOD,
GATEWAY_INITIALIZE_METHOD,
appServerCallParams,
appServerNotifyParams,
appServerRespondErrorParams,
appServerRespondParams,
isGatewayOwnedMethod,
type GatewayEvent,
type GatewayInitializeResponse,
} from "./protocol.ts";
export type CodexGatewayAppServer = CodexEventEmitter & {
connect?(): Promise<void>;
close?(): void;
request<T = unknown>(method: string, params?: unknown): Promise<T>;
notify(method: string, params?: unknown): void;
respond(id: JsonRpcId, result: unknown): void;
respondError(id: JsonRpcId, code: number, message: string, data?: unknown): void;
};
export type CodexGatewayPeer = {
send(message: string): void;
};
export type CodexGatewayProtocolServerOptions = {
appServer: CodexGatewayAppServer;
now?: () => Date;
serverName?: string;
serverVersion?: string;
flowInspection?: boolean;
gatewayCommands?: string[];
};
export class CodexGatewayProtocolServer {
readonly appServer: CodexGatewayAppServer;
#peers = new Set<CodexGatewayPeer>();
#now: () => Date;
#serverName: string;
#serverVersion: string;
#flowInspection: boolean;
#gatewayCommands: string[];
constructor(options: CodexGatewayProtocolServerOptions) {
this.appServer = options.appServer;
this.#now = options.now ?? (() => new Date());
this.#serverName = options.serverName ?? "codex-gateway-local";
this.#serverVersion = options.serverVersion ?? "0.1.0";
this.#flowInspection = options.flowInspection ?? false;
this.#gatewayCommands = options.gatewayCommands ?? [];
this.appServer.on("notification", (message) => {
this.broadcastNotification(APP_SERVER_NOTIFICATION_METHOD, { message });
});
this.appServer.on("request", (message) => {
this.broadcastNotification(APP_SERVER_REQUEST_METHOD, { message });
});
this.appServer.on("error", (error) => {
this.broadcastGatewayEvent({
type: "appServer.error",
at: this.#now().toISOString(),
message: errorMessage(error),
});
});
this.appServer.on("close", (code, reason) => {
this.broadcastGatewayEvent({
type: "appServer.closed",
at: this.#now().toISOString(),
code: typeof code === "number" ? code : null,
reason: typeof reason === "string" ? reason : null,
});
});
}
addPeer(peer: CodexGatewayPeer): void {
this.#peers.add(peer);
this.sendGatewayEvent(peer, {
type: "connected",
at: this.#now().toISOString(),
});
}
removePeer(peer: CodexGatewayPeer): void {
this.#peers.delete(peer);
}
async handleMessage(peer: CodexGatewayPeer, data: string): Promise<void> {
let parsed: unknown;
try {
parsed = JSON.parse(data) as unknown;
} catch {
peer.send(JSON.stringify(errorResponse(null, -32700, "Parse error")));
return;
}
if (isJsonRpcNotification(parsed)) {
await this.#handleNotification(parsed);
return;
}
if (!isJsonRpcRequest(parsed)) {
peer.send(JSON.stringify(errorResponse(null, -32600, "Invalid request")));
return;
}
const response = await this.#handleRequest(parsed);
peer.send(JSON.stringify(response));
}
broadcastNotification(method: string, params?: unknown): void {
const message: JsonRpcNotification = { jsonrpc: "2.0", method, params };
const data = JSON.stringify(message);
for (const peer of this.#peers) {
peer.send(data);
}
}
broadcastGatewayEvent(event: GatewayEvent): void {
this.broadcastNotification(GATEWAY_EVENT_METHOD, { event });
}
sendGatewayEvent(peer: CodexGatewayPeer, event: GatewayEvent): void {
peer.send(JSON.stringify({
jsonrpc: "2.0",
method: GATEWAY_EVENT_METHOD,
params: { event },
} satisfies JsonRpcNotification));
}
async #handleRequest(request: JsonRpcRequest): Promise<JsonRpcResponse> {
try {
if (request.method === GATEWAY_INITIALIZE_METHOD) {
return successResponse(request.id, this.#initializeResponse());
}
if (request.method === APP_SERVER_CALL_METHOD) {
const params = appServerCallParams(request.params);
if (!params) {
return errorResponse(request.id, -32602, "Invalid appServer.call params");
}
const result = await this.appServer.request(params.method, params.params);
return successResponse(request.id, result);
}
if (request.method === APP_SERVER_NOTIFY_METHOD) {
const params = appServerNotifyParams(request.params);
if (!params) {
return errorResponse(request.id, -32602, "Invalid appServer.notify params");
}
this.appServer.notify(params.method, params.params);
return successResponse(request.id, { ok: true });
}
if (request.method === APP_SERVER_RESPOND_METHOD) {
const params = appServerRespondParams(request.params);
if (!params) {
return errorResponse(request.id, -32602, "Invalid appServer.respond params");
}
this.appServer.respond(params.id, params.result);
return successResponse(request.id, { ok: true });
}
if (request.method === APP_SERVER_RESPOND_ERROR_METHOD) {
const params = appServerRespondErrorParams(request.params);
if (!params) {
return errorResponse(
request.id,
-32602,
"Invalid appServer.respondError params",
);
}
this.appServer.respondError(
params.id,
params.code,
params.message,
params.data,
);
return successResponse(request.id, { ok: true });
}
if (isGatewayOwnedMethod(request.method)) {
this.broadcastGatewayEvent({
type: "unsupportedGatewayCommand",
at: this.#now().toISOString(),
method: request.method,
});
return errorResponse(
request.id,
-32601,
`Gateway command is not implemented: ${request.method}`,
);
}
return errorResponse(request.id, -32601, `Unknown gateway method: ${request.method}`);
} catch (error) {
return errorResponse(request.id, -32603, errorMessage(error));
}
}
async #handleNotification(notification: JsonRpcNotification): Promise<void> {
try {
if (notification.method === APP_SERVER_NOTIFY_METHOD) {
const params = appServerNotifyParams(notification.params);
if (!params) {
this.broadcastGatewayEvent({
type: "appServer.error",
at: this.#now().toISOString(),
message: "Invalid appServer.notify params",
});
return;
}
this.appServer.notify(params.method, params.params);
return;
}
if (isGatewayOwnedMethod(notification.method)) {
this.broadcastGatewayEvent({
type: "unsupportedGatewayCommand",
at: this.#now().toISOString(),
method: notification.method,
});
}
} catch (error) {
this.broadcastGatewayEvent({
type: "appServer.error",
at: this.#now().toISOString(),
message: errorMessage(error),
});
}
}
#initializeResponse(): GatewayInitializeResponse {
return {
ok: true,
serverInfo: {
name: this.#serverName,
version: this.#serverVersion,
},
capabilities: {
appServerPassThrough: true,
gatewayCommands: this.#gatewayCommands,
flowInspection: this.#flowInspection,
},
};
}
}
function successResponse(id: JsonRpcId, result: unknown): JsonRpcResponse {
return { jsonrpc: "2.0", id, result };
}
function errorResponse(
id: JsonRpcId | null,
code: number,
message: string,
data?: unknown,
): JsonRpcResponse {
return { jsonrpc: "2.0", id: id ?? 0, error: { code, message, data } };
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export type { JsonRpcMessage };

View file

@ -3,6 +3,16 @@ export {
type CodexAppServerClientOptions,
type CodexAppServerTransport,
} from "./app-server/client.ts";
export {
CodexGatewayClient,
CodexGatewayProtocolServer,
type CodexGatewayAppServer,
type CodexGatewayClientOptions,
type CodexGatewayPeer,
type CodexGatewayProtocolServerOptions,
type CodexGatewayTransport,
type GatewayEvent,
} from "./gateway/index.ts";
export {
CodexStdioTransport,
DEFAULT_CODEX_COMMAND,

View file

@ -0,0 +1,244 @@
import { describe, expect, test } from "bun:test";
import {
CodexGatewayClient,
CodexGatewayProtocolServer,
type CodexGatewayAppServer,
type CodexGatewayPeer,
} from "../src/gateway/index.ts";
import { CodexEventEmitter } from "../src/app-server/events.ts";
import type {
JsonRpcId,
JsonRpcNotification,
JsonRpcResponse,
} from "../src/app-server/rpc.ts";
describe("Codex gateway protocol", () => {
test("server proxies appServer.call without interpreting native app-server methods", async () => {
const appServer = new FakeAppServer();
const server = new CodexGatewayProtocolServer({ appServer });
const peer = new MemoryPeer();
server.addPeer(peer);
await server.handleMessage(peer, JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "appServer.call",
params: {
method: "thread/list",
params: { limit: 2 },
},
}));
expect(appServer.requests).toEqual([
{ method: "thread/list", params: { limit: 2 } },
]);
expect(peer.response(1)?.result).toEqual({
method: "thread/list",
params: { limit: 2 },
});
});
test("server declares gateway-owned commands but does not fake implementations", async () => {
const appServer = new FakeAppServer();
const server = new CodexGatewayProtocolServer({ appServer });
const peer = new MemoryPeer();
server.addPeer(peer);
await server.handleMessage(peer, JSON.stringify({
jsonrpc: "2.0",
id: "delegation",
method: "gateway.delegation.start",
params: { prompt: "do work" },
}));
expect(appServer.requests).toEqual([]);
expect(peer.response("delegation")?.error?.code).toBe(-32601);
expect(peer.notifications("gateway.event")).toContainEqual(
expect.objectContaining({
method: "gateway.event",
params: {
event: expect.objectContaining({
type: "unsupportedGatewayCommand",
method: "gateway.delegation.start",
}),
},
}),
);
});
test("server proxies appServer.notify notifications without a response", async () => {
const appServer = new FakeAppServer();
const server = new CodexGatewayProtocolServer({ appServer });
const peer = new MemoryPeer();
await server.handleMessage(peer, JSON.stringify({
jsonrpc: "2.0",
method: "appServer.notify",
params: {
method: "initialized",
params: { ok: true },
},
}));
expect(appServer.notifications).toEqual([
{ method: "initialized", params: { ok: true } },
]);
expect(peer.messages).toEqual([]);
});
test("client uses appServer.call for native helpers and unwraps app-server notifications", async () => {
const transport = new FakeGatewayTransport();
const client = new CodexGatewayClient({
transport,
clientName: "test-web",
clientTitle: "Test Web",
clientVersion: "0.1.0",
});
const notifications: JsonRpcNotification[] = [];
client.on("notification", (message) => notifications.push(message));
await client.connect();
await client.listThreads({ limit: 5, sourceKinds: [] });
client.notify("initialized", { ok: true });
transport.emit("notification", {
jsonrpc: "2.0",
method: "appServer.notification",
params: {
message: {
jsonrpc: "2.0",
method: "turn/completed",
params: { threadId: "thread-1" },
},
},
});
expect(transport.requests).toEqual([
{
method: "gateway.initialize",
params: {
clientInfo: {
name: "test-web",
title: "Test Web",
version: "0.1.0",
},
capabilities: {
appServerPassThrough: true,
},
},
},
{
method: "appServer.call",
params: {
method: "thread/list",
params: { limit: 5, sourceKinds: [] },
},
},
{
method: "appServer.notify",
params: {
method: "initialized",
params: { ok: true },
},
},
]);
expect(notifications).toEqual([
{
jsonrpc: "2.0",
method: "turn/completed",
params: { threadId: "thread-1" },
},
]);
});
});
class FakeAppServer extends CodexEventEmitter implements CodexGatewayAppServer {
requests: Array<{ method: string; params?: unknown }> = [];
notifications: Array<{ method: string; params?: unknown }> = [];
responses: Array<{ id: JsonRpcId; result: unknown }> = [];
responseErrors: Array<{
id: JsonRpcId;
code: number;
message: string;
data?: unknown;
}> = [];
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
this.requests.push({ method, params });
return { method, params } as T;
}
notify(method: string, params?: unknown): void {
this.notifications.push({ method, params });
}
respond(id: JsonRpcId, result: unknown): void {
this.responses.push({ id, result });
}
respondError(
id: JsonRpcId,
code: number,
message: string,
data?: unknown,
): void {
this.responseErrors.push({ id, code, message, data });
}
}
class MemoryPeer implements CodexGatewayPeer {
messages: unknown[] = [];
send(message: string): void {
this.messages.push(JSON.parse(message) as unknown);
}
response(id: JsonRpcId): JsonRpcResponse | undefined {
return this.messages.find((message): message is JsonRpcResponse =>
isRecord(message) && message.id === id
);
}
notifications(method: string): JsonRpcNotification[] {
return this.messages.filter((message): message is JsonRpcNotification =>
isRecord(message) && !("id" in message) && message.method === method
);
}
}
class FakeGatewayTransport extends CodexEventEmitter {
readonly requestTimeoutMs = 60_000;
requests: Array<{ method: string; params?: unknown }> = [];
started = false;
start(): void {
this.started = true;
}
close(): void {
this.started = false;
}
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
this.requests.push({ method, params });
if (method === "gateway.initialize") {
return {
ok: true,
serverInfo: { name: "fake", version: "0.1.0" },
capabilities: {
appServerPassThrough: true,
gatewayCommands: [],
flowInspection: false,
},
} as T;
}
return {} as T;
}
notify(method: string, params?: unknown): void {
this.requests.push({ method, params });
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}