diff --git a/apps/gateway/package.json b/apps/gateway/package.json new file mode 100644 index 0000000..06a14ab --- /dev/null +++ b/apps/gateway/package.json @@ -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:" + } +} diff --git a/apps/gateway/src/args.ts b/apps/gateway/src/args.ts new file mode 100644 index 0000000..2a611fc --- /dev/null +++ b/apps/gateway/src/args.ts @@ -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 = 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 to bind. Defaults to 127.0.0.1. + --port Port to bind. Defaults to 3586. + --app-server-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 +`; +} diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts new file mode 100644 index 0000000..d746b36 --- /dev/null +++ b/apps/gateway/src/index.ts @@ -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 { + 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, 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, +): 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, + client: CodexAppServerClient, +): Promise { + 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(); diff --git a/apps/gateway/test/args.test.ts b/apps/gateway/test/args.test.ts new file mode 100644 index 0000000..36dbf2d --- /dev/null +++ b/apps/gateway/test/args.test.ts @@ -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, + }); + }); +}); diff --git a/apps/gateway/tsconfig.json b/apps/gateway/tsconfig.json new file mode 100644 index 0000000..fd371bf --- /dev/null +++ b/apps/gateway/tsconfig.json @@ -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"] +} diff --git a/apps/web/package.json b/apps/web/package.json index 6c967e4..bb48fb6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:*", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a50317a..6d06a9d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ( @@ -58,9 +59,15 @@ export function App() { } function BareCodexApp() { - const clientRef = useRef(null); + const clientRef = useRef(null); const authRef = useRef(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(); const [status, setStatus] = useState("disconnected"); const [error, setError] = useState(); @@ -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() {

Codex Bare

- {connectedUrl ?? "No app-server connection"} + {connectedUrl ?? "No gateway connection"}

setWsUrl(event.target.value)} - placeholder="ws://127.0.0.1:3585" + placeholder="ws://127.0.0.1:3586" value={wsUrl} />
@@ -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) { return parts.filter(Boolean).join(" "); } diff --git a/apps/web/src/gateway-url.ts b/apps/web/src/gateway-url.ts new file mode 100644 index 0000000..552d0b9 --- /dev/null +++ b/apps/web/src/gateway-url.ts @@ -0,0 +1,20 @@ +export const gatewayStorageKey = "codex-bare.gateway-url"; + +export type GatewayUrlOptions = { + envUrl?: string; + location: Pick; + storage?: Pick; +}; + +export function initialGatewayWsUrl(options: GatewayUrlOptions): string { + return options.storage?.getItem(gatewayStorageKey) ?? + options.envUrl ?? + proxiedGatewayWsUrl(options.location); +} + +export function proxiedGatewayWsUrl( + location: Pick, +): string { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${location.host}/__codex-gateway`; +} diff --git a/apps/web/test/gateway-url.test.ts b/apps/web/test/gateway-url.test.ts new file mode 100644 index 0000000..8054ec2 --- /dev/null +++ b/apps/web/test/gateway-url.test.ts @@ -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([ + [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"); + }); +}); diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 8d38ba2..30147f6 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -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"], diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index d42cc0c..417c0d5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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"], diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a07dd4e..aa73983 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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) => { diff --git a/bun.lock b/bun.lock index 377b3ae..5f2a10c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/pages/concepts/gateway-backend-process.md b/docs/pages/concepts/gateway-backend-process.md index 114304d..89f3485 100644 --- a/docs/pages/concepts/gateway-backend-process.md +++ b/docs/pages/concepts/gateway-backend-process.md @@ -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 diff --git a/docs/pages/concepts/gateway-backends.md b/docs/pages/concepts/gateway-backends.md index 5a4b213..10fc235 100644 --- a/docs/pages/concepts/gateway-backends.md +++ b/docs/pages/concepts/gateway-backends.md @@ -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 diff --git a/docs/pages/guides/run-web-over-local-gateway.md b/docs/pages/guides/run-web-over-local-gateway.md new file mode 100644 index 0000000..05d49f4 --- /dev/null +++ b/docs/pages/guides/run-web-over-local-gateway.md @@ -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:///__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. diff --git a/docs/pages/reference/packages.md b/docs/pages/reference/packages.md index a72d2ec..a30c37c 100644 --- a/docs/pages/reference/packages.md +++ b/docs/pages/reference/packages.md @@ -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. diff --git a/docs/tome.config.js b/docs/tome.config.js index 69d4073..67606be 100644 --- a/docs/tome.config.js +++ b/docs/tome.config.js @@ -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", ], }, { diff --git a/package.json b/package.json index 3c7daf9..ecceb4c 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/codex-client/package.json b/packages/codex-client/package.json index 5e979fd..bad5454 100644 --- a/packages/codex-client/package.json +++ b/packages/codex-client/package.json @@ -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" diff --git a/packages/codex-client/src/app-server/client.ts b/packages/codex-client/src/app-server/client.ts index 720969b..94aabc4 100644 --- a/packages/codex-client/src/app-server/client.ts +++ b/packages/codex-client/src/app-server/client.ts @@ -83,6 +83,10 @@ export class CodexAppServerClient extends CodexEventEmitter { return this.transport.request(method, params); } + notify(method: string, params?: unknown): void { + this.transport.notify(method, params); + } + respond(id: JsonRpcId, result: unknown): void { this.transport.respond(id, result); } diff --git a/packages/codex-client/src/browser.ts b/packages/codex-client/src/browser.ts index ef9f536..d9a88f9 100644 --- a/packages/codex-client/src/browser.ts +++ b/packages/codex-client/src/browser.ts @@ -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, diff --git a/packages/codex-client/src/gateway/client.ts b/packages/codex-client/src/gateway/client.ts new file mode 100644 index 0000000..fd2ea70 --- /dev/null +++ b/packages/codex-client/src/gateway/client.ts @@ -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(method: string, params?: unknown): Promise; + 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 { + if (this.#connected) { + return; + } + this.transport.start(); + await this.transport.request( + 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(method: string, params?: unknown): Promise { + return this.transport.request(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(method: string, params?: unknown): Promise { + return this.transport.request(method, params); + } + + startThread( + params: v2.ThreadStartParams, + ): Promise { + return this.request("thread/start", params); + } + + resumeThread( + params: v2.ThreadResumeParams, + ): Promise { + return this.request("thread/resume", params); + } + + listThreads(params: v2.ThreadListParams): Promise { + return this.request("thread/list", params); + } + + readThread(params: v2.ThreadReadParams): Promise { + return this.request("thread/read", params); + } + + injectThreadItems( + params: v2.ThreadInjectItemsParams, + ): Promise { + return this.request("thread/inject_items", params); + } + + startTurn(params: v2.TurnStartParams): Promise { + return this.request("turn/start", params); + } + + steerTurn(params: v2.TurnSteerParams): Promise { + return this.request("turn/steer", params); + } + + interruptTurn( + params: v2.TurnInterruptParams, + ): Promise { + return this.request("turn/interrupt", params); + } + + getAccount( + params: v2.GetAccountParams = { refreshToken: false }, + ): Promise { + return this.request("account/read", params); + } +} + +export type { GatewayEvent }; diff --git a/packages/codex-client/src/gateway/index.ts b/packages/codex-client/src/gateway/index.ts new file mode 100644 index 0000000..e8c8767 --- /dev/null +++ b/packages/codex-client/src/gateway/index.ts @@ -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"; diff --git a/packages/codex-client/src/gateway/protocol.ts b/packages/codex-client/src/gateway/protocol.ts new file mode 100644 index 0000000..ac90c2b --- /dev/null +++ b/packages/codex-client/src/gateway/protocol.ts @@ -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; +}; + +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 { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/codex-client/src/gateway/server.ts b/packages/codex-client/src/gateway/server.ts new file mode 100644 index 0000000..ac40fd5 --- /dev/null +++ b/packages/codex-client/src/gateway/server.ts @@ -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; + close?(): void; + request(method: string, params?: unknown): Promise; + 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(); + #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 { + 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 { + 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 { + 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 }; diff --git a/packages/codex-client/src/index.ts b/packages/codex-client/src/index.ts index ee63a94..31bcc5f 100644 --- a/packages/codex-client/src/index.ts +++ b/packages/codex-client/src/index.ts @@ -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, diff --git a/packages/codex-client/test/gateway.test.ts b/packages/codex-client/test/gateway.test.ts new file mode 100644 index 0000000..58e0deb --- /dev/null +++ b/packages/codex-client/test/gateway.test.ts @@ -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(method: string, params?: unknown): Promise { + 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(method: string, params?: unknown): Promise { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +}