Route web through local Codex gateway
This commit is contained in:
parent
b549004678
commit
c96668ec76
28 changed files with 1567 additions and 40 deletions
25
apps/gateway/package.json
Normal file
25
apps/gateway/package.json
Normal 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
99
apps/gateway/src/args.ts
Normal 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
152
apps/gateway/src/index.ts
Normal 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();
|
||||
61
apps/gateway/test/args.test.ts
Normal file
61
apps/gateway/test/args.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
25
apps/gateway/tsconfig.json
Normal file
25
apps/gateway/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
}
|
||||
|
|
|
|||
20
apps/web/src/gateway-url.ts
Normal file
20
apps/web/src/gateway-url.ts
Normal 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`;
|
||||
}
|
||||
41
apps/web/test/gateway-url.test.ts
Normal file
41
apps/web/test/gateway-url.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
17
bun.lock
17
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=="],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
docs/pages/guides/run-web-over-local-gateway.md
Normal file
52
docs/pages/guides/run-web-over-local-gateway.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
190
packages/codex-client/src/gateway/client.ts
Normal file
190
packages/codex-client/src/gateway/client.ts
Normal 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 };
|
||||
40
packages/codex-client/src/gateway/index.ts
Normal file
40
packages/codex-client/src/gateway/index.ts
Normal 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";
|
||||
213
packages/codex-client/src/gateway/protocol.ts
Normal file
213
packages/codex-client/src/gateway/protocol.ts
Normal 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;
|
||||
}
|
||||
271
packages/codex-client/src/gateway/server.ts
Normal file
271
packages/codex-client/src/gateway/server.ts
Normal 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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
244
packages/codex-client/test/gateway.test.ts
Normal file
244
packages/codex-client/test/gateway.test.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue