From 9135783091ac7a94daf12188d60e29690ce6743e Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Mon, 18 May 2026 17:42:28 +0800 Subject: [PATCH] clarify embedded runtime docs and tests --- .gitignore | 1 + Cargo.toml | 11 - Dockerfile | 2 +- README.md | 255 +-- README_zh.md | 238 +-- docs/api.md | 461 ----- docs/architecture.md | 50 - docs/integration-guide.md | 377 ----- docs/release-format.md | 146 -- docs/todo-p0.md | 402 ----- docs/todo.md | 209 --- lab/design/ui-style-gallery.html | 1496 ----------------- public/index.html | 4 +- {rust-src => src}/auth.rs | 0 .../cli.rs => src/bin/codex-gateway-cli.rs | 0 {rust-src => src}/bridge/mod.rs | 0 {rust-src => src}/bridge/notifications.rs | 0 {rust-src => src}/bridge/process.rs | 0 {rust-src => src}/bridge/protocol.rs | 0 {rust-src => src}/bridge/rpc.rs | 0 {rust-src => src}/bridge/server_requests.rs | 0 {rust-src => src}/bridge/state.rs | 0 {rust-src => src}/bridge/transcript.rs | 0 {rust-src => src}/bridge/workspace_tools.rs | 0 {rust-src => src}/config.rs | 66 +- {rust-src => src}/deployments.rs | 0 {rust-src => src}/devbox.rs | 5 +- {rust-src => src}/env_config.rs | 0 {rust-src => src}/error.rs | 0 {rust-src => src}/lib.rs | 0 {rust-src => src}/main.rs | 16 +- {rust-src => src}/models.rs | 0 {rust-src => src}/remote_gateway.rs | 0 {rust-src => src}/runtime.rs | 0 {rust-src => src}/session_manager.rs | 28 +- tests/http_integration.rs | 10 +- 36 files changed, 191 insertions(+), 3586 deletions(-) delete mode 100644 docs/api.md delete mode 100644 docs/architecture.md delete mode 100644 docs/integration-guide.md delete mode 100644 docs/release-format.md delete mode 100644 docs/todo-p0.md delete mode 100644 docs/todo.md delete mode 100644 lab/design/ui-style-gallery.html rename {rust-src => src}/auth.rs (100%) rename rust-src/cli.rs => src/bin/codex-gateway-cli.rs (100%) rename {rust-src => src}/bridge/mod.rs (100%) rename {rust-src => src}/bridge/notifications.rs (100%) rename {rust-src => src}/bridge/process.rs (100%) rename {rust-src => src}/bridge/protocol.rs (100%) rename {rust-src => src}/bridge/rpc.rs (100%) rename {rust-src => src}/bridge/server_requests.rs (100%) rename {rust-src => src}/bridge/state.rs (100%) rename {rust-src => src}/bridge/transcript.rs (100%) rename {rust-src => src}/bridge/workspace_tools.rs (100%) rename {rust-src => src}/config.rs (75%) rename {rust-src => src}/deployments.rs (100%) rename {rust-src => src}/devbox.rs (99%) rename {rust-src => src}/env_config.rs (100%) rename {rust-src => src}/error.rs (100%) rename {rust-src => src}/lib.rs (100%) rename {rust-src => src}/main.rs (98%) rename {rust-src => src}/models.rs (100%) rename {rust-src => src}/remote_gateway.rs (100%) rename {rust-src => src}/runtime.rs (100%) rename {rust-src => src}/session_manager.rs (96%) diff --git a/.gitignore b/.gitignore index e425602..38d4a27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ .DS_Store target/ +.qoder/ diff --git a/Cargo.toml b/Cargo.toml index d903655..6fcc99e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,17 +3,6 @@ name = "codex-gateway" version = "0.6.0" edition = "2024" -[lib] -path = "rust-src/lib.rs" - -[[bin]] -name = "codex-gateway" -path = "rust-src/main.rs" - -[[bin]] -name = "codex-gateway-cli" -path = "rust-src/cli.rs" - [dependencies] async-stream = "0.3" axum = { version = "0.8", default-features = false, features = ["http1", "json", "query", "tokio"] } diff --git a/Dockerfile b/Dockerfile index 7104353..e179e3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM rust:1-bookworm AS builder WORKDIR /build COPY Cargo.toml Cargo.lock ./ -COPY rust-src ./rust-src +COPY src ./src RUN cargo build --release --bin codex-gateway --bin codex-gateway-cli diff --git a/README.md b/README.md index e9e6c85..78ce286 100644 --- a/README.md +++ b/README.md @@ -2,135 +2,71 @@ Chinese version: [README_zh.md](./README_zh.md) -This repository is a minimal multi-session gateway for verifying that `codex app-server` can be exposed as a small HTTP/SSE service. +Codex Gateway is a Rust HTTP/SSE gateway for running isolated Codex sessions behind a small API and browser UI. -Architecture notes: [docs/architecture.md](./docs/architecture.md) +Generated Qoder documentation may exist under `.qoder/`; it is generated output and should not be edited by hand or committed as source documentation. This README is the maintained human entry point. -API reference: [docs/api.md](./docs/api.md) +## Runtime Shape -The current shape is: +The default runtime is `embedded`: -1. external clients call a Rust HTTP API -2. the Rust service creates one Codex bridge per session -3. each bridge spawns its own local `codex app-server` child process over `stdio` -4. streamed notifications are forwarded back to that client over SSE +1. A client creates a session through the Rust gateway. +2. The session owns a `CodexAppServerBridge`. +3. The bridge starts and manages one `codex app-server` subprocess over stdio. +4. App-server notifications are folded into session state and streamed to the client over SSE. -Official references used while building this: +The optional `devbox` runtime is a remote execution backend: -- [Codex App Server](https://developers.openai.com/codex/app-server/) -- [Codex CLI quickstart](https://developers.openai.com/codex/quickstart/#setup) -- [Codex configuration reference](https://developers.openai.com/codex/config-reference/) -- [Codex CI/CD auth](https://developers.openai.com/codex/auth/ci-cd-auth) +1. The outer gateway creates a Devbox runtime. +2. It waits for the gateway inside Devbox to become ready. +3. It creates a remote session in that inner gateway. +4. The inner gateway uses the `embedded` runtime to run `codex app-server`. -## What is in here +`devbox` is runtime infrastructure, not a product mode. -- `rust-src/main.rs`: Rust HTTP server with session APIs, SSE streams, health endpoints, and static file serving -- `rust-src/bridge.rs`: reusable bridge for `initialize`, `account/read`, `model/list`, `thread/start`, `turn/start`, and notification handling -- `rust-src/runtime.rs`: shared runtime helpers for API-key login and `openai_base_url` overrides -- `rust-src/session_manager.rs`: multi-session lifecycle manager for bridges and TTL cleanup -- `rust-src/cli.rs`: one-shot CLI smoke test for a single bridge -- `public/index.html`: minimal browser UI -- `public/app.js`: browser behavior that creates its own API session and listens to its own SSE stream -- `public/styles.css`: intentionally simple UI styling -- `Dockerfile`: multi-stage image that builds the Rust gateway and installs the Codex CLI on Linux +## Brain Deployment API -## Runtime model +`POST /api/deployments` is a Brain application reserved API. It is not intended to describe a general deployment product surface. -This is no longer a single shared in-memory conversation. - -- `POST /api/sessions` creates a new session -- each session owns one `CodexAppServerBridge` -- each bridge owns one `codex app-server` subprocess -- all `/state`, `/events`, `/turn`, and `/thread/new` calls are scoped to one session id -- sessions expire after an idle TTL and are also removable explicitly with `DELETE /api/sessions/:id` - -That makes the service usable by multiple callers without sharing one thread or transcript. +The endpoint creates a Codex task that deploys a repository and reports a machine-readable deployment result. When the active session runtime is Devbox-backed, Gateway bootstraps the Devbox runtime before starting the Brain deployment task. ## HTTP API -### Health - - `GET /healthz` - `GET /readyz` - -### Sessions - - `POST /api/sessions` - - body: `{ "model": "optional-model-id" }` - - returns: `{ ok, sessionId, session, state }` - `GET /api/sessions/:id/state` - - returns the latest session metadata plus the current bridge state snapshot - `GET /api/sessions/:id/events` - - SSE stream for that session only - `POST /api/sessions/:id/turn` - - body: `{ "prompt": "..." }` - `POST /api/sessions/:id/turn/interrupt` - - requests cancellation of the current in-flight turn while keeping the session - `POST /api/sessions/:id/thread/new` - - body: `{ "model": "optional-model-id" }` +- `POST /api/sessions/:id/thread/resume` - `DELETE /api/sessions/:id` - - closes the session and its child process - -### Important behavior - -- `codex app-server` is launched with `sandbox_mode="danger-full-access"` and `approval_policy="never"` -- legacy and modern approval requests are auto-accepted when the app-server still surfaces them -- dynamic tool calls receive a structured tool result; unsupported tools fail explicitly instead of stalling the turn -- server-initiated requests that still require interactive UI are rejected or cancelled explicitly -- session state is in memory only -- gateway auth is optional and is enabled only when `CODEX_GATEWAY_JWT_SECRET` is set -- one session can only have one active turn at a time -- in-flight turns can be interrupted without deleting the session +- `GET /api/threads` +- `GET /api/threads/:threadId` +- `POST /api/deployments` +- `GET /api/deployments/:threadId` -## Local usage +Legacy single-session routes such as `/api/state`, `/api/events`, `/api/turn`, and `/api/thread/new` are removed and return `410 Gone`. -### Web UI +## Local Usage -Start the local server: +Start the gateway: ```bash CODEX_GATEWAY_OPENAI_API_KEY=sk-... \ -CODEX_GATEWAY_OPENAI_BASE_URL=https://sub2api-xnldrpuk.usw-1.sealos.app \ +CODEX_GATEWAY_OPENAI_BASE_URL=https://example-openai-compatible-endpoint.test \ CODEX_GATEWAY_JWT_SECRET=replace-with-your-hs256-secret \ cargo run --bin codex-gateway ``` -Then open: +Open: ```text http://127.0.0.1:1317 ``` -The page restores the last live session when possible, falls back to resuming the last thread, and subscribes to its own SSE stream. When JWT auth is enabled, paste a bearer token into the `Auth` panel before using the page. - -### CLI smoke test - -Run the one-shot harness: - -```bash -cargo run --bin codex-gateway-cli -- -``` - -Or with a custom prompt: - -```bash -cargo run --bin codex-gateway-cli -- "Reply with exactly the single word ready." -``` - -## Verification - -If you want to verify the project manually, the shortest path is: - -1. Start the service with `cargo run --bin codex-gateway`. -2. Check `http://127.0.0.1:1317/healthz`. -3. Check `http://127.0.0.1:1317/readyz`. -4. Open `http://127.0.0.1:1317` and wait for the page status to become `ready`. -5. Send `Reply with exactly the single word ready. Do not call tools.` from the page. -6. Confirm that the transcript shows `ready`. - -If you want to verify the API directly instead of the page: - -Create a session: +Quick API smoke test: ```bash curl -X POST http://127.0.0.1:1317/api/sessions \ @@ -138,121 +74,40 @@ curl -X POST http://127.0.0.1:1317/api/sessions \ -d '{}' ``` -Send a turn: - -```bash -curl -X POST http://127.0.0.1:1317/api/sessions//turn \ - -H 'Content-Type: application/json' \ - -d '{"prompt":"Reply with exactly the single word ready. Do not call tools."}' -``` - -Read the latest state: - -```bash -curl http://127.0.0.1:1317/api/sessions//state -``` - -If the transcript contains `ready`, the gateway, bridge, and `codex app-server` handshake are all working. - -## Environment variables +## Configuration -Gateway-owned settings use the `CODEX_GATEWAY_` prefix for better discoverability. +Gateway-owned settings use the `CODEX_GATEWAY_` prefix. -- `CODEX_GATEWAY_HOST`: bind address for the Rust server. Defaults to `0.0.0.0`. +- `CODEX_GATEWAY_HOST`: bind address. Defaults to `0.0.0.0`. - `CODEX_GATEWAY_PORT`: bind port. Defaults to `1317`. -- `CODEX_GATEWAY_CWD`: working directory passed to `thread/start`. Defaults to the repository root. -- `CODEX_GATEWAY_CODEX_BIN`: path to the `codex` executable if it is not on `PATH`. -- `CODEX_GATEWAY_MODEL`: preferred default model for new bridges. -- `CODEX_GATEWAY_OPENAI_API_KEY`: API key used at startup to run `codex login --with-api-key`. -- `CODEX_GATEWAY_OPENAI_BASE_URL`: upstream OpenAI-compatible base URL. When set, the gateway configures Codex to use a custom provider with `supports_websockets = false`. +- `CODEX_GATEWAY_CWD`: working directory passed to `thread/start`. +- `CODEX_GATEWAY_CODEX_BIN`: path to the `codex` executable. +- `CODEX_GATEWAY_MODEL`: preferred default model. +- `CODEX_GATEWAY_OPENAI_API_KEY`: API key used at startup for `codex login --with-api-key`. +- `CODEX_GATEWAY_OPENAI_BASE_URL`: upstream OpenAI-compatible base URL. +- `CODEX_GATEWAY_JWT_SECRET`: optional HS256 JWT secret. - `CODEX_GATEWAY_MAX_SESSIONS`: maximum live sessions. Defaults to `12`. -- `CODEX_GATEWAY_MAX_DEPLOYMENTS`: maximum active deployment tasks. Defaults to `4`. - `CODEX_GATEWAY_SESSION_TTL_MS`: idle session TTL. Defaults to `1800000`. -- `CODEX_GATEWAY_DEPLOYMENT_TIMEOUT_MS`: deployment task timeout and deployment session keepalive window. Defaults to `3600000`. - `CODEX_GATEWAY_SESSION_SWEEP_INTERVAL_MS`: cleanup sweep interval. Defaults to `60000`. -- `CODEX_GATEWAY_CODEX_HOME`: Codex runtime home for auth cache, logs, history, and config. In Docker this defaults to `/codex-home`. -- `CODEX_GATEWAY_DEBUG`: enables raw bridge message debugging when set to `1`. -- `CODEX_GATEWAY_JWT_SECRET`: optional HS256 JWT secret. When set, the gateway requires a valid bearer token for all routes except `/healthz` and `/readyz`. -- `CODEX_GATEWAY_SESSION_RUNTIME`: session runtime backend. Defaults to `local`; set to `devbox` to create a Devbox runtime before each session. -- `CODEX_GATEWAY_DEVBOX_BASE_URL`: Devbox API base URL. If omitted in devbox mode, the gateway derives `https://devbox-server.${SEALOS_HOST}` from `SEALOS_HOST`. -- `CODEX_GATEWAY_DEVBOX_TOKEN`: Devbox API bearer token. `DEVBOX_TOKEN` is also accepted. -- `CODEX_GATEWAY_DEVBOX_JWT_SIGNING_KEY`: HS256 signing key used when no Devbox token is configured. `DEVBOX_JWT_SIGNING_KEY` is also accepted. -- `CODEX_GATEWAY_DEVBOX_NAMESPACE`: Devbox namespace. Defaults to `ns-test`. -- `CODEX_GATEWAY_DEVBOX_RUNTIME_IMAGE`: optional Devbox runtime image override. -- `CODEX_GATEWAY_DEVBOX_ARCHIVE_AFTER_PAUSE_TIME`: Devbox archive delay after pause. Defaults to `24h`. -- `CODEX_GATEWAY_DEVBOX_WAIT_TIMEOUT_SECONDS`: timeout while waiting for a new Devbox to become `Running`. Defaults to `60`. -- `CODEX_GATEWAY_DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS`: timeout while waiting for the Codex Gateway inside Devbox to pass health and readiness checks. Defaults to `60`. -- `CODEX_GATEWAY_DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS`: timeout for the bootstrap command. Defaults to `300`. - -## Docker - -The container image builds the Rust gateway binary, then installs the Codex CLI on Linux with `npm install -g @openai/codex`, which matches the official Codex CLI quickstart. - -Build the image: - -```bash -docker build -t codex-gateway . -``` - -Run it: - -```bash -docker run --rm \ - -p 1317:1317 \ - -e CODEX_GATEWAY_OPENAI_API_KEY=sk-... \ - -e CODEX_GATEWAY_OPENAI_BASE_URL=https://sub2api-xnldrpuk.usw-1.sealos.app \ - -e CODEX_GATEWAY_JWT_SECRET=replace-with-your-hs256-secret \ - -e CODEX_GATEWAY_HOST=0.0.0.0 \ - -e CODEX_GATEWAY_PORT=1317 \ - -e CODEX_GATEWAY_MAX_SESSIONS=8 \ - codex-gateway -``` +- `CODEX_GATEWAY_SESSION_RUNTIME`: session runtime backend. Defaults to `embedded`. Supported values are `embedded` and `devbox`. +- `CODEX_GATEWAY_MAX_DEPLOYMENTS`: maximum active Brain deployment tasks. Defaults to `4`. +- `CODEX_GATEWAY_DEPLOYMENT_TIMEOUT_MS`: Brain deployment timeout and session keepalive window. Defaults to `3600000`. + +Devbox-related settings are only used when the runtime is `devbox`: + +- `CODEX_GATEWAY_DEVBOX_BASE_URL` +- `CODEX_GATEWAY_DEVBOX_TOKEN` +- `CODEX_GATEWAY_DEVBOX_JWT_SIGNING_KEY` +- `CODEX_GATEWAY_DEVBOX_NAMESPACE` +- `CODEX_GATEWAY_DEVBOX_RUNTIME_IMAGE` +- `CODEX_GATEWAY_DEVBOX_ARCHIVE_AFTER_PAUSE_TIME` +- `CODEX_GATEWAY_DEVBOX_WAIT_TIMEOUT_SECONDS` +- `CODEX_GATEWAY_DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS` +- `CODEX_GATEWAY_DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS` -Notes: - -- if `CODEX_GATEWAY_OPENAI_API_KEY` is set, the container runs `codex login --with-api-key` automatically before starting the gateway -- `CODEX_GATEWAY_OPENAI_BASE_URL` is the preferred way to point Codex at a third-party OpenAI-compatible endpoint; the gateway maps it to a custom Codex provider instead of the built-in `openai` provider -- if `CODEX_GATEWAY_JWT_SECRET` is set, clients must send `Authorization: Bearer ` on normal HTTP requests; the built-in Web UI also supports pasting the token into the sidebar -- you do not need to mount `CODEX_GATEWAY_CODEX_HOME` for normal API-key-based startup; mount it only if you want Codex state to persist across container restarts -- if you want Codex to operate on another workspace inside the container, set `CODEX_GATEWAY_CWD` and mount that path too -- this is a PoC deployment shape, not a hardened public service -- after the container starts, use the same health/API/Web UI verification flow described above - -## GitHub Container Registry - -GitHub Actions can publish this image to GHCR after pushes to `main` and version tags such as `v0.6.0`. - -Published tags: - -- `ghcr.io/labring/codex-gateway:main` for the latest `main` branch image -- `ghcr.io/labring/codex-gateway:sha-` for each published commit -- `ghcr.io/labring/codex-gateway:v0.6.0`, `0.6.0`, `0.6`, `0`, and `latest` when pushing a version tag - -Pull the current `main` image: - -```bash -docker pull ghcr.io/labring/codex-gateway:main -``` - -Run it the same way as the local image: +## Verification ```bash -docker run --rm \ - -p 1317:1317 \ - -e CODEX_GATEWAY_OPENAI_API_KEY=sk-... \ - -e CODEX_GATEWAY_OPENAI_BASE_URL=https://sub2api-xnldrpuk.usw-1.sealos.app \ - -e CODEX_GATEWAY_HOST=0.0.0.0 \ - -e CODEX_GATEWAY_PORT=1317 \ - -e CODEX_GATEWAY_MAX_SESSIONS=8 \ - ghcr.io/labring/codex-gateway:main +cargo fmt --check +cargo test ``` - -If the package is private, authenticate to GHCR before pulling it. - -## Current limitations - -- no built-in rate limiting -- no durable session persistence -- approval UI is intentionally absent because this gateway defaults to full access -- each live session consumes a `codex app-server` subprocess -- browser clients can recover from reloads by storing the session id and thread id, but gateway sessions themselves are still in-memory only diff --git a/README_zh.md b/README_zh.md index 4537cb3..02f7630 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,98 +1,61 @@ # Codex Gateway -English version: [README.md](./README.md) +英文版:[README.md](./README.md) -这个仓库当前的定位,是一个最小化的多 session `Codex gateway`,用于验证 `codex app-server` 能不能通过 Rust HTTP/SSE 服务对外暴露。 +Codex Gateway 是一个 Rust HTTP/SSE 网关,用来通过小型 API 和浏览器 UI 运行相互隔离的 Codex session。 -架构说明: [docs/architecture.md](./docs/architecture.md) +`.qoder/` 下可能存在 Qoder 自动生成文档;它是生成物,不作为人工源码文档编辑或提交。这个 README 是当前仓库维护的人工入口。 -API 文档: [docs/api.md](./docs/api.md) +## Runtime 形态 -## 当前形态 +默认 runtime 是 `embedded`: -整体链路是: +1. 客户端通过 Rust gateway 创建 session。 +2. session 拥有一个 `CodexAppServerBridge`。 +3. bridge 通过 stdio 启动并管理一个 `codex app-server` 子进程。 +4. app-server 的通知会写入 session state,并通过 SSE 推给客户端。 -1. 外部客户端调用 Rust HTTP API -2. Rust 服务为每个 session 创建一个 Codex bridge -3. 每个 bridge 启动一个自己的本地 `codex app-server` 子进程 -4. `codex app-server` 的通知通过 SSE 回推给对应 session 的客户端 +可选的 `devbox` runtime 是远端执行后端: -官方参考: +1. 外层 gateway 创建 Devbox runtime。 +2. 外层 gateway 等待 Devbox 内部的 gateway ready。 +3. 外层 gateway 在内部 gateway 里创建远端 session。 +4. 内部 gateway 使用 `embedded` runtime 运行 `codex app-server`。 -- [Codex App Server](https://developers.openai.com/codex/app-server/) -- [Codex CLI Quickstart](https://developers.openai.com/codex/quickstart/#setup) -- [Codex configuration reference](https://developers.openai.com/codex/config-reference/) -- [Codex CI/CD auth](https://developers.openai.com/codex/auth/ci-cd-auth) +`devbox` 是 runtime 基础设施,不是产品模式。 -## 目录说明 +## Brain Deployment API -- `rust-src/main.rs`:Rust HTTP 服务本体,提供 session API、SSE、健康检查和静态文件 -- `rust-src/bridge.rs`:协议桥接层,负责 `initialize`、`account/read`、`model/list`、`thread/start`、`turn/start` 和通知处理 -- `rust-src/runtime.rs`:运行时辅助模块,负责 API key 登录和 `openai_base_url` 覆盖 -- `rust-src/session_manager.rs`:多 session 生命周期管理,包含 TTL 回收 -- `rust-src/cli.rs`:单次 CLI 冒烟验证 -- `public/index.html`:最小化 Web UI -- `public/app.js`:浏览器端逻辑,会自动创建自己的 session 并订阅自己的 SSE -- `public/styles.css`:样式 -- `Dockerfile`:多阶段容器镜像,负责构建 Rust 二进制并安装 Codex CLI +`POST /api/deployments` 是为 Brain 应用预留的接口,不是通用部署产品接口。 -## 运行模型 - -现在已经不是单个全局共享会话了。 - -- `POST /api/sessions` 会创建一个新 session -- 每个 session 拥有一个自己的 `CodexAppServerBridge` -- 每个 bridge 拥有一个自己的 `codex app-server` 子进程 -- `/state`、`/events`、`/turn`、`/thread/new` 都是 session 级接口 -- session 会在空闲超时后自动清理,也可以手动 `DELETE /api/sessions/:id` - -这意味着多个调用方不会再共用同一个 thread 或 transcript。 +这个接口会创建一个 Codex task,用来部署仓库并返回机器可读的部署结果。当当前 session runtime 由 Devbox 承载时,Gateway 会先 bootstrap Devbox runtime,再启动 Brain deployment task。 ## HTTP API -### 健康检查 - - `GET /healthz` - `GET /readyz` - -### Session 接口 - - `POST /api/sessions` - - 请求体:`{ "model": "可选模型 ID" }` - - 返回:`{ ok, sessionId, session, state }` - `GET /api/sessions/:id/state` - - 返回 session 信息和当前 bridge 状态快照 - `GET /api/sessions/:id/events` - - 只属于该 session 的 SSE 流 - `POST /api/sessions/:id/turn` - - 请求体:`{ "prompt": "..." }` - `POST /api/sessions/:id/turn/interrupt` - - 请求停止当前正在运行的 turn,同时保留 session - `POST /api/sessions/:id/thread/new` - - 请求体:`{ "model": "可选模型 ID" }` +- `POST /api/sessions/:id/thread/resume` - `DELETE /api/sessions/:id` - - 关闭该 session 及其子进程 - -### 当前 PoC 行为 +- `GET /api/threads` +- `GET /api/threads/:threadId` +- `POST /api/deployments` +- `GET /api/deployments/:threadId` -- `codex app-server` 会以 `sandbox_mode="danger-full-access"` 和 `approval_policy="never"` 启动 -- 如果旧版或新版 approval request 仍然出现,Gateway 会自动接受 -- dynamic tool call 会收到结构化 tool result;不支持的 tool 会显式失败,而不是把 turn 卡住 -- 仍然依赖交互式 UI 的 server 发起请求会被显式拒绝或取消 -- session 状态只存在内存里,不持久化 -- gateway 鉴权是可选的,只有设置 `CODEX_GATEWAY_JWT_SECRET` 时才会开启 -- 单个 session 同一时间只能有一个 active turn -- 正在运行的 turn 可以被 interrupt,不需要删除整个 session +旧的单 session 路由已经移除,例如 `/api/state`、`/api/events`、`/api/turn`、`/api/thread/new`,现在会返回 `410 Gone`。 ## 本地运行 -### Web UI - -启动服务: +启动 gateway: ```bash CODEX_GATEWAY_OPENAI_API_KEY=sk-... \ -CODEX_GATEWAY_OPENAI_BASE_URL=https://sub2api-xnldrpuk.usw-1.sealos.app \ +CODEX_GATEWAY_OPENAI_BASE_URL=https://example-openai-compatible-endpoint.test \ CODEX_GATEWAY_JWT_SECRET=replace-with-your-hs256-secret \ cargo run --bin codex-gateway ``` @@ -103,35 +66,7 @@ cargo run --bin codex-gateway http://127.0.0.1:1317 ``` -页面会优先恢复上一次仍然存活的 session,失败时尝试恢复上一次的 thread,并自动连接它自己的 SSE 流。开启 JWT 鉴权后,需要先在侧边栏的 `Auth` 输入框里填入 Bearer token。 - -### CLI 冒烟 - -```bash -cargo run --bin codex-gateway-cli -- -``` - -或自定义 prompt: - -```bash -cargo run --bin codex-gateway-cli -- "Reply with exactly the single word ready." -``` - -## 手动验证 - -如果你要快速验证这个项目能不能跑,建议按这个顺序: - -1. 执行 `cargo run --bin codex-gateway` -2. 访问 `http://127.0.0.1:1317/healthz` -3. 访问 `http://127.0.0.1:1317/readyz` -4. 打开 `http://127.0.0.1:1317` -5. 等页面里的 `Status` 变成 `ready` -6. 发送 `Reply with exactly the single word ready. Do not call tools.` -7. 确认 Transcript 里出现 `ready` - -如果你想直接验证 API,而不是页面: - -创建 session: +快速 API 验证: ```bash curl -X POST http://127.0.0.1:1317/api/sessions \ @@ -139,109 +74,40 @@ curl -X POST http://127.0.0.1:1317/api/sessions \ -d '{}' ``` -发送 turn: - -```bash -curl -X POST http://127.0.0.1:1317/api/sessions//turn \ - -H 'Content-Type: application/json' \ - -d '{"prompt":"Reply with exactly the single word ready. Do not call tools."}' -``` - -查看状态: +## 配置 -```bash -curl http://127.0.0.1:1317/api/sessions//state -``` - -如果 transcript 里出现 `ready`,就说明 gateway、bridge 和 `codex app-server` 之间的链路已经跑通。 - -## 环境变量 +Gateway 自有配置统一使用 `CODEX_GATEWAY_` 前缀。 -gateway 自有配置统一使用 `CODEX_GATEWAY_` 前缀,便于和 Codex CLI 原生变量区分。 - -- `CODEX_GATEWAY_HOST`:Rust 服务监听地址,默认 `0.0.0.0` +- `CODEX_GATEWAY_HOST`:监听地址,默认 `0.0.0.0` - `CODEX_GATEWAY_PORT`:监听端口,默认 `1317` -- `CODEX_GATEWAY_CWD`:传给 `thread/start` 的工作目录,默认仓库根目录 -- `CODEX_GATEWAY_CODEX_BIN`:`codex` 可执行文件路径,默认从 `PATH` 查找 -- `CODEX_GATEWAY_MODEL`:新 bridge 默认模型 +- `CODEX_GATEWAY_CWD`:传给 `thread/start` 的工作目录 +- `CODEX_GATEWAY_CODEX_BIN`:`codex` 可执行文件路径 +- `CODEX_GATEWAY_MODEL`:默认模型 - `CODEX_GATEWAY_OPENAI_API_KEY`:启动时用于执行 `codex login --with-api-key` 的 API key -- `CODEX_GATEWAY_OPENAI_BASE_URL`:推荐使用的上游 OpenAI-compatible `base_url`。设置后,gateway 会把它配置成一个关闭 websocket 的自定义 Codex provider -- `CODEX_GATEWAY_MAX_SESSIONS`:最大同时在线 session 数,默认 `12` +- `CODEX_GATEWAY_OPENAI_BASE_URL`:上游 OpenAI-compatible base URL +- `CODEX_GATEWAY_JWT_SECRET`:可选 HS256 JWT secret +- `CODEX_GATEWAY_MAX_SESSIONS`:最大在线 session 数,默认 `12` - `CODEX_GATEWAY_SESSION_TTL_MS`:空闲 session TTL,默认 `1800000` - `CODEX_GATEWAY_SESSION_SWEEP_INTERVAL_MS`:清理扫描间隔,默认 `60000` -- `CODEX_GATEWAY_CODEX_HOME`:Codex 运行目录,包含认证缓存、日志、历史和配置;Docker 默认值是 `/codex-home` -- `CODEX_GATEWAY_DEBUG`:设为 `1` 时输出原始 bridge 消息,便于调试 -- `CODEX_GATEWAY_JWT_SECRET`:可选的 HS256 JWT secret。设置后,除了 `/healthz` 和 `/readyz` 外,其他路由都需要合法的 Bearer token - -## Docker - -容器镜像会先构建 Rust gateway 二进制,再通过 `npm install -g @openai/codex` 在 Linux 中安装 Codex CLI,这和官方 Quickstart 一致。 - -构建镜像: - -```bash -docker build -t codex-gateway . -``` - -运行容器: - -```bash -docker run --rm \ - -p 1317:1317 \ - -e CODEX_GATEWAY_OPENAI_API_KEY=sk-... \ - -e CODEX_GATEWAY_OPENAI_BASE_URL=https://sub2api-xnldrpuk.usw-1.sealos.app \ - -e CODEX_GATEWAY_JWT_SECRET=replace-with-your-hs256-secret \ - -e CODEX_GATEWAY_HOST=0.0.0.0 \ - -e CODEX_GATEWAY_PORT=1317 \ - -e CODEX_GATEWAY_MAX_SESSIONS=8 \ - codex-gateway -``` - -说明: - -- 如果设置了 `CODEX_GATEWAY_OPENAI_API_KEY`,容器会在启动 gateway 前自动执行 `codex login --with-api-key` -- `CODEX_GATEWAY_OPENAI_BASE_URL` 是把 Codex 指向第三方 OpenAI-compatible endpoint 的推荐方式;gateway 会把它映射成自定义 provider,而不是内建 `openai` provider -- 如果设置了 `CODEX_GATEWAY_JWT_SECRET`,普通 HTTP 请求需要带 `Authorization: Bearer `;内置 Web UI 也支持在侧边栏直接填写 token -- 普通 API key 启动不需要挂载 `CODEX_GATEWAY_CODEX_HOME`;只有在你希望容器重启后保留 Codex 状态时才需要挂载 -- 如果要让 Codex 在容器里操作别的工作目录,需要同时设置 `CODEX_GATEWAY_CWD` 并挂载对应路径 -- 这是 PoC 部署方式,不是生产加固版本 -- 容器启动后,验证方法和本地运行时完全一样 +- `CODEX_GATEWAY_SESSION_RUNTIME`:session runtime backend,默认 `embedded`;支持值只有 `embedded` 和 `devbox` +- `CODEX_GATEWAY_MAX_DEPLOYMENTS`:最大并发 Brain deployment task 数,默认 `4` +- `CODEX_GATEWAY_DEPLOYMENT_TIMEOUT_MS`:Brain deployment 超时和 session keepalive 窗口,默认 `3600000` -## GitHub Container Registry +Devbox 相关配置只在 runtime 为 `devbox` 时使用: -GitHub Actions 可以在推送到 `main` 以及版本 tag(例如 `v0.6.0`)后,把镜像发布到 GHCR。 +- `CODEX_GATEWAY_DEVBOX_BASE_URL` +- `CODEX_GATEWAY_DEVBOX_TOKEN` +- `CODEX_GATEWAY_DEVBOX_JWT_SIGNING_KEY` +- `CODEX_GATEWAY_DEVBOX_NAMESPACE` +- `CODEX_GATEWAY_DEVBOX_RUNTIME_IMAGE` +- `CODEX_GATEWAY_DEVBOX_ARCHIVE_AFTER_PAUSE_TIME` +- `CODEX_GATEWAY_DEVBOX_WAIT_TIMEOUT_SECONDS` +- `CODEX_GATEWAY_DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS` +- `CODEX_GATEWAY_DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS` -发布出来的 tag 规则: - -- `ghcr.io/labring/codex-gateway:main` 表示当前 `main` 分支最新镜像 -- `ghcr.io/labring/codex-gateway:sha-` 表示每次发布对应的提交镜像 -- 推送版本 tag 时,会额外发布 `v0.6.0`、`0.6.0`、`0.6`、`0` 和 `latest` - -拉取当前 `main` 镜像: +## 验证 ```bash -docker pull ghcr.io/labring/codex-gateway:main +cargo fmt --check +cargo test ``` - -运行方式和本地构建镜像一致: - -```bash -docker run --rm \ - -p 1317:1317 \ - -e CODEX_GATEWAY_OPENAI_API_KEY=sk-... \ - -e CODEX_GATEWAY_OPENAI_BASE_URL=https://sub2api-xnldrpuk.usw-1.sealos.app \ - -e CODEX_GATEWAY_HOST=0.0.0.0 \ - -e CODEX_GATEWAY_PORT=1317 \ - -e CODEX_GATEWAY_MAX_SESSIONS=8 \ - ghcr.io/labring/codex-gateway:main -``` - -如果包可见性是私有的,拉取前需要先登录 GHCR。 - -## 当前限制 - -- 没有内建限流 -- 没有持久化 session -- 没有审批 UI,因为当前 Gateway 默认使用最高权限 -- 每个活跃 session 都会占用一个 `codex app-server` 子进程 -- 浏览器可以通过保存 `sessionId` 和 `threadId` 做刷新恢复,但 gateway session 本身仍然只存在内存里 diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 7d31e87..0000000 --- a/docs/api.md +++ /dev/null @@ -1,461 +0,0 @@ -# API Reference - -本文档列出当前 Rust gateway 支持的 HTTP API 和 SSE 事件。 - -默认本地地址: - -```text -http://127.0.0.1:1317 -``` - -如果启动时设置了 `CODEX_GATEWAY_PORT`,端口以实际配置为准。 - -## Auth - -默认不启用鉴权。 - -如果服务端设置了 `CODEX_GATEWAY_JWT_SECRET`,除以下两个接口外,其他路由都需要合法 JWT: - -- `GET /healthz` -- `GET /readyz` - -普通 HTTP 请求使用: - -```http -Authorization: Bearer -``` - -SSE 请求也支持 query 参数: - -```text -/api/sessions/:id/events?access_token= -``` - -或: - -```text -/api/sessions/:id/events?token= -``` - -JWT 当前使用 HS256 校验,并要求包含 `exp`。 - -## Common Response Objects - -### Session - -```json -{ - "id": "session-id", - "createdAt": "2026-04-15T01:00:00Z", - "lastAccessAt": "2026-04-15T01:00:00Z", - "expiresAt": "2026-04-15T01:30:00Z" -} -``` - -### State - -`state` 是当前 session 对应 bridge 的状态快照,主要字段包括: - -```json -{ - "ready": true, - "cwd": "/workspace", - "startedAt": "2026-04-15T01:00:00Z", - "runtime": {}, - "account": {}, - "models": [], - "selectedModel": "gpt-5.4", - "threadId": "thread-id", - "threadStatus": { "type": "idle" }, - "currentTurnId": null, - "activeTurn": false, - "lastTurnStatus": null, - "transcript": [], - "recentEvents": [] -} -``` - -## Endpoints - -### GET /healthz - -健康检查。这个接口不需要鉴权。 - -Response: - -```json -{ - "ok": true, - "uptimeSeconds": 12 -} -``` - -### GET /readyz - -就绪检查。这个接口不需要鉴权。 - -Response: - -```json -{ - "ok": true, - "activeSessions": 0 -} -``` - -### POST /api/sessions - -创建一个新的 gateway session。 - -每个 session 会启动一个独立的 `codex app-server` 子进程。 - -Request body 可以为空,也可以传模型,或恢复一个已有 thread: - -```json -{ - "model": "gpt-5.4", - "resumeThreadId": "thread-id" -} -``` - -Response: - -```json -{ - "ok": true, - "sessionId": "session-id", - "session": { - "id": "session-id", - "createdAt": "2026-04-15T01:00:00Z", - "lastAccessAt": "2026-04-15T01:00:00Z", - "expiresAt": "2026-04-15T01:30:00Z" - }, - "state": {} -} -``` - -常见错误: - -- `503`:达到最大并发 session 数。 -- `500`:启动、初始化 `codex app-server` 或恢复 thread 失败。 - -### GET /api/threads - -列出 app-server 可见的历史 thread。 - -Query 参数: - -- `cursor`:分页 cursor。 -- `limit`:返回数量。 -- `sortKey`:排序字段,例如 `created_at` 或 `updated_at`。 -- `archived`:是否查询归档 thread。 -- `cwd`:按工作目录精确过滤。 -- `searchTerm`:按标题/预览文本过滤。 - -Response: - -```json -{ - "ok": true, - "threads": [], - "nextCursor": null, - "raw": {} -} -``` - -说明: - -- 这个接口内部调用 `thread/list`。 -- 如果当前已有 live session,会复用一个 live session 的 app-server bridge。 -- 如果当前没有 live session,会临时启动一个 app-server bridge 查询,查询后关闭。 - -### GET /api/threads/:threadId - -读取指定 thread 的完整历史。 - -Response: - -```json -{ - "ok": true, - "threadId": "thread-id", - "thread": {}, - "raw": {} -} -``` - -说明: - -- 这个接口内部调用 `thread/read`,并设置 `includeTurns=true`。 -- `thread.turns[].items[]` 包含历史消息、工具调用等 app-server 原始 thread item。 - -### GET /api/sessions/:id/state - -获取指定 session 的当前状态快照。 - -Response: - -```json -{ - "ok": true, - "sessionId": "session-id", - "session": {}, - "state": {} -} -``` - -常见错误: - -- `404`:session 不存在或已过期。 - -### GET /api/sessions/:id/events - -订阅指定 session 的 SSE 事件流。 - -连接建立后,服务端会先发送两个事件: - -```text -event: session -data: {...} - -event: state -data: {...} -``` - -后续可能出现的事件: - -| Event | 含义 | -| --- | --- | -| `session` | 当前 session metadata。连接建立时发送。 | -| `state` | 当前 bridge 状态快照。状态变化时发送。 | -| `notification` | `codex app-server` 发来的普通通知。 | -| `server-request` | `codex app-server` 发起的请求,例如 approval request、dynamic tool call、tool user input。gateway 会自动接受可自动处理的 approval,给 tool call 返回结构化结果,并对无法交互完成的请求显式取消或拒绝。 | -| `warning` | gateway 或 bridge 产生的警告。 | -| `raw` | 原始 app-server 消息。仅在 `CODEX_GATEWAY_DEBUG=1` 时可能出现。 | -| `session-closed` | session 被关闭、过期或 gateway shutdown。 | - -连接保活: - -- 服务端每 15 秒发送一次 SSE keepalive。 - -常见错误: - -- `401`:开启鉴权后 token 缺失或无效。 -- `404`:session 不存在或已过期。 - -### POST /api/sessions/:id/turn - -向指定 session 的当前 thread 发送一次用户输入。 - -Request body: - -```json -{ - "prompt": "Reply with exactly OK." -} -``` - -Response: - -```json -{ - "ok": true, - "sessionId": "session-id", - "session": {}, - "state": {} -} -``` - -说明: - -- 这个接口只负责启动一次 turn。 -- 过程输出主要通过 `GET /api/sessions/:id/events` 获取。 -- 同一个 session 同一时间只允许一个 active turn。 -- 如果要停止当前回复,调用 `POST /api/sessions/:id/turn/interrupt`。 - -常见错误: - -- `400`:`prompt` 为空或请求体不是合法 JSON。 -- `404`:session 不存在或已过期。 -- `409`:当前 session 已经有 active turn。 - -### POST /api/sessions/:id/turn/interrupt - -请求停止指定 session 里正在运行的当前 turn。 - -Request body 可以为空。 - -Response: - -```json -{ - "ok": true, - "sessionId": "session-id", - "session": {}, - "state": {} -} -``` - -说明: - -- 这个接口对应 `codex app-server` 的 `turn/interrupt`。 -- session 会保留。 -- 当前 thread 会保留。 -- 已有上下文会保留。 -- 当前 turn 会结束为 `interrupted` 状态。 -- 这适合实现产品里的 “Stop generating”。 -- 这不是删除 session。 - -常见错误: - -- `404`:session 不存在或已过期。 -- `409`:当前没有 active turn,或 active turn 还没有可中断的 `turnId`。 -- `500`:`turn/interrupt` 调用失败。 - -### POST /api/sessions/:id/thread/new - -在同一个 session 内新开一个 thread。 - -Request body 可以为空,也可以传模型: - -```json -{ - "model": "gpt-5.4" -} -``` - -Response: - -```json -{ - "ok": true, - "sessionId": "session-id", - "session": {}, - "state": {} -} -``` - -说明: - -- session 保留。 -- `codex app-server` 子进程保留。 -- 当前 transcript 会被清空。 -- 新 thread 使用请求里的 `model`,没有传时复用当前 selected model。 - -常见错误: - -- `404`:session 不存在或已过期。 -- `500`:`thread/start` 失败。 - -### POST /api/sessions/:id/thread/resume - -在同一个 gateway session 内恢复一个历史 thread。 - -Request body: - -```json -{ - "threadId": "thread-id" -} -``` - -Response: - -```json -{ - "ok": true, - "sessionId": "session-id", - "session": {}, - "state": {} -} -``` - -说明: - -- session 保留。 -- `codex app-server` 子进程保留。 -- 当前 thread 切换为请求里的历史 thread。 -- 后续 `POST /api/sessions/:id/turn` 会继续写入恢复后的 thread。 - -常见错误: - -- `400`:`threadId` 为空或请求体不是合法 JSON。 -- `404`:session 不存在或已过期。 -- `409`:当前 session 已经有 active turn。 -- `500`:`thread/resume` 失败。 - -### DELETE /api/sessions/:id - -删除指定 session。 - -Response: - -```json -{ - "ok": true, - "sessionId": "session-id" -} -``` - -说明: - -- 这是关闭整个 session 的接口。 -- 会停止对应 Bridge。 -- 会 kill 对应的 `codex app-server` 子进程。 -- session 的内存状态会丢失。 -- 如果只想停止当前 AI 回复并保留上下文,请用 `POST /api/sessions/:id/turn/interrupt`。 - -常见错误: - -- `404`:session 不存在或已过期。 - -## Static Routes - -这些路由服务内置 Web UI,也会经过可选 JWT 鉴权: - -| Method | Path | 用途 | -| --- | --- | --- | -| `GET` | `/` | Web UI HTML | -| `GET` | `/app.js` | Web UI JavaScript | -| `GET` | `/styles.css` | Web UI CSS | - -## Removed Legacy Routes - -旧的单 session API 已移除。以下路由当前会返回 `410 Gone`: - -| Method | Path | -| --- | --- | -| `GET`, `POST` | `/api/state` | -| `GET`, `POST` | `/api/events` | -| `GET`, `POST` | `/api/turn` | -| `GET`, `POST` | `/api/thread/new` | - -Response: - -```json -{ - "error": "Legacy single-session endpoints were removed. Create a session first via POST /api/sessions." -} -``` - -## Error Format - -普通 JSON 错误响应格式: - -```json -{ - "error": "message" -} -``` - -常见状态码: - -| Status | 含义 | -| --- | --- | -| `400` | 请求体不是合法 JSON,或必要字段为空。 | -| `401` | 开启鉴权后 token 缺失或无效。 | -| `404` | 路由不存在,或 session 不存在。 | -| `409` | 当前 session 已经有 active turn,或没有可中断的 active turn。 | -| `410` | 访问已移除的旧单 session API。 | -| `503` | 达到最大并发 session 数。 | -| `500` | 子进程、Codex app-server 或内部状态错误。 | diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 9c50dbc..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,50 +0,0 @@ -# Architecture - -这个项目可以按运行时分成 5 个核心组件。 - -## Component Summary - -```mermaid -flowchart LR - UI["Web UI / API Client"] - Gateway["Rust HTTP/SSE Gateway"] - Sessions["Session Manager"] - Bridge["Codex Bridge"] - AppServer["codex app-server"] - - UI --> Gateway - Gateway --> Sessions - Sessions --> Bridge - Bridge --> AppServer - AppServer --> Bridge - Bridge --> Sessions - Sessions --> Gateway - Gateway --> UI -``` - -## Components - -| Component | 分工 | 关键文件 | -| --- | --- | --- | -| Web UI / API Client | 用户入口。负责创建 session、连接 SSE、发送 prompt、展示结果。Web UI 只是内置调试页面,外部业务系统也可以直接调用 HTTP API。 | `public/index.html`, `public/app.js` | -| Rust HTTP/SSE Gateway | 对外服务入口。负责 HTTP 路由、请求校验、健康检查、静态文件、SSE 输出。它不直接和 Codex 模型交互。 | `rust-src/main.rs` | -| Session Manager | 多会话管理。负责创建 session、查找 session、刷新 TTL、限制最大 session 数、清理过期 session、关闭 session 资源。 | `rust-src/session_manager.rs` | -| Codex Bridge | 协议桥。每个 session 一个 bridge,负责启动并管理对应的 `codex app-server` 子进程,通过 stdio 发送 `initialize`、`thread/start`、`turn/start` 等请求,并把 app-server 通知转成 gateway 状态。 | `rust-src/bridge.rs`, `rust-src/models.rs` | -| `codex app-server` | Codex 真正的运行体。负责 Codex thread、turn、模型调用和工具请求等底层逻辑。它是外部 `codex` CLI 提供的子进程,不是本仓库自己实现的模块。 | 由 `codex app-server` 命令启动 | - -## Supporting Parts - -除了 5 个核心组件,还有几个辅助模块: - -| Part | 分工 | 关键文件 | -| --- | --- | --- | -| Auth | 可选 JWT 鉴权。设置 `CODEX_GATEWAY_JWT_SECRET` 后,普通 API 和 SSE 都需要 token。 | `rust-src/auth.rs` | -| Runtime / Env Config | 读取 `CODEX_GATEWAY_*` 配置,处理 API key 登录、base URL 覆盖、Codex 子进程环境变量。 | `rust-src/config.rs`, `rust-src/env_config.rs`, `rust-src/runtime.rs` | -| CLI Smoke Test | 本地一次性验证链路:启动 bridge、发 prompt、等待结果。 | `rust-src/cli.rs` | -| Docker / CI | 构建 Rust 二进制、安装 Codex CLI、发布容器镜像。 | `Dockerfile`, `.github/workflows/container.yml` | - -## One Sentence - -这个项目的核心分工是: - -`Web UI / API Client` 负责输入输出,`Rust Gateway` 负责对外 API,`Session Manager` 负责多会话生命周期,`Codex Bridge` 负责协议转换和子进程管理,`codex app-server` 负责真正的 Codex 执行。 diff --git a/docs/integration-guide.md b/docs/integration-guide.md deleted file mode 100644 index e0f9085..0000000 --- a/docs/integration-guide.md +++ /dev/null @@ -1,377 +0,0 @@ -# Codex Gateway 对接说明 - -## 1. 推荐对接流程 - -一个完整接入流程通常是: - -1. 调用 `POST /api/sessions` 创建 session,或传 `resumeThreadId` 恢复历史 thread -2. 保存返回的 `sessionId` 和 `state.threadId` -3. 连接 `GET /api/sessions/:id/events` 订阅该 session 的 SSE -4. 调用 `POST /api/sessions/:id/turn` 发送用户输入 -5. 通过 SSE 或 `GET /api/sessions/:id/state` 获取当前状态和结果 -6. 如果需要停止当前回复,调用 `POST /api/sessions/:id/turn/interrupt` -7. 使用结束后调用 `DELETE /api/sessions/:id` - -推荐把一个页面、一个用户会话、或一个业务任务映射到一个 gateway session。 - -## 2. 接口说明 - -### 2.1 创建 session - -**请求** - -```http -POST /api/sessions -Content-Type: application/json -``` - -可选请求体: - -```json -{ - "model": "gpt-5.4", - "resumeThreadId": "9d7a5c2d-thread" -} -``` - -**响应示例** - -```json -{ - "ok": true, - "sessionId": "9d7a5c2d-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "session": { - "id": "9d7a5c2d-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "createdAt": "2026-04-09T02:00:00.000Z", - "lastAccessAt": "2026-04-09T02:00:00.000Z", - "expiresAt": "2026-04-09T02:30:00.000Z" - }, - "state": { - "ready": true, - "selectedModel": "gpt-5.4" - } -} -``` - -说明: - -- `sessionId` 是后续所有操作的主键 -- `state.threadId` 是 Codex thread 的主键,刷新恢复时应该保存 -- 一个 session 对应一个独立的 `codex app-server` 子进程 -- 如果不传 `model`,会使用服务端默认模型 -- 如果传 `resumeThreadId`,新 session 会恢复该历史 thread - -### 2.2 订阅事件流 - -**请求** - -```http -GET /api/sessions/:id/events -Accept: text/event-stream -``` - -这是一个 SSE 长连接,用来接收该 session 的状态变化和消息事件。 - -建议: - -- 创建 session 后尽快连接 SSE -- 前端做自动重连 -- 业务侧保存 `sessionId` 和 `threadId` -- 页面刷新后先尝试复用 `sessionId` -- 如果 session 已过期,再用 `threadId` 作为 `resumeThreadId` 创建新 session - -### 2.3 发送 prompt - -**请求** - -```http -POST /api/sessions/:id/turn -Content-Type: application/json -``` - -请求体: - -```json -{ - "prompt": "请帮我总结这个目录下的代码结构" -} -``` - -**响应示例** - -```json -{ - "ok": true, - "sessionId": "9d7a5c2d-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "session": { - "id": "9d7a5c2d-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "createdAt": "2026-04-09T02:00:00.000Z", - "lastAccessAt": "2026-04-09T02:00:10.000Z", - "expiresAt": "2026-04-09T02:30:10.000Z" - }, - "state": { - "ready": true, - "activeTurn": true - } -} -``` - -说明: - -- 这个接口只负责启动一次 turn -- 过程性输出主要通过 SSE 观察 -- 最终结果也可以通过 `GET /api/sessions/:id/state` 中的 `transcript` 查看 - -### 2.4 停止当前 turn - -**请求** - -```http -POST /api/sessions/:id/turn/interrupt -Content-Type: application/json -``` - -请求体可以为空。 - -用途: - -- 停止当前正在运行的 AI 回复 -- 保留当前 session -- 保留当前 thread 和已有上下文 -- 适合实现 “Stop generating” - -说明: - -- 这个接口不是删除 session -- 如果当前没有 active turn,会返回 `409` -- 如果 active turn 还没有可中断的 `turnId`,也会返回 `409` - -### 2.5 查询状态 - -**请求** - -```http -GET /api/sessions/:id/state -``` - -这个接口返回当前 session 的完整状态快照,适合以下场景: - -- 页面初始化时补一次状态 -- SSE 中断后做兜底同步 -- 排查问题时查看 `transcript`、`selectedModel`、`threadStatus` - -### 2.6 新开 thread - -**请求** - -```http -POST /api/sessions/:id/thread/new -Content-Type: application/json -``` - -可选请求体: - -```json -{ - "model": "gpt-5.4" -} -``` - -用途: - -- 在保留同一个 session 的前提下,新开一个 thread -- 适合需要“清空上下文重新开始”,但又不想重建整个 session 的场景 - -### 2.7 查看和读取历史 thread - -列出历史 thread: - -```http -GET /api/threads?limit=20&sortKey=updated_at -``` - -读取指定 thread: - -```http -GET /api/threads/:threadId -``` - -用途: - -- 做历史会话列表 -- 打开历史 thread 预览完整 turns/items -- 结合 `resumeThreadId` 或 thread resume 接口继续对话 - -### 2.8 恢复历史 thread - -创建新 session 时恢复: - -```http -POST /api/sessions -Content-Type: application/json -``` - -```json -{ - "resumeThreadId": "thread-id" -} -``` - -在现有 session 内恢复: - -```http -POST /api/sessions/:id/thread/resume -Content-Type: application/json -``` - -```json -{ - "threadId": "thread-id" -} -``` - -说明: - -- 当前 session 有 active turn 时不能 resume,会返回 `409` -- resume 成功后,后续 prompt 会继续写入恢复后的 thread - -### 2.9 删除 session - -**请求** - -```http -DELETE /api/sessions/:id -``` - -用途: - -- 主动释放这个 session 对应的 `codex app-server` 子进程 -- 任务完成、或会话明确结束时,建议主动调用 -- 普通页面刷新不建议主动删除 session,否则会破坏刷新恢复体验 - -## 2.10 可选鉴权 - -如果服务端设置了 `CODEX_GATEWAY_JWT_SECRET`,除了 `/healthz` 和 `/readyz` 以外,其他路由都需要携带合法的 HS256 JWT。 - -普通 HTTP 请求请带: - -```http -Authorization: Bearer -``` - -SSE 场景如果不方便设置请求头,也可以通过 query 参数传: - -```text -/api/sessions/:id/events?access_token= -``` - -## 3. 最小示例 - -### 3.1 创建 session - -```bash -curl -X POST http://127.0.0.1:1317/api/sessions \ - -H 'Authorization: Bearer ' \ - -H 'Content-Type: application/json' \ - -d '{}' -``` - -### 3.2 发起一次 turn - -```bash -curl -X POST http://127.0.0.1:1317/api/sessions//turn \ - -H 'Authorization: Bearer ' \ - -H 'Content-Type: application/json' \ - -d '{"prompt":"Reply with exactly OK."}' -``` - -### 3.3 查询状态 - -```bash -curl http://127.0.0.1:1317/api/sessions//state \ - -H 'Authorization: Bearer ' -``` - -### 3.4 删除 session - -```bash -curl -X DELETE http://127.0.0.1:1317/api/sessions/ \ - -H 'Authorization: Bearer ' -``` - -## 4. 前端接入示例 - -下面是一个最小的浏览器侧接入思路: - -```js -const token = ""; - -const createResponse = await fetch("/api/sessions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ model: "gpt-5.4" }), -}); - -const created = await createResponse.json(); -const sessionId = created.sessionId; - -const events = new EventSource(`/api/sessions/${sessionId}/events`); -events.onmessage = (event) => { - console.log("sse message", event.data); -}; - -await fetch(`/api/sessions/${sessionId}/turn`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt: "请解释一下这个仓库的用途" }), -}); -``` - -如果要在标签页关闭时清理 session,可以在页面退出前补一个删除调用。 - -## 5. Session 生命周期 - -一个 session 会在以下情况下被销毁: - -- 调用 `DELETE /api/sessions/:id` 显式删除 -- 超过空闲 TTL 被自动回收 -- gateway 进程退出时统一清理 - -当前默认值是: - -- `CODEX_GATEWAY_SESSION_TTL_MS = 1800000` -- `CODEX_GATEWAY_SESSION_SWEEP_INTERVAL_MS = 60000` - -也就是: - -- 默认空闲 30 分钟自动过期 -- 每 60 秒扫描一次过期 session - -只要该 session 还有 API 调用或内部事件流动,它的 `expiresAt` 就会刷新。 - -## 6. 资源占用和边界 - -接入方需要特别注意以下几点: - -- 每个活跃 session 都会占用一个独立的 `codex app-server` 子进程 -- 一个 gateway 实例当前默认只使用一套统一的 API key -- 当前不支持“每个 session 使用不同 API key” -- 当前没有内建用户鉴权、配额、限流和持久化 ownership - -所以建议: - -- 一个页面或一个任务尽量复用一个 session -- 用完及时删除,不要无限创建 -- 如果要对公网开放,前面最好再加一层业务鉴权或接入层 - -## 7. 部署侧需要准备什么 - -对接方通常不需要关心服务如何部署,但部署方至少需要配置: - -- `CODEX_GATEWAY_OPENAI_API_KEY` -- `CODEX_GATEWAY_OPENAI_BASE_URL` - -当前 gateway 会把 `CODEX_GATEWAY_OPENAI_BASE_URL` 映射成一个自定义 Codex provider,并显式关闭 websocket transport,以兼容当前使用的第三方 OpenAI-compatible upstream。 diff --git a/docs/release-format.md b/docs/release-format.md deleted file mode 100644 index 2aadcb0..0000000 --- a/docs/release-format.md +++ /dev/null @@ -1,146 +0,0 @@ -# Release Format - -本文档约束本仓库后续 GitHub Release 的命名、正文结构、发布产物和校验说明格式。 - -## Version Tags - -正式 release 必须使用 `vX.Y.Z` 格式,例如 `v0.5.1`。 - -发布前需要同步更新版本号: - -- `Cargo.toml` 的 `package.version` -- `package.json` 的 `version` - -Git tag 应使用 annotated tag,tag message 使用: - -```text -Release vX.Y.Z -``` - -## Release Title - -正式 release 标题使用 tag 本身: - -```text -vX.Y.Z -``` - -例如: - -```text -v0.5.1 -``` - -## Release Body - -正式 release 正文必须使用以下四个二级标题,并保持顺序不变: - -```md -## Changes - -- ... - -## Release assets - -- `codex-gateway-darwin-arm64.tar.gz` -- `codex-gateway-darwin-arm64.tar.gz.sha256` -- `codex-gateway-linux-amd64.tar.gz` -- `codex-gateway-linux-amd64.tar.gz.sha256` - -## Container image - -- `ghcr.io/labring/codex-gateway:latest` -- `ghcr.io/labring/codex-gateway:vX.Y.Z` - -## Validation - -- ... -``` - -## Changes - -`Changes` 部分用 bullet list 描述本次 release 的用户可见变化和重要内部变化。 - -要求: - -- 每条变更使用一句完整说明。 -- 优先写行为变化、API 变化、运行时变化、镜像变化、兼容性变化。 -- 不需要逐条复制 commit message。 -- 如果包含 breaking change,需要在对应 bullet 中明确说明。 - -## Release Assets - -正式 release 应上传以下 assets: - -- `codex-gateway-darwin-arm64.tar.gz` -- `codex-gateway-darwin-arm64.tar.gz.sha256` -- `codex-gateway-linux-amd64.tar.gz` -- `codex-gateway-linux-amd64.tar.gz.sha256` - -正文中的 asset 文件名必须使用反引号包裹,并与实际上传文件名完全一致。 - -## Container Image - -正式 release 正文至少列出以下镜像: - -- `ghcr.io/labring/codex-gateway:latest` -- `ghcr.io/labring/codex-gateway:vX.Y.Z` - -推送 `vX.Y.Z` tag 后,GitHub Actions 会额外发布 semver 相关镜像 tag: - -- `X.Y.Z` -- `X.Y` -- `X` -- `latest` - -如需在 `Changes` 中说明镜像发布范围,可以写明完整 tag 集合,例如: - -```md -- Publish GHCR image tags for `v0.5.1`, `0.5.1`, `0.5`, `0`, and `latest`. -``` - -## Validation - -`Validation` 部分必须列出本次 release 实际完成的校验和构建。 - -常见条目包括: - -- `cargo fmt --check` passed. -- `cargo check` passed. -- `cargo test` passed. -- Darwin arm64 release binaries were built locally and packaged with the static web assets. -- Linux amd64 release binaries were built from the repository Docker builder target and packaged with the static web assets. -- GitHub Actions Container workflow passed for `vX.Y.Z`. - -只写实际执行过并通过的检查。不要把未执行的检查写进 release notes。 - -## Prereleases - -预发布可以使用描述性 tag,例如: - -```text -rust-preview-YYYYMMDD-N -``` - -预发布标题可以使用可读名称,例如: - -```text -Rust preview YYYY-MM-DD -``` - -预发布正文可以使用自由格式,但必须说明: - -- 对应 commit 或日期。 -- 包含哪些 assets。 -- 目标平台。 -- 重要运行时要求。 -- 与正式 release 的差异。 - -## Historical Notes - -本仓库历史格式演进如下: - -- `v0.2.0` 使用 GitHub 自动生成 release notes,没有固定四段结构,也没有二进制 assets。 -- `rust-preview-20260410-1` 是 macOS arm64 Rust 预览包,使用自由格式并标记为 prerelease。 -- 从 `v0.3.0` 起,正式 release 使用 `Changes`、`Release assets`、`Container image`、`Validation` 四段结构。 -- `v0.5.1` 是当前推荐格式基准:asset 和 image 均使用反引号,`Validation` 明确列出测试和各平台构建来源。 diff --git a/docs/todo-p0.md b/docs/todo-p0.md deleted file mode 100644 index 69928a5..0000000 --- a/docs/todo-p0.md +++ /dev/null @@ -1,402 +0,0 @@ -# P0: Thread history and resume - -本文档展开 `docs/todo.md` 里的 P0:让用户刷新页面后能回到原来的会话,并能查看、读取、恢复历史 thread。 - -当前状态:初版已实现。Gateway 已暴露 thread list/read/resume API,`POST /api/sessions` 已支持 `resumeThreadId`,内置 Web UI 已支持刷新恢复和轻量历史列表。 - -## 背景 - -当前 gateway 已经是多 session 架构: - -- 一个 gateway session 对应一个 `CodexAppServerBridge` -- 一个 bridge 会启动并持有一个 `codex app-server` 子进程 -- 当前 thread、transcript、turn 状态都保存在这个 session 对应的内存状态里 -- 浏览器 demo 打开页面时会自动 `POST /api/sessions` 创建新 session -- 页面关闭或刷新时,demo 会尽量 `DELETE /api/sessions/:id` - -这意味着当前 demo 的行为是:刷新页面后,用户基本会进入一个全新的 session,旧 thread 不会自动回到页面。 - -## 目标 - -P0 要解决的是会话连续性,而不是泛泛的持久化。 - -核心目标: - -- 页面刷新后可以恢复到刷新前的工作上下文 -- 用户可以看到历史 thread 列表 -- 用户可以打开某个历史 thread 并读取完整历史 -- 用户可以基于历史 thread 继续发送新的 turn - -非目标: - -- 不在 P0 里做 approval UI -- 不在 P0 里做 fork、rollback、archive、rename -- 不在 P0 里做 Web IDE 文件浏览或命令执行 API -- 不要求 gateway 自己持久化完整 transcript,优先复用 app-server 已有 thread 能力 - -## 当前缺口 - -### 1. Gateway session 只存在内存里 - -`SessionManager` 目前用内存 `HashMap>` 管理 session。 - -影响: - -- gateway 进程重启后,所有 gateway session 都会消失 -- session 过期后,旧 session id 不能再使用 -- 页面刷新如果没有复用旧 session id,就会创建新 session - -### 2. 前端 demo 不保存 session id - -`public/app.js` 当前初始化时总是创建新 session: - -```js -await createSession(); -connectEvents(); -``` - -影响: - -- 页面刷新后不会尝试复用旧 session -- 即使后端旧 session 还没有过期,前端也不会重新连接它 - -### 3. 前端 demo 在 pagehide 时删除 session - -当前 demo 会在 `pagehide` 时尽量删除当前 session。 - -影响: - -- 正常刷新或关闭标签页时,旧 session 很可能被主动关闭 -- 这和“刷新恢复”目标冲突 - -### 4. Gateway 没有暴露 app-server 的 thread 历史能力 - -app-server 已有这些能力: - -- `thread/list` -- `thread/read` -- `thread/resume` - -初版实现前,gateway 只暴露: - -- `thread/start` -- `turn/start` -- `turn/interrupt` - -影响: - -- 前端无法列出历史 thread -- 前端无法读取某个 thread 的完整消息 -- 前端无法恢复一个已有 thread 继续对话 - -## 设计原则 - -### Session 和 thread 要分清楚 - -Gateway session 是 gateway 的运行时连接和资源边界。 - -它包含: - -- 一个 app-server 子进程 -- SSE 订阅能力 -- 当前 bridge 状态 -- session TTL - -Codex thread 是 app-server 里的对话上下文。 - -它包含: - -- 对话历史 -- turn 历史 -- 工作目录和模型等 thread 相关信息 -- 可被 app-server list/read/resume 的持久记录 - -P0 不应该把这两个概念混成一个东西。刷新恢复可以分两层做: - -1. 如果旧 gateway session 还活着,优先复用旧 session。 -2. 如果旧 gateway session 已经没了,但 app-server 还能找到旧 thread,则创建新 gateway session 并 resume 旧 thread。 - -### Gateway 优先做薄封装 - -thread 历史的事实来源应该是 app-server。 - -Gateway 不应该在 P0 里重新设计一套 transcript 数据库。除非 app-server 返回的数据不足以支持 UI,再考虑补充极小的 gateway metadata。 - -### P0 先覆盖刷新恢复,不追求完整产品化会话管理 - -P0 的完成标准是用户不再因为刷新页面丢上下文。 - -更完整的能力,例如 thread 命名、归档、fork、rollback,可以放在 P1/P2。 - -## 建议 API - -### GET /api/threads - -列出 app-server 可见的历史 thread。 - -内部调用: - -```text -thread/list -``` - -建议响应: - -```json -{ - "ok": true, - "threads": [ - { - "id": "thread-id", - "title": "optional title", - "cwd": "/workspace", - "model": "gpt-5.4", - "createdAt": "2026-04-15T01:00:00Z", - "updatedAt": "2026-04-15T01:05:00Z" - } - ] -} -``` - -说明: - -- 字段以 app-server 实际返回为准 -- gateway 可以先透传 `raw`,再逐步稳定成前端需要的结构 -- 这个接口不绑定某个 gateway session,因为它查询的是 app-server 的历史 thread 能力 - -### GET /api/threads/:threadId - -读取指定 thread 的完整历史。 - -内部调用: - -```text -thread/read -``` - -建议响应: - -```json -{ - "ok": true, - "threadId": "thread-id", - "thread": {}, - "items": [] -} -``` - -说明: - -- `thread` 保存 thread metadata -- `items` 保存 app-server 返回的历史消息、turn、工具事件等 -- 前端可以用这个接口渲染历史详情 - -### POST /api/sessions - -保留当前创建新 session 的能力,但建议扩展请求体: - -```json -{ - "model": "gpt-5.4", - "resumeThreadId": "thread-id" -} -``` - -行为: - -- 如果没有 `resumeThreadId`,保持现有逻辑:启动新 bridge 和新 thread -- 如果传了 `resumeThreadId`,启动 bridge 后调用 `thread/resume` -- resume 成功后,session 的 `state.threadId` 应该等于这个 thread id -- resume 成功后,session 的 `state.transcript` 应该尽量根据 thread 历史重建 - -### POST /api/sessions/:id/thread/resume - -在现有 session 内切换到一个历史 thread。 - -内部调用: - -```text -thread/resume -``` - -请求: - -```json -{ - "threadId": "thread-id" -} -``` - -响应: - -```json -{ - "ok": true, - "sessionId": "session-id", - "session": {}, - "state": {} -} -``` - -说明: - -- 当前 session 如果有 active turn,应返回 `409` -- resume 成功后,后续 `POST /api/sessions/:id/turn` 应继续写入恢复后的 thread -- resume 成功后,gateway 应通过 SSE 发送新的 `state` - -## 前端行为 - -### 刷新恢复 - -建议前端保存最近使用的 session 和 thread: - -```text -localStorage.codexGatewaySessionId -localStorage.codexGatewayThreadId -``` - -页面加载时: - -1. 如果本地有 `sessionId`,先调用 `GET /api/sessions/:id/state` -2. 如果 session 存在,继续连接 `/api/sessions/:id/events` -3. 如果 session 不存在,但本地有 `threadId`,调用 `POST /api/sessions` 并传 `resumeThreadId` -4. 如果两者都没有或恢复失败,则创建新 session - -页面刷新时: - -- 不应在普通刷新场景主动删除 session -- 可以只关闭 SSE,让后端 TTL 负责清理闲置 session -- 明确点击 “End session” 或类似动作时,再调用 `DELETE /api/sessions/:id` - -### 历史列表 - -UI 可以新增一个轻量历史入口: - -- 打开时调用 `GET /api/threads` -- 点击某个 thread 后调用 `GET /api/threads/:threadId` 预览历史 -- 选择继续时调用 `POST /api/sessions/:id/thread/resume` - -P0 的历史列表只需要可用,不要求命名、归档、搜索。 - -## 后端实现建议 - -### Bridge - -新增方法: - -- `list_threads()` -- `read_thread(thread_id)` -- `resume_thread(thread_id)` - -这些方法都应该通过现有 JSON-RPC `request` 机制调用 app-server。 - -resume 成功后需要更新 bridge state: - -- `thread_id` -- `thread_status` -- `current_turn_id` -- `active_turn` -- `last_turn_status` -- `transcript` - -其中 `transcript` 可以先做最小映射:只提取用户和 assistant 文本消息。工具调用、文件变化、命令输出等复杂 item 可以后续再完善。 - -### SessionManager - -新增方法: - -- `resume_thread(session_id, thread_id)` -- 可选:`create_session_with_resume(model, thread_id)` - -注意: - -- resume 前检查 active turn -- resume 成功后刷新 TTL -- session 仍然是内存对象,不需要 P0 里持久化 session manager - -### HTTP routes - -新增路由: - -- `GET /api/threads` -- `GET /api/threads/:threadId` -- `POST /api/sessions/:id/thread/resume` - -可选扩展: - -- `POST /api/sessions` 支持 `resumeThreadId` - -### SSE - -resume 成功后,现有 `state` 事件就够用。 - -不需要新增专门的 SSE event,除非前端需要区分“新建 thread”和“恢复 thread”。 - -## 错误语义 - -建议错误码: - -- `400`:缺少或非法 `threadId` -- `404`:thread 不存在,或 session 不存在 -- `409`:当前 session 有 active turn,不能 resume -- `500`:app-server thread/list、thread/read、thread/resume 调用失败 - -建议错误信息保持可读,例如: - -```json -{ - "ok": false, - "error": "Cannot resume thread while a turn is active" -} -``` - -## 验收标准 - -### 刷新恢复 - -- 用户打开 demo,发送一条 prompt,等待 assistant 回复 -- 用户刷新页面 -- 页面重新打开后仍显示原 thread id -- 页面重新打开后能看到刷新前的 transcript -- 用户继续发送 prompt,新的 turn 接在原 thread 后面 - -### session 兜底 - -- 用户打开 demo,发送一条 prompt -- 手动让原 gateway session 失效或删除 -- 刷新页面 -- 前端能基于保存的 thread id 创建新 session 并 resume - -### 历史读取 - -- `GET /api/threads` 能返回历史 thread 列表 -- `GET /api/threads/:threadId` 能返回指定 thread 的历史内容 -- 历史列表点击某个 thread 后,可以恢复并继续对话 - -### 回归 - -- 不带 `resumeThreadId` 创建 session 时,现有 demo 行为仍可工作 -- `POST /api/sessions/:id/thread/new` 仍能开启新 thread -- active turn 期间 resume 返回 `409` -- SSE 断线重连仍能拿到当前 state - -## 推荐实施顺序 - -1. 后端 bridge 增加 `thread/list`、`thread/read`、`thread/resume` 封装 -2. HTTP 暴露 `GET /api/threads` 和 `GET /api/threads/:threadId` -3. HTTP 暴露 `POST /api/sessions/:id/thread/resume` -4. 扩展 `POST /api/sessions` 支持 `resumeThreadId` -5. 前端保存 `sessionId` 和 `threadId` -6. 前端刷新时先复用 session,失败后 resume thread -7. 前端去掉刷新时自动删除 session 的行为,改成显式结束 -8. 补充 API 文档和集成说明 - -## Open questions - -- `thread/list` 返回是否已经包含 title、cwd、model、createdAt、updatedAt? -- `thread/read` 返回的 item schema 是否能稳定映射到当前 `TranscriptEntry`? -- `thread/resume` 是否要求传 cwd/model,还是只需要 thread id? -- 不同用户之间的 thread 历史如何隔离?当前 JWT 只做访问控制,还没有 thread owner 语义。 -- 如果 gateway 运行在容器或多副本环境,app-server 的 thread 存储路径是否共享? - -这些问题不阻塞 P0 设计,但实现前需要用本机 `codex app-server generate-ts` 或实际请求确认 app-server 协议细节。 diff --git a/docs/todo.md b/docs/todo.md deleted file mode 100644 index a161abf..0000000 --- a/docs/todo.md +++ /dev/null @@ -1,209 +0,0 @@ -# Todo - -这个文件记录 Codex app-server 已支持、但当前 gateway 还没有暴露的高价值能力。 - -## P0 - -### Thread history and resume - -Status: initial implementation landed. - -Detail: [`docs/todo-p0.md`](todo-p0.md) - -App-server methods: - -- `thread/list` -- `thread/read` -- `thread/resume` - -Why: - -- 支持刷新页面后恢复会话。 -- 支持历史会话列表。 -- 支持读取完整 thread 历史。 - -Implemented: - -- `GET /api/threads` -- `GET /api/threads/:threadId` -- `POST /api/sessions` 支持 `resumeThreadId` -- `POST /api/sessions/:id/thread/resume` -- 内置 Web UI 会保存 `sessionId` 和 `threadId`,刷新后先复用 session,失败后恢复 thread。 - -Remaining gap: - -- Gateway session 仍然只存在内存里,进程重启后需要依赖 app-server thread 历史恢复。 -- 历史列表 UI 仍是最小实现,还没有搜索、归档、命名等完整会话管理能力。 - -## P1 - -### Steer active turn - -App-server method: - -- `turn/steer` - -Why: - -- AI 正在回复时,用户可以追加指令。 -- 适合“换个方向”“简短一点”“不要改文件”等场景。 - -Current gap: - -- Gateway 只支持 `turn/start` 和 `turn/interrupt`。 -- 不支持对 active turn 追加输入。 - -### Fork thread - -App-server method: - -- `thread/fork` - -Why: - -- 允许用户从当前上下文分叉,尝试另一个方案。 -- 原 thread 不被破坏。 - -Current gap: - -- Gateway 当前只能新开空 thread。 -- 不支持从已有 thread fork。 - -### Rollback turns - -App-server method: - -- `thread/rollback` - -Why: - -- 用户可以撤销最近 N 个 turns。 -- 比新开 thread 更适合修正跑偏的上下文。 - -Current gap: - -- Gateway 没有上下文级撤销能力。 - -### Review current work - -App-server method: - -- `review/start` - -Why: - -- 可以封装成 “Review current changes”。 -- 适合本地变更或 PR 前自检。 - -Current gap: - -- Gateway 没有 review API。 - -## P2 - -### Thread naming and archive - -App-server methods: - -- `thread/name/set` -- `thread/archive` -- `thread/unarchive` - -Why: - -- 历史会话列表需要标题。 -- 用户需要归档和恢复旧 thread。 - -Current gap: - -- Gateway 没有 thread 名称、归档、恢复归档 API。 - -### Compact long thread - -App-server method: - -- `thread/compact/start` - -Why: - -- 长会话需要压缩上下文。 -- 有助于控制上下文长度和成本。 - -Current gap: - -- Gateway 没有手动 compact API。 - -## P3 - -### Command execution API - -App-server methods: - -- `command/exec` -- `command/exec/write` -- `command/exec/resize` -- `command/exec/terminate` - -Why: - -- 可以在 Web UI 中运行测试、查看命令输出、管理终端。 - -Risk: - -- 安全边界复杂。 -- 需要鉴权、审计、权限策略和沙箱策略。 - -Current gap: - -- Gateway 没有独立命令执行 API。 - -### Filesystem API - -App-server methods: - -- `fs/readFile` -- `fs/writeFile` -- `fs/readDirectory` -- `fs/getMetadata` -- `fs/createDirectory` -- `fs/remove` -- `fs/copy` - -Why: - -- 可以做 Web IDE 文件浏览和编辑。 - -Risk: - -- 对公网暴露风险高。 -- 需要严格的路径、权限和审计设计。 - -Current gap: - -- Gateway 没有文件系统 API。 - -## Capability discovery - -这些能力适合后续做控制台或设置页时再接: - -- `skills/list` -- `plugin/list` -- `plugin/read` -- `plugin/install` -- `plugin/uninstall` -- `app/list` -- `mcpServerStatus/list` -- `mcpServer/resource/read` -- `mcpServer/oauth/login` -- `config/mcpServer/reload` -- `config/read` -- `config/value/write` -- `config/batchWrite` -- `experimentalFeature/list` -- `collaborationMode/list` - -## Notes - -- `turn/interrupt` 已经通过 `POST /api/sessions/:id/turn/interrupt` 暴露。 -- `command/exec` 和 `fs/*` 很强,但不建议第一批直接对公网开放。 -- 上述能力来源于 Codex app-server API overview 和本机 `codex app-server generate-ts` 生成的协议类型。 diff --git a/lab/design/ui-style-gallery.html b/lab/design/ui-style-gallery.html deleted file mode 100644 index e8e2c7d..0000000 --- a/lab/design/ui-style-gallery.html +++ /dev/null @@ -1,1496 +0,0 @@ - - - - - - - Codex Gateway Sage polish gallery - - - - - -
-
-
Codex Gateway Sage action area
-

One workbench. Five action bars.

-
- -
- -
-
-
-
01 / single action with context
-

Sage workbench note

-
-

- Removes search and lets the primary action breathe, with a small - contextual note taking the place of the second button. -

-
-
- -
-
-
- Repository summary - gpt-5.4 -
-
- - -
-
-
-
- user -
Summarize the repository and keep the answer short.
-
-
- assistant -
This is a Rust HTTP/SSE gateway around codex app-server. The browser UI should behave like a small chat client.
-
-
-
-
-
Ask Codex about the current workspace...
-
- full access sandbox - -
-
-
- Diagnosticshidden -
-
session7f41c9a2
-
threadthd_92ab18
-
ssestreaming
-
cwd/workspace/codex-gateway
-
-
-
-
-
-
- -
-
-
-
02 / primary action plus compact tool
-

Sage workbench split

-
-

- Keeps the main action full-size and moves the secondary affordance - into a compact icon-style control. -

-
-
- -
-
-
- Repository summary - gpt-5.4 -
-
- - -
-
-
-
- user -
What should stay visible in the default interface?
-
-
- assistant -
Keep conversations, the selected chat, composer, model context, and ready state visible. Move raw bridge details into diagnostics.
-
-
-
-
-
Message Codex Gateway...
-
- ready - -
-
-
- Diagnosticsclosed -
-
session7f41c9a2
-
threadthd_92ab18
-
events30 recent
-
authbearer configured
-
-
-
-
-
-
- -
-
-
-
03 / action with sidebar status
-

Sage workbench status

-
-

- Replaces search with useful state: how many conversations are visible - and whether the workspace is ready. -

-
-
- -
-
-
- Repository summary - gpt-5.4 -
-
- - -
-
-
-
- user -
Make the demo feel less like a dashboard.
-
-
- assistant -
Default to a chat canvas with a conversation rail. Diagnostics can remain accessible, but should not compete with the current message.
-
-
-
-
-
Ask Codex to inspect, edit, or test...
-
- full access sandbox - -
-
-
- Diagnosticshidden -
-
session7f41c9a2
-
threadthd_92ab18
-
ssestreaming
-
cwd/workspace/codex-gateway
-
-
-
-
-
-
- -
-
-
-
04 / grouped action panel
-

Sage workbench card

-
-

- Turns the top action area into a small command panel with one clear - button and compact operational metadata. -

-
-
- -
-
-
- Repository summary - gpt-5.4 -
-
- - -
-
-
-
- user -
Show all conversations, but keep runtime logs out of the way.
-
-
- assistant -
Conversation history belongs in the rail. Runtime logs belong behind diagnostics so the main task stays readable.
-
-
-
-
-
Ask Codex about the current workspace...
-
- ready / idle - -
-
-
- Diagnosticsclosed -
-
session7f41c9a2
-
threadthd_92ab18
-
ssestreaming
-
cwd/workspace/codex-gateway
-
-
-
-
-
-
- -
-
-
-
05 / ruled command strip
-

Sage workbench rule

-
-

- A stricter command strip. It keeps the top of the sidebar deliberate - without adding another button. -

-
-
- -
-
-
- Repository summary - gpt-5.4 -
-
- - -
-
-
-
- user -
Keep the left sidebar, but make the interface feel like a real app.
-
-
- assistant -
The app shell can feel polished without exposing every bridge detail. Conversation history stays visible; diagnostics stay folded.
-
-
-
-
-
Ask Codex to inspect the workspace...
-
- full access sandbox - -
-
-
- Diagnosticshidden -
-
session7f41c9a2
-
threadthd_92ab18
-
ssestreaming
-
cwd/workspace/codex-gateway
-
-
-
-
-
-
- - diff --git a/public/index.html b/public/index.html index 4778730..4a935f2 100644 --- a/public/index.html +++ b/public/index.html @@ -13,8 +13,8 @@

Multi-session gateway

Codex Gateway

- Browser UI on top of a local Rust gateway. The gateway creates a - session-scoped bridge, starts codex app-server, and + Browser UI on top of the Rust gateway. The gateway creates a + session-scoped embedded bridge, starts codex app-server, and streams its events into this page.

diff --git a/rust-src/auth.rs b/src/auth.rs similarity index 100% rename from rust-src/auth.rs rename to src/auth.rs diff --git a/rust-src/cli.rs b/src/bin/codex-gateway-cli.rs similarity index 100% rename from rust-src/cli.rs rename to src/bin/codex-gateway-cli.rs diff --git a/rust-src/bridge/mod.rs b/src/bridge/mod.rs similarity index 100% rename from rust-src/bridge/mod.rs rename to src/bridge/mod.rs diff --git a/rust-src/bridge/notifications.rs b/src/bridge/notifications.rs similarity index 100% rename from rust-src/bridge/notifications.rs rename to src/bridge/notifications.rs diff --git a/rust-src/bridge/process.rs b/src/bridge/process.rs similarity index 100% rename from rust-src/bridge/process.rs rename to src/bridge/process.rs diff --git a/rust-src/bridge/protocol.rs b/src/bridge/protocol.rs similarity index 100% rename from rust-src/bridge/protocol.rs rename to src/bridge/protocol.rs diff --git a/rust-src/bridge/rpc.rs b/src/bridge/rpc.rs similarity index 100% rename from rust-src/bridge/rpc.rs rename to src/bridge/rpc.rs diff --git a/rust-src/bridge/server_requests.rs b/src/bridge/server_requests.rs similarity index 100% rename from rust-src/bridge/server_requests.rs rename to src/bridge/server_requests.rs diff --git a/rust-src/bridge/state.rs b/src/bridge/state.rs similarity index 100% rename from rust-src/bridge/state.rs rename to src/bridge/state.rs diff --git a/rust-src/bridge/transcript.rs b/src/bridge/transcript.rs similarity index 100% rename from rust-src/bridge/transcript.rs rename to src/bridge/transcript.rs diff --git a/rust-src/bridge/workspace_tools.rs b/src/bridge/workspace_tools.rs similarity index 100% rename from rust-src/bridge/workspace_tools.rs rename to src/bridge/workspace_tools.rs diff --git a/rust-src/config.rs b/src/config.rs similarity index 75% rename from rust-src/config.rs rename to src/config.rs index b500d96..d0efb33 100644 --- a/rust-src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ use crate::env_config::{ SESSION_SWEEP_INTERVAL_MS_ENV, SESSION_TTL_MS_ENV, read_bool_flag, read_env, read_u16, read_u64, read_usize, }; +use crate::error::AppError; #[derive(Debug, Clone)] pub struct ClientInfo { @@ -26,7 +27,7 @@ pub struct AuthConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionRuntimeMode { - Local, + Embedded, Devbox, } @@ -50,7 +51,6 @@ pub struct AppConfig { pub port: u16, pub bridge_cwd: PathBuf, pub public_dir: PathBuf, - pub lab_dir: PathBuf, pub codex_bin: String, pub debug: bool, pub default_model: Option, @@ -66,20 +66,18 @@ pub struct AppConfig { } impl AppConfig { - pub fn from_env(root_dir: PathBuf) -> Self { + pub fn from_env(root_dir: PathBuf) -> Result { let public_dir = root_dir.join("public"); - let lab_dir = root_dir.join("lab"); - let session_runtime = read_session_runtime(); + let session_runtime = read_session_runtime()?; let devbox = read_devbox_config(session_runtime); - Self { + Ok(Self { host: read_env(HOST_ENV).unwrap_or_else(|| "0.0.0.0".to_string()), port: read_u16(PORT_ENV).unwrap_or(1317), bridge_cwd: read_env(BRIDGE_CWD_ENV) .map(PathBuf::from) .unwrap_or_else(|| root_dir.clone()), public_dir, - lab_dir, codex_bin: read_env(CODEX_BIN_ENV).unwrap_or_else(|| "codex".to_string()), debug: read_bool_flag(DEBUG_ENV), default_model: read_env(DEFAULT_MODEL_ENV), @@ -102,18 +100,26 @@ impl AppConfig { auth: read_env(JWT_SECRET_ENV).map(|jwt_secret| AuthConfig { jwt_secret }), session_runtime, devbox, - } + }) } } -fn read_session_runtime() -> SessionRuntimeMode { - match read_env(SESSION_RUNTIME_ENV) - .unwrap_or_else(|| "local".to_string()) +fn read_session_runtime() -> Result { + parse_session_runtime(read_env(SESSION_RUNTIME_ENV).as_deref()).map_err(AppError::bad_request) +} + +fn parse_session_runtime(value: Option<&str>) -> Result { + match value + .unwrap_or("embedded") + .trim() .to_ascii_lowercase() .as_str() { - "devbox" => SessionRuntimeMode::Devbox, - _ => SessionRuntimeMode::Local, + "embedded" => Ok(SessionRuntimeMode::Embedded), + "devbox" => Ok(SessionRuntimeMode::Devbox), + other => Err(format!( + "Unsupported CODEX_GATEWAY_SESSION_RUNTIME value `{other}`. Supported values: embedded, devbox" + )), } } @@ -152,3 +158,37 @@ fn read_devbox_config(session_runtime: SessionRuntimeMode) -> Option serde_json::Value { let mut env = serde_json::Map::new(); env.insert("CODEX_GATEWAY_HOST".to_string(), json!("0.0.0.0")); env.insert("CODEX_GATEWAY_PORT".to_string(), json!("1317")); - env.insert("CODEX_GATEWAY_SESSION_RUNTIME".to_string(), json!("local")); + env.insert( + "CODEX_GATEWAY_SESSION_RUNTIME".to_string(), + json!("embedded"), + ); env.insert( "CODEX_GATEWAY_SESSION_TTL_MS".to_string(), json!(ttl.as_millis().to_string()), diff --git a/rust-src/env_config.rs b/src/env_config.rs similarity index 100% rename from rust-src/env_config.rs rename to src/env_config.rs diff --git a/rust-src/error.rs b/src/error.rs similarity index 100% rename from rust-src/error.rs rename to src/error.rs diff --git a/rust-src/lib.rs b/src/lib.rs similarity index 100% rename from rust-src/lib.rs rename to src/lib.rs diff --git a/rust-src/main.rs b/src/main.rs similarity index 98% rename from rust-src/main.rs rename to src/main.rs index a7a63cb..e49bfd0 100644 --- a/rust-src/main.rs +++ b/src/main.rs @@ -35,7 +35,6 @@ struct AppState { session_manager: SessionManager, deployment_registry: DeploymentRegistry, public_dir: PathBuf, - lab_dir: PathBuf, } #[derive(Debug, Default, Deserialize)] @@ -85,13 +84,12 @@ async fn main() -> Result<(), AppError> { init_tracing(); let root_dir = env::current_dir()?; - let config = AppConfig::from_env(root_dir); + let config = AppConfig::from_env(root_dir)?; info!( host = %config.host, port = config.port, bridge_cwd = %config.bridge_cwd.display(), public_dir = %config.public_dir.display(), - lab_dir = %config.lab_dir.display(), codex_bin = %config.codex_bin, auth_enabled = config.auth.is_some(), debug = config.debug, @@ -111,7 +109,6 @@ async fn main() -> Result<(), AppError> { session_manager: session_manager.clone(), deployment_registry, public_dir: config.public_dir.clone(), - lab_dir: config.lab_dir.clone(), }; let app = build_router(state); @@ -141,7 +138,6 @@ fn build_router(state: AppState) -> Router { .route("/", get(index_html)) .route("/app.js", get(app_js)) .route("/styles.css", get(styles_css)) - .route("/ui-style-gallery.html", get(ui_style_gallery_html)) .route( "/api/state", get(legacy_single_session_gone).post(legacy_single_session_gone), @@ -226,16 +222,6 @@ async fn styles_css(State(state): State) -> Result, -) -> Result { - serve_static_file( - state.lab_dir.join("design/ui-style-gallery.html"), - "text/html; charset=utf-8", - ) - .await -} - async fn legacy_single_session_gone() -> Result, AppError> { Err(AppError::gone( "Legacy single-session endpoints were removed. Create a session first via POST /api/sessions.", diff --git a/rust-src/models.rs b/src/models.rs similarity index 100% rename from rust-src/models.rs rename to src/models.rs diff --git a/rust-src/remote_gateway.rs b/src/remote_gateway.rs similarity index 100% rename from rust-src/remote_gateway.rs rename to src/remote_gateway.rs diff --git a/rust-src/runtime.rs b/src/runtime.rs similarity index 100% rename from rust-src/runtime.rs rename to src/runtime.rs diff --git a/rust-src/session_manager.rs b/src/session_manager.rs similarity index 96% rename from rust-src/session_manager.rs rename to src/session_manager.rs index b420816..0648a3a 100644 --- a/rust-src/session_manager.rs +++ b/src/session_manager.rs @@ -38,7 +38,7 @@ struct Session { } enum SessionBackend { - Local { bridge: CodexAppServerBridge }, + Embedded { bridge: CodexAppServerBridge }, RemoteDevbox(Box), } @@ -218,7 +218,7 @@ impl SessionManager { let session = self.require_session(session_id)?; info!(session_id = %session_id, "subscribing to session events"); let receiver = match &session.backend { - SessionBackend::Local { bridge } => bridge.subscribe(), + SessionBackend::Embedded { bridge } => bridge.subscribe(), SessionBackend::RemoteDevbox(_) => { return Err(AppError::internal( "Remote Devbox session events are not proxied yet", @@ -317,11 +317,11 @@ impl SessionManager { .await; } - self.create_local_backend(model, resume_thread_id, metadata) + self.create_embedded_backend(model, resume_thread_id, metadata) .await } - async fn create_local_backend( + async fn create_embedded_backend( &self, model: Option, resume_thread_id: Option, @@ -347,7 +347,7 @@ impl SessionManager { return Err(error); } - Ok(SessionBackend::Local { bridge }) + Ok(SessionBackend::Embedded { bridge }) } async fn create_remote_devbox_backend( @@ -508,7 +508,7 @@ impl Session { fn state(&self) -> BridgeStateSnapshot { match &self.backend { - SessionBackend::Local { bridge } => bridge.get_state(), + SessionBackend::Embedded { bridge } => bridge.get_state(), SessionBackend::RemoteDevbox(backend) => backend.state.read().unwrap().clone(), } } @@ -519,7 +519,7 @@ impl Session { fn refresh_devbox_lease(&self, ttl: Duration) -> Result<(), AppError> { match &self.backend { - SessionBackend::Local { .. } => Ok(()), + SessionBackend::Embedded { .. } => Ok(()), SessionBackend::RemoteDevbox(backend) => { let runtime = backend.runtime.clone(); tokio::spawn(async move { @@ -534,7 +534,7 @@ impl Session { async fn list_threads(&self, params: Value) -> Result { match &self.backend { - SessionBackend::Local { bridge } => bridge.list_threads(params).await, + SessionBackend::Embedded { bridge } => bridge.list_threads(params).await, SessionBackend::RemoteDevbox(backend) => { Ok(backend.gateway.list_threads(params).await?) } @@ -543,7 +543,7 @@ impl Session { async fn read_thread(&self, thread_id: &str) -> Result { match &self.backend { - SessionBackend::Local { bridge } => bridge.read_thread(thread_id).await, + SessionBackend::Embedded { bridge } => bridge.read_thread(thread_id).await, SessionBackend::RemoteDevbox(backend) => { Ok(backend.gateway.read_thread(thread_id).await?) } @@ -552,7 +552,7 @@ impl Session { async fn send_prompt(&self, prompt: &str) -> Result { match &self.backend { - SessionBackend::Local { bridge } => { + SessionBackend::Embedded { bridge } => { bridge.send_prompt(prompt).await?; Ok(bridge.get_state()) } @@ -569,7 +569,7 @@ impl Session { async fn interrupt_turn(&self) -> Result { match &self.backend { - SessionBackend::Local { bridge } => { + SessionBackend::Embedded { bridge } => { bridge.interrupt_turn().await?; Ok(bridge.get_state()) } @@ -589,7 +589,7 @@ impl Session { model: Option, ) -> Result { match &self.backend { - SessionBackend::Local { bridge } => { + SessionBackend::Embedded { bridge } => { bridge.start_new_thread(model).await?; Ok(bridge.get_state()) } @@ -606,7 +606,7 @@ impl Session { async fn resume_thread(&self, thread_id: &str) -> Result { match &self.backend { - SessionBackend::Local { bridge } => { + SessionBackend::Embedded { bridge } => { bridge.resume_thread(thread_id).await?; Ok(bridge.get_state()) } @@ -623,7 +623,7 @@ impl Session { async fn close(&self, reason: &str) -> Result<(), AppError> { match &self.backend { - SessionBackend::Local { bridge } => { + SessionBackend::Embedded { bridge } => { bridge.broadcast_session_closed(&self.id, reason); bridge.stop().await } diff --git a/tests/http_integration.rs b/tests/http_integration.rs index 74de605..edfd694 100644 --- a/tests/http_integration.rs +++ b/tests/http_integration.rs @@ -46,7 +46,7 @@ fn gateway_session_thread_and_turn_http_flow_against_fake_app_server() { assert_eq!(turn.get("ok").and_then(Value::as_bool), Some(true)); let state = gateway.wait_for_json(&format!("/api/sessions/{session_id}/state"), |payload| { - payload + let has_assistant_reply = payload .pointer("/state/transcript") .and_then(Value::as_array) .is_some_and(|entries| { @@ -54,7 +54,13 @@ fn gateway_session_thread_and_turn_http_flow_against_fake_app_server() { entry.get("role").and_then(Value::as_str) == Some("assistant") && entry.get("text").and_then(Value::as_str) == Some("fake assistant reply") }) - }) + }); + + has_assistant_reply + && payload + .pointer("/state/lastTurnStatus") + .and_then(Value::as_str) + == Some("completed") }); assert_eq!( state