From 800967e7e7954ec6bba66395b5b3acd5f09a6555 Mon Sep 17 00:00:00 2001 From: pionxe Date: Thu, 23 Apr 2026 18:41:33 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(gateway):=20=E8=90=BD=E5=9C=B0=20RFC?= =?UTF-8?q?#420=20=E5=8F=8C=E4=BA=A7=E7=89=A9=E5=8F=91=E5=B8=83=E4=B8=8E?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=96=B9=E6=8E=A5=E5=85=A5=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 gateway-only 可执行入口 neocode-gateway,并与 `neocode gateway` 复用同一启动内核 - 引入共享 launcher,固定发现顺序: NEOCODE_GATEWAY_BIN > PATH(neocode-gateway) > 回退 neocode gateway - 在 url-dispatch 不可达路径接入“先拉起后重拨”流程,限制为单次受控回退 - 增加结构化启动决策日志字段与白名单断言测试,降低双入口行为漂移风险 - 扩展发布与安装体系:goreleaser 双产物、install 脚本 flavor + dry-run + checksum 校验 - CI 增加 gateway-only 冒烟链路与安装脚本 dry-run 回归检查 - 补齐文档体系:RFC 设计、第三方接入指南、RPC API(XGO 风格)、错误字典、兼容性策略 - 增加基于 Go 结构体的 JSON 示例自动生成机制,输出 docs/generated 示例文件 测试: - go build ./... - go test ./internal/gateway/launcher ./internal/gateway/adapters/urlscheme ./internal/gateway/protocol ./scripts - go test ./internal/cli -run "TestNewGatewayStandaloneCommandPassesFlagsToRunner|TestNewGatewayStandaloneCommandRejectsInvalidLogLevel|TestGatewaySubcommandAndStandaloneCommandAreOptionEquivalent|TestExecuteGatewayServerUsesStandaloneCommand|TestGatewaySubcommandAndStandaloneCommandPropagateSameRunnerError" 关联:RFC#420 --- .github/workflows/ci.yml | 83 +++++ .goreleaser.yaml | 53 ++- README.md | 274 +++----------- cmd/neocode-gateway/main.go | 16 + docs/gateway-compatibility.md | 53 +++ docs/gateway-detailed-design.md | 315 +++++++--------- docs/gateway-error-catalog.md | 21 ++ docs/gateway-rpc-api.md | 336 ++++++++++++++++++ docs/generated/gateway-rpc-examples.json | 75 ++++ docs/guides/gateway-integration-guide.md | 144 ++++++++ docs/guides/update.md | 48 ++- internal/cli/gateway_commands.go | 82 ++--- internal/cli/gateway_standalone.go | 15 + internal/cli/gateway_standalone_test.go | 174 +++++++++ .../gateway/adapters/urlscheme/dispatcher.go | 213 ++++++++++- .../adapters/urlscheme/dispatcher_test.go | 155 ++++++++ internal/gateway/launcher/launcher.go | 101 ++++++ internal/gateway/launcher/launcher_test.go | 159 +++++++++ internal/gateway/protocol/docgen_generate.go | 3 + scripts/generate_gateway_rpc_examples.go | 155 ++++++++ scripts/install.ps1 | 135 ++++--- scripts/install.sh | 174 ++++++--- 22 files changed, 2212 insertions(+), 572 deletions(-) create mode 100644 cmd/neocode-gateway/main.go create mode 100644 docs/gateway-compatibility.md create mode 100644 docs/gateway-error-catalog.md create mode 100644 docs/gateway-rpc-api.md create mode 100644 docs/generated/gateway-rpc-examples.json create mode 100644 docs/guides/gateway-integration-guide.md create mode 100644 internal/cli/gateway_standalone.go create mode 100644 internal/cli/gateway_standalone_test.go create mode 100644 internal/gateway/launcher/launcher.go create mode 100644 internal/gateway/launcher/launcher_test.go create mode 100644 internal/gateway/protocol/docgen_generate.go create mode 100644 scripts/generate_gateway_rpc_examples.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cd0f9f9..d246cd97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,92 @@ jobs: - name: Build run: go build ./... + - name: Gateway-only smoke + shell: bash + run: | + set -euo pipefail + + socket_path="/tmp/neocode-gateway-${RANDOM}.sock" + http_port="$((18080 + RANDOM % 1000))" + http_addr="127.0.0.1:${http_port}" + gateway_bin="/tmp/neocode-gateway" + gateway_log="/tmp/neocode-gateway.log" + + go build -o "${gateway_bin}" ./cmd/neocode-gateway + "${gateway_bin}" --listen "${socket_path}" --http-listen "${http_addr}" --log-level info >"${gateway_log}" 2>&1 & + gateway_pid=$! + + cleanup() { + if kill -0 "${gateway_pid}" >/dev/null 2>&1; then + kill "${gateway_pid}" || true + wait "${gateway_pid}" || true + fi + rm -f "${socket_path}" "${gateway_bin}" /tmp/gateway-healthz.json /tmp/gateway-rpc.json + } + trap cleanup EXIT + + for _ in $(seq 1 60); do + if curl -fsS "http://${http_addr}/healthz" > /tmp/gateway-healthz.json; then + break + fi + sleep 0.2 + done + test -s /tmp/gateway-healthz.json + + rpc_status="$(curl -sS -o /tmp/gateway-rpc.json -w "%{http_code}" \ + -X POST "http://${http_addr}/rpc" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"smoke-1","method":"gateway.ping","params":{}}')" + if [[ "${rpc_status}" != "401" ]]; then + echo "unexpected /rpc status: ${rpc_status}" >&2 + cat /tmp/gateway-rpc.json >&2 + cat "${gateway_log}" >&2 || true + exit 1 + fi + grep -q '"gateway_code":"unauthorized"' /tmp/gateway-rpc.json + - name: Test with coverage run: go test ./... -covermode=atomic -coverprofile=coverage.out + - name: Install script dry-run regression (bash) + shell: bash + env: + NEOCODE_INSTALL_LATEST_TAG: v0.0.0-test + run: | + set -euo pipefail + + full_output="$(bash ./scripts/install.sh --flavor full --dry-run)" + gateway_output="$(bash ./scripts/install.sh --flavor gateway --dry-run)" + + echo "${full_output}" | grep -Eq '^asset=neocode_(Linux|Darwin)_(x86_64|arm64)\.tar\.gz$' + echo "${gateway_output}" | grep -Eq '^asset=neocode-gateway_(Linux|Darwin)_(x86_64|arm64)\.tar\.gz$' + echo "${full_output}" | grep -Eq '^download_url=https://github.com/1024XEngineer/neo-code/releases/download/.+/neocode_(Linux|Darwin)_(x86_64|arm64)\.tar\.gz$' + echo "${gateway_output}" | grep -Eq '^download_url=https://github.com/1024XEngineer/neo-code/releases/download/.+/neocode-gateway_(Linux|Darwin)_(x86_64|arm64)\.tar\.gz$' + echo "${full_output}" | grep -Eq '^checksum_url=https://github.com/1024XEngineer/neo-code/releases/download/.+/checksums\.txt$' + echo "${gateway_output}" | grep -Eq '^checksum_url=https://github.com/1024XEngineer/neo-code/releases/download/.+/checksums\.txt$' + + - name: Install script dry-run regression (PowerShell) + shell: pwsh + env: + NEOCODE_INSTALL_LATEST_TAG: v0.0.0-test + run: | + $fullLines = & ./scripts/install.ps1 -Flavor full -DryRun + $gatewayLines = & ./scripts/install.ps1 -Flavor gateway -DryRun + + $fullAsset = ($fullLines | Where-Object { $_ -like 'asset=*' } | Select-Object -First 1) + $gatewayAsset = ($gatewayLines | Where-Object { $_ -like 'asset=*' } | Select-Object -First 1) + $fullDownload = ($fullLines | Where-Object { $_ -like 'download_url=*' } | Select-Object -First 1) + $gatewayDownload = ($gatewayLines | Where-Object { $_ -like 'download_url=*' } | Select-Object -First 1) + $fullChecksum = ($fullLines | Where-Object { $_ -like 'checksum_url=*' } | Select-Object -First 1) + $gatewayChecksum = ($gatewayLines | Where-Object { $_ -like 'checksum_url=*' } | Select-Object -First 1) + + if ($fullAsset -notmatch '^asset=neocode_Windows_(x86_64|arm64)\.zip$') { throw "Unexpected full asset line: $fullAsset" } + if ($gatewayAsset -notmatch '^asset=neocode-gateway_Windows_(x86_64|arm64)\.zip$') { throw "Unexpected gateway asset line: $gatewayAsset" } + if ($fullDownload -notmatch '^download_url=https://github.com/1024XEngineer/neo-code/releases/download/.+/neocode_Windows_(x86_64|arm64)\.zip$') { throw "Unexpected full download URL: $fullDownload" } + if ($gatewayDownload -notmatch '^download_url=https://github.com/1024XEngineer/neo-code/releases/download/.+/neocode-gateway_Windows_(x86_64|arm64)\.zip$') { throw "Unexpected gateway download URL: $gatewayDownload" } + if ($fullChecksum -notmatch '^checksum_url=https://github.com/1024XEngineer/neo-code/releases/download/.+/checksums\.txt$') { throw "Unexpected full checksum URL: $fullChecksum" } + if ($gatewayChecksum -notmatch '^checksum_url=https://github.com/1024XEngineer/neo-code/releases/download/.+/checksums\.txt$') { throw "Unexpected gateway checksum URL: $gatewayChecksum" } + - name: Upload coverage to Codecov continue-on-error: true uses: codecov/codecov-action@v5 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a1919fba..8dc24f54 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,16 +1,15 @@ -# .goreleaser.yaml project_name: neocode -version: 2 # 必须声明为 v2 语法 +version: 2 before: hooks: - # 每次构建前清理模块并下载依赖 - go mod tidy - go mod download builds: - - env: - - CGO_ENABLED=0 # 禁用 CGO,确保生成纯静态链接的二进制文件 + - id: neocode + env: + - CGO_ENABLED=0 ldflags: - -s -w -X 'neo-code/internal/version.Version={{.Version}}' goos: @@ -20,19 +19,49 @@ builds: goarch: - amd64 - arm64 - # 指定 main.go 的路径(根据 NeoCode 的实际目录调整) - main: ./cmd/neocode/main.go - # 编译出的二进制文件名 + main: ./cmd/neocode/main.go binary: neocode + - id: neocode-gateway + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X 'neo-code/internal/version.Version={{.Version}}' + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + main: ./cmd/neocode-gateway/main.go + binary: neocode-gateway + archives: - - format: tar.gz - # 为 Windows 提供单独的 zip 格式 + - id: neocode + ids: + - neocode + format: tar.gz + format_overrides: + - goos: windows + format: zip + name_template: >- + neocode_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + + - id: neocode-gateway + ids: + - neocode-gateway + format: tar.gz format_overrides: - goos: windows format: zip name_template: >- - {{ .ProjectName }}_ + neocode-gateway_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 @@ -40,7 +69,7 @@ archives: {{- if .Arm }}v{{ .Arm }}{{ end }} checksum: - name_template: 'checksums.txt' + name_template: checksums.txt changelog: sort: asc diff --git a/README.md b/README.md index 53e0baf0..3f767e05 100644 --- a/README.md +++ b/README.md @@ -1,274 +1,114 @@ # NeoCode -> 基于 Go + Bubble Tea 的本地 Coding Agent +基于 Go + Bubble Tea 的本地 AI Coding Agent,主链路为: -## NeoCode 是什么? -NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-Act-Observe)循环模式,围绕以下主链路工作: +`用户输入(TUI) -> Gateway -> Runtime -> Tools -> 结果回传 -> UI 展示` -`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` +## 产物形态 -它适合希望在本地工作流中完成代码理解、修改、调试与自动化操作的开发者。 +本项目提供双产物发布: -## 项目介绍页 +1. `neocode`:默认完整客户端入口(含 `gateway` 子命令)。 +2. `neocode-gateway`:Gateway-Only 服务端入口(不含 TUI 主入口语义)。 -- 仓库内置了基于 VitePress 的 GitHub Pages 站点源码,目录为 `www/` -- 启用仓库的 GitHub Pages 并选择 `GitHub Actions` 后,站点将发布到: - `https://<仓库拥有者>.github.io/neo-code/` -- 本地预览站点可使用: - ```bash - cd www - pnpm install - pnpm docs:dev - ``` -- 开发服务器启动后,默认从 `http://localhost:5173/neo-code/` 访问首页 +## 快速开始 -## 有什么能力? -- 终端原生 TUI 交互体验(Bubble Tea) -- Agent 可调用内置工具完成文件与命令相关任务 -- 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`) -- 支持上下文压缩(`/compact`),帮助长会话保持可用 -- 支持工作区隔离(`--workdir`、`/cwd`) -- 会话持久化与恢复,降低重复沟通成本 -- 支持持久记忆查看、显式写入与后台自动提取,保留跨会话偏好与项目事实 +### 1) 从源码运行 -## 怎么用(快速开始) - -### 1) 环境要求 -- Go `1.25+` -- 可用的 API Key(如 OpenAI、Gemini、OpenLL、Qiniu) - -### 2) 一键安装 -macOS / Linux: -```bash -curl -fsSL https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.sh | bash -``` - -Windows PowerShell: -```powershell -irm https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.ps1 | iex -``` - -### 3) 从源码运行 ```bash git clone https://github.com/1024XEngineer/neo-code.git cd neo-code go run ./cmd/neocode ``` -Gateway 子命令(Step 1 骨架): +### 2) 启动网关(两种等价方式) ```bash -go run ./cmd/neocode gateway +go run ./cmd/neocode gateway --http-listen 127.0.0.1:8080 ``` -指定网络访问面监听地址(默认 `127.0.0.1:8080`,仅允许 Loopback): - ```bash -go run ./cmd/neocode gateway --http-listen 127.0.0.1:8080 +go run ./cmd/neocode-gateway --http-listen 127.0.0.1:8080 ``` -网络访问面骨架端点(EPIC-GW-04): - -- `POST /rpc`:单次 JSON-RPC 请求入口 -- `GET /ws`:WebSocket 流式入口(含心跳) -- `GET /sse`:SSE 流式入口(MVP 默认触发 `gateway.ping`,含心跳) - -安全限制:为防止跨站攻击,网关网络面默认开启严格的 Origin 校验。当前仅允许 -`http://localhost`、`http://127.0.0.1`、`http://[::1]` 以及 `app://` 前缀来源连入; -非允许来源的跨域调用会被拦截并返回 `403`。 - -注:上述白名单机制仅针对携带 `Origin` 头的浏览器跨站请求生效。若请求不携带 `Origin` 头 -(例如 `curl`、Postman 或本地后端脚本直连),网关默认放行。 - -URL Scheme 派发骨架命令(EPIC-GW-02A): +### 3) URL 唤醒分发 ```bash go run ./cmd/neocode url-dispatch --url "neocode://review?path=README.md" ``` -> `url-dispatch` 会将 `neocode://` URL 转发到本地 Gateway,并输出结构化响应。 -> -> 注意:当前 MVP 版本仅支持 `review` 动作,且必须携带 `path` 参数(如 `neocode://review?path=README.md`);其余动作会在网关侧被拦截拒绝。 +当网关不可达时,`url-dispatch` 会按固定发现顺序尝试自动拉起: + +1. `NEOCODE_GATEWAY_BIN` 显式路径 +2. `PATH` 中 `neocode-gateway` +3. 回退当前可执行 `neocode gateway` -设置 API Key 示例(按你使用的 provider 选择): +## 安装脚本 + +### Linux / macOS ```bash -export OPENAI_API_KEY="your_key_here" -export GEMINI_API_KEY="your_key_here" -export AI_API_KEY="your_key_here" -export QINIU_API_KEY="your_key_here" +curl -fsSL https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.sh | bash ``` -Windows PowerShell: -```powershell -$env:OPENAI_API_KEY = "your_key_here" -$env:GEMINI_API_KEY = "your_key_here" -$env:AI_API_KEY = "your_key_here" -$env:QINIU_API_KEY = "your_key_here" +可选 flavor: + +```bash +bash ./scripts/install.sh --flavor full +bash ./scripts/install.sh --flavor gateway ``` -按工作区启动(仅当前进程生效): +Dry-run(仅输出资产 URL / checksum URL): ```bash -go run ./cmd/neocode --workdir /path/to/workspace +bash ./scripts/install.sh --flavor gateway --dry-run ``` -Gateway 转发与自动拉起说明: - -- `neocode` 默认通过本地 Gateway(优先 IPC)转发 runtime 请求与事件流 -- 启动时会先探测本地网关;若未运行会自动后台拉起并等待就绪(无感) -- 若自动拉起后仍不可达或握手失败,会直接报错退出(Fail Fast) - -### 4) 首次使用与常用命令 -- `/help`:查看命令帮助 -- `/provider`:打开 provider 选择器 -- `/model`:打开 model 选择器 -- `/compact`:压缩当前会话上下文 -- `/status`:查看当前会话与运行状态 -- `/cwd [path]`:查看或设置当前会话工作区 -- `/memo`:查看记忆索引 -- `/remember `:保存记忆 -- `/forget `:按关键词删除记忆 -- `/skills`:查看当前可用 skills(含当前会话激活标记) -- `/skill use `:在当前会话启用 skill -- `/skill off `:在当前会话停用 skill -- `/skill active`:查看当前会话已激活 skills -- `& `:在当前工作区执行本地命令 - -示例输入: -```text -请先阅读当前项目目录结构并给出模块职责摘要 -帮我在 internal/runtime 下定位与 tool result 回灌相关逻辑 +### Windows PowerShell + +```powershell +irm https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.ps1 | iex ``` -## 配置入口 +可选 flavor 与 dry-run: -- 主配置文件:`~/.neocode/config.yaml` -- 自定义 Provider:`~/.neocode/providers//provider.yaml` +```powershell +.\scripts\install.ps1 -Flavor full +.\scripts\install.ps1 -Flavor gateway +.\scripts\install.ps1 -Flavor gateway -DryRun +``` -配置原则(用户侧重点): +## 部署拓扑建议 -- API Key 通过环境变量注入,不写入 `config.yaml` -- `--workdir` 只影响当前运行,不会回写到配置文件 -- TUI 默认通过 Gateway 连接 runtime,启动时会自动探测并在必要时后台拉起网关 +1. 本地内嵌(默认):`neocode` 进程内通过 `gateway` 子命令管理网关。 +2. 独立网关服务:使用 `neocode-gateway` 作为可审计、可独立运维的网关进程。 -详细配置请参考:[docs/guides/configuration.md](docs/guides/configuration.md) +默认监听保持回环地址(`127.0.0.1`);对外暴露必须显式配置并补齐鉴权与 ACL。 -## 内部结构补充 +## 升级与回滚(最小流程) -- `internal/context`:负责主会话 system prompt 的 section 组装、动态上下文注入与消息裁剪。 -- `internal/runtime`:负责 ReAct 主循环、tool 调用编排、compact 触发与 reminder 注入时机。 -- `internal/subagent`:负责子代理角色策略、执行约束与输出契约。 -- `internal/promptasset`:负责受版本管理的静态 prompt 模板资产,使用 `go:embed` 编译进程序,供 `context`、`runtime`、`subagent` 读取。 +1. 升级后先验证 `GET /healthz`。 +2. 再验证 `/rpc` 最小请求(含未鉴权失败路径)。 +3. 如异常,回滚到上一个已验证版本的二进制与配置。 -## 文档导航 +## 文档索引 +- [Gateway 详细设计 RFC](docs/gateway-detailed-design.md) +- [Gateway 第三方接入协作指南](docs/guides/gateway-integration-guide.md) +- [Gateway RPC API(XGO 风格)](docs/gateway-rpc-api.md) +- [Gateway 错误字典](docs/gateway-error-catalog.md) +- [Gateway 兼容性策略](docs/gateway-compatibility.md) - [配置指南](docs/guides/configuration.md) -- [扩展 Provider](docs/guides/adding-providers.md) -- [Runtime/Provider 事件流](docs/runtime-provider-event-flow.md) -- [Session 持久化设计](docs/session-persistence-design.md) -- [Context Compact 说明](docs/context-compact.md) -- [Tools 与 TUI 集成](docs/tools-and-tui-integration.md) -- [Skills 设计与使用](docs/skills-system-design.md) -- [MCP 配置指南](docs/guides/mcp-configuration.md) -- [更新与升级](docs/guides/update.md) - -## 如何参与 - -欢迎通过 Issue 和 PR 参与共建。 - -1. 在 [Issues](https://github.com/1024XEngineer/neo-code/issues) 先沟通问题或需求。 -2. Fork 仓库并创建功能分支。 -3. 完成开发并确保改动聚焦、边界清晰。 -4. 本地自检: - ```bash - gofmt -w ./cmd ./internal - go test ./... - go build ./... - ``` -5. 提交 PR 到主仓库并说明变更目的、影响范围和验证方式。 - -提交前请确认: -- 不提交明文密钥、个人配置或会话数据 -- 不提交无关改动与临时文件 - -## 在仓库内直接创建 Issue(Skills + 自动化) - -仓库提供三类同前缀 skill(位于 `.agents/skills/`): - -- `issue-rfc-proposal`(提案类,RFC 风格) -- `issue-rfc-architecture`(架构类,RFC 风格) -- `issue-rfc-implementation`(实现类,执行单风格) +- [更新指南](docs/guides/update.md) -先安装 skills 到仓库内常见 AI Coding 工具目录: +## 开发与验证 ```bash -make install-skills +go build ./... +go test ./... +gofmt -w ./cmd ./internal ``` -默认会安装到以下目录(均在仓库内): - -- `.codex/skills` -- `.claude/skills` -- `.cursor/skills` -- `.windsurf/skills` - -如需自定义安装目标,可设置环境变量 `SKILL_INSTALL_TARGETS`(冒号分隔目录): - -```bash -SKILL_INSTALL_TARGETS=".codex/skills:.claude/skills" make install-skills -``` - -Skill 内部调用脚本 `scripts/create_issue.sh` 创建 issue。你也可以直接执行脚本: - -```bash -./scripts/create_issue.sh --type proposal --title "统一会话中断恢复语义" -./scripts/create_issue.sh --type architecture --title "Runtime 与 Session 账本边界梳理" -./scripts/create_issue.sh --type implementation --title "补齐流式中断持久化" --labels "bug,priority-high" -``` - -脚本可选参数: - -- `--repo `:指定目标仓库(默认自动识别当前仓库) -- `--body-file `:自定义 issue 正文文件(不传则使用内置模板) -- `--labels `:追加标签(逗号分隔) - -## 网关运维与安全(GW-06) - -- 静默认证(Silent Auth): - - 启动 `neocode gateway` 时会自动读取 `~/.neocode/auth.json`。 - - 若凭证不存在或损坏,会自动生成高强度 token 并写回该文件。 - - `url-dispatch` 会自动读取同一 token 并先发送 `gateway.authenticate`,再发送业务请求。 -- 认证与授权顺序:`Auth -> ACL -> Dispatch`。 - - 未认证返回 `unauthorized`。 - - 已认证但不允许的方法返回 `access_denied`。 -- 运维端点: - - 免鉴权:`GET /healthz`、`GET /version` - - 需鉴权:`GET /metrics`、`GET /metrics.json`(`Authorization: Bearer `) -- 关键默认治理参数(可通过 `config.yaml` 的 `gateway.*` 配置): - - `max_frame_bytes=1MiB` - - `ipc_max_connections=128` - - `http_max_request_bytes=1MiB` - - `http_max_stream_connections=128` - - `ipc_read/write_sec=30/30` - - `http_read/write/shutdown_sec=15/15/2` - -详细设计文档:[`docs/gateway-detailed-design.md`](docs/gateway-detailed-design.md) - -### Gateway JSON-RPC 方法清单(当前实现) - -- `gateway.authenticate`:连接级鉴权握手 -- `gateway.ping`:探活 -- `gateway.bindStream`:会话流绑定 -- `gateway.run`:发起一次运行(Accepted-ACK,异步执行) -- `gateway.compact`:触发会话压缩 -- `gateway.cancel`:按 `run_id` 精确取消目标运行(`run_id` 必填) -- `gateway.listSessions`:查询会话摘要列表 -- `gateway.loadSession`:加载单个会话详情 -- `gateway.resolvePermission`:提交权限审批结果 -- `wake.openUrl`:处理 `neocode://` 唤醒请求 -- `gateway.event`:网关推送通知事件(notification) - ## License MIT diff --git a/cmd/neocode-gateway/main.go b/cmd/neocode-gateway/main.go new file mode 100644 index 00000000..e0046af7 --- /dev/null +++ b/cmd/neocode-gateway/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + "fmt" + "os" + + "neo-code/internal/cli" +) + +func main() { + if err := cli.ExecuteGatewayServer(context.Background(), os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "neocode-gateway: %v\n", err) + os.Exit(1) + } +} diff --git a/docs/gateway-compatibility.md b/docs/gateway-compatibility.md new file mode 100644 index 00000000..90cbb012 --- /dev/null +++ b/docs/gateway-compatibility.md @@ -0,0 +1,53 @@ +# Gateway 兼容性与弃用策略 + +本文定义 Gateway 对外契约的版本兼容规则,适用于方法、字段、错误码与发布资产。 + +## 1. 兼容性分层 + +1. Stable(稳定层):默认向后兼容,不做破坏性改动。 +2. Experimental(实验层):允许演进,但必须有显式标注与迁移说明。 + +当前分层: + +1. Stable Core:`gateway.authenticate`、`gateway.ping`、`gateway.bindStream`、`gateway.run`、`gateway.compact`、`gateway.cancel`、`gateway.listSessions`、`gateway.loadSession`、`gateway.resolvePermission`、`gateway.event` +2. Experimental:`wake.openUrl` + +## 2. 字段弃用生命周期(必须遵守) + +### 2.1 标准流程 + +1. **v1.2 标记 Deprecated** + 字段继续可用;文档、日志、响应元信息中标记 `deprecated: true`(或等效说明)。 +2. **v1.3 兼容保留期** + 新客户端 SHOULD 停止依赖该字段;服务端保持兼容读取/写出策略。 +3. **v1.4 正式移除** + 字段从请求/响应契约中删除;若客户端仍发送,返回可诊断错误(通常 `invalid_frame` 或 `unsupported_action`,视场景而定)。 + +### 2.2 示例 + +若字段 `params.legacy_x` 计划移除: + +1. v1.2:文档标记 Deprecated,并在 release notes 给迁移路径。 +2. v1.3:继续接受 `legacy_x`,但服务端优先使用新字段。 +3. v1.4:拒绝 `legacy_x`,返回明确错误与替代字段提示。 + +## 3. 破坏性变更门禁 + +以下变更 MUST 走 RFC 流程并通过灰度窗口: + +1. 删除 Stable 方法。 +2. 修改 Stable 方法必填字段语义。 +3. 修改稳定 `gateway_code` 含义。 +4. 改变资产命名规则(下载 URL / checksum 路径)。 + +## 4. 双产物发布兼容承诺 + +1. `neocode`:保留现有主入口行为。 +2. `neocode-gateway`:仅承载网关服务语义。 +3. 同参条件下,`neocode gateway` 与 `neocode-gateway` MUST 行为等价(参数归一化、错误语义、关键日志字段)。 + +## 5. 回滚原则 + +1. 升级失败时 SHOULD 先回滚二进制版本,再恢复配置。 +2. 回滚版本 MUST 与当前稳定协议兼容(至少同主版本)。 +3. 回滚步骤必须在发布说明中提供可执行命令与验证点(`/healthz`、`/rpc` 最小请求)。 diff --git a/docs/gateway-detailed-design.md b/docs/gateway-detailed-design.md index fab1df83..b8ef0a08 100644 --- a/docs/gateway-detailed-design.md +++ b/docs/gateway-detailed-design.md @@ -1,210 +1,139 @@ -# Gateway 详细设计(EPIC-GW-06) +# Gateway 详细设计 RFC(RFC-420 实施版) -## 1. 目标与边界 +## 1. Abstract -Gateway 是 NeoCode 的协议与路由中枢,职责是: +本文定义 NeoCode Gateway 的工程化落地方案,目标是同时满足: -- 生命周期管理(IPC + HTTP/WS/SSE 并行启动、优雅关闭) -- 协议归一化(外层 JSON-RPC 2.0,内层 `gateway.MessageFrame`) -- 鉴权与 ACL(`Auth -> ACL -> Dispatch`) -- 会话流式中继(session/run/channel 精准投递) +1. `neocode` 继续提供 TUI 主入口,不破坏现有使用路径。 +2. 新增 `neocode-gateway` 作为 Gateway-Only 官方发布物,供第三方稳定接入。 +3. 固化网关自动拉起契约与一致性测试,避免双入口长期漂移。 -Gateway **不承载业务逻辑**,不会做模型推理、工具编排与 Provider 选择。业务执行仅由 Runtime 决定。 +本文为内部设计文档,关注“为什么这样设计、如何实现、如何验收”。 -## 2. 架构图(含进程边界) +## 2. Motivation + +### 2.1 解耦 + +网关层作为控制面中继,应可被独立部署与审计。 +单独发布 `neocode-gateway` 后,第三方可以将其视为服务端组件,而不需要引入 TUI 语义。 + +### 2.2 并发与稳定性 + +URL 派发(`url-dispatch`)在网关未启动时需要确定性恢复策略。 +本次引入统一 launcher,固定发现顺序并限制单次回退,避免无限重试。 + +### 2.3 资产与运维复用 + +`neocode gateway` 与 `neocode-gateway` 复用同一启动内核、同一参数归一化路径。 +发布与安装通过 flavor 模式复用一套脚本,减少运维面分叉。 + +## 3. Architecture + +### 3.1 进程拓扑 ```mermaid flowchart LR - subgraph ClientProcess["客户端进程边界"] - CLI["CLI / TUI"] - WEB["Web / Desktop UI"] - EXT["External Adapter\nURL Scheme / Clipboard"] - end - - subgraph GatewayProcess["Gateway 进程边界"] - IPC["IPC Listener\nUDS / Named Pipe"] - NET["HTTP/WS/SSE Listener"] - AUTH["Auth + ACL"] - NORM["JSON-RPC -> MessageFrame\nNormalize"] - ROUTER["Dispatch + Stream Relay"] - OPS["Health / Version / Metrics"] - end - - subgraph RuntimeProcess["Runtime 进程边界"] - RT["RuntimePort\n编排与事件流"] - TOOLS["Tools"] - PROVIDER["Provider Adapter"] - end - - subgraph CloudBoundary["云端边界"] - CLOUD["Cloud LLM API"] - end - - CLI --> IPC - WEB --> NET - EXT --> IPC - EXT --> NET - - IPC --> AUTH - NET --> AUTH - AUTH --> NORM - NORM --> ROUTER - ROUTER --> RT - ROUTER --> OPS - - RT --> TOOLS - RT --> PROVIDER - PROVIDER --> CLOUD + U["用户/TUI"] --> A["neocode (主入口)"] + A --> B["neocode gateway 子命令"] + T["第三方客户端"] --> C["neocode-gateway (Gateway-Only)"] + B --> G["Gateway 内核"] + C --> G + G --> R["RuntimePort"] ``` -## 3. 核心时序图 +### 3.2 启动内核共享 -### 3.1 本地控制面链路(Client -> Gateway -> Runtime -> Client) +以下逻辑必须共享: -```mermaid -sequenceDiagram - box rgb(238, 246, 255) 客户端进程 - participant C as "Client (CLI/WS/SSE)" - end - box rgb(241, 255, 241) Gateway 进程 - participant G as "Gateway Listener" - participant A as "Auth + ACL" - participant D as "Normalize + Dispatch" - participant R as "Stream Relay" - end - box rgb(255, 249, 238) Runtime 进程 - participant RT as "RuntimePort" - end - - C->>G: JSON-RPC request - G->>A: 校验 Token / ACL - A-->>G: allow - G->>D: Normalize(JSON-RPC -> MessageFrame) - D->>RT: RuntimePort 调用(无业务改写) - RT-->>D: 结果 / 事件 - D->>R: MessageFrame(event/ack/error) - R-->>C: JSON-RPC response/notification -``` +1. 参数归一化(listen、http-listen、timeouts、limits、metrics)。 +2. 配置加载与覆盖优先级(flags > config > defaults)。 +3. 鉴权与 ACL 装配。 +4. IPC/HTTP server 启停流程。 -### 3.2 云端调用链路(Runtime -> Provider -> Cloud API) +### 3.3 自动拉起状态机(url-dispatch) ```mermaid -sequenceDiagram - box rgb(238, 246, 255) 客户端进程 - participant C as "Client" - end - box rgb(241, 255, 241) Gateway 进程 - participant G as "Gateway" - end - box rgb(255, 249, 238) Runtime 进程 - participant RT as "Runtime" - participant P as "Provider Adapter" - end - box rgb(255, 240, 245) 云端边界 - participant LLM as "Cloud LLM API" - end - - C->>G: gateway.run / wake.openUrl - G->>RT: 透传规范化请求 - RT->>P: 选择并调用 Provider - P->>LLM: HTTP API - LLM-->>P: streaming/result - P-->>RT: 统一 Provider 结果 - RT-->>G: runtime events - G-->>C: gateway.event / result +stateDiagram-v2 + [*] --> DialGateway + DialGateway --> Connected: 直连成功 + DialGateway --> ResolveLauncher: 直连失败 + ResolveLauncher --> Launching: 解析可执行成功 + ResolveLauncher --> Failed: 解析失败 + Launching --> WaitReady: 启动成功 + Launching --> Failed: 启动失败 + WaitReady --> RedialOnce: 网关可连通 + WaitReady --> Failed: 超时/取消 + RedialOnce --> Connected: 重拨成功 + RedialOnce --> Failed: 重拨失败 + Connected --> [*] + Failed --> [*] ``` -## 4. 数据流向(本地端与云端区别) - -- 本地控制面: - - 客户端只与 Gateway 通信(IPC/HTTP/WS/SSE)。 - - Gateway 负责协议、连接、鉴权、路由与中继。 - - 本地控制面不直接触达云端。 -- 云端调用: - - 仅 Runtime 与 Provider 层触达 Cloud API。 - - Gateway 不感知模型厂商细节,不拼接 Provider 私有字段。 - -## 5. 对外接口清单 - -### 5.1 面向客户端接口 - -| 接口 | 方向 | 认证 | 说明 | -|---|---|---|---| -| IPC (UDS / Named Pipe) | Client -> Gateway | `gateway.authenticate` 握手后复用 | 本地控制面主入口 | -| `POST /rpc` | Client -> Gateway | `Authorization: Bearer ` | 单次 JSON-RPC 请求 | -| `GET /ws` | Client <-> Gateway | `gateway.authenticate` 握手后复用 | 双向流式请求与通知 | -| `GET /sse` | Client <- Gateway | `?token=` | 单向流式通知与心跳 | -| `GET /healthz` | Client -> Gateway | 无 | 健康检查 | -| `GET /version` | Client -> Gateway | 无 | 版本信息 | -| `GET /metrics` | Client -> Gateway | Bearer Token | Prometheus 指标 | -| `GET /metrics.json` | Client -> Gateway | Bearer Token | JSON 指标快照 | - -### 5.2 JSON-RPC 方法 - -| Method | 方向 | 说明 | -|---|---|---| -| `gateway.authenticate` | request/response | 连接级鉴权,成功后复用认证态 | -| `gateway.ping` | request/response | 健康探针 | -| `gateway.bindStream` | request/response | 会话流绑定 | -| `gateway.run` | request/response | 发起一次运行请求 | -| `gateway.compact` | request/response | 触发一次会话压缩 | -| `gateway.cancel` | request/response | 按 `run_id` 精确取消目标运行(`run_id` 必填) | -| `gateway.listSessions` | request/response | 查询会话摘要列表 | -| `gateway.loadSession` | request/response | 加载单个会话详情 | -| `gateway.resolvePermission` | request/response | 提交权限审批决策 | -| `wake.openUrl` | request/response | URL Scheme 唤醒入口 | -| `gateway.event` | notification | Gateway 推送运行时事件 | - -### 5.3 面向 Runtime 接口(`RuntimePort`) - -| 方法 | 说明 | -|---|---| -| `Run(ctx, input)` | 发起一次运行编排 | -| `Compact(ctx, input)` | 执行会话压缩 | -| `ResolvePermission(ctx, input)` | 回填权限审批结果 | -| `CancelRun(ctx, input)` | 按 `run_id` 精确取消目标运行 | -| `Events()` | 订阅运行时事件流 | -| `ListSessions(ctx)` | 获取会话摘要 | -| `LoadSession(ctx, input)` | 按 `session_id` 加载会话详情(含主体信息) | - -## 6. 安全与治理基线 - -### 6.1 Silent Auth - -- Token 文件:`~/.neocode/auth.json` -- 启动网关时自动加载;缺失或损坏自动重建 -- 文件结构:`version`, `token`, `created_at`, `updated_at` - -### 6.2 ACL 与错误模型 - -- 执行顺序:`Auth -> ACL -> Dispatch` -- 错误返回统一: - - JSON-RPC:`error.code` - - Gateway 稳定码:`error.data.gateway_code` -- 关键稳定码:`unauthorized`, `access_denied`, `invalid_frame`, `unsupported_action`, `timeout`(runtime 调用超时) - -### 6.3 默认治理参数 - -| 配置项 | 默认值 | -|---|---| -| `gateway.limits.max_frame_bytes` | `1048576` | -| `gateway.limits.ipc_max_connections` | `128` | -| `gateway.limits.http_max_request_bytes` | `1048576` | -| `gateway.limits.http_max_stream_connections` | `128` | -| `gateway.timeouts.ipc_read_sec` | `30` | -| `gateway.timeouts.ipc_write_sec` | `30` | -| `gateway.timeouts.http_read_sec` | `15` | -| `gateway.timeouts.http_write_sec` | `15` | -| `gateway.timeouts.http_shutdown_sec` | `2` | -| `gateway.observability.metrics_enabled` | `true` | - -## 7. 配置优先级 - -- `flags > config.yaml > default constants` -- 当前支持通过 `~/.neocode/config.yaml` 的 `gateway.*` 段配置治理参数。 - -## 8. 非目标(本期) - -- 不新增 Provider/Tools 业务能力 -- 不引入外网公开监听与 TLS -- 不在 Gateway 内实现 Runtime 业务决策 +发现顺序固定为: + +1. `NEOCODE_GATEWAY_BIN` 显式路径 +2. `PATH` 中的 `neocode-gateway` +3. 当前可执行回退 `neocode gateway` + +约束:仅允许一次受控回退,失败后返回确定性错误。 + +### 3.4 子进程回收 + +launcher 启动网关后立即 `Release` 进程句柄,不阻塞 url-dispatch 主流程。 +网关生命周期由目标进程自身管理,url-dispatch 仅负责“拉起 + 等待可连通 + 单次重拨”。 + +## 4. Design Trade-offs + +### 4.1 RPC vs REST + +1. 控制面统一 JSON-RPC,保留请求-响应与通知语义。 +2. 仅保留少量 REST 端点(`/healthz`、`/version`、`/metrics*`)作为运维辅助。 + +### 4.2 静默日志 vs 可观测性 + +1. 保持请求日志结构化(字段白名单可断言)。 +2. launcher 决策日志新增关键字段:`launch_mode`、`resolved_exec`、`listen_address`、`auth_mode`。 +3. 测试绑定字段语义,不绑定整行文案,降低日志格式微调带来的脆弱性。 + +## 5. Security & Reliability + +### 5.1 认证与 ACL + +1. 默认保持回环监听,不默认公网暴露。 +2. 控制面执行链:`Auth -> ACL -> Dispatch`。 +3. 未鉴权调用 `/rpc` 返回 `unauthorized`,供第三方稳定处理。 + +### 5.2 连接重置与重试 + +1. url-dispatch 仅在首拨失败时触发一次 launcher 回退。 +2. 回退后仅重拨一次,避免无界重试。 + +### 5.3 心跳与超时 + +1. WS/SSE 保持心跳机制。 +2. launcher 等待窗口默认 `3s`,受调用上下文截止时间约束。 + +## 6. Compatibility + +### 6.1 稳定核心(Stable) + +`gateway.authenticate`、`gateway.ping`、`gateway.bindStream`、`gateway.run`、`gateway.compact`、`gateway.cancel`、`gateway.listSessions`、`gateway.loadSession`、`gateway.resolvePermission`、`gateway.event` + +### 6.2 实验扩展(Experimental) + +`wake.openUrl`(用于唤醒链路,后续可能继续演进) + +### 6.3 版本策略 + +采用“稳定核心 + 实验扩展”: + +1. Stable 方法遵循兼容承诺,不做破坏性变更。 +2. Experimental 方法允许演进,但必须在文档标记并给出迁移路径。 + +## 7. Acceptance Criteria + +1. `neocode gateway` 与 `neocode-gateway` 在同参场景下行为等价(归一化参数、错误结果一致)。 +2. 可执行发现顺序严格按契约执行,且只发生单次回退。 +3. 日志白名单字段具备自动化断言:`listen_address`、`auth_mode`、`request_id`、`method`、`source`、`status`、`gateway_code`。 +4. CI 包含 gateway-only 冒烟链路:启动 -> `/healthz` -> `/rpc` 未鉴权失败 -> 清理。 +5. 安装脚本支持 `full|gateway` flavor 与 dry-run,且 URL/资产/checksum 命名规则可回归验证。 diff --git a/docs/gateway-error-catalog.md b/docs/gateway-error-catalog.md new file mode 100644 index 00000000..7c7e482f --- /dev/null +++ b/docs/gateway-error-catalog.md @@ -0,0 +1,21 @@ +# Gateway 错误字典(HTTP / JSON-RPC / gateway_code 对照) + +> 处理建议:客户端 SHOULD 以 `gateway_code` 作为主分支条件,HTTP 与 JSON-RPC 作为传输层辅助信息。 + +| gateway_code | HTTP 状态(`/rpc`) | JSON-RPC `error.code` | Reasoning(触发逻辑) | 客户端建议 | +|---|---:|---:|---|---| +| `invalid_frame` | 200 | -32602 或 -32700 | 请求体不是合法 JSON、JSON-RPC 结构非法、`params` 解码失败、字段类型错误。 | 直接失败,修正请求结构后重试。 | +| `invalid_action` | 200 | -32602 | 方法参数语义非法(如 `bindStream.channel` 非法)、运行被取消映射为动作无效。 | 直接失败,修正参数或状态机。 | +| `invalid_multimodal_payload` | 200 | -32602 | `gateway.run` 的多模态片段结构不符合约束(类型/字段不合法)。 | 直接失败,修正 payload。 | +| `missing_required_field` | 200 | -32602 | 缺失必填字段(如 `params.session_id`、`params.request_id`、`payload.run_id`)。 | 直接失败,补齐字段。 | +| `unsupported_action` | 200 | -32601 | 方法不存在或当前版本未实现。 | 降级到兼容方法,或提示版本不支持。 | +| `internal_error` | 200 | -32603 | 网关内部异常、运行时不可用、不可归类的执行失败。 | 可短暂重试;持续失败需告警。 | +| `timeout` | 200 | -32603 | Gateway 调用 runtime 超过操作超时窗口。 | 可重试并增加客户端超时预算;必要时调用 `gateway.cancel`。 | +| `unauthorized` | 401 | -32602 | 未提供有效 token 或连接未完成认证。 | 刷新凭据并重新认证,不建议盲重试。 | +| `access_denied` | 403 | -32602 | 已认证但 ACL/主体权限不允许当前动作或资源访问。 | 直接失败,提示权限不足。 | +| `resource_not_found` | 200 | -32602 | 目标资源在业务层不存在或不可见(典型为会话/运行目标查无记录);不是“格式错误”。 | 可提示用户检查 `session_id/run_id` 是否真实存在。 | + +## 说明 + +1. HTTP 状态映射在 `/rpc` 路径中仅对 `unauthorized` 与 `access_denied` 使用 401/403,其余错误通常仍返回 200 + JSON-RPC 错误体。 +2. `resource_not_found` 的判断来自 runtime 语义错误映射(查不到目标),而非参数格式校验;格式问题通常进入 `invalid_frame` 或 `missing_required_field`。 diff --git a/docs/gateway-rpc-api.md b/docs/gateway-rpc-api.md new file mode 100644 index 00000000..8d0549ba --- /dev/null +++ b/docs/gateway-rpc-api.md @@ -0,0 +1,336 @@ +# Gateway RPC API(XGO 风格) + +本文描述 Gateway 控制面的 JSON-RPC 合约。 +关键行为使用 RFC 术语:`MUST` / `SHOULD` / `MAY`。 + +## 自动示例生成 + +为避免“文实不符”,仓库提供了基于 Go 结构体的自动示例生成: + +1. 生成命令:`go generate ./internal/gateway/protocol` +2. 产出文件:`docs/generated/gateway-rpc-examples.json` + +## 通用约束 + +1. 协议版本 MUST 为 `jsonrpc: "2.0"`。 +2. 客户端 MUST 提供可关联的 `id`。 +3. 建议优先以 `error.data.gateway_code` 作为错误分支主键。 +4. 除实验能力外,本文方法默认稳定(Stable)。 + +--- + +## Method: gateway.authenticate + +- Stability: Stable +- Auth Required: No(本方法用于建立认证态) +- Request Schema (Go Struct): + +```go +type AuthenticateParams struct { + Token string `json:"token"` +} +``` + +- Response Schema: + - Success: + +```json +{ + "jsonrpc": "2.0", + "id": "auth-1", + "result": { + "type": "ack", + "action": "authenticate", + "request_id": "auth-1" + } + } +``` + + - Failure(示例): + +```json +{ + "jsonrpc": "2.0", + "id": "auth-1", + "error": { + "code": -32602, + "message": "unauthorized", + "data": { "gateway_code": "unauthorized" } + } +} +``` + +- Observation: + - Prometheus: `gateway_requests_total{method="gateway.authenticate",...}` + - 日志:结构化请求日志字段 `request_id/method/source/status/gateway_code` + +--- + +## Method: gateway.ping + +- Stability: Stable +- Auth Required: Yes +- Request Schema: + +```go +// params 可为空对象 {} +``` + +- Response Schema: + - Success 返回 `ack`,action=`ping` + - Failure 返回标准 `error`(`unauthorized` / `access_denied` 等) +- Observation: + - Prometheus: `gateway_requests_total{method="gateway.ping",...}` + - 日志:请求级结构化日志 + +--- + +## Method: gateway.bindStream + +- Stability: Stable +- Auth Required: Yes +- Request Schema (Go Struct): + +```go +type BindStreamParams struct { + SessionID string `json:"session_id"` // MUST + RunID string `json:"run_id,omitempty"` // MAY + Channel string `json:"channel,omitempty"` // all|ipc|ws|sse, default all +} +``` + +- Response Schema: + - Success: + +```json +{ + "jsonrpc": "2.0", + "id": "bind-1", + "result": { + "type": "ack", + "action": "bind_stream", + "request_id": "bind-1", + "session_id": "sess-1", + "run_id": "run-1", + "payload": { + "message": "stream binding updated", + "channel": "ws" + } + } +} +``` + + - Failure(示例): + +```json +{ + "jsonrpc": "2.0", + "id": "bind-1", + "error": { + "code": -32602, + "message": "missing required field: params.session_id", + "data": { "gateway_code": "missing_required_field" } + } +} +``` + +- 双向交互细节(重点): + 1. 客户端在 WS/SSE 建立后 SHOULD 先调用 `gateway.bindStream`。 + 2. 绑定成功后,网关将该连接注册为 `session_id`(可选 `run_id`)的事件订阅者。 + 3. 后续 `gateway.event` 通知将按绑定关系定向推送,而不是广播给所有连接。 + 4. 重连后 MUST 重新绑定;绑定关系不保证跨连接自动继承。 + +- Observation: + - Prometheus: `gateway_requests_total{method="gateway.bindStream",...}` + - 连接指标:`gateway_connections_active{channel="ws|sse"}` + - 日志:`request_id/method/source/status/gateway_code` + +--- + +## Method: gateway.run + +- Stability: Stable +- Auth Required: Yes +- Request Schema (Go Struct): + +```go +type RunInputMedia struct { + URI string `json:"uri"` + MimeType string `json:"mime_type"` + FileName string `json:"file_name,omitempty"` +} + +type RunInputPart struct { + Type string `json:"type"` // text|image + Text string `json:"text,omitempty"` + Media *RunInputMedia `json:"media,omitempty"` +} + +type RunParams struct { + SessionID string `json:"session_id,omitempty"` + RunID string `json:"run_id,omitempty"` + InputText string `json:"input_text,omitempty"` + InputParts []RunInputPart `json:"input_parts,omitempty"` + Workdir string `json:"workdir,omitempty"` +} +``` + +- Response Schema: + - Success(受理即返回): + +```json +{ + "jsonrpc": "2.0", + "id": "run-req-1", + "result": { + "type": "ack", + "action": "run", + "request_id": "run-req-1", + "session_id": "sess-1", + "run_id": "run-1", + "payload": { + "message": "run accepted" + } + } +} +``` + + - Failure(示例): + +```json +{ + "jsonrpc": "2.0", + "id": "run-req-1", + "error": { + "code": -32602, + "message": "missing required field: ...", + "data": { "gateway_code": "missing_required_field" } + } +} +``` + +- 双向交互细节(重点): + 1. `gateway.run` 是异步模型:网关在 runtime 真正完成前先返回 `ack`。 + 2. 客户端 MUST 使用 `session_id + run_id` 追踪后续 `gateway.event` 通知。 + 3. 若请求未提供 `run_id`,网关会按规则归一化(优先请求显式值,其次回退 `request_id`,再生成内部 ID)。 + 4. 运行中的进度/完成/错误通过 `gateway.event` 推送;客户端 SHOULD 处理乱序与重连重订阅。 + 5. 取消流程使用 `gateway.cancel`,且 `run_id` 为必填关联键。 + +- Observation: + - Prometheus: `gateway_requests_total{method="gateway.run",...}` + - 异步失败日志:`gateway run async failed: request_id=... session_id=... run_id=... code=...` + - 请求日志:`request_id/method/source/status/gateway_code` + +--- + +## Method: gateway.compact + +- Stability: Stable +- Auth Required: Yes +- Request Schema: + +```go +type CompactParams struct { + SessionID string `json:"session_id"` + RunID string `json:"run_id,omitempty"` +} +``` + +- Response Schema: + - Success: `ack` + compact 结果 + - Failure: 标准 `error` +- Observation: + - `gateway_requests_total{method="gateway.compact",...}` + +--- + +## Method: gateway.cancel + +- Stability: Stable +- Auth Required: Yes +- Request Schema: + +```go +type CancelParams struct { + SessionID string `json:"session_id,omitempty"` + RunID string `json:"run_id,omitempty"` // MUST(业务语义必填) +} +``` + +- Response Schema: + - Success: `ack`,payload 包含取消结果 + - Failure: `missing_required_field` / `resource_not_found` / `access_denied` 等 +- Observation: + - `gateway_requests_total{method="gateway.cancel",...}` + +--- + +## Method: gateway.listSessions + +- Stability: Stable +- Auth Required: Yes +- Request Schema: 空对象 `{}` 或省略 `params` +- Response Schema: `ack` + sessions 摘要列表 +- Observation: + - `gateway_requests_total{method="gateway.listSessions",...}` + +--- + +## Method: gateway.loadSession + +- Stability: Stable +- Auth Required: Yes +- Request Schema: + +```go +type LoadSessionParams struct { + SessionID string `json:"session_id"` +} +``` + +- Response Schema: `ack` + session 详情 +- Observation: + - `gateway_requests_total{method="gateway.loadSession",...}` + +--- + +## Method: gateway.resolvePermission + +- Stability: Stable +- Auth Required: Yes +- Request Schema: + +```go +type ResolvePermissionParams struct { + RequestID string `json:"request_id"` // MUST + Decision string `json:"decision"` // allow_once|allow_session|reject +} +``` + +- Response Schema: `ack`(提交成功)或标准 `error` +- Observation: + - `gateway_requests_total{method="gateway.resolvePermission",...}` + +--- + +## Method: gateway.event + +- Stability: Stable +- Auth Required: Yes(由连接态决定) +- Request Schema: N/A(通知方法,由网关下推) +- Response Schema: N/A +- Observation: + - 通过 WS/SSE/IPC 连接投递 + - 与 `gateway.bindStream` 绑定关系联动 + +--- + +## Method: wake.openUrl + +- Stability: Experimental +- Auth Required: Yes(同连接鉴权策略) +- Request Schema: `WakeIntent`(action/session/workdir/params) +- Response Schema: `ack` 或标准 `error` +- Observation: + - 统计进入 `gateway_requests_total{method="wake.openUrl",...}` + - 与 url-dispatch 自动拉起链路联动 diff --git a/docs/generated/gateway-rpc-examples.json b/docs/generated/gateway-rpc-examples.json new file mode 100644 index 00000000..85845f5b --- /dev/null +++ b/docs/generated/gateway-rpc-examples.json @@ -0,0 +1,75 @@ +{ + "gateway.bindStream": { + "request": { + "jsonrpc": "2.0", + "id": "bind-1", + "method": "gateway.bindStream", + "params": { + "session_id": "sess-1", + "run_id": "run-1", + "channel": "ws" + } + }, + "response": { + "jsonrpc": "2.0", + "id": "bind-1", + "result": { + "type": "ack", + "action": "bind_stream", + "request_id": "bind-1", + "run_id": "run-1", + "session_id": "sess-1", + "payload": { + "channel": "ws", + "message": "stream binding updated" + } + } + } + }, + "gateway.run": { + "request": { + "jsonrpc": "2.0", + "id": "run-req-1", + "method": "gateway.run", + "params": { + "session_id": "sess-1", + "run_id": "run-1", + "input_text": "Please review README", + "input_parts": [ + { + "type": "text", + "text": "Please review README" + } + ], + "workdir": "/workspace/demo" + } + }, + "response": { + "jsonrpc": "2.0", + "id": "run-req-1", + "result": { + "type": "ack", + "action": "run", + "request_id": "run-req-1", + "run_id": "run-1", + "session_id": "sess-1", + "payload": { + "message": "run accepted" + } + } + } + }, + "common.error": { + "response": { + "jsonrpc": "2.0", + "id": "req-err-1", + "error": { + "code": -32602, + "message": "unauthorized", + "data": { + "gateway_code": "unauthorized" + } + } + } + } +} diff --git a/docs/guides/gateway-integration-guide.md b/docs/guides/gateway-integration-guide.md new file mode 100644 index 00000000..51f7d8d4 --- /dev/null +++ b/docs/guides/gateway-integration-guide.md @@ -0,0 +1,144 @@ +# Gateway 第三方接入协作指南 + +本文面向第三方客户端开发者,目标是让接入方在不阅读源码的前提下完成最小接入与常见故障定位。 + +## 1. Getting Started + +### 1.1 启动方式 + +推荐优先使用 Gateway-Only 发布物: + +```bash +neocode-gateway --http-listen 127.0.0.1:8080 +``` + +兼容方式: + +```bash +neocode gateway --http-listen 127.0.0.1:8080 +``` + +### 1.2 最小握手 + +1. 连接 `/rpc`、`/ws` 或 `IPC`。 +2. 发送 `gateway.authenticate`(或在 HTTP 头使用 Bearer Token)。 +3. 可选发送 `gateway.bindStream` 绑定会话流。 +4. 发送 `gateway.run`。 + +## 2. Message Protocol + +网关控制面统一 JSON-RPC 2.0。 + +### 2.1 请求结构 + +```json +{ + "jsonrpc": "2.0", + "id": "req-1", + "method": "gateway.run", + "params": { + "session_id": "sess-1", + "input_text": "请审查 README" + } +} +``` + +### 2.2 响应结构 + +```json +{ + "jsonrpc": "2.0", + "id": "req-1", + "result": { + "type": "ack", + "action": "gateway.run", + "request_id": "req-1" + } +} +``` + +### 2.3 通知结构 + +网关事件使用 `gateway.event` 通知,典型包含 run 进度、完成、错误。 + +## 3. Status Codes + +接入方应同时处理三层状态: + +1. HTTP 状态(如 401)。 +2. JSON-RPC `error.code`(如 `-32602`)。 +3. 网关稳定码 `error.data.gateway_code`(如 `unauthorized`)。 + +建议以 `gateway_code` 作为应用层分支主键。 + +## 4. Client Best Practices + +1. MUST 实现断线重连,并在重连后重新认证。 +2. SHOULD 对幂等请求使用客户端 request id,便于重试去重。 +3. MUST 对 `gateway.run` 与流事件建立会话/运行关联键(`session_id` + `run_id`)。 +4. SHOULD 对瞬时错误做指数退避重试;对鉴权/参数错误直接失败。 +5. SHOULD 维护心跳超时策略,及时回收失活连接。 + +## 5. Failure Playbook + +### 5.1 连接失败 + +现象:dial 失败或 `gateway_unreachable`。 +处理:优先检查网关进程、监听地址、权限与本机防火墙。 + +### 5.2 认证失败 + +现象:HTTP `401` 或 `gateway_code=unauthorized`。 +处理:检查 token 文件、Bearer 头格式、token 是否过期/错配。 + +### 5.3 参数错误 + +现象:`gateway_code=missing_required_field` 或 `invalid_action`。 +处理:按 API 文档逐项校验 `params` 必填字段与枚举值。 + +### 5.4 超时与取消 + +现象:`gateway_code=timeout` 或运行长时间无事件。 +处理:客户端触发 `gateway.cancel`,并按 run_id 做状态回收。 + +## 6. 部署拓扑建议 + +1. 本地内嵌网关:`neocode gateway`,适合单机开发工作流。 +2. 独立网关服务:`neocode-gateway`,适合第三方客户端统一接入与独立运维。 + +建议: + +1. 默认仅监听回环地址。 +2. 对外暴露时显式配置监听地址与访问边界,不应直接公网裸露。 + +## 7. 最小鉴权 / ACL 模板 + +最小安全基线(示意): + +```yaml +gateway: + security: + acl_mode: strict + # token_file 默认为 ~/.neocode/auth.json + network: + listen: 127.0.0.1:8080 +``` + +接入方 MUST 满足: + +1. 通过 `gateway.authenticate` 或 Bearer Token 建立认证态。 +2. 对 `unauthorized` 与 `access_denied` 做明确分支处理。 + +## 8. 升级与回滚步骤 + +升级后验收: + +1. `GET /healthz` 返回成功。 +2. `/rpc` 未鉴权请求返回 `unauthorized`(用于验证鉴权链路)。 +3. `gateway.run` 最小链路可达。 + +回滚步骤: + +1. 停止当前网关进程。 +2. 切回上一版已验证二进制。 +3. 重复上述验收步骤。 diff --git a/docs/guides/update.md b/docs/guides/update.md index 5b52c67f..e3552987 100644 --- a/docs/guides/update.md +++ b/docs/guides/update.md @@ -1,26 +1,52 @@ -# 更新与升级 +# 更新与回滚指南 -## 自动检测 +## 1. 自动更新检测 -- `neocode` 启动时会在后台静默检测最新版本(默认 3 秒超时)。 -- 为避免干扰 Bubble Tea TUI 交互,更新提示会在应用退出、终端屏幕恢复后输出。 -- `url-dispatch` 与 `update` 子命令会跳过该检测流程。 +1. `neocode` 启动时会后台检测新版本(默认 3 秒超时)。 +2. 为避免干扰 TUI,提示在程序退出后展示。 +3. `url-dispatch` 与 `update` 子命令默认跳过静默检测。 -## 手动升级 +## 2. 手动升级 -使用以下命令升级到最新稳定版: +升级到最新稳定版本: ```bash neocode update ``` -如需包含预发布版本: +包含预发布版本: ```bash neocode update --prerelease ``` -## 版本来源 +## 3. 双产物安装建议 -- 发布构建会通过 `ldflags` 注入版本号到 `internal/version.Version`。 -- 本地开发构建默认版本为 `dev`。 +1. Full 模式:安装 `neocode`。 +2. Gateway 模式:安装 `neocode-gateway`。 + +安装脚本支持 flavor: + +```bash +bash ./scripts/install.sh --flavor full +bash ./scripts/install.sh --flavor gateway +``` + +```powershell +.\scripts\install.ps1 -Flavor full +.\scripts\install.ps1 -Flavor gateway +``` + +## 4. 升级后验证(推荐) + +1. `GET /healthz` 返回 200。 +2. `/rpc` 未鉴权请求返回预期失败(`gateway_code=unauthorized`)。 +3. 必要时执行一次最小 `gateway.run` 冒烟。 + +## 5. 回滚步骤 + +1. 停止当前网关进程。 +2. 回退到上一版已验证二进制。 +3. 重新启动并执行第 4 节验证步骤。 + +若回滚后仍异常,优先检查配置文件兼容性与 token 文件状态。 diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index a359ce9c..0c58b495 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -1,4 +1,4 @@ -package cli +package cli import ( "context" @@ -102,13 +102,26 @@ func defaultNewAuthManager(path string) (gateway.TokenAuthenticator, error) { return gatewayauth.NewManager(path) } -// newGatewayCommand 创建并返回网关子命令,负责启动本地 Gateway 进程。 +// newGatewayCommand 创建并返回根命令下的 gateway 子命令,负责启动本地 Gateway 进程。 func newGatewayCommand() *cobra.Command { + return newGatewayServerCommand("gateway", "Start local gateway server", mustReadInheritedWorkdir) +} + +// NewGatewayStandaloneCommand 鍒涘缓 gateway-only 鐙珛鍏ュ彛鍛戒护锛岀‘淇濅粎鏆撮湶缃戝叧鏈嶅姟璇箟銆?func NewGatewayStandaloneCommand() *cobra.Command { + standaloneWorkdir := "" + command := newGatewayServerCommand("neocode-gateway", "Start NeoCode gateway-only server", func(*cobra.Command) string { + return standaloneWorkdir + }) + command.Flags().StringVar(&standaloneWorkdir, "workdir", "", "宸ヤ綔鐩綍锛堣鐩栨湰娆¤繍琛屽伐浣滃尯锛?) + return command +} + +// newGatewayServerCommand 鏋勫缓缃戝叧鍚姩鍛戒护锛屽苟澶嶇敤缁熶竴鍙傛暟褰掍竴鍖栦笌鎵ц璺緞銆?func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command) string) *cobra.Command { options := &gatewayCommandOptions{} cmd := &cobra.Command{ - Use: "gateway", - Short: "Start local gateway server", + Use: strings.TrimSpace(use), + Short: strings.TrimSpace(short), SilenceUsage: true, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -116,6 +129,10 @@ func newGatewayCommand() *cobra.Command { if err != nil { return err } + normalizedWorkdir := "" + if readWorkdir != nil { + normalizedWorkdir = strings.TrimSpace(readWorkdir(cmd)) + } return runGatewayCommand(cmd.Context(), gatewayCommandOptions{ ListenAddress: strings.TrimSpace(options.ListenAddress), @@ -123,7 +140,7 @@ func newGatewayCommand() *cobra.Command { LogLevel: normalizedLogLevel, TokenFile: strings.TrimSpace(options.TokenFile), ACLMode: strings.TrimSpace(options.ACLMode), - Workdir: strings.TrimSpace(mustReadInheritedWorkdir(cmd)), + Workdir: normalizedWorkdir, MaxFrameBytes: options.MaxFrameBytes, IPCMaxConnections: options.IPCMaxConnections, @@ -176,8 +193,7 @@ func newGatewayCommand() *cobra.Command { return cmd } -// normalizeGatewayLogLevel 对网关日志级别做归一化并校验合法值。 -func normalizeGatewayLogLevel(logLevel string) (string, error) { +// normalizeGatewayLogLevel 瀵圭綉鍏虫棩蹇楃骇鍒仛褰掍竴鍖栧苟鏍¢獙鍚堟硶鍊笺€?func normalizeGatewayLogLevel(logLevel string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(logLevel)) switch normalized { case "debug", "info", "warn", "error": @@ -187,8 +203,7 @@ func normalizeGatewayLogLevel(logLevel string) (string, error) { } } -// mustReadInheritedWorkdir 在子命令中安全读取继承的 --workdir,读取失败时回退为空值。 -func mustReadInheritedWorkdir(cmd *cobra.Command) string { +// mustReadInheritedWorkdir 鍦ㄥ瓙鍛戒护涓畨鍏ㄨ鍙栫户鎵跨殑 --workdir锛岃鍙栧け璐ユ椂鍥為€€涓虹┖鍊笺€?func mustReadInheritedWorkdir(cmd *cobra.Command) string { if cmd == nil { return "" } @@ -199,8 +214,7 @@ func mustReadInheritedWorkdir(cmd *cobra.Command) string { return workdir } -// defaultGatewayCommandRunner 使用网关服务骨架启动本地 IPC 监听并处理信号退出。 -func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOptions) error { +// defaultGatewayCommandRunner 浣跨敤缃戝叧鏈嶅姟楠ㄦ灦鍚姩鏈湴 IPC 鐩戝惉骞跺鐞嗕俊鍙烽€€鍑恒€?func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOptions) error { logger := log.New(os.Stderr, "neocode-gateway: ", log.LstdFlags) logger.Printf("starting gateway (log-level=%s)", options.LogLevel) @@ -322,8 +336,7 @@ type gatewayIdleShutdownController struct { timer *time.Timer } -// newGatewayIdleShutdownController 创建网关空闲自退控制器:连接数归零后延迟退出,有连接恢复则取消退出。 -func newGatewayIdleShutdownController(logger *log.Logger, cancel context.CancelFunc) *gatewayIdleShutdownController { +// newGatewayIdleShutdownController 鍒涘缓缃戝叧绌洪棽鑷€€鎺у埗鍣細杩炴帴鏁板綊闆跺悗寤惰繜閫€鍑猴紝鏈夎繛鎺ユ仮澶嶅垯鍙栨秷閫€鍑恒€?func newGatewayIdleShutdownController(logger *log.Logger, cancel context.CancelFunc) *gatewayIdleShutdownController { return &gatewayIdleShutdownController{ logger: logger, idleTimeout: defaultGatewayIdleShutdownDelay, @@ -331,8 +344,7 @@ func newGatewayIdleShutdownController(logger *log.Logger, cancel context.CancelF } } -// observe 接收 IPC 活跃连接数快照并维护空闲退出计时器。 -func (c *gatewayIdleShutdownController) observe(active int) { +// observe 鎺ユ敹 IPC 娲昏穬杩炴帴鏁板揩鐓у苟缁存姢绌洪棽閫€鍑鸿鏃跺櫒銆?func (c *gatewayIdleShutdownController) observe(active int) { if c == nil { return } @@ -372,8 +384,7 @@ func (c *gatewayIdleShutdownController) observe(active int) { }) } -// close 释放空闲退出控制器持有的计时器资源。 -func (c *gatewayIdleShutdownController) close() { +// close 閲婃斁绌洪棽閫€鍑烘帶鍒跺櫒鎸佹湁鐨勮鏃跺櫒璧勬簮銆?func (c *gatewayIdleShutdownController) close() { if c == nil { return } @@ -385,8 +396,7 @@ func (c *gatewayIdleShutdownController) close() { } } -// buildGatewayControlPlaneACL 基于配置构造控制面 ACL 策略,未知模式直接拒绝启动。 -func buildGatewayControlPlaneACL(aclMode string) (*gateway.ControlPlaneACL, error) { +// buildGatewayControlPlaneACL 鍩轰簬閰嶇疆鏋勯€犳帶鍒堕潰 ACL 绛栫暐锛屾湭鐭ユā寮忕洿鎺ユ嫆缁濆惎鍔ㄣ€?func buildGatewayControlPlaneACL(aclMode string) (*gateway.ControlPlaneACL, error) { normalizedACLMode := strings.ToLower(strings.TrimSpace(aclMode)) if normalizedACLMode == "" { normalizedACLMode = string(gateway.ACLModeStrict) @@ -399,8 +409,7 @@ func buildGatewayControlPlaneACL(aclMode string) (*gateway.ControlPlaneACL, erro } } -// applyGatewayFlagOverrides 将 CLI flags 覆盖到网关配置,优先级高于 config.yaml。 -func applyGatewayFlagOverrides(gatewayConfig *config.GatewayConfig, options gatewayCommandOptions) { +// applyGatewayFlagOverrides 灏?CLI flags 瑕嗙洊鍒扮綉鍏抽厤缃紝浼樺厛绾ч珮浜?config.yaml銆?func applyGatewayFlagOverrides(gatewayConfig *config.GatewayConfig, options gatewayCommandOptions) { if gatewayConfig == nil { return } @@ -440,18 +449,15 @@ func applyGatewayFlagOverrides(gatewayConfig *config.GatewayConfig, options gate } } -// defaultNewGatewayServer 创建默认网关服务实例,供命令层启动流程调用。 -func defaultNewGatewayServer(options gateway.ServerOptions) (gatewayServer, error) { +// defaultNewGatewayServer 鍒涘缓榛樿缃戝叧鏈嶅姟瀹炰緥锛屼緵鍛戒护灞傚惎鍔ㄦ祦绋嬭皟鐢ㄣ€?func defaultNewGatewayServer(options gateway.ServerOptions) (gatewayServer, error) { return gateway.NewServer(options) } -// defaultNewGatewayNetworkServer 创建默认网关网络访问面服务实例,供命令层启动流程调用。 -func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) { +// defaultNewGatewayNetworkServer 鍒涘缓榛樿缃戝叧缃戠粶璁块棶闈㈡湇鍔″疄渚嬶紝渚涘懡浠ゅ眰鍚姩娴佺▼璋冪敤銆?func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) { return gateway.NewNetworkServer(options) } -// newURLDispatchCommand 创建 URL Scheme 派发子命令骨架,仅做参数收敛与调用转发。 -func newURLDispatchCommand() *cobra.Command { +// newURLDispatchCommand 鍒涘缓 URL Scheme 娲惧彂瀛愬懡浠ら鏋讹紝浠呭仛鍙傛暟鏀舵暃涓庤皟鐢ㄨ浆鍙戙€?func newURLDispatchCommand() *cobra.Command { options := &urlDispatchCommandOptions{} cmd := &cobra.Command{ @@ -493,8 +499,7 @@ func newURLDispatchCommand() *cobra.Command { return cmd } -// defaultURLDispatchCommandRunner 执行 URL 唤醒请求并将结果以结构化 JSON 输出。 -func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCommandOptions) error { +// defaultURLDispatchCommandRunner 鎵ц URL 鍞ら啋璇锋眰骞跺皢缁撴灉浠ョ粨鏋勫寲 JSON 杈撳嚭銆?func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCommandOptions) error { authToken, authErr := loadAuthToken(options.TokenFile) if authErr != nil { writeErr := writeDispatchError(os.Stderr, authErr) @@ -530,8 +535,7 @@ func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCom return nil } -// loadGatewayAuthToken 读取静默认证 token;若文件不存在则回退为空以兼容无鉴权模式。 -func loadGatewayAuthToken(path string) (string, error) { +// loadGatewayAuthToken 璇诲彇闈欓粯璁よ瘉 token锛涜嫢鏂囦欢涓嶅瓨鍦ㄥ垯鍥為€€涓虹┖浠ュ吋瀹规棤閴存潈妯″紡銆?func loadGatewayAuthToken(path string) (string, error) { token, err := gatewayauth.LoadTokenFromFile(path) if err == nil { return token, nil @@ -545,8 +549,7 @@ func loadGatewayAuthToken(path string) (string, error) { return "", err } -// normalizeDispatchURL 对 url-dispatch 输入做最小归一化,详细校验交由 dispatcher 完成。 -func normalizeDispatchURL(rawURL string) (string, error) { +// normalizeDispatchURL 瀵?url-dispatch 杈撳叆鍋氭渶灏忓綊涓€鍖栵紝璇︾粏鏍¢獙浜ょ敱 dispatcher 瀹屾垚銆?func normalizeDispatchURL(rawURL string) (string, error) { normalized := strings.TrimSpace(rawURL) if normalized == "" { return "", errors.New("missing required --url or positional ") @@ -554,8 +557,7 @@ func normalizeDispatchURL(rawURL string) (string, error) { return normalized, nil } -// writeURLDispatchSuccessOutput 将 url-dispatch 成功结果输出为结构化 JSON。 -func writeURLDispatchSuccessOutput(writer io.Writer, result urlscheme.DispatchResult) error { +// writeURLDispatchSuccessOutput 灏?url-dispatch 鎴愬姛缁撴灉杈撳嚭涓虹粨鏋勫寲 JSON銆?func writeURLDispatchSuccessOutput(writer io.Writer, result urlscheme.DispatchResult) error { return encodeJSONLine(writer, urlDispatchSuccessOutput{ Status: "ok", ListenAddress: result.ListenAddress, @@ -565,8 +567,7 @@ func writeURLDispatchSuccessOutput(writer io.Writer, result urlscheme.DispatchRe }) } -// writeURLDispatchErrorOutput 将 url-dispatch 错误结果输出为结构化 JSON。 -func writeURLDispatchErrorOutput(writer io.Writer, err error) error { +// writeURLDispatchErrorOutput 灏?url-dispatch 閿欒缁撴灉杈撳嚭涓虹粨鏋勫寲 JSON銆?func writeURLDispatchErrorOutput(writer io.Writer, err error) error { code := "internal_error" message := err.Error() @@ -583,15 +584,14 @@ func writeURLDispatchErrorOutput(writer io.Writer, err error) error { }) } -// writeURLDispatchFallbackErrorOutput 在结构化错误输出失败时提供兜底 JSON,避免命令静默退出。 -func writeURLDispatchFallbackErrorOutput(writer io.Writer) error { +// writeURLDispatchFallbackErrorOutput 鍦ㄧ粨鏋勫寲閿欒杈撳嚭澶辫触鏃舵彁渚涘厹搴?JSON锛岄伩鍏嶅懡浠ら潤榛橀€€鍑恒€?func writeURLDispatchFallbackErrorOutput(writer io.Writer) error { _, err := fmt.Fprintln(writer, fallbackDispatchErrorJSON) return err } -// encodeJSONLine 将对象编码为单行 JSON,并写入目标输出流。 -func encodeJSONLine(writer io.Writer, payload any) error { +// encodeJSONLine 灏嗗璞$紪鐮佷负鍗曡 JSON锛屽苟鍐欏叆鐩爣杈撳嚭娴併€?func encodeJSONLine(writer io.Writer, payload any) error { encoder := json.NewEncoder(writer) encoder.SetEscapeHTML(false) return encoder.Encode(payload) } + diff --git a/internal/cli/gateway_standalone.go b/internal/cli/gateway_standalone.go new file mode 100644 index 00000000..cb13f7c8 --- /dev/null +++ b/internal/cli/gateway_standalone.go @@ -0,0 +1,15 @@ +package cli + +import ( + "context" + + "neo-code/internal/app" +) + +// ExecuteGatewayServer 执行 gateway-only 独立命令入口,保持与 `neocode gateway` 一致的参数与行为。 +func ExecuteGatewayServer(ctx context.Context, args []string) error { + app.EnsureConsoleUTF8() + command := NewGatewayStandaloneCommand() + command.SetArgs(args) + return command.ExecuteContext(ctx) +} diff --git a/internal/cli/gateway_standalone_test.go b/internal/cli/gateway_standalone_test.go new file mode 100644 index 00000000..83e59254 --- /dev/null +++ b/internal/cli/gateway_standalone_test.go @@ -0,0 +1,174 @@ +package cli + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" +) + +func TestNewGatewayStandaloneCommandPassesFlagsToRunner(t *testing.T) { + originalRunner := runGatewayCommand + t.Cleanup(func() { runGatewayCommand = originalRunner }) + + var captured gatewayCommandOptions + runGatewayCommand = func(_ context.Context, options gatewayCommandOptions) error { + captured = options + return nil + } + + command := NewGatewayStandaloneCommand() + command.SetArgs([]string{ + "--listen", " /tmp/gateway.sock ", + "--http-listen", " 127.0.0.1:19080 ", + "--log-level", " WARN ", + "--workdir", " /workspace/project ", + "--metrics-enabled", + }) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + + if captured.ListenAddress != "/tmp/gateway.sock" { + t.Fatalf("listen address = %q, want %q", captured.ListenAddress, "/tmp/gateway.sock") + } + if captured.HTTPAddress != "127.0.0.1:19080" { + t.Fatalf("http address = %q, want %q", captured.HTTPAddress, "127.0.0.1:19080") + } + if captured.LogLevel != "warn" { + t.Fatalf("log level = %q, want %q", captured.LogLevel, "warn") + } + if captured.Workdir != "/workspace/project" { + t.Fatalf("workdir = %q, want %q", captured.Workdir, "/workspace/project") + } + if !captured.MetricsEnabledOverridden || !captured.MetricsEnabled { + t.Fatalf("metrics flags = %#v, want overridden + true", captured) + } +} + +func TestNewGatewayStandaloneCommandRejectsInvalidLogLevel(t *testing.T) { + command := NewGatewayStandaloneCommand() + command.SetArgs([]string{"--log-level", "trace"}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected invalid log level error") + } + if !strings.Contains(err.Error(), "invalid --log-level") { + t.Fatalf("error = %v, want contains %q", err, "invalid --log-level") + } +} + +func TestGatewaySubcommandAndStandaloneCommandAreOptionEquivalent(t *testing.T) { + originalRunner := runGatewayCommand + originalPreload := runGlobalPreload + t.Cleanup(func() { runGatewayCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } + + captured := make([]gatewayCommandOptions, 0, 2) + runGatewayCommand = func(_ context.Context, options gatewayCommandOptions) error { + captured = append(captured, options) + return nil + } + + rootCommand := NewRootCommand() + rootCommand.SetArgs([]string{ + "--workdir", "/workspace/project", + "gateway", + "--listen", "/tmp/gateway.sock", + "--http-listen", "127.0.0.1:19080", + "--log-level", "warn", + "--max-frame-bytes", "1024", + "--ipc-max-connections", "32", + "--http-max-request-bytes", "2048", + "--http-max-stream-connections", "64", + "--ipc-read-sec", "10", + "--ipc-write-sec", "11", + "--http-read-sec", "12", + "--http-write-sec", "13", + "--http-shutdown-sec", "14", + "--metrics-enabled", + }) + if err := rootCommand.ExecuteContext(context.Background()); err != nil { + t.Fatalf("root command execute error = %v", err) + } + + standaloneCommand := NewGatewayStandaloneCommand() + standaloneCommand.SetArgs([]string{ + "--workdir", "/workspace/project", + "--listen", "/tmp/gateway.sock", + "--http-listen", "127.0.0.1:19080", + "--log-level", "warn", + "--max-frame-bytes", "1024", + "--ipc-max-connections", "32", + "--http-max-request-bytes", "2048", + "--http-max-stream-connections", "64", + "--ipc-read-sec", "10", + "--ipc-write-sec", "11", + "--http-read-sec", "12", + "--http-write-sec", "13", + "--http-shutdown-sec", "14", + "--metrics-enabled", + }) + if err := standaloneCommand.ExecuteContext(context.Background()); err != nil { + t.Fatalf("standalone command execute error = %v", err) + } + + if len(captured) != 2 { + t.Fatalf("captured options count = %d, want %d", len(captured), 2) + } + if !reflect.DeepEqual(captured[0], captured[1]) { + t.Fatalf("options mismatch:\nsubcommand=%#v\nstandalone=%#v", captured[0], captured[1]) + } +} + +func TestExecuteGatewayServerUsesStandaloneCommand(t *testing.T) { + originalRunner := runGatewayCommand + t.Cleanup(func() { runGatewayCommand = originalRunner }) + + var captured gatewayCommandOptions + runGatewayCommand = func(_ context.Context, options gatewayCommandOptions) error { + captured = options + return nil + } + + err := ExecuteGatewayServer(context.Background(), []string{ + "--workdir", "/workspace/project", + "--listen", "/tmp/gateway.sock", + "--http-listen", "127.0.0.1:19080", + "--log-level", "info", + }) + if err != nil { + t.Fatalf("ExecuteGatewayServer() error = %v", err) + } + if captured.Workdir != "/workspace/project" { + t.Fatalf("workdir = %q, want %q", captured.Workdir, "/workspace/project") + } +} + +func TestGatewaySubcommandAndStandaloneCommandPropagateSameRunnerError(t *testing.T) { + originalRunner := runGatewayCommand + originalPreload := runGlobalPreload + t.Cleanup(func() { runGatewayCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + runGlobalPreload = func(context.Context) error { return nil } + + expectedErr := errors.New("gateway runner failed") + runGatewayCommand = func(_ context.Context, _ gatewayCommandOptions) error { + return expectedErr + } + + rootCommand := NewRootCommand() + rootCommand.SetArgs([]string{"gateway"}) + rootErr := rootCommand.ExecuteContext(context.Background()) + if !errors.Is(rootErr, expectedErr) { + t.Fatalf("root command error = %v, want %v", rootErr, expectedErr) + } + + standaloneCommand := NewGatewayStandaloneCommand() + standaloneErr := standaloneCommand.ExecuteContext(context.Background()) + if !errors.Is(standaloneErr, expectedErr) { + t.Fatalf("standalone command error = %v, want %v", standaloneErr, expectedErr) + } +} diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 6bf391df..14ec9ffb 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -6,12 +6,15 @@ import ( "encoding/json" "errors" "fmt" + "log" "net" + "os" "strings" "sync/atomic" "time" "neo-code/internal/gateway" + "neo-code/internal/gateway/launcher" "neo-code/internal/gateway/protocol" "neo-code/internal/gateway/transport" ) @@ -27,6 +30,10 @@ const ( ErrorCodeInternal = "internal_error" // defaultDispatchIOTimeout 表示单次调度读写超时时间。 defaultDispatchIOTimeout = 10 * time.Second + // defaultGatewayLaunchTimeout 表示自动拉起网关后等待可连通的最长时间。 + defaultGatewayLaunchTimeout = 3 * time.Second + // defaultGatewayLaunchRetryInterval 表示等待网关可连通时的轮询间隔。 + defaultGatewayLaunchRetryInterval = 100 * time.Millisecond ) var dispatchRequestCounter uint64 @@ -64,6 +71,12 @@ type Dispatcher struct { resolveListenAddressFn func(string) (string, error) dialFn func(address string) (net.Conn, error) requestIDFn func() string + resolveLaunchSpecFn func() (launcher.LaunchSpec, error) + startGatewayFn func(launcher.LaunchSpec) error + nowFn func() time.Time + sleepFn func(time.Duration) + autoLaunchGateway bool + logger *log.Logger } // NewDispatcher 创建默认 URL Scheme 调度器。 @@ -72,6 +85,16 @@ func NewDispatcher() *Dispatcher { resolveListenAddressFn: transport.ResolveListenAddress, dialFn: transport.Dial, requestIDFn: nextDispatchRequestID, + resolveLaunchSpecFn: func() (launcher.LaunchSpec, error) { + return launcher.ResolveGatewayLaunchSpec(launcher.ResolveOptions{ + ExplicitBinary: strings.TrimSpace(os.Getenv(launcher.EnvGatewayBinary)), + }) + }, + startGatewayFn: launcher.StartDetachedGateway, + nowFn: time.Now, + sleepFn: time.Sleep, + autoLaunchGateway: true, + logger: log.New(os.Stderr, "url-dispatch: ", log.LstdFlags), } } @@ -87,9 +110,10 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis return DispatchResult{}, newDispatchError(ErrorCodeInternal, fmt.Sprintf("resolve listen address: %v", err)) } - conn, err := d.dialFn(listenAddress) + requestID := d.requestIDFn() + conn, err := d.dialGatewayWithFallback(ctx, listenAddress, requestID, request.AuthToken) if err != nil { - return DispatchResult{}, newDispatchError(ErrorCodeGatewayUnavailable, fmt.Sprintf("dial gateway failed: %v", err)) + return DispatchResult{}, err } defer func() { _ = conn.Close() @@ -114,7 +138,7 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis requestFrame := gateway.MessageFrame{ Type: gateway.FrameTypeRequest, Action: gateway.FrameActionWakeOpenURL, - RequestID: d.requestIDFn(), + RequestID: requestID, SessionID: intent.SessionID, Workdir: intent.Workdir, Payload: intent, @@ -263,6 +287,189 @@ func (d *Dispatcher) callRPC(ctx context.Context, conn net.Conn, request protoco return response, nil } +type launchDecisionLogEntry struct { + RequestID string `json:"request_id"` + Method string `json:"method"` + Source string `json:"source"` + Status string `json:"status"` + GatewayCode string `json:"gateway_code"` + ListenAddress string `json:"listen_address"` + AuthMode string `json:"auth_mode"` + LaunchMode string `json:"launch_mode,omitempty"` + ResolvedExec string `json:"resolved_exec,omitempty"` + Message string `json:"message,omitempty"` +} + +// dialGatewayWithFallback 先尝试直连网关,若失败且启用了自动拉起则按约定发现顺序拉起后重拨一次。 +func (d *Dispatcher) dialGatewayWithFallback( + ctx context.Context, + listenAddress string, + requestID string, + authToken string, +) (net.Conn, error) { + connection, err := d.dialFn(listenAddress) + if err == nil { + return connection, nil + } + if !d.autoLaunchGateway { + return nil, newDispatchError(ErrorCodeGatewayUnavailable, fmt.Sprintf("dial gateway failed: %v", err)) + } + if launchErr := d.launchGateway(ctx, listenAddress, requestID, authToken); launchErr != nil { + return nil, newDispatchError( + ErrorCodeGatewayUnavailable, + fmt.Sprintf("dial gateway failed: %v; launch gateway failed: %v", err, launchErr), + ) + } + retriedConnection, retryErr := d.dialFn(listenAddress) + if retryErr != nil { + return nil, newDispatchError( + ErrorCodeGatewayUnavailable, + fmt.Sprintf("dial gateway failed after single fallback: %v", retryErr), + ) + } + return retriedConnection, nil +} + +// launchGateway 按固定发现顺序拉起网关,并在单次回退窗口内等待网关可连通。 +func (d *Dispatcher) launchGateway(ctx context.Context, listenAddress string, requestID string, authToken string) error { + if err := ensureDispatchContextActive(ctx); err != nil { + return err + } + + resolveLaunchSpecFn := d.resolveLaunchSpecFn + if resolveLaunchSpecFn == nil { + return errors.New("gateway launcher is unavailable") + } + startGatewayFn := d.startGatewayFn + if startGatewayFn == nil { + return errors.New("gateway launcher start function is unavailable") + } + + spec, err := resolveLaunchSpecFn() + if err != nil { + d.emitLaunchDecisionLog(launchDecisionLogEntry{ + RequestID: requestID, + Method: string(protocol.MethodWakeOpenURL), + Source: "url-dispatch", + Status: "launch_failed", + GatewayCode: ErrorCodeGatewayUnavailable, + ListenAddress: listenAddress, + AuthMode: resolveAuthMode(authToken), + Message: err.Error(), + }) + return err + } + + d.emitLaunchDecisionLog(launchDecisionLogEntry{ + RequestID: requestID, + Method: string(protocol.MethodWakeOpenURL), + Source: "url-dispatch", + Status: "launch_attempt", + ListenAddress: listenAddress, + AuthMode: resolveAuthMode(authToken), + LaunchMode: spec.LaunchMode, + ResolvedExec: spec.Executable, + }) + if err := startGatewayFn(spec); err != nil { + d.emitLaunchDecisionLog(launchDecisionLogEntry{ + RequestID: requestID, + Method: string(protocol.MethodWakeOpenURL), + Source: "url-dispatch", + Status: "launch_failed", + GatewayCode: ErrorCodeGatewayUnavailable, + ListenAddress: listenAddress, + AuthMode: resolveAuthMode(authToken), + LaunchMode: spec.LaunchMode, + ResolvedExec: spec.Executable, + Message: err.Error(), + }) + return err + } + + if err := d.waitGatewayReady(ctx, listenAddress); err != nil { + d.emitLaunchDecisionLog(launchDecisionLogEntry{ + RequestID: requestID, + Method: string(protocol.MethodWakeOpenURL), + Source: "url-dispatch", + Status: "launch_failed", + GatewayCode: ErrorCodeGatewayUnavailable, + ListenAddress: listenAddress, + AuthMode: resolveAuthMode(authToken), + LaunchMode: spec.LaunchMode, + ResolvedExec: spec.Executable, + Message: err.Error(), + }) + return err + } + + d.emitLaunchDecisionLog(launchDecisionLogEntry{ + RequestID: requestID, + Method: string(protocol.MethodWakeOpenURL), + Source: "url-dispatch", + Status: "launch_ready", + ListenAddress: listenAddress, + AuthMode: resolveAuthMode(authToken), + LaunchMode: spec.LaunchMode, + ResolvedExec: spec.Executable, + }) + return nil +} + +// waitGatewayReady 在单次回退窗口内轮询网关连通性,超时后返回确定性错误。 +func (d *Dispatcher) waitGatewayReady(ctx context.Context, listenAddress string) error { + nowFn := d.nowFn + if nowFn == nil { + nowFn = time.Now + } + sleepFn := d.sleepFn + if sleepFn == nil { + sleepFn = time.Sleep + } + + deadline := nowFn().Add(defaultGatewayLaunchTimeout) + if ctx != nil { + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + } + + for { + if err := ensureDispatchContextActive(ctx); err != nil { + return err + } + connection, err := d.dialFn(listenAddress) + if err == nil { + _ = connection.Close() + return nil + } + if !nowFn().Before(deadline) { + return fmt.Errorf("gateway did not become reachable within %s", defaultGatewayLaunchTimeout) + } + sleepFn(defaultGatewayLaunchRetryInterval) + } +} + +// emitLaunchDecisionLog 输出 launcher 决策日志,采用字段白名单断言友好的结构化 JSON。 +func (d *Dispatcher) emitLaunchDecisionLog(entry launchDecisionLogEntry) { + if d == nil || d.logger == nil { + return + } + raw, err := json.Marshal(entry) + if err != nil { + d.logger.Printf(`{"status":"launch_log_encode_failed","message":"%s"}`, strings.TrimSpace(err.Error())) + return + } + d.logger.Print(string(raw)) +} + +// resolveAuthMode 归一化调度鉴权模式,便于日志与兼容性测试稳定断言。 +func resolveAuthMode(authToken string) string { + if strings.TrimSpace(authToken) == "" { + return "disabled" + } + return "required" +} + // Dispatch 使用默认调度器执行 URL 转发。 func Dispatch(ctx context.Context, request DispatchRequest) (DispatchResult, error) { return NewDispatcher().Dispatch(ctx, request) diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 1e4a47c8..8f25a456 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -6,12 +6,14 @@ import ( "encoding/json" "errors" "io" + "log" "net" "strings" "testing" "time" "neo-code/internal/gateway" + "neo-code/internal/gateway/launcher" "neo-code/internal/gateway/protocol" "neo-code/internal/gateway/transport" ) @@ -278,6 +280,159 @@ func TestDispatcherDispatchInputAndDialErrors(t *testing.T) { } } +func TestDispatcherDialGatewayWithSingleLaunchFallback(t *testing.T) { + t.Run("launch succeeds and second dial succeeds", func(t *testing.T) { + dialCalls := 0 + dispatcher := &Dispatcher{ + dialFn: func(string) (net.Conn, error) { + dialCalls++ + if dialCalls == 1 { + return nil, errors.New("not ready") + } + return &stubDispatchConn{}, nil + }, + autoLaunchGateway: true, + resolveLaunchSpecFn: func() (launcher.LaunchSpec, error) { + return launcher.LaunchSpec{ + LaunchMode: launcher.LaunchModePathBinary, + Executable: "/usr/local/bin/neocode-gateway", + }, nil + }, + startGatewayFn: func(launcher.LaunchSpec) error { return nil }, + nowFn: time.Now, + sleepFn: func(time.Duration) {}, + } + + connection, err := dispatcher.dialGatewayWithFallback(context.Background(), "stub://gateway", "wake-1", "") + if err != nil { + t.Fatalf("dialGatewayWithFallback() error = %v", err) + } + if connection == nil { + t.Fatal("expected non-nil connection") + } + if dialCalls != 3 { + t.Fatalf("dial calls = %d, want %d", dialCalls, 3) + } + }) + + t.Run("single fallback and deterministic error", func(t *testing.T) { + dialCalls := 0 + now := time.Unix(200, 0) + dispatcher := &Dispatcher{ + dialFn: func(string) (net.Conn, error) { + dialCalls++ + return nil, errors.New("still unreachable") + }, + autoLaunchGateway: true, + resolveLaunchSpecFn: func() (launcher.LaunchSpec, error) { + return launcher.LaunchSpec{ + LaunchMode: launcher.LaunchModePathBinary, + Executable: "/usr/local/bin/neocode-gateway", + }, nil + }, + startGatewayFn: func(launcher.LaunchSpec) error { return nil }, + nowFn: func() time.Time { + current := now + now = now.Add(4 * time.Second) + return current + }, + sleepFn: func(time.Duration) {}, + } + + _, err := dispatcher.dialGatewayWithFallback(context.Background(), "stub://gateway", "wake-2", "") + if err == nil { + t.Fatal("expected unreachable error") + } + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeGatewayUnavailable { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeGatewayUnavailable) + } + if !strings.Contains(dispatchErr.Message, "launch gateway failed") { + t.Fatalf("error message = %q, want contains launch failure", dispatchErr.Message) + } + if dialCalls != 2 { + t.Fatalf("dial calls = %d, want %d", dialCalls, 2) + } + }) +} + +func TestDispatcherLaunchDecisionLogWhitelistFields(t *testing.T) { + assertPayload := func(t *testing.T, entry launchDecisionLogEntry, expected map[string]string) { + t.Helper() + buffer := &bytes.Buffer{} + dispatcher := &Dispatcher{ + logger: log.New(buffer, "", 0), + } + dispatcher.emitLaunchDecisionLog(entry) + + var payload map[string]any + if err := json.Unmarshal(buffer.Bytes(), &payload); err != nil { + t.Fatalf("decode launch log payload: %v", err) + } + for fieldName, expectedValue := range expected { + value, ok := payload[fieldName] + if !ok { + t.Fatalf("missing field %q", fieldName) + } + textValue, ok := value.(string) + if !ok { + t.Fatalf("field %q type = %T, want string", fieldName, value) + } + if textValue != expectedValue { + t.Fatalf("field %q = %q, want %q", fieldName, textValue, expectedValue) + } + } + } + + assertPayload(t, launchDecisionLogEntry{ + RequestID: "wake-123", + Method: protocol.MethodWakeOpenURL, + Source: "url-dispatch", + Status: "launch_attempt", + GatewayCode: "", + ListenAddress: "127.0.0.1:8080", + AuthMode: "required", + LaunchMode: launcher.LaunchModePathBinary, + ResolvedExec: "/usr/local/bin/neocode-gateway", + }, map[string]string{ + "request_id": "wake-123", + "method": protocol.MethodWakeOpenURL, + "source": "url-dispatch", + "status": "launch_attempt", + "gateway_code": "", + "listen_address": "127.0.0.1:8080", + "auth_mode": "required", + "launch_mode": launcher.LaunchModePathBinary, + "resolved_exec": "/usr/local/bin/neocode-gateway", + }) + + assertPayload(t, launchDecisionLogEntry{ + RequestID: "wake-124", + Method: protocol.MethodWakeOpenURL, + Source: "url-dispatch", + Status: "launch_failed", + GatewayCode: ErrorCodeGatewayUnavailable, + ListenAddress: "127.0.0.1:8080", + AuthMode: "disabled", + LaunchMode: launcher.LaunchModeFallbackSubcommand, + ResolvedExec: "/usr/local/bin/neocode", + Message: "launch failed", + }, map[string]string{ + "request_id": "wake-124", + "method": protocol.MethodWakeOpenURL, + "source": "url-dispatch", + "status": "launch_failed", + "gateway_code": ErrorCodeGatewayUnavailable, + "listen_address": "127.0.0.1:8080", + "auth_mode": "disabled", + "launch_mode": launcher.LaunchModeFallbackSubcommand, + "resolved_exec": "/usr/local/bin/neocode", + }) +} + func TestDispatcherDispatchFailsFastOnCanceledContextBeforeIO(t *testing.T) { conn := &stubDispatchConn{} dispatcher := &Dispatcher{ diff --git a/internal/gateway/launcher/launcher.go b/internal/gateway/launcher/launcher.go new file mode 100644 index 00000000..0f4ea54d --- /dev/null +++ b/internal/gateway/launcher/launcher.go @@ -0,0 +1,101 @@ +package launcher + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +const ( + // EnvGatewayBinary 定义显式网关可执行路径的环境变量名。 + EnvGatewayBinary = "NEOCODE_GATEWAY_BIN" + // LaunchModeExplicitPath 表示命中显式路径配置。 + LaunchModeExplicitPath = "explicit_path" + // LaunchModePathBinary 表示命中 PATH 中的 neocode-gateway。 + LaunchModePathBinary = "path_neocode_gateway" + // LaunchModeFallbackSubcommand 表示回退到当前可执行的 gateway 子命令。 + LaunchModeFallbackSubcommand = "fallback_neocode_gateway_subcommand" +) + +// LaunchSpec 描述网关拉起决策结果。 +type LaunchSpec struct { + LaunchMode string + Executable string + Args []string +} + +// ResolveOptions 描述网关拉起解析所需输入。 +type ResolveOptions struct { + ExplicitBinary string +} + +// ResolveGatewayLaunchSpec 解析网关可执行发现顺序: +// 显式路径(NEOCODE_GATEWAY_BIN) > PATH(neocode-gateway) > 当前可执行 + gateway 子命令。 +func ResolveGatewayLaunchSpec(options ResolveOptions) (LaunchSpec, error) { + return resolveGatewayLaunchSpecWithDeps(options, exec.LookPath, os.Executable) +} + +// StartDetachedGateway 以非阻塞方式拉起网关进程并释放父进程句柄。 +func StartDetachedGateway(spec LaunchSpec) error { + executable := strings.TrimSpace(spec.Executable) + if executable == "" { + return fmt.Errorf("empty gateway executable") + } + command := exec.Command(executable, spec.Args...) + command.Stdin = nil + command.Stdout = os.Stderr + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + return err + } + return command.Process.Release() +} + +func resolveGatewayLaunchSpecWithDeps( + options ResolveOptions, + lookPathFn func(string) (string, error), + executableFn func() (string, error), +) (LaunchSpec, error) { + resolveByLookup := func(binary string) (string, error) { + resolved, err := lookPathFn(strings.TrimSpace(binary)) + if err != nil { + return "", fmt.Errorf("resolve executable %q: %w", strings.TrimSpace(binary), err) + } + return strings.TrimSpace(resolved), nil + } + + explicitBinary := strings.TrimSpace(options.ExplicitBinary) + if explicitBinary != "" { + resolved, err := resolveByLookup(explicitBinary) + if err != nil { + return LaunchSpec{}, err + } + return LaunchSpec{ + LaunchMode: LaunchModeExplicitPath, + Executable: resolved, + }, nil + } + + if resolved, err := resolveByLookup("neocode-gateway"); err == nil { + return LaunchSpec{ + LaunchMode: LaunchModePathBinary, + Executable: resolved, + }, nil + } + + currentExecutable, err := executableFn() + if err != nil { + return LaunchSpec{}, fmt.Errorf("resolve current executable: %w", err) + } + trimmedCurrentExecutable := strings.TrimSpace(currentExecutable) + if trimmedCurrentExecutable == "" { + return LaunchSpec{}, fmt.Errorf("resolve current executable: empty executable path") + } + + return LaunchSpec{ + LaunchMode: LaunchModeFallbackSubcommand, + Executable: trimmedCurrentExecutable, + Args: []string{"gateway"}, + }, nil +} diff --git a/internal/gateway/launcher/launcher_test.go b/internal/gateway/launcher/launcher_test.go new file mode 100644 index 00000000..f40f27d6 --- /dev/null +++ b/internal/gateway/launcher/launcher_test.go @@ -0,0 +1,159 @@ +package launcher + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + "time" +) + +func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { + t.Run("explicit binary has highest priority", func(t *testing.T) { + spec, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{ExplicitBinary: "/opt/tools/neocode-gateway"}, + func(binary string) (string, error) { + if binary == "/opt/tools/neocode-gateway" { + return binary, nil + } + return "", errors.New("unexpected lookup") + }, + func() (string, error) { + return "/usr/local/bin/neocode", nil + }, + ) + if err != nil { + t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) + } + if spec.LaunchMode != LaunchModeExplicitPath { + t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, LaunchModeExplicitPath) + } + if spec.Executable != "/opt/tools/neocode-gateway" { + t.Fatalf("executable = %q, want %q", spec.Executable, "/opt/tools/neocode-gateway") + } + if len(spec.Args) != 0 { + t.Fatalf("args = %#v, want empty", spec.Args) + } + }) + + t.Run("path binary preferred over fallback", func(t *testing.T) { + spec, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{}, + func(binary string) (string, error) { + if binary == "neocode-gateway" { + return "/usr/local/bin/neocode-gateway", nil + } + return "", errors.New("unexpected lookup") + }, + func() (string, error) { + return "/usr/local/bin/neocode", nil + }, + ) + if err != nil { + t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) + } + if spec.LaunchMode != LaunchModePathBinary { + t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, LaunchModePathBinary) + } + if spec.Executable != "/usr/local/bin/neocode-gateway" { + t.Fatalf("executable = %q, want %q", spec.Executable, "/usr/local/bin/neocode-gateway") + } + if len(spec.Args) != 0 { + t.Fatalf("args = %#v, want empty", spec.Args) + } + }) + + t.Run("fallback to current executable subcommand", func(t *testing.T) { + spec, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{}, + func(string) (string, error) { + return "", errors.New("not found") + }, + func() (string, error) { + return "/usr/local/bin/neocode", nil + }, + ) + if err != nil { + t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) + } + if spec.LaunchMode != LaunchModeFallbackSubcommand { + t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, LaunchModeFallbackSubcommand) + } + if spec.Executable != "/usr/local/bin/neocode" { + t.Fatalf("executable = %q, want %q", spec.Executable, "/usr/local/bin/neocode") + } + if !reflect.DeepEqual(spec.Args, []string{"gateway"}) { + t.Fatalf("args = %#v, want %#v", spec.Args, []string{"gateway"}) + } + }) + + t.Run("explicit binary lookup failure returns error", func(t *testing.T) { + _, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{ExplicitBinary: "/missing/neocode-gateway"}, + func(string) (string, error) { + return "", errors.New("missing") + }, + func() (string, error) { + return "/usr/local/bin/neocode", nil + }, + ) + if err == nil { + t.Fatal("expected explicit lookup error") + } + }) + + t.Run("fallback fails when current executable unavailable", func(t *testing.T) { + _, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{}, + func(string) (string, error) { + return "", errors.New("not found") + }, + func() (string, error) { + return "", errors.New("unavailable") + }, + ) + if err == nil { + t.Fatal("expected fallback resolution error") + } + }) +} + +func TestStartDetachedGateway(t *testing.T) { + t.Run("empty executable rejected", func(t *testing.T) { + err := StartDetachedGateway(LaunchSpec{}) + if err == nil { + t.Fatal("expected empty executable error") + } + }) + + t.Run("starts process successfully", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows command start behavior differs in sandbox; skip process spawn assertion") + } + scriptDir := t.TempDir() + markerPath := filepath.Join(scriptDir, "started.txt") + scriptPath := filepath.Join(scriptDir, "start-gateway.sh") + scriptContent := "#!/bin/sh\nprintf 'ok' > \"$1\"\n" + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0o700); err != nil { + t.Fatalf("write script: %v", err) + } + + if err := StartDetachedGateway(LaunchSpec{ + Executable: scriptPath, + Args: []string{markerPath}, + }); err != nil { + t.Fatalf("StartDetachedGateway() error = %v", err) + } + + // 子进程异步启动,给少量时间完成写入。 + for i := 0; i < 20; i++ { + if _, err := os.Stat(markerPath); err == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("expected marker file %q to be created", markerPath) + }) +} diff --git a/internal/gateway/protocol/docgen_generate.go b/internal/gateway/protocol/docgen_generate.go new file mode 100644 index 00000000..cefb4dbf --- /dev/null +++ b/internal/gateway/protocol/docgen_generate.go @@ -0,0 +1,3 @@ +package protocol + +//go:generate go run ../../../scripts/generate_gateway_rpc_examples.go diff --git a/scripts/generate_gateway_rpc_examples.go b/scripts/generate_gateway_rpc_examples.go new file mode 100644 index 00000000..81a333f6 --- /dev/null +++ b/scripts/generate_gateway_rpc_examples.go @@ -0,0 +1,155 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "neo-code/internal/gateway" + "neo-code/internal/gateway/protocol" +) + +type generatedExamples struct { + GatewayBindStream struct { + Request protocol.JSONRPCRequest `json:"request"` + Response protocol.JSONRPCResponse `json:"response"` + } `json:"gateway.bindStream"` + GatewayRun struct { + Request protocol.JSONRPCRequest `json:"request"` + Response protocol.JSONRPCResponse `json:"response"` + } `json:"gateway.run"` + CommonError struct { + Response protocol.JSONRPCResponse `json:"response"` + } `json:"common.error"` +} + +func main() { + examples, err := buildExamples() + if err != nil { + fail("build examples", err) + } + raw, err := json.MarshalIndent(examples, "", " ") + if err != nil { + fail("marshal examples", err) + } + outputPath := filepath.Join("docs", "generated", "gateway-rpc-examples.json") + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + fail("create output directory", err) + } + if err := os.WriteFile(outputPath, append(raw, '\n'), 0o644); err != nil { + fail("write output file", err) + } + fmt.Printf("generated %s\n", outputPath) +} + +func buildExamples() (generatedExamples, error) { + var examples generatedExamples + + bindStreamRequestIDRaw, err := marshalRaw("bind-1") + if err != nil { + return generatedExamples{}, err + } + bindStreamParamsRaw, err := marshalRaw(protocol.BindStreamParams{ + SessionID: "sess-1", + RunID: "run-1", + Channel: "ws", + }) + if err != nil { + return generatedExamples{}, err + } + examples.GatewayBindStream.Request = protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: bindStreamRequestIDRaw, + Method: protocol.MethodGatewayBindStream, + Params: bindStreamParamsRaw, + } + bindStreamResultRaw, err := marshalRaw(gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionBindStream, + RequestID: "bind-1", + SessionID: "sess-1", + RunID: "run-1", + Payload: map[string]any{ + "message": "stream binding updated", + "channel": "ws", + }, + }) + if err != nil { + return generatedExamples{}, err + } + examples.GatewayBindStream.Response = protocol.JSONRPCResponse{ + JSONRPC: protocol.JSONRPCVersion, + ID: bindStreamRequestIDRaw, + Result: bindStreamResultRaw, + } + + runRequestIDRaw, err := marshalRaw("run-req-1") + if err != nil { + return generatedExamples{}, err + } + runParamsRaw, err := marshalRaw(protocol.RunParams{ + SessionID: "sess-1", + RunID: "run-1", + InputText: "Please review README", + InputParts: []protocol.RunInputPart{ + {Type: "text", Text: "Please review README"}, + }, + Workdir: "/workspace/demo", + }) + if err != nil { + return generatedExamples{}, err + } + examples.GatewayRun.Request = protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: runRequestIDRaw, + Method: protocol.MethodGatewayRun, + Params: runParamsRaw, + } + runResultRaw, err := marshalRaw(gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionRun, + RequestID: "run-req-1", + SessionID: "sess-1", + RunID: "run-1", + Payload: map[string]any{ + "message": "run accepted", + }, + }) + if err != nil { + return generatedExamples{}, err + } + examples.GatewayRun.Response = protocol.JSONRPCResponse{ + JSONRPC: protocol.JSONRPCVersion, + ID: runRequestIDRaw, + Result: runResultRaw, + } + + commonErrorRequestIDRaw, err := marshalRaw("req-err-1") + if err != nil { + return generatedExamples{}, err + } + examples.CommonError.Response = protocol.NewJSONRPCErrorResponse( + commonErrorRequestIDRaw, + protocol.NewJSONRPCError( + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeUnauthorized.String()), + "unauthorized", + gateway.ErrorCodeUnauthorized.String(), + ), + ) + + return examples, nil +} + +func marshalRaw(payload any) (json.RawMessage, error) { + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return json.RawMessage(raw), nil +} + +func fail(message string, err error) { + fmt.Fprintf(os.Stderr, "%s: %v\n", message, err) + os.Exit(1) +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a4dbfbb9..455af835 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,69 +1,100 @@ +param( + [string]$Flavor = "full", + [switch]$DryRun +) + $ErrorActionPreference = "Stop" -# 配置仓库信息 $Repo = "1024XEngineer/neo-code" -$ProjectName = "neocode" -$BinaryName = "neocode.exe" +$Flavor = $Flavor.ToLowerInvariant() +if ($Flavor -notin @("full", "gateway")) { + throw "Unsupported -Flavor value: $Flavor (expected full|gateway)" +} -Write-Host "🚀 开始安装 $BinaryName..." -ForegroundColor Cyan +switch ($Flavor) { + "full" { + $AssetPrefix = "neocode" + $BinaryName = "neocode.exe" + } + "gateway" { + $AssetPrefix = "neocode-gateway" + $BinaryName = "neocode-gateway.exe" + } +} -# 1. 获取系统架构 -$Arch = $env:PROCESSOR_ARCHITECTURE -if ($Arch -eq "AMD64") { - $ArchName = "x86_64" -} elseif ($Arch -eq "ARM64") { - $ArchName = "arm64" -} else { - Write-Error "❌ 不支持的系统架构: $Arch" - exit +$Architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToUpperInvariant() +switch ($Architecture) { + "X64" { $ArchName = "x86_64" } + "AMD64" { $ArchName = "x86_64" } + "ARM64" { $ArchName = "arm64" } + default { throw "Unsupported architecture: $Architecture" } } -# 2. 从 GitHub API 获取最新 Release 版本号 -Write-Host "🔍 正在获取最新版本信息..." -$ApiUrl = "https://api.github.com/repos/$Repo/releases/latest" -try { - $LatestRelease = Invoke-RestMethod -Uri $ApiUrl - $LatestTag = $LatestRelease.tag_name -} catch { - Write-Error "❌ 无法获取最新版本,请检查网络或 GitHub 访问权限。" - exit +if (![string]::IsNullOrWhiteSpace($env:NEOCODE_INSTALL_LATEST_TAG)) { + $LatestTag = $env:NEOCODE_INSTALL_LATEST_TAG +} +else { + Write-Host "Resolving latest release metadata..." + $LatestTag = (Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest").tag_name + if ([string]::IsNullOrWhiteSpace($LatestTag)) { + throw "Failed to resolve latest release tag from GitHub API." + } } -Write-Host "📦 发现最新版本: $LatestTag" -# 3. 拼接下载链接 -$ZipFile = "${ProjectName}_Windows_${ArchName}.zip" +$ZipFile = "${AssetPrefix}_Windows_${ArchName}.zip" $DownloadUrl = "https://github.com/$Repo/releases/download/$LatestTag/$ZipFile" +$ChecksumUrl = "https://github.com/$Repo/releases/download/$LatestTag/checksums.txt" + +if ($DryRun) { + Write-Output "flavor=$Flavor" + Write-Output "asset=$ZipFile" + Write-Output "download_url=$DownloadUrl" + Write-Output "checksum_url=$ChecksumUrl" + exit 0 +} -# 4. 下载并解压到临时目录 -$TempDir = Join-Path $env:TEMP "neocode_install" -if (Test-Path $TempDir) { Remove-Item -Recurse -Force $TempDir } -New-Item -ItemType Directory -Force -Path $TempDir | Out-Null -$ZipPath = Join-Path $TempDir $ZipFile +$TempDir = Join-Path $env:TEMP "neocode_install_$([Guid]::NewGuid().ToString('N'))" +New-Item -Path $TempDir -ItemType Directory -Force | Out-Null +try { + $ZipPath = Join-Path $TempDir $ZipFile + $ChecksumPath = Join-Path $TempDir "checksums.txt" -Write-Host "⬇️ 正在下载压缩包..." -Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath + Write-Host "Downloading $ZipFile..." + Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath + Write-Host "Downloading checksums..." + Invoke-WebRequest -Uri $ChecksumUrl -OutFile $ChecksumPath -Write-Host "📦 正在解压..." -Expand-Archive -Path $ZipPath -DestinationPath $TempDir -Force + $ChecksumLine = Get-Content -Path $ChecksumPath | Where-Object { + ($_ -match "^[0-9a-fA-F]{64}\s+\*?$([Regex]::Escape($ZipFile))$") + } | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($ChecksumLine)) { + throw "Failed to find checksum entry for $ZipFile." + } + $ExpectedHash = (($ChecksumLine -split "\s+")[0]).ToLowerInvariant() + $ActualHash = (Get-FileHash -Path $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($ActualHash -ne $ExpectedHash) { + throw "Checksum verification failed for $ZipFile. Expected=$ExpectedHash Actual=$ActualHash" + } -# 5. 部署到用户目录 -$InstallDir = Join-Path $env:LOCALAPPDATA "NeoCode" -if (!(Test-Path $InstallDir)) { - New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -} -Write-Host "⚙️ 正在将可执行文件部署到 $InstallDir..." -Copy-Item -Path (Join-Path $TempDir $BinaryName) -Destination $InstallDir -Force + Write-Host "Extracting archive..." + Expand-Archive -Path $ZipPath -DestinationPath $TempDir -Force -# 6. 配置环境变量 PATH -$UserPath = [Environment]::GetEnvironmentVariable("PATH", "User") -if ($UserPath -notmatch [regex]::Escape($InstallDir)) { - Write-Host "🔧 正在配置环境变量..." - $NewPath = "$UserPath;$InstallDir" - [Environment]::SetEnvironmentVariable("PATH", $NewPath, "User") - Write-Host "⚠️ 环境变量已更新!安装完成后,请重启终端(PowerShell/CMD)以使命令生效。" -ForegroundColor Yellow -} + $InstallDir = Join-Path $env:LOCALAPPDATA "NeoCode" + if (!(Test-Path $InstallDir)) { + New-Item -Path $InstallDir -ItemType Directory -Force | Out-Null + } + Copy-Item -Path (Join-Path $TempDir $BinaryName) -Destination $InstallDir -Force -# 7. 清理临时文件 -Remove-Item -Recurse -Force $TempDir + $UserPath = [Environment]::GetEnvironmentVariable("PATH", "User") + if ($UserPath -notmatch [Regex]::Escape($InstallDir)) { + [Environment]::SetEnvironmentVariable("PATH", "$UserPath;$InstallDir", "User") + Write-Host "Updated PATH. Re-open PowerShell/CMD to apply changes." -ForegroundColor Yellow + } -Write-Host "✅ 安装成功!" -ForegroundColor Green + Write-Host "Installed $BinaryName ($Flavor) from $LatestTag." -ForegroundColor Green +} +finally { + if (Test-Path $TempDir) { + Remove-Item -Path $TempDir -Recurse -Force + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 2d7129db..b4c8c8e7 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,61 +1,149 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail -# 配置仓库信息 -REPO="1024XEngineer/neo-code" -PROJECT_NAME="neocode" -BINARY_NAME="neocode" +REPO="1024XEngineer/neo-code" +DEFAULT_FLAVOR="full" -echo "🚀 开始安装 $BINARY_NAME..." +flavor="$DEFAULT_FLAVOR" +dry_run=0 -# 1. 获取系统和架构信息 -OS="$(uname -s)" -ARCH="$(uname -m)" +usage() { + cat <<'USAGE' +Usage: install.sh [--flavor full|gateway] [--dry-run] -if [ "$OS" = "Linux" ]; then - OS_NAME="Linux" -elif [ "$OS" = "Darwin" ]; then - OS_NAME="Darwin" -else - echo "❌ 不支持的操作系统: $OS" +Options: + --flavor Install artifact flavor. Default: full + --dry-run Print resolved asset URLs/checksum URL and exit without installing + -h, --help Show this help message +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --flavor) + if [[ $# -lt 2 ]]; then + echo "Error: --flavor requires a value" >&2 + exit 1 + fi + flavor="$(echo "$2" | tr '[:upper:]' '[:lower:]')" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +case "$flavor" in + full) + asset_prefix="neocode" + binary_name="neocode" + ;; + gateway) + asset_prefix="neocode-gateway" + binary_name="neocode-gateway" + ;; + *) + echo "Error: unsupported --flavor value: $flavor (expected full|gateway)" >&2 exit 1 -fi + ;; +esac + +os="$(uname -s)" +arch="$(uname -m)" + +case "$os" in + Linux) os_name="Linux" ;; + Darwin) os_name="Darwin" ;; + *) + echo "Error: unsupported operating system: $os" >&2 + exit 1 + ;; +esac + +case "$arch" in + x86_64|amd64) arch_name="x86_64" ;; + aarch64|arm64) arch_name="arm64" ;; + *) + echo "Error: unsupported architecture: $arch" >&2 + exit 1 + ;; +esac -if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then - ARCH_NAME="x86_64" -elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then - ARCH_NAME="arm64" +if [[ -n "${NEOCODE_INSTALL_LATEST_TAG:-}" ]]; then + latest_tag="${NEOCODE_INSTALL_LATEST_TAG}" else - echo "❌ 不支持的系统架构: $ARCH" + echo "Resolving latest release metadata..." + latest_tag="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + if [[ -z "$latest_tag" ]]; then + echo "Error: failed to resolve latest release tag from GitHub API" >&2 exit 1 + fi fi -# 2. 从 GitHub API 获取最新 Release 版本号 -echo "🔍 正在获取最新版本信息..." -LATEST_TAG=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') +asset_name="${asset_prefix}_${os_name}_${arch_name}.tar.gz" +download_url="https://github.com/${REPO}/releases/download/${latest_tag}/${asset_name}" +checksum_url="https://github.com/${REPO}/releases/download/${latest_tag}/checksums.txt" -if [ -z "$LATEST_TAG" ]; then - echo "❌ 无法获取最新版本,请检查网络或 GitHub 访问权限。" - exit 1 +if [[ "$dry_run" -eq 1 ]]; then + echo "flavor=${flavor}" + echo "asset=${asset_name}" + echo "download_url=${download_url}" + echo "checksum_url=${checksum_url}" + exit 0 fi -echo "📦 发现最新版本: $LATEST_TAG" -# 3. 拼接下载链接 (匹配 GoReleaser 默认命名) -TAR_FILE="${PROJECT_NAME}_${OS_NAME}_${ARCH_NAME}.tar.gz" -DOWNLOAD_URL="https://github.com/$REPO/releases/download/$LATEST_TAG/$TAR_FILE" +temp_dir="$(mktemp -d)" +cleanup() { + rm -rf "${temp_dir}" +} +trap cleanup EXIT + +archive_path="${temp_dir}/${asset_name}" +checksums_path="${temp_dir}/checksums.txt" + +echo "Downloading ${asset_name}..." +curl -fsSL -o "${archive_path}" "${download_url}" -# 4. 下载并解压 -echo "⬇️ 正在下载: $DOWNLOAD_URL" -curl -L -o "$TAR_FILE" "$DOWNLOAD_URL" +echo "Downloading checksums..." +curl -fsSL -o "${checksums_path}" "${checksum_url}" + +expected_checksum="$(awk -v asset="${asset_name}" '$2 == asset || $2 == "*"asset { print $1; exit }' "${checksums_path}")" +if [[ -z "${expected_checksum}" ]]; then + echo "Error: failed to find checksum entry for ${asset_name}" >&2 + exit 1 +fi -echo "📦 正在解压..." -tar -xzf "$TAR_FILE" "$BINARY_NAME" +if command -v sha256sum >/dev/null 2>&1; then + actual_checksum="$(sha256sum "${archive_path}" | awk '{print $1}')" +elif command -v shasum >/dev/null 2>&1; then + actual_checksum="$(shasum -a 256 "${archive_path}" | awk '{print $1}')" +else + echo "Error: sha256sum/shasum is required to verify checksums" >&2 + exit 1 +fi + +if [[ "${actual_checksum}" != "${expected_checksum}" ]]; then + echo "Error: checksum verification failed for ${asset_name}" >&2 + echo "Expected: ${expected_checksum}" >&2 + echo "Actual: ${actual_checksum}" >&2 + exit 1 +fi -# 5. 安装到全局 PATH -echo "⚙️ 正在安装到 /usr/local/bin (此步可能需要输入密码以获取 sudo 权限)..." -sudo mv "$BINARY_NAME" /usr/local/bin/ +echo "Extracting ${binary_name}..." +tar -xzf "${archive_path}" -C "${temp_dir}" "${binary_name}" -# 6. 清理临时文件 -rm "$TAR_FILE" +echo "Installing ${binary_name} to /usr/local/bin (sudo may prompt)..." +sudo mv "${temp_dir}/${binary_name}" /usr/local/bin/ -echo "✅ 安装成功!请在终端运行 '$BINARY_NAME --help' 开始使用。" +echo "Installed ${binary_name} (${flavor}) from ${latest_tag}." From fa8d483e08048a4d0c8f89a1cd8036ff3ba64161 Mon Sep 17 00:00:00 2001 From: pionxe Date: Thu, 23 Apr 2026 18:50:54 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix(gateway):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E9=87=8D=E6=8B=A3=E5=90=8E=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=8E=E6=96=87=E6=A1=A3=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E6=9E=84=E5=BB=BA=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/gateway_commands.go | 65 +++++++++++++------- internal/gateway/protocol/docgen_generate.go | 2 +- scripts/generate_gateway_rpc_examples.go | 3 + 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index 0c58b495..b579e445 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -1,4 +1,4 @@ -package cli +package cli import ( "context" @@ -107,16 +107,18 @@ func newGatewayCommand() *cobra.Command { return newGatewayServerCommand("gateway", "Start local gateway server", mustReadInheritedWorkdir) } -// NewGatewayStandaloneCommand 鍒涘缓 gateway-only 鐙珛鍏ュ彛鍛戒护锛岀‘淇濅粎鏆撮湶缃戝叧鏈嶅姟璇箟銆?func NewGatewayStandaloneCommand() *cobra.Command { +// NewGatewayStandaloneCommand 鍒涘缓 gateway-only 鐙珛鍏ュ彛鍛戒护锛岀‘淇濅粎鏆撮湶缃戝叧鏈嶅姟璇箟銆? +func NewGatewayStandaloneCommand() *cobra.Command { standaloneWorkdir := "" command := newGatewayServerCommand("neocode-gateway", "Start NeoCode gateway-only server", func(*cobra.Command) string { return standaloneWorkdir }) - command.Flags().StringVar(&standaloneWorkdir, "workdir", "", "宸ヤ綔鐩綍锛堣鐩栨湰娆¤繍琛屽伐浣滃尯锛?) + command.Flags().StringVar(&standaloneWorkdir, "workdir", "", "workdir override for this gateway process") return command } -// newGatewayServerCommand 鏋勫缓缃戝叧鍚姩鍛戒护锛屽苟澶嶇敤缁熶竴鍙傛暟褰掍竴鍖栦笌鎵ц璺緞銆?func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command) string) *cobra.Command { +// newGatewayServerCommand 鏋勫缓缃戝叧鍚姩鍛戒护锛屽苟澶嶇敤缁熶竴鍙傛暟褰掍竴鍖栦笌鎵ц璺緞銆? +func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command) string) *cobra.Command { options := &gatewayCommandOptions{} cmd := &cobra.Command{ @@ -193,7 +195,8 @@ func newGatewayCommand() *cobra.Command { return cmd } -// normalizeGatewayLogLevel 瀵圭綉鍏虫棩蹇楃骇鍒仛褰掍竴鍖栧苟鏍¢獙鍚堟硶鍊笺€?func normalizeGatewayLogLevel(logLevel string) (string, error) { +// normalizeGatewayLogLevel 瀵圭綉鍏虫棩蹇楃骇鍒仛褰掍竴鍖栧苟鏍¢獙鍚堟硶鍊笺€? +func normalizeGatewayLogLevel(logLevel string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(logLevel)) switch normalized { case "debug", "info", "warn", "error": @@ -203,7 +206,8 @@ func newGatewayCommand() *cobra.Command { } } -// mustReadInheritedWorkdir 鍦ㄥ瓙鍛戒护涓畨鍏ㄨ鍙栫户鎵跨殑 --workdir锛岃鍙栧け璐ユ椂鍥為€€涓虹┖鍊笺€?func mustReadInheritedWorkdir(cmd *cobra.Command) string { +// mustReadInheritedWorkdir 鍦ㄥ瓙鍛戒护涓畨鍏ㄨ鍙栫户鎵跨殑 --workdir锛岃鍙栧け璐ユ椂鍥為€€涓虹┖鍊笺€? +func mustReadInheritedWorkdir(cmd *cobra.Command) string { if cmd == nil { return "" } @@ -214,7 +218,8 @@ func newGatewayCommand() *cobra.Command { return workdir } -// defaultGatewayCommandRunner 浣跨敤缃戝叧鏈嶅姟楠ㄦ灦鍚姩鏈湴 IPC 鐩戝惉骞跺鐞嗕俊鍙烽€€鍑恒€?func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOptions) error { +// defaultGatewayCommandRunner 浣跨敤缃戝叧鏈嶅姟楠ㄦ灦鍚姩鏈湴 IPC 鐩戝惉骞跺鐞嗕俊鍙烽€€鍑恒€? +func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOptions) error { logger := log.New(os.Stderr, "neocode-gateway: ", log.LstdFlags) logger.Printf("starting gateway (log-level=%s)", options.LogLevel) @@ -336,7 +341,8 @@ type gatewayIdleShutdownController struct { timer *time.Timer } -// newGatewayIdleShutdownController 鍒涘缓缃戝叧绌洪棽鑷€€鎺у埗鍣細杩炴帴鏁板綊闆跺悗寤惰繜閫€鍑猴紝鏈夎繛鎺ユ仮澶嶅垯鍙栨秷閫€鍑恒€?func newGatewayIdleShutdownController(logger *log.Logger, cancel context.CancelFunc) *gatewayIdleShutdownController { +// newGatewayIdleShutdownController 鍒涘缓缃戝叧绌洪棽鑷€€鎺у埗鍣細杩炴帴鏁板綊闆跺悗寤惰繜閫€鍑猴紝鏈夎繛鎺ユ仮澶嶅垯鍙栨秷閫€鍑恒€? +func newGatewayIdleShutdownController(logger *log.Logger, cancel context.CancelFunc) *gatewayIdleShutdownController { return &gatewayIdleShutdownController{ logger: logger, idleTimeout: defaultGatewayIdleShutdownDelay, @@ -344,7 +350,8 @@ type gatewayIdleShutdownController struct { } } -// observe 鎺ユ敹 IPC 娲昏穬杩炴帴鏁板揩鐓у苟缁存姢绌洪棽閫€鍑鸿鏃跺櫒銆?func (c *gatewayIdleShutdownController) observe(active int) { +// observe 鎺ユ敹 IPC 娲昏穬杩炴帴鏁板揩鐓у苟缁存姢绌洪棽閫€鍑鸿鏃跺櫒銆? +func (c *gatewayIdleShutdownController) observe(active int) { if c == nil { return } @@ -384,7 +391,8 @@ type gatewayIdleShutdownController struct { }) } -// close 閲婃斁绌洪棽閫€鍑烘帶鍒跺櫒鎸佹湁鐨勮鏃跺櫒璧勬簮銆?func (c *gatewayIdleShutdownController) close() { +// close 閲婃斁绌洪棽閫€鍑烘帶鍒跺櫒鎸佹湁鐨勮鏃跺櫒璧勬簮銆? +func (c *gatewayIdleShutdownController) close() { if c == nil { return } @@ -396,7 +404,8 @@ type gatewayIdleShutdownController struct { } } -// buildGatewayControlPlaneACL 鍩轰簬閰嶇疆鏋勯€犳帶鍒堕潰 ACL 绛栫暐锛屾湭鐭ユā寮忕洿鎺ユ嫆缁濆惎鍔ㄣ€?func buildGatewayControlPlaneACL(aclMode string) (*gateway.ControlPlaneACL, error) { +// buildGatewayControlPlaneACL 鍩轰簬閰嶇疆鏋勯€犳帶鍒堕潰 ACL 绛栫暐锛屾湭鐭ユā寮忕洿鎺ユ嫆缁濆惎鍔ㄣ€? +func buildGatewayControlPlaneACL(aclMode string) (*gateway.ControlPlaneACL, error) { normalizedACLMode := strings.ToLower(strings.TrimSpace(aclMode)) if normalizedACLMode == "" { normalizedACLMode = string(gateway.ACLModeStrict) @@ -409,7 +418,8 @@ type gatewayIdleShutdownController struct { } } -// applyGatewayFlagOverrides 灏?CLI flags 瑕嗙洊鍒扮綉鍏抽厤缃紝浼樺厛绾ч珮浜?config.yaml銆?func applyGatewayFlagOverrides(gatewayConfig *config.GatewayConfig, options gatewayCommandOptions) { +// applyGatewayFlagOverrides 灏?CLI flags 瑕嗙洊鍒扮綉鍏抽厤缃紝浼樺厛绾ч珮浜?config.yaml銆? +func applyGatewayFlagOverrides(gatewayConfig *config.GatewayConfig, options gatewayCommandOptions) { if gatewayConfig == nil { return } @@ -449,15 +459,18 @@ type gatewayIdleShutdownController struct { } } -// defaultNewGatewayServer 鍒涘缓榛樿缃戝叧鏈嶅姟瀹炰緥锛屼緵鍛戒护灞傚惎鍔ㄦ祦绋嬭皟鐢ㄣ€?func defaultNewGatewayServer(options gateway.ServerOptions) (gatewayServer, error) { +// defaultNewGatewayServer 鍒涘缓榛樿缃戝叧鏈嶅姟瀹炰緥锛屼緵鍛戒护灞傚惎鍔ㄦ祦绋嬭皟鐢ㄣ€? +func defaultNewGatewayServer(options gateway.ServerOptions) (gatewayServer, error) { return gateway.NewServer(options) } -// defaultNewGatewayNetworkServer 鍒涘缓榛樿缃戝叧缃戠粶璁块棶闈㈡湇鍔″疄渚嬶紝渚涘懡浠ゅ眰鍚姩娴佺▼璋冪敤銆?func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) { +// defaultNewGatewayNetworkServer 鍒涘缓榛樿缃戝叧缃戠粶璁块棶闈㈡湇鍔″疄渚嬶紝渚涘懡浠ゅ眰鍚姩娴佺▼璋冪敤銆? +func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) { return gateway.NewNetworkServer(options) } -// newURLDispatchCommand 鍒涘缓 URL Scheme 娲惧彂瀛愬懡浠ら鏋讹紝浠呭仛鍙傛暟鏀舵暃涓庤皟鐢ㄨ浆鍙戙€?func newURLDispatchCommand() *cobra.Command { +// newURLDispatchCommand 鍒涘缓 URL Scheme 娲惧彂瀛愬懡浠ら鏋讹紝浠呭仛鍙傛暟鏀舵暃涓庤皟鐢ㄨ浆鍙戙€? +func newURLDispatchCommand() *cobra.Command { options := &urlDispatchCommandOptions{} cmd := &cobra.Command{ @@ -499,7 +512,8 @@ type gatewayIdleShutdownController struct { return cmd } -// defaultURLDispatchCommandRunner 鎵ц URL 鍞ら啋璇锋眰骞跺皢缁撴灉浠ョ粨鏋勫寲 JSON 杈撳嚭銆?func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCommandOptions) error { +// defaultURLDispatchCommandRunner 鎵ц URL 鍞ら啋璇锋眰骞跺皢缁撴灉浠ョ粨鏋勫寲 JSON 杈撳嚭銆? +func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCommandOptions) error { authToken, authErr := loadAuthToken(options.TokenFile) if authErr != nil { writeErr := writeDispatchError(os.Stderr, authErr) @@ -535,7 +549,8 @@ type gatewayIdleShutdownController struct { return nil } -// loadGatewayAuthToken 璇诲彇闈欓粯璁よ瘉 token锛涜嫢鏂囦欢涓嶅瓨鍦ㄥ垯鍥為€€涓虹┖浠ュ吋瀹规棤閴存潈妯″紡銆?func loadGatewayAuthToken(path string) (string, error) { +// loadGatewayAuthToken 璇诲彇闈欓粯璁よ瘉 token锛涜嫢鏂囦欢涓嶅瓨鍦ㄥ垯鍥為€€涓虹┖浠ュ吋瀹规棤閴存潈妯″紡銆? +func loadGatewayAuthToken(path string) (string, error) { token, err := gatewayauth.LoadTokenFromFile(path) if err == nil { return token, nil @@ -549,7 +564,8 @@ type gatewayIdleShutdownController struct { return "", err } -// normalizeDispatchURL 瀵?url-dispatch 杈撳叆鍋氭渶灏忓綊涓€鍖栵紝璇︾粏鏍¢獙浜ょ敱 dispatcher 瀹屾垚銆?func normalizeDispatchURL(rawURL string) (string, error) { +// normalizeDispatchURL 瀵?url-dispatch 杈撳叆鍋氭渶灏忓綊涓€鍖栵紝璇︾粏鏍¢獙浜ょ敱 dispatcher 瀹屾垚銆? +func normalizeDispatchURL(rawURL string) (string, error) { normalized := strings.TrimSpace(rawURL) if normalized == "" { return "", errors.New("missing required --url or positional ") @@ -557,7 +573,8 @@ type gatewayIdleShutdownController struct { return normalized, nil } -// writeURLDispatchSuccessOutput 灏?url-dispatch 鎴愬姛缁撴灉杈撳嚭涓虹粨鏋勫寲 JSON銆?func writeURLDispatchSuccessOutput(writer io.Writer, result urlscheme.DispatchResult) error { +// writeURLDispatchSuccessOutput 灏?url-dispatch 鎴愬姛缁撴灉杈撳嚭涓虹粨鏋勫寲 JSON銆? +func writeURLDispatchSuccessOutput(writer io.Writer, result urlscheme.DispatchResult) error { return encodeJSONLine(writer, urlDispatchSuccessOutput{ Status: "ok", ListenAddress: result.ListenAddress, @@ -567,7 +584,8 @@ type gatewayIdleShutdownController struct { }) } -// writeURLDispatchErrorOutput 灏?url-dispatch 閿欒缁撴灉杈撳嚭涓虹粨鏋勫寲 JSON銆?func writeURLDispatchErrorOutput(writer io.Writer, err error) error { +// writeURLDispatchErrorOutput 灏?url-dispatch 閿欒缁撴灉杈撳嚭涓虹粨鏋勫寲 JSON銆? +func writeURLDispatchErrorOutput(writer io.Writer, err error) error { code := "internal_error" message := err.Error() @@ -584,14 +602,15 @@ type gatewayIdleShutdownController struct { }) } -// writeURLDispatchFallbackErrorOutput 鍦ㄧ粨鏋勫寲閿欒杈撳嚭澶辫触鏃舵彁渚涘厹搴?JSON锛岄伩鍏嶅懡浠ら潤榛橀€€鍑恒€?func writeURLDispatchFallbackErrorOutput(writer io.Writer) error { +// writeURLDispatchFallbackErrorOutput 鍦ㄧ粨鏋勫寲閿欒杈撳嚭澶辫触鏃舵彁渚涘厹搴?JSON锛岄伩鍏嶅懡浠ら潤榛橀€€鍑恒€? +func writeURLDispatchFallbackErrorOutput(writer io.Writer) error { _, err := fmt.Fprintln(writer, fallbackDispatchErrorJSON) return err } -// encodeJSONLine 灏嗗璞$紪鐮佷负鍗曡 JSON锛屽苟鍐欏叆鐩爣杈撳嚭娴併€?func encodeJSONLine(writer io.Writer, payload any) error { +// encodeJSONLine 灏嗗璞$紪鐮佷负鍗曡 JSON锛屽苟鍐欏叆鐩爣杈撳嚭娴併€? +func encodeJSONLine(writer io.Writer, payload any) error { encoder := json.NewEncoder(writer) encoder.SetEscapeHTML(false) return encoder.Encode(payload) } - diff --git a/internal/gateway/protocol/docgen_generate.go b/internal/gateway/protocol/docgen_generate.go index cefb4dbf..eeae0c2f 100644 --- a/internal/gateway/protocol/docgen_generate.go +++ b/internal/gateway/protocol/docgen_generate.go @@ -1,3 +1,3 @@ package protocol -//go:generate go run ../../../scripts/generate_gateway_rpc_examples.go +//go:generate go run -tags gatewaydocgen ../../../scripts/generate_gateway_rpc_examples.go diff --git a/scripts/generate_gateway_rpc_examples.go b/scripts/generate_gateway_rpc_examples.go index 81a333f6..af1886e6 100644 --- a/scripts/generate_gateway_rpc_examples.go +++ b/scripts/generate_gateway_rpc_examples.go @@ -1,3 +1,6 @@ +//go:build gatewaydocgen +// +build gatewaydocgen + package main import ( From 083d76aaa37ab2dd29fbc7d8128ec2df2c537efd Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 11:42:25 +0000 Subject: [PATCH 03/15] fix(cli): restore UTF-8 Chinese comments in gateway commands Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/cli/gateway_commands.go | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index b579e445..252f61c2 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -107,7 +107,7 @@ func newGatewayCommand() *cobra.Command { return newGatewayServerCommand("gateway", "Start local gateway server", mustReadInheritedWorkdir) } -// NewGatewayStandaloneCommand 鍒涘缓 gateway-only 鐙珛鍏ュ彛鍛戒护锛岀‘淇濅粎鏆撮湶缃戝叧鏈嶅姟璇箟銆? +// NewGatewayStandaloneCommand 创建 gateway-only 独立入口命令,确保仅暴露网关服务语义。 func NewGatewayStandaloneCommand() *cobra.Command { standaloneWorkdir := "" command := newGatewayServerCommand("neocode-gateway", "Start NeoCode gateway-only server", func(*cobra.Command) string { @@ -117,7 +117,7 @@ func NewGatewayStandaloneCommand() *cobra.Command { return command } -// newGatewayServerCommand 鏋勫缓缃戝叧鍚姩鍛戒护锛屽苟澶嶇敤缁熶竴鍙傛暟褰掍竴鍖栦笌鎵ц璺緞銆? +// newGatewayServerCommand 构建网关启动命令,并复用统一参数归一化与执行路径。 func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command) string) *cobra.Command { options := &gatewayCommandOptions{} @@ -195,7 +195,7 @@ func newGatewayServerCommand(use, short string, readWorkdir func(*cobra.Command) return cmd } -// normalizeGatewayLogLevel 瀵圭綉鍏虫棩蹇楃骇鍒仛褰掍竴鍖栧苟鏍¢獙鍚堟硶鍊笺€? +// normalizeGatewayLogLevel 对网关日志级别做归一化并校验合法值。 func normalizeGatewayLogLevel(logLevel string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(logLevel)) switch normalized { @@ -206,7 +206,7 @@ func normalizeGatewayLogLevel(logLevel string) (string, error) { } } -// mustReadInheritedWorkdir 鍦ㄥ瓙鍛戒护涓畨鍏ㄨ鍙栫户鎵跨殑 --workdir锛岃鍙栧け璐ユ椂鍥為€€涓虹┖鍊笺€? +// mustReadInheritedWorkdir 在子命令中安全读取继承的 --workdir,读取失败时回退为空值。 func mustReadInheritedWorkdir(cmd *cobra.Command) string { if cmd == nil { return "" @@ -218,7 +218,7 @@ func mustReadInheritedWorkdir(cmd *cobra.Command) string { return workdir } -// defaultGatewayCommandRunner 浣跨敤缃戝叧鏈嶅姟楠ㄦ灦鍚姩鏈湴 IPC 鐩戝惉骞跺鐞嗕俊鍙烽€€鍑恒€? +// defaultGatewayCommandRunner 使用网关服务骨架启动本地 IPC 监听并处理中断退出。 func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOptions) error { logger := log.New(os.Stderr, "neocode-gateway: ", log.LstdFlags) logger.Printf("starting gateway (log-level=%s)", options.LogLevel) @@ -341,7 +341,7 @@ type gatewayIdleShutdownController struct { timer *time.Timer } -// newGatewayIdleShutdownController 鍒涘缓缃戝叧绌洪棽鑷€€鎺у埗鍣細杩炴帴鏁板綊闆跺悗寤惰繜閫€鍑猴紝鏈夎繛鎺ユ仮澶嶅垯鍙栨秷閫€鍑恒€? +// newGatewayIdleShutdownController 创建网关空闲退出控制器:连接归零后延迟退出,连接恢复则取消退出。 func newGatewayIdleShutdownController(logger *log.Logger, cancel context.CancelFunc) *gatewayIdleShutdownController { return &gatewayIdleShutdownController{ logger: logger, @@ -350,7 +350,7 @@ func newGatewayIdleShutdownController(logger *log.Logger, cancel context.CancelF } } -// observe 鎺ユ敹 IPC 娲昏穬杩炴帴鏁板揩鐓у苟缁存姢绌洪棽閫€鍑鸿鏃跺櫒銆? +// observe 接收 IPC 活跃连接数快照并维护空闲退出计时器。 func (c *gatewayIdleShutdownController) observe(active int) { if c == nil { return @@ -391,7 +391,7 @@ func (c *gatewayIdleShutdownController) observe(active int) { }) } -// close 閲婃斁绌洪棽閫€鍑烘帶鍒跺櫒鎸佹湁鐨勮鏃跺櫒璧勬簮銆? +// close 释放空闲退出控制器持有的计时器资源。 func (c *gatewayIdleShutdownController) close() { if c == nil { return @@ -404,7 +404,7 @@ func (c *gatewayIdleShutdownController) close() { } } -// buildGatewayControlPlaneACL 鍩轰簬閰嶇疆鏋勯€犳帶鍒堕潰 ACL 绛栫暐锛屾湭鐭ユā寮忕洿鎺ユ嫆缁濆惎鍔ㄣ€? +// buildGatewayControlPlaneACL 基于配置构造控制面 ACL 策略,未知模式直接拒绝启动。 func buildGatewayControlPlaneACL(aclMode string) (*gateway.ControlPlaneACL, error) { normalizedACLMode := strings.ToLower(strings.TrimSpace(aclMode)) if normalizedACLMode == "" { @@ -418,7 +418,7 @@ func buildGatewayControlPlaneACL(aclMode string) (*gateway.ControlPlaneACL, erro } } -// applyGatewayFlagOverrides 灏?CLI flags 瑕嗙洊鍒扮綉鍏抽厤缃紝浼樺厛绾ч珮浜?config.yaml銆? +// applyGatewayFlagOverrides 将 CLI flags 覆盖到网关配置,优先级高于 config.yaml。 func applyGatewayFlagOverrides(gatewayConfig *config.GatewayConfig, options gatewayCommandOptions) { if gatewayConfig == nil { return @@ -459,17 +459,17 @@ func applyGatewayFlagOverrides(gatewayConfig *config.GatewayConfig, options gate } } -// defaultNewGatewayServer 鍒涘缓榛樿缃戝叧鏈嶅姟瀹炰緥锛屼緵鍛戒护灞傚惎鍔ㄦ祦绋嬭皟鐢ㄣ€? +// defaultNewGatewayServer 创建默认网关服务实例,供命令层启动流程调用。 func defaultNewGatewayServer(options gateway.ServerOptions) (gatewayServer, error) { return gateway.NewServer(options) } -// defaultNewGatewayNetworkServer 鍒涘缓榛樿缃戝叧缃戠粶璁块棶闈㈡湇鍔″疄渚嬶紝渚涘懡浠ゅ眰鍚姩娴佺▼璋冪敤銆? +// defaultNewGatewayNetworkServer 创建默认网关网络访问服务实例,供命令层启动流程调用。 func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) { return gateway.NewNetworkServer(options) } -// newURLDispatchCommand 鍒涘缓 URL Scheme 娲惧彂瀛愬懡浠ら鏋讹紝浠呭仛鍙傛暟鏀舵暃涓庤皟鐢ㄨ浆鍙戙€? +// newURLDispatchCommand 创建 URL Scheme 派发子命令骨架,仅做参数收敛与调用转发。 func newURLDispatchCommand() *cobra.Command { options := &urlDispatchCommandOptions{} @@ -512,7 +512,7 @@ func newURLDispatchCommand() *cobra.Command { return cmd } -// defaultURLDispatchCommandRunner 鎵ц URL 鍞ら啋璇锋眰骞跺皢缁撴灉浠ョ粨鏋勫寲 JSON 杈撳嚭銆? +// defaultURLDispatchCommandRunner 执行 URL 唤醒请求并将结果以结构化 JSON 输出。 func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCommandOptions) error { authToken, authErr := loadAuthToken(options.TokenFile) if authErr != nil { @@ -549,7 +549,7 @@ func defaultURLDispatchCommandRunner(ctx context.Context, options urlDispatchCom return nil } -// loadGatewayAuthToken 璇诲彇闈欓粯璁よ瘉 token锛涜嫢鏂囦欢涓嶅瓨鍦ㄥ垯鍥為€€涓虹┖浠ュ吋瀹规棤閴存潈妯″紡銆? +// loadGatewayAuthToken 读取默认认证 token;若文件不存在则回退为空以兼容无鉴权模式。 func loadGatewayAuthToken(path string) (string, error) { token, err := gatewayauth.LoadTokenFromFile(path) if err == nil { @@ -564,7 +564,7 @@ func loadGatewayAuthToken(path string) (string, error) { return "", err } -// normalizeDispatchURL 瀵?url-dispatch 杈撳叆鍋氭渶灏忓綊涓€鍖栵紝璇︾粏鏍¢獙浜ょ敱 dispatcher 瀹屾垚銆? +// normalizeDispatchURL 对 url-dispatch 输入做最小归一化,详细校验交由 dispatcher 完成。 func normalizeDispatchURL(rawURL string) (string, error) { normalized := strings.TrimSpace(rawURL) if normalized == "" { @@ -573,7 +573,7 @@ func normalizeDispatchURL(rawURL string) (string, error) { return normalized, nil } -// writeURLDispatchSuccessOutput 灏?url-dispatch 鎴愬姛缁撴灉杈撳嚭涓虹粨鏋勫寲 JSON銆? +// writeURLDispatchSuccessOutput 将 url-dispatch 成功结果输出为结构化 JSON。 func writeURLDispatchSuccessOutput(writer io.Writer, result urlscheme.DispatchResult) error { return encodeJSONLine(writer, urlDispatchSuccessOutput{ Status: "ok", @@ -584,7 +584,7 @@ func writeURLDispatchSuccessOutput(writer io.Writer, result urlscheme.DispatchRe }) } -// writeURLDispatchErrorOutput 灏?url-dispatch 閿欒缁撴灉杈撳嚭涓虹粨鏋勫寲 JSON銆? +// writeURLDispatchErrorOutput 将 url-dispatch 错误结果输出为结构化 JSON。 func writeURLDispatchErrorOutput(writer io.Writer, err error) error { code := "internal_error" message := err.Error() @@ -602,13 +602,13 @@ func writeURLDispatchErrorOutput(writer io.Writer, err error) error { }) } -// writeURLDispatchFallbackErrorOutput 鍦ㄧ粨鏋勫寲閿欒杈撳嚭澶辫触鏃舵彁渚涘厹搴?JSON锛岄伩鍏嶅懡浠ら潤榛橀€€鍑恒€? +// writeURLDispatchFallbackErrorOutput 在结构化错误输出失败时提供兜底 JSON,避免命令静默退出。 func writeURLDispatchFallbackErrorOutput(writer io.Writer) error { _, err := fmt.Fprintln(writer, fallbackDispatchErrorJSON) return err } -// encodeJSONLine 灏嗗璞$紪鐮佷负鍗曡 JSON锛屽苟鍐欏叆鐩爣杈撳嚭娴併€? +// encodeJSONLine 将对象编码为单行 JSON,并写入目标输出流。 func encodeJSONLine(writer io.Writer, payload any) error { encoder := json.NewEncoder(writer) encoder.SetEscapeHTML(false) From 7885f7be4e0012370c7b9f887ea59408cea1413a Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 11:51:50 +0000 Subject: [PATCH 04/15] chore: resolve merge conflicts with main Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + Makefile | 9 +- README.md | 65 +++++ docs/guides/gateway-integration-guide.md | 310 +++++++++++++++++------ 4 files changed, 309 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d246cd97..bac60180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: go-version-file: go.mod cache: true + - name: Verify gateway docs are generated + run: make docs-gateway-check + - name: Build run: go build ./... diff --git a/Makefile b/Makefile index 42c1c63b..c7548ef1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,11 @@ -.PHONY: install-skills +.PHONY: install-skills docs-gateway docs-gateway-check install-skills: @./scripts/install_skills.sh + +docs-gateway: + @go run ./scripts/generate_gateway_rpc_examples + +docs-gateway-check: + @go run ./scripts/generate_gateway_rpc_examples + @git diff --exit-code -- docs/reference/gateway-rpc-api.md diff --git a/README.md b/README.md index 3f767e05..36c9df35 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,35 @@ bash ./scripts/install.sh --flavor gateway --dry-run irm https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.ps1 | iex ``` +Gateway 转发与自动拉起说明: + +- `neocode` 默认通过本地 Gateway(优先 IPC)转发 runtime 请求与事件流 +- 启动时会先探测本地网关;若未运行会自动后台拉起并等待就绪(无感) +- 若自动拉起后仍不可达或握手失败,会直接报错退出(Fail Fast) + +### 常用命令 + +- `/help`:查看命令帮助 +- `/provider`:打开 provider 选择器 +- `/model`:打开 model 选择器 +- `/compact`:压缩当前会话上下文 +- `/status`:查看当前会话与运行状态 +- `/cwd [path]`:查看或设置当前会话工作区 +- `/memo`:查看记忆索引 +- `/remember `:保存记忆 +- `/forget `:按关键词删除记忆 +- `/skills`:查看当前可用 skills(含当前会话激活标记) +- `/skill use `:在当前会话启用 skill +- `/skill off `:在当前会话停用 skill +- `/skill active`:查看当前会话已激活 skills + +示例输入: + +```text +请先阅读当前项目目录结构并给出模块职责摘要 +帮我在 internal/runtime 下定位与 tool result 回灌相关逻辑 +``` + 可选 flavor 与 dry-run: ```powershell @@ -99,8 +128,44 @@ irm https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/instal - [Gateway 错误字典](docs/gateway-error-catalog.md) - [Gateway 兼容性策略](docs/gateway-compatibility.md) - [配置指南](docs/guides/configuration.md) +- [扩展 Provider](docs/guides/adding-providers.md) +- [Runtime/Provider 事件流](docs/runtime-provider-event-flow.md) +- [Session 持久化设计](docs/session-persistence-design.md) +- [Context Compact 说明](docs/context-compact.md) +- [Tools 与 TUI 集成](docs/tools-and-tui-integration.md) +- [Skills 设计与使用](docs/skills-system-design.md) +- [MCP 配置指南](docs/guides/mcp-configuration.md) - [更新指南](docs/guides/update.md) +## 如何参与 + +欢迎通过 Issue 和 PR 参与共建。 + +1. 在 [Issues](https://github.com/1024XEngineer/neo-code/issues) 先沟通问题或需求。 +2. Fork 仓库并创建功能分支。 +3. 完成开发并确保改动聚焦、边界清晰。 +4. 本地自检: + ```bash + make docs-gateway-check + gofmt -w ./cmd ./internal + go test ./... + go build ./... + ``` +5. 提交 PR 到主仓库并说明变更目的、影响范围和验证方式。 + +提交前请确认: + +- 不提交明文密钥、个人配置或会话数据 +- 不提交无关改动与临时文件 + +## 在仓库内直接创建 Issue(Skills + 自动化) + +仓库提供三类同前缀 skill(位于 `.agents/skills/`): + +- `issue-rfc-proposal`(提案类,RFC 风格) +- `issue-rfc-architecture`(架构类,RFC 风格) +- `issue-rfc-implementation`(实现类,执行单风格) + ## 开发与验证 ```bash diff --git a/docs/guides/gateway-integration-guide.md b/docs/guides/gateway-integration-guide.md index 51f7d8d4..6d12d1ec 100644 --- a/docs/guides/gateway-integration-guide.md +++ b/docs/guides/gateway-integration-guide.md @@ -1,144 +1,300 @@ # Gateway 第三方接入协作指南 -本文面向第三方客户端开发者,目标是让接入方在不阅读源码的前提下完成最小接入与常见故障定位。 +本文面向接入 NeoCode Gateway 的第三方开发者,目标是让你在不阅读源码的前提下完成: + +1. 建立连接并通过认证。 +2. 发起一次完整运行并消费事件流。 +3. 在常见错误下快速定位与恢复。 ## 1. Getting Started -### 1.1 启动方式 +### 1.1 接入前提 -推荐优先使用 Gateway-Only 发布物: +- Gateway 已启动(本地 IPC 或 HTTP/WS/SSE 控制面)。 +- 已获取有效认证 Token(默认来自 `~/.neocode/auth.json`)。 +- 客户端具备 JSON-RPC 2.0 编解码能力。 -```bash -neocode-gateway --http-listen 127.0.0.1:8080 -``` +### 1.2 传输通道选择 + +| 通道 | 适用场景 | 认证方式 | 备注 | +|---|---|---|---| +| IPC | 本地桌面/CLI | `gateway.authenticate` | 延迟低,推荐本机应用 | +| `POST /rpc` | 单次调用 | `Authorization: Bearer ` | 无长连接 | +| `GET /ws` | 双向长连接 | `gateway.authenticate`(可附带 token) | 适合事件+请求双向复用 | +| `GET /sse` | 单向事件流 | `?token=` | 仅服务端推送 | + +### 1.3 最小握手流程(推荐) -兼容方式: +推荐顺序: -```bash -neocode gateway --http-listen 127.0.0.1:8080 +1. 连接 WS(或 IPC)。 +2. 调用 `gateway.authenticate`。 +3. 调用 `gateway.bindStream` 绑定 `session_id` 与可选 `run_id`。 +4. 调用 `gateway.run`,收到 `ack`。 +5. 持续消费 `gateway.event` 通知。 +6. 必要时调用 `gateway.cancel`。 + +### 1.4 示例:`gateway.authenticate` + +```json +{ + "jsonrpc": "2.0", + "id": "req-auth-1", + "method": "gateway.authenticate", + "params": { + "token": "" + } +} ``` -### 1.2 最小握手 +成功响应(示例): -1. 连接 `/rpc`、`/ws` 或 `IPC`。 -2. 发送 `gateway.authenticate`(或在 HTTP 头使用 Bearer Token)。 -3. 可选发送 `gateway.bindStream` 绑定会话流。 -4. 发送 `gateway.run`。 +```json +{ + "jsonrpc": "2.0", + "id": "req-auth-1", + "result": { + "type": "ack", + "action": "authenticate", + "request_id": "req-auth-1", + "payload": { + "message": "authenticated", + "subject_id": "local_admin" + } + } +} +``` ## 2. Message Protocol -网关控制面统一 JSON-RPC 2.0。 +### 2.1 JSON-RPC 请求/响应 -### 2.1 请求结构 +请求: ```json { "jsonrpc": "2.0", - "id": "req-1", + "id": "req-123", "method": "gateway.run", - "params": { - "session_id": "sess-1", - "input_text": "请审查 README" - } + "params": {} } ``` -### 2.2 响应结构 +成功响应: ```json { "jsonrpc": "2.0", - "id": "req-1", + "id": "req-123", "result": { "type": "ack", - "action": "gateway.run", - "request_id": "req-1" + "action": "run", + "request_id": "req-123", + "session_id": "session-1", + "run_id": "run-1", + "payload": { + "message": "run accepted" + } + } +} +``` + +错误响应: + +```json +{ + "jsonrpc": "2.0", + "id": "req-123", + "error": { + "code": -32602, + "message": "missing required field: params.run_id", + "data": { + "gateway_code": "missing_required_field" + } + } +} +``` + +### 2.2 Notification(`gateway.event`) + +网关会主动推送: + +```json +{ + "jsonrpc": "2.0", + "method": "gateway.event", + "params": { + "type": "event", + "action": "run", + "session_id": "session-1", + "run_id": "run-1", + "payload": { + "event_type": "run_progress", + "payload": { + "runtime_event_type": "agent_chunk", + "turn": 1, + "phase": "reasoning", + "timestamp": "2026-04-22T09:00:00Z", + "payload_version": 2, + "payload": "..." + } + } } } ``` -### 2.3 通知结构 +说明: -网关事件使用 `gateway.event` 通知,典型包含 run 进度、完成、错误。 +- `params` 是统一 `MessageFrame`。 +- `payload.event_type` 为网关层三态:`run_progress`、`run_done`、`run_error`。 +- 内层 `payload.payload` 为 runtime 事件 envelope,第三方可按需消费。 + +### 2.3 方法契约分层 + +稳定核心方法: + +- `gateway.authenticate` +- `gateway.ping` +- `gateway.bindStream` +- `gateway.run` +- `gateway.compact` +- `gateway.cancel` +- `gateway.listSessions` +- `gateway.loadSession` +- `gateway.resolvePermission` +- `gateway.event` + +实验扩展: + +- `wake.openUrl` + +### 2.4 参数约束(高频) + +- `gateway.cancel`:`params.run_id` 必填。 +- `gateway.bindStream`:`params.session_id` 必填,`channel` 允许 `all|ipc|ws|sse`。 +- `gateway.run`:`input_text` 或 `input_parts` 至少一个非空。 +- 参数解析为严格模式,未知字段会触发参数错误。 ## 3. Status Codes -接入方应同时处理三层状态: +### 3.1 三层错误信号 + +1. HTTP 状态码(仅网络入口可见)。 +2. JSON-RPC `error.code`(标准码)。 +3. `error.data.gateway_code`(网关稳定语义码)。 -1. HTTP 状态(如 401)。 -2. JSON-RPC `error.code`(如 `-32602`)。 -3. 网关稳定码 `error.data.gateway_code`(如 `unauthorized`)。 +### 3.2 HTTP 映射 -建议以 `gateway_code` 作为应用层分支主键。 +| 场景 | HTTP 状态 | +|---|---| +| `unauthorized` | `401` | +| `access_denied` | `403` | +| 其他业务错误 | `200`(错误在 JSON-RPC `error` 中) | + +### 3.3 稳定 `gateway_code` + +| gateway_code | 含义 | 建议动作 | +|---|---|---| +| `invalid_frame` | 协议帧非法/JSON 解析失败 | 修正请求格式 | +| `invalid_action` | 方法参数非法或动作无效 | 修正参数值 | +| `invalid_multimodal_payload` | 多模态输入格式不合法 | 修正 `input_parts` | +| `missing_required_field` | 缺失必填字段 | 补齐字段 | +| `unsupported_action` | 方法未实现或不支持 | 升级客户端或降级调用 | +| `internal_error` | 网关内部错误 | 重试并记录 request_id | +| `timeout` | 网关下游调用超时 | 指数退避重试 | +| `unauthorized` | 认证失败 | 刷新 token 并重新认证 | +| `access_denied` | ACL 或资源权限拒绝 | 检查方法权限 | +| `resource_not_found` | 目标会话或运行不存在 | 校验 `session_id/run_id` | ## 4. Client Best Practices -1. MUST 实现断线重连,并在重连后重新认证。 -2. SHOULD 对幂等请求使用客户端 request id,便于重试去重。 -3. MUST 对 `gateway.run` 与流事件建立会话/运行关联键(`session_id` + `run_id`)。 -4. SHOULD 对瞬时错误做指数退避重试;对鉴权/参数错误直接失败。 -5. SHOULD 维护心跳超时策略,及时回收失活连接。 +### 4.1 连接与重连 + +- 客户端 `SHOULD` 使用指数退避重连(如 200ms 起步,逐步扩展到 3s 上限)。 +- 每次重连成功后 `MUST` 重新 `authenticate`,并按需重新 `bindStream`。 +- 对于长连接,`SHOULD` 周期性调用 `gateway.ping` 维持活性并刷新绑定。 + +### 4.2 请求幂等与关联 + +- `id` `MUST` 全局唯一(至少在进程生命周期内)。 +- `run_id` `SHOULD` 由客户端生成并持久化,便于取消与追踪。 +- 日志中 `SHOULD` 记录 `request_id/session_id/run_id` 三元组。 + +### 4.3 超时与重试 + +- `gateway.run` 为异步受理,不应等待“最终模型输出”才算成功。 +- 对 `ping/auth/bindStream/list/load` 这类短调用,`SHOULD` 采用较短超时并允许小次数重试。 +- 对 `timeout` 或网络传输错误,`MAY` 重试;对 `invalid_*` 或 `missing_required_field`,不应盲目重试。 + +### 4.4 事件消费 + +- 客户端 `MUST` 区分响应与通知,不能把 `gateway.event` 当作请求响应。 +- 事件消费协程 `SHOULD` 与请求协程解耦,避免背压阻塞导致断连。 + +### 4.5 安全建议 + +- Token 只放内存与受控安全存储,不应写入日志。 +- 浏览器场景需遵循 Origin 白名单策略,避免跨站调用失败。 ## 5. Failure Playbook -### 5.1 连接失败 +### 5.1 连接失败(dial refused / no such pipe) -现象:dial 失败或 `gateway_unreachable`。 -处理:优先检查网关进程、监听地址、权限与本机防火墙。 +排查步骤: -### 5.2 认证失败 +1. 确认 Gateway 进程是否在运行。 +2. 确认地址配置是否与 Gateway 监听地址一致。 +3. 若客户端启用自动拉起,检查自动拉起日志与探测窗口是否超时。 -现象:HTTP `401` 或 `gateway_code=unauthorized`。 -处理:检查 token 文件、Bearer 头格式、token 是否过期/错配。 +恢复动作: -### 5.3 参数错误 +- 执行指数退避重连;重连后重新认证与绑定。 -现象:`gateway_code=missing_required_field` 或 `invalid_action`。 -处理:按 API 文档逐项校验 `params` 必填字段与枚举值。 +### 5.2 认证失败(`unauthorized`) -### 5.4 超时与取消 +排查步骤: -现象:`gateway_code=timeout` 或运行长时间无事件。 -处理:客户端触发 `gateway.cancel`,并按 run_id 做状态回收。 +1. 检查 Token 来源是否正确(是否读取了期望的 token 文件)。 +2. 校验 Header 或 query 参数是否丢失或污染空格。 -## 6. 部署拓扑建议 +恢复动作: -1. 本地内嵌网关:`neocode gateway`,适合单机开发工作流。 -2. 独立网关服务:`neocode-gateway`,适合第三方客户端统一接入与独立运维。 +- 刷新 token,重新执行 `gateway.authenticate`。 -建议: +### 5.3 参数错误(`missing_required_field` / `invalid_action`) -1. 默认仅监听回环地址。 -2. 对外暴露时显式配置监听地址与访问边界,不应直接公网裸露。 +排查步骤: -## 7. 最小鉴权 / ACL 模板 +1. 对照方法契约检查必填字段。 +2. 检查是否发送了未定义字段(严格解码会拒绝)。 -最小安全基线(示意): +恢复动作: -```yaml -gateway: - security: - acl_mode: strict - # token_file 默认为 ~/.neocode/auth.json - network: - listen: 127.0.0.1:8080 -``` +- 修正请求体,不要直接重试同一无效请求。 + +### 5.4 超时(`timeout`) + +排查步骤: + +1. 区分是网络超时还是 gateway 下游调用超时。 +2. 检查当前并发、请求体大小与运行时负载。 + +恢复动作: -接入方 MUST 满足: +- 降低并发、启用退避重试、增加调用级超时预算。 -1. 通过 `gateway.authenticate` 或 Bearer Token 建立认证态。 -2. 对 `unauthorized` 与 `access_denied` 做明确分支处理。 +### 5.5 连接重置后的恢复 -## 8. 升级与回滚步骤 +恢复顺序(必须按序): -升级后验收: +1. 重建连接。 +2. 重新认证。 +3. 重新绑定流(`gateway.bindStream`)。 +4. 根据业务状态决定是否补发请求或仅恢复事件订阅。 -1. `GET /healthz` 返回成功。 -2. `/rpc` 未鉴权请求返回 `unauthorized`(用于验证鉴权链路)。 -3. `gateway.run` 最小链路可达。 +--- -回滚步骤: +协作约定: -1. 停止当前网关进程。 -2. 切回上一版已验证二进制。 -3. 重复上述验收步骤。 +- 当你接入的是“稳定核心方法 + 稳定错误码”,网关小版本升级不应破坏基础兼容性。 +- 当你使用实验扩展(如 `wake.openUrl`),应预留版本探测与降级处理逻辑。 From 06aa0f7c98fe68ba6f2471147c4c449caa435631 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 12:15:20 +0000 Subject: [PATCH 05/15] fix(makefile): align gateway docs check with generated artifact Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index c7548ef1..3b5260e6 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,8 @@ install-skills: @./scripts/install_skills.sh docs-gateway: - @go run ./scripts/generate_gateway_rpc_examples + @go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go docs-gateway-check: - @go run ./scripts/generate_gateway_rpc_examples - @git diff --exit-code -- docs/reference/gateway-rpc-api.md + @go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go + @git diff --exit-code -- docs/generated/gateway-rpc-examples.json From 222f2df5019a8358335e77b63022b8f9dda15d15 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 12:30:24 +0000 Subject: [PATCH 06/15] fix(conflict): resolve Makefile merge conflict with main Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- Makefile | 6 +++--- scripts/generate_gateway_rpc_examples/main.go | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 scripts/generate_gateway_rpc_examples/main.go diff --git a/Makefile b/Makefile index 3b5260e6..c7548ef1 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,8 @@ install-skills: @./scripts/install_skills.sh docs-gateway: - @go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go + @go run ./scripts/generate_gateway_rpc_examples docs-gateway-check: - @go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go - @git diff --exit-code -- docs/generated/gateway-rpc-examples.json + @go run ./scripts/generate_gateway_rpc_examples + @git diff --exit-code -- docs/reference/gateway-rpc-api.md diff --git a/scripts/generate_gateway_rpc_examples/main.go b/scripts/generate_gateway_rpc_examples/main.go new file mode 100644 index 00000000..2d5e75ba --- /dev/null +++ b/scripts/generate_gateway_rpc_examples/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "os" + "os/exec" +) + +// main 作为兼容入口转调带 gatewaydocgen 标签的生成器,保持旧命令路径可用。 +func main() { + cmd := exec.Command("go", "run", "-tags", "gatewaydocgen", "./scripts/generate_gateway_rpc_examples.go") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "run gateway rpc example generator: %v\n", err) + os.Exit(1) + } +} From d5633b7c8d9a7a4ef5032becf5701b5b2a8dd0c5 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 12:32:07 +0000 Subject: [PATCH 07/15] fix(conflict): align gateway doc generator path with origin main Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- docs/reference/gateway-rpc-api.md | 1332 +++++++++++++++++ scripts/generate_gateway_rpc_examples/main.go | 554 ++++++- 2 files changed, 1878 insertions(+), 8 deletions(-) create mode 100644 docs/reference/gateway-rpc-api.md diff --git a/docs/reference/gateway-rpc-api.md b/docs/reference/gateway-rpc-api.md new file mode 100644 index 00000000..6082aaee --- /dev/null +++ b/docs/reference/gateway-rpc-api.md @@ -0,0 +1,1332 @@ +# Gateway RPC API + +本文档定义 Gateway 对外 JSON-RPC 协议契约,面向第三方客户端实现者。 +术语遵循 RFC 规范语义:`MUST`、`SHOULD`、`MAY`。 + +## 0. 通用协议基线 + +### 0.1 统一请求信封 + +```go +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` // 固定 "2.0" + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} +``` + +规范约束: + +1. `jsonrpc` `MUST` 为 `"2.0"`。 +2. `id` `MUST` 提供,且不可为 `null` 或空字符串。 +3. `params` `MUST` 通过严格解码(`DisallowUnknownFields`),未知字段会触发错误。 + +### 0.2 统一响应信封 + +```go +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result,omitempty"` // 成功时为 MessageFrame + Error *JSONRPCError `json:"error,omitempty"` // 失败时为 JSON-RPC Error +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data *JSONRPCErrorData `json:"data,omitempty"` +} + +type JSONRPCErrorData struct { + GatewayCode string `json:"gateway_code,omitempty"` +} +``` + +### 0.3 MessageFrame(Result 载荷) + +```go +type MessageFrame struct { + Type FrameType `json:"type"` // ack / event / error + Action FrameAction `json:"action,omitempty"` + RequestID string `json:"request_id,omitempty"` + RunID string `json:"run_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + InputText string `json:"input_text,omitempty"` + InputParts []InputPart `json:"input_parts,omitempty"` + Workdir string `json:"workdir,omitempty"` + Payload any `json:"payload,omitempty"` + Error *FrameError `json:"error,omitempty"` +} +``` + +### 0.4 HTTP 与 JSON-RPC 映射 + +1. `/rpc` 默认 `HTTP 200`,错误通过 JSON-RPC `error` 返回。 +2. `/rpc` 仅当 `gateway_code=unauthorized` 返回 `HTTP 401`。 +3. `/rpc` 仅当 `gateway_code=access_denied` 返回 `HTTP 403`。 + +### 0.5 观测基线 + +Observation(通用): + +1. 每个请求都会计入 `gateway_requests_total{source,method,status}`。 +2. 认证失败会计入 `gateway_auth_failures_total{source,reason}`。 +3. ACL 拒绝会计入 `gateway_acl_denied_total{source,method}`。 +4. 请求日志包含 `request_id/session_id/method/source/status/gateway_code/latency_ms`。 +5. `gateway.ping` 成功日志默认静默,失败仍记录。 + +--- + +## 1. gateway.authenticate + +Method: `gateway.authenticate` +Stability: `Stable` +Auth Required: `No` + +Request Schema(JSON Schema): + +```json +{ + "type": "object", + "required": ["jsonrpc", "id", "method", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "type": ["string", "number"] }, + "method": { "const": "gateway.authenticate" }, + "params": { + "type": "object", + "required": ["token"], + "additionalProperties": false, + "properties": { + "token": { "type": "string", "minLength": 1 } + } + } + }, + "additionalProperties": false +} +``` + +Response Schema: + +1. Success(完整 payload): + +```json +{ + "jsonrpc": "2.0", + "id": "req-1", + "result": { + "type": "ack", + "action": "authenticate", + "request_id": "req-1", + "payload": { + "message": "authenticated", + "subject_id": "local_admin" + } + } +} +``` + +2. Failure(完整 payload): + +```json +{ + "jsonrpc": "2.0", + "id": "req-1", + "error": { + "code": -32602, + "message": "missing required field: params.token", + "data": { + "gateway_code": "missing_required_field" + } + } +} +``` + +Observation: + +1. 失败时会增长 `gateway_auth_failures_total`。 +2. 请求日志会记录认证态(`auth_state`)变化。 + +--- + +## 2. gateway.ping + +Method: `gateway.ping` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema(Go Struct): + +```go +type PingParams struct{} +``` + +Response Schema: + +1. Success: + +```json +{ + "jsonrpc": "2.0", + "id": "req-2", + "result": { + "type": "ack", + "action": "ping", + "request_id": "req-2", + "payload": { + "message": "pong", + "version": "dev" + } + } +} +``` + +2. Failure:统一 `error` 信封。 + +Observation: + +1. 成功 `ping` 默认不输出请求日志(降噪)。 +2. 对已绑定连接,`ping` 会刷新绑定 TTL(续期)。 + +--- + +## 3. gateway.bindStream + +Method: `gateway.bindStream` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema(Go Struct): + +```go +type BindStreamParams struct { + SessionID string `json:"session_id"` // MUST + RunID string `json:"run_id,omitempty"` // MAY + Channel string `json:"channel,omitempty"` // all|ipc|ws|sse,默认 all +} +``` + +请求约束: + +1. `session_id` `MUST` 非空。 +2. `channel` `MUST` 属于 `all|ipc|ws|sse`。 +3. 若 `channel != all`,其值 `MUST` 与连接自身通道一致,否则返回 `invalid_action`。 +4. 单连接绑定数上限为 `128`,超过上限返回 `invalid_action`。 + +Response Schema: + +1. Success(完整 payload): + +```json +{ + "jsonrpc": "2.0", + "id": "req-3", + "result": { + "type": "ack", + "action": "bind_stream", + "request_id": "req-3", + "session_id": "session-1", + "run_id": "run-1", + "payload": { + "message": "stream binding updated", + "channel": "all" + } + } +} +``` + +2. Failure(完整 payload): + +```json +{ + "jsonrpc": "2.0", + "id": "req-3", + "error": { + "code": -32602, + "message": "invalid bind_stream channel", + "data": { + "gateway_code": "invalid_action" + } + } +} +``` + +### 双向交互细节(重点) + +1. 客户端调用 `gateway.bindStream` 后,先收到 `ack(bind_stream)`。 +2. 后续 runtime 事件通过 `gateway.event` 反向推送,不再通过 `gateway.bindStream` 响应体承载。 +3. 绑定匹配规则(运行事件): +1. 当事件 `run_id` 非空时:`run_id` 精确绑定可收到;`run_id` 为空的 session 级绑定也可收到。 +2. 当事件 `run_id` 为空时:仅 session 级绑定(`run_id=""`)可收到。 +4. 绑定 TTL 为 `15m`,清理周期为 `30s`。客户端 `SHOULD` 周期调用 `gateway.ping` 续期。 +5. 自动绑定生效规则: +1. 除 `authenticate`、`bindStream`、`ping` 外,其它请求若携带 `session_id/run_id`,网关会自动续绑。 +2. 自动续绑不替代显式绑定;第三方客户端仍 `SHOULD` 在重连后主动执行 `bindStream`。 + +### 交互时序 + +```mermaid +sequenceDiagram + participant C as Client + participant G as Gateway + participant R as Runtime + + C->>G: gateway.bindStream(session_id, run_id?, channel?) + G-->>C: ack(bind_stream) + C->>G: gateway.run(...) + G-->>C: ack(run accepted) + R-->>G: runtime event + G-->>C: gateway.event(notification) +``` + +Observation: + +1. `gateway_requests_total{method="gateway.bindStream",status="ok|error"}`。 +2. `gateway_connections_active{channel}` 反映连接活跃数。 +3. 慢消费者导致队列满时会增加 `gateway_stream_dropped_total{reason="queue_full"}`。 + +--- + +## 4. gateway.run + +Method: `gateway.run` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema(Go Struct): + +```go +type RunParams struct { + SessionID string `json:"session_id,omitempty"` // 推荐显式传递 + RunID string `json:"run_id,omitempty"` // 可选 + InputText string `json:"input_text,omitempty"` // 与 input_parts 至少一个非空 + InputParts []RunInputPart `json:"input_parts,omitempty"` // text|image + Workdir string `json:"workdir,omitempty"` // 请求级工作目录覆盖 +} + +type RunInputPart struct { + Type string `json:"type"` // text|image + Text string `json:"text,omitempty"` // text MUST + Media *RunInputMedia `json:"media,omitempty"` // image MUST +} +``` + +请求约束: + +1. `input_text` 与 `input_parts` 至少一项非空。 +2. `input_parts` 中: +1. `type=text` 时 `text` `MUST` 非空。 +2. `type=image` 时 `media.uri` 与 `media.mime_type` `MUST` 非空。 +3. 未知字段会因严格解码触发 `invalid_frame`。 +4. `run_id` 归一化顺序为:显式 `run_id` > `request_id` > 网关生成 `run_`。 + +Response Schema: + +1. Success(完整 payload,表示“受理成功”而非“执行完成”): + +```json +{ + "jsonrpc": "2.0", + "id": "req-4", + "result": { + "type": "ack", + "action": "run", + "request_id": "req-4", + "session_id": "session-1", + "run_id": "run-1", + "payload": { + "message": "run accepted" + } + } +} +``` + +2. Failure(完整 payload,发生在受理前校验或授权阶段): + +```json +{ + "jsonrpc": "2.0", + "id": "req-4", + "error": { + "code": -32602, + "message": "input_parts[image] requires media.uri", + "data": { + "gateway_code": "invalid_multimodal_payload" + } + } +} +``` + +### 双向交互细节(重点) + +1. `gateway.run` 采用“异步受理”模型:网关先返回 `ack(run accepted)`。 +2. 运行结果通过 `gateway.event` 持续回流,客户端 `MUST` 以事件流判断最终状态。 +3. 事件类型映射: +1. `run_progress`:常规过程事件。 +2. `run_done`:运行完成。 +3. `run_error`:运行失败或取消。 +4. `gateway.event.params.payload.payload` 载荷中包含 runtime envelope: +1. `runtime_event_type` +2. `turn` +3. `phase` +4. `timestamp` +5. `payload_version`(当前为 `2`) +6. `payload` +5. HTTP 来源的 `run` 会在网关内部脱离请求取消(`context.WithoutCancel`),避免客户端短连接中断导致任务误取消。 +6. `ack` 后若 runtime 立即失败,网关仅记录日志(`gateway run async failed`),不会再补发同步 JSON-RPC error。客户端 `SHOULD` 为 run 设置完成超时并结合 `gateway.event` 或 `gateway.cancel` 做兜底。 +7. 客户端中断执行时,调用 `gateway.cancel`(按 `run_id` 精确取消)。 + +### 交互时序 + +```mermaid +sequenceDiagram + participant C as Client + participant G as Gateway + participant R as Runtime + + C->>G: gateway.run(session_id, run_id?, input_text/input_parts) + G-->>C: ack(run accepted) + R-->>G: runtime event(progress...) + G-->>C: gateway.event(run_progress) + R-->>G: runtime event(done|error) + G-->>C: gateway.event(run_done|run_error) + C->>G: gateway.cancel(run_id) (optional) + G-->>C: ack(cancel) +``` + +Observation: + +1. `gateway_requests_total{method="gateway.run",status="ok|error"}`。 +2. 请求日志记录 `latency_ms` 与 `gateway_code`。 +3. 异步阶段异常会写日志:`gateway run async failed`。 + +--- + +## 5. gateway.compact + +Method: `gateway.compact` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema(Go Struct): + +```go +type CompactParams struct { + SessionID string `json:"session_id"` // MUST + RunID string `json:"run_id,omitempty"` // MAY +} +``` + +Response Schema: + +1. Success: + +```json +{ + "jsonrpc": "2.0", + "id": "req-5", + "result": { + "type": "ack", + "action": "compact", + "request_id": "req-5", + "session_id": "session-1", + "payload": { + "applied": true, + "before_chars": 12345, + "after_chars": 4567, + "saved_ratio": 0.63, + "trigger_mode": "manual", + "transcript_id": "compact-1", + "transcript_path": ".neocode/transcripts/compact-1.md" + } + } +} +``` + +2. Failure:统一 `error` 信封,超时通常为 `gateway_code=timeout`。 + +Observation: + +1. `gateway_requests_total` 会记录 compact 成败。 +2. runtime 超时会写入结构化错误日志。 + +--- + +## 6. gateway.cancel + +Method: `gateway.cancel` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema(Go Struct): + +```go +type CancelParams struct { + SessionID string `json:"session_id,omitempty"` + RunID string `json:"run_id,omitempty"` // MUST +} +``` + +Response Schema: + +1. Success: + +```json +{ + "jsonrpc": "2.0", + "id": "req-6", + "result": { + "type": "ack", + "action": "cancel", + "request_id": "req-6", + "payload": { + "canceled": true, + "run_id": "run-1" + } + } +} +``` + +2. Failure:统一 `error` 信封,常见 `missing_required_field` 或 `resource_not_found`。 + +Observation: + +1. 未命中运行目标常由 runtime 桥接映射为 `resource_not_found`。 +2. 调用行为会被请求日志完整记录。 + +--- + +## 7. gateway.listSessions + +Method: `gateway.listSessions` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema(JSON Schema): + +```json +{ + "type": "object", + "required": ["jsonrpc", "id", "method"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "type": ["string", "number"] }, + "method": { "const": "gateway.listSessions" } + }, + "additionalProperties": false +} +``` + +Response Schema: + +1. Success: + +```json +{ + "jsonrpc": "2.0", + "id": "req-7", + "result": { + "type": "ack", + "action": "list_sessions", + "request_id": "req-7", + "payload": { + "sessions": [ + { + "id": "session-1", + "title": "debug http gateway", + "created_at": "2026-04-22T09:00:00Z", + "updated_at": "2026-04-22T09:30:00Z" + } + ] + } + } +} +``` + +2. Failure:统一 `error` 信封。 + +Observation: + +1. 仅有请求级指标与日志,无流式副作用。 + +--- + +## 8. gateway.loadSession + +Method: `gateway.loadSession` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema(Go Struct): + +```go +type LoadSessionParams struct { + SessionID string `json:"session_id"` // MUST +} +``` + +Response Schema: + +1. Success: + +```json +{ + "jsonrpc": "2.0", + "id": "req-8", + "result": { + "type": "ack", + "action": "load_session", + "request_id": "req-8", + "session_id": "session-1", + "payload": { + "id": "session-1", + "title": "debug http gateway", + "created_at": "2026-04-22T09:00:00Z", + "updated_at": "2026-04-22T09:30:00Z", + "workdir": "C:/repo", + "messages": [] + } + } +} +``` + +2. Failure:统一 `error` 信封,常见 `missing_required_field/access_denied`。 + +Observation: + +1. 当前默认桥接实现可能在会话不存在时自动创建会话。 +2. 第三方客户端 `SHOULD` 以响应内容为准,不应假设 “不存在必报错”。 + +--- + +## 9. gateway.resolvePermission + +Method: `gateway.resolvePermission` +Stability: `Stable` +Auth Required: `Yes` + +Request Schema(Go Struct): + +```go +type ResolvePermissionParams struct { + RequestID string `json:"request_id"` // MUST + Decision string `json:"decision"` // allow_once|allow_session|reject +} +``` + +Response Schema: + +1. Success: + +```json +{ + "jsonrpc": "2.0", + "id": "req-9", + "result": { + "type": "ack", + "action": "resolve_permission", + "request_id": "req-9", + "payload": { + "request_id": "perm-1", + "decision": "allow_once", + "message": "permission resolved" + } + } +} +``` + +2. Failure:统一 `error` 信封,常见 `missing_required_field/invalid_action`。 + +Observation: + +1. 建议业务侧按 `request_id` 建立审批链路追踪。 + +--- + +## 10. wake.openUrl + +Method: `wake.openUrl` +Stability: `Experimental` +Auth Required: `Yes` + +Request Schema(Go Struct): + +```go +type WakeIntent struct { + Action string `json:"action"` // 当前主要为 review + SessionID string `json:"session_id,omitempty"` + Workdir string `json:"workdir,omitempty"` + Params map[string]string `json:"params,omitempty"` + RawURL string `json:"raw_url,omitempty"` +} +``` + +Response Schema: + +1. Success: + +```json +{ + "jsonrpc": "2.0", + "id": "req-10", + "result": { + "type": "ack", + "action": "wake.openUrl", + "request_id": "req-10", + "session_id": "session-1", + "payload": { + "message": "wake intent accepted", + "action": "review", + "params": { + "path": "README.md" + } + } + } +} +``` + +2. Failure:统一 `error` 信封。实验能力建议调用方具备降级逻辑。 + +Observation: + +1. 与稳定方法共享同一套指标与日志链路。 + +--- + +## 11. gateway.event(服务端通知) + +Method: `gateway.event` +Stability: `Stable` +Auth Required: `Connection-Scoped Yes` + +Request Schema: `N/A`(客户端不可主动调用) + +Response Schema(通知完整 payload): + +```json +{ + "jsonrpc": "2.0", + "method": "gateway.event", + "params": { + "type": "event", + "action": "run", + "session_id": "session-1", + "run_id": "run-1", + "payload": { + "event_type": "run_progress|run_done|run_error", + "payload": { + "runtime_event_type": "agent_chunk|agent_done|error|...", + "turn": 3, + "phase": "reasoning", + "timestamp": "2026-04-22T09:01:02.123456789Z", + "payload_version": 2, + "payload": {} + } + } + } +} +``` + +Observation: + +1. 连接活跃数通过 `gateway_connections_active{channel}` 观测。 +2. 事件丢弃通过 `gateway_stream_dropped_total{reason}` 观测。 + +--- + +## 附录:客户端实现要点 + +1. 重连后固定执行:`authenticate -> bindStream -> ping(保活)`。 +2. 对 `gateway.run` 必须使用“异步受理”心智:`ack` 仅表示入队受理。 +3. 异常处理应优先读取 `error.data.gateway_code`,再读取 `message`。 +4. 事件驱动端建议对每个 `run_id` 建立完成超时,防止 ACK 后无终态事件造成悬挂。 + +--- + +## 附录:结构体自动生成 JSON 示例 + +本附录由脚本自动生成,确保示例与 Go 结构体一致。 +生成命令:`go run ./scripts/generate_gateway_rpc_examples` + + +> 以下 JSON 示例由 `go run ./scripts/generate_gateway_rpc_examples` 自动生成。 +> 如结构体或字段标签发生变更,请重新执行生成命令。 + +### gateway.authenticate + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-auth-1", + "method": "gateway.authenticate", + "params": { + "token": "\u003cTOKEN\u003e" + } +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-auth-1", + "result": { + "type": "ack", + "action": "authenticate", + "request_id": "req-auth-1", + "payload": { + "message": "authenticated", + "subject_id": "local_admin" + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-auth-1", + "error": { + "code": -32602, + "message": "invalid auth token", + "data": { + "gateway_code": "unauthorized" + } + } +} +``` + +### gateway.ping + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-ping-1", + "method": "gateway.ping", + "params": {} +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-ping-1", + "result": { + "type": "ack", + "action": "ping", + "request_id": "req-ping-1", + "payload": { + "message": "pong", + "version": "dev" + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-ping-1", + "error": { + "code": -32602, + "message": "unauthorized", + "data": { + "gateway_code": "unauthorized" + } + } +} +``` + +### gateway.bindStream + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-bind-1", + "method": "gateway.bindStream", + "params": { + "session_id": "session-demo-1", + "run_id": "run-demo-1", + "channel": "all" + } +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-bind-1", + "result": { + "type": "ack", + "action": "bind_stream", + "request_id": "req-bind-1", + "run_id": "run-demo-1", + "session_id": "session-demo-1", + "payload": { + "channel": "all", + "message": "stream binding updated" + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-bind-1", + "error": { + "code": -32602, + "message": "invalid bind_stream channel", + "data": { + "gateway_code": "invalid_action" + } + } +} +``` + +Notes: + +1. `bindStream` 仅建立订阅绑定,后续运行事件通过 `gateway.event` 推送。 + +### gateway.run + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-run-1", + "method": "gateway.run", + "params": { + "session_id": "session-demo-1", + "run_id": "run-demo-1", + "input_text": "请分析这段代码并给出改进建议", + "input_parts": [ + { + "type": "text", + "text": "补充一段上下文描述" + }, + { + "type": "image", + "media": { + "uri": "file:///tmp/screenshot.png", + "mime_type": "image/png", + "file_name": "screenshot.png" + } + } + ], + "workdir": "C:/workspace/demo" + } +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-run-1", + "result": { + "type": "ack", + "action": "run", + "request_id": "req-run-1", + "run_id": "run-demo-1", + "session_id": "session-demo-1", + "payload": { + "message": "run accepted" + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-run-1", + "error": { + "code": -32602, + "message": "input_parts[image] requires media.uri", + "data": { + "gateway_code": "invalid_multimodal_payload" + } + } +} +``` + +Notes: + +1. `Success Response` 只代表受理成功,不代表运行完成。 +2. 运行完成或失败需要通过 `gateway.event` 的 `run_done/run_error` 判断。 + +### gateway.compact + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-compact-1", + "method": "gateway.compact", + "params": { + "session_id": "session-demo-1", + "run_id": "run-demo-1" + } +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-compact-1", + "result": { + "type": "ack", + "action": "compact", + "request_id": "req-compact-1", + "run_id": "run-demo-1", + "session_id": "session-demo-1", + "payload": { + "Applied": true, + "BeforeChars": 12345, + "AfterChars": 4567, + "SavedRatio": 0.63, + "TriggerMode": "manual", + "TranscriptID": "compact-demo-1", + "TranscriptPath": ".neocode/transcripts/compact-demo-1.md" + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-compact-1", + "error": { + "code": -32603, + "message": "compact timed out", + "data": { + "gateway_code": "timeout" + } + } +} +``` + +### gateway.cancel + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-cancel-1", + "method": "gateway.cancel", + "params": { + "session_id": "session-demo-1", + "run_id": "run-demo-1" + } +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-cancel-1", + "result": { + "type": "ack", + "action": "cancel", + "request_id": "req-cancel-1", + "payload": { + "canceled": true, + "run_id": "run-demo-1" + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-cancel-1", + "error": { + "code": -32602, + "message": "cancel target not found", + "data": { + "gateway_code": "resource_not_found" + } + } +} +``` + +### gateway.listSessions + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-list-1", + "method": "gateway.listSessions" +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-list-1", + "result": { + "type": "ack", + "action": "list_sessions", + "request_id": "req-list-1", + "payload": { + "sessions": [ + { + "id": "session-demo-1", + "title": "gateway 文档联调", + "created_at": "2026-04-22T09:00:00Z", + "updated_at": "2026-04-22T09:10:00Z" + } + ] + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-list-1", + "error": { + "code": -32602, + "message": "unauthorized", + "data": { + "gateway_code": "unauthorized" + } + } +} +``` + +### gateway.loadSession + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-load-1", + "method": "gateway.loadSession", + "params": { + "session_id": "session-demo-1" + } +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-load-1", + "result": { + "type": "ack", + "action": "load_session", + "request_id": "req-load-1", + "session_id": "session-demo-1", + "payload": { + "id": "session-demo-1", + "title": "gateway 文档联调", + "created_at": "2026-04-22T09:00:00Z", + "updated_at": "2026-04-22T09:10:00Z", + "workdir": "C:/workspace/demo" + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-load-1", + "error": { + "code": -32602, + "message": "load_session access denied", + "data": { + "gateway_code": "access_denied" + } + } +} +``` + +### gateway.resolvePermission + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-permission-1", + "method": "gateway.resolvePermission", + "params": { + "request_id": "perm-request-1", + "decision": "allow_once" + } +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-permission-1", + "result": { + "type": "ack", + "action": "resolve_permission", + "request_id": "req-permission-1", + "payload": { + "decision": "allow_once", + "message": "permission resolved", + "request_id": "perm-request-1" + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-permission-1", + "error": { + "code": -32602, + "message": "invalid resolve_permission decision", + "data": { + "gateway_code": "invalid_action" + } + } +} +``` + +### wake.openUrl + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-wake-1", + "method": "wake.openUrl", + "params": { + "action": "review", + "session_id": "session-demo-1", + "workdir": "C:/workspace/demo", + "params": { + "path": "README.md" + }, + "raw_url": "neocode://review?path=README.md" + } +} +``` + +Success Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-wake-1", + "result": { + "type": "ack", + "action": "wake.openUrl", + "request_id": "req-wake-1", + "session_id": "session-demo-1", + "payload": { + "action": "review", + "message": "wake intent accepted", + "params": { + "path": "README.md" + } + } + } +} +``` + +Failure Response: + +```json +{ + "jsonrpc": "2.0", + "id": "req-wake-1", + "error": { + "code": -32602, + "message": "missing required field: params.path", + "data": { + "gateway_code": "missing_required_field" + } + } +} +``` + +### gateway.event + +Notification: + +```json +{ + "jsonrpc": "2.0", + "method": "gateway.event", + "params": { + "type": "event", + "action": "run", + "run_id": "run-demo-1", + "session_id": "session-demo-1", + "payload": { + "event_type": "run_progress", + "payload": { + "payload": { + "delta": "正在分析请求..." + }, + "payload_version": 4, + "phase": "reasoning", + "runtime_event_type": "agent_chunk", + "timestamp": "2026-04-22T09:01:02.123456789Z", + "turn": 3 + } + } + } +} +``` + diff --git a/scripts/generate_gateway_rpc_examples/main.go b/scripts/generate_gateway_rpc_examples/main.go index 2d5e75ba..df359817 100644 --- a/scripts/generate_gateway_rpc_examples/main.go +++ b/scripts/generate_gateway_rpc_examples/main.go @@ -1,18 +1,556 @@ package main import ( + "encoding/json" "fmt" "os" - "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "neo-code/internal/gateway" + "neo-code/internal/gateway/protocol" + "neo-code/internal/runtime/controlplane" +) + +const ( + // targetDocPath 表示需要回填自动生成示例的目标文档路径。 + targetDocPath = "docs/reference/gateway-rpc-api.md" + // beginMarker 标记自动生成区块起始位置。 + beginMarker = "" + // endMarker 标记自动生成区块结束位置。 + endMarker = "" ) -// main 作为兼容入口转调带 gatewaydocgen 标签的生成器,保持旧命令路径可用。 +// methodExample 描述单个方法在文档中的自动生成示例片段。 +type methodExample struct { + Method string + Request any + Success any + Failure any + Notification any + Notes []string +} + +// main 生成 Gateway JSON 示例并回填至 API 文档标记区块。 func main() { - cmd := exec.Command("go", "run", "-tags", "gatewaydocgen", "./scripts/generate_gateway_rpc_examples.go") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "run gateway rpc example generator: %v\n", err) - os.Exit(1) + documentPath := filepath.Clean(targetDocPath) + originalContent, err := os.ReadFile(documentPath) + if err != nil { + exitWithError("读取 API 文档失败", err) + } + + generatedBlock, err := renderGeneratedBlock() + if err != nil { + exitWithError("渲染自动生成区块失败", err) + } + + updatedContent, err := replaceGeneratedBlock(string(originalContent), generatedBlock) + if err != nil { + exitWithError("回填自动生成区块失败", err) + } + + if updatedContent == string(originalContent) { + fmt.Printf("Gateway JSON 示例已是最新,无需更新:%s\n", documentPath) + return + } + + if err := os.WriteFile(documentPath, []byte(updatedContent), 0o644); err != nil { + exitWithError("写入 API 文档失败", err) + } + + fmt.Printf("Gateway JSON 示例已更新:%s\n", documentPath) +} + +// renderGeneratedBlock 渲染附录中的自动生成内容。 +func renderGeneratedBlock() (string, error) { + examples, err := buildMethodExamples() + if err != nil { + return "", err } + + var builder strings.Builder + builder.WriteString("> 以下 JSON 示例由 `go run ./scripts/generate_gateway_rpc_examples` 自动生成。\n") + builder.WriteString("> 如结构体或字段标签发生变更,请重新执行生成命令。\n\n") + + for _, example := range examples { + builder.WriteString("### ") + builder.WriteString(example.Method) + builder.WriteString("\n\n") + + if example.Request != nil { + builder.WriteString("Request:\n\n```json\n") + builder.WriteString(mustPrettyJSON(example.Request)) + builder.WriteString("\n```\n\n") + } + if example.Success != nil { + builder.WriteString("Success Response:\n\n```json\n") + builder.WriteString(mustPrettyJSON(example.Success)) + builder.WriteString("\n```\n\n") + } + if example.Failure != nil { + builder.WriteString("Failure Response:\n\n```json\n") + builder.WriteString(mustPrettyJSON(example.Failure)) + builder.WriteString("\n```\n\n") + } + if example.Notification != nil { + builder.WriteString("Notification:\n\n```json\n") + builder.WriteString(mustPrettyJSON(example.Notification)) + builder.WriteString("\n```\n\n") + } + if len(example.Notes) > 0 { + builder.WriteString("Notes:\n\n") + for index, note := range example.Notes { + builder.WriteString(strconv.Itoa(index + 1)) + builder.WriteString(". ") + builder.WriteString(note) + builder.WriteString("\n") + } + builder.WriteString("\n") + } + } + + return strings.TrimRight(builder.String(), "\n"), nil +} + +// buildMethodExamples 基于 Go 结构体构造 RPC 方法示例集合。 +func buildMethodExamples() ([]methodExample, error) { + runRequestID := "req-run-1" + runID := "run-demo-1" + sessionID := "session-demo-1" + + authenticateRequest := buildRequest( + "req-auth-1", + protocol.MethodGatewayAuthenticate, + protocol.AuthenticateParams{Token: ""}, + ) + authenticateSuccess := buildSuccessResponse("req-auth-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionAuthenticate, + RequestID: "req-auth-1", + Payload: map[string]string{ + "message": "authenticated", + "subject_id": "local_admin", + }, + }) + authenticateFailure := buildFailureResponse( + "req-auth-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeUnauthorized.String()), + "invalid auth token", + gateway.ErrorCodeUnauthorized.String(), + ) + + pingRequest := buildRequest("req-ping-1", protocol.MethodGatewayPing, map[string]any{}) + pingSuccess := buildSuccessResponse("req-ping-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionPing, + RequestID: "req-ping-1", + Payload: map[string]string{ + "message": "pong", + "version": "dev", + }, + }) + pingFailure := buildFailureResponse( + "req-ping-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeUnauthorized.String()), + "unauthorized", + gateway.ErrorCodeUnauthorized.String(), + ) + + bindRequest := buildRequest("req-bind-1", protocol.MethodGatewayBindStream, protocol.BindStreamParams{ + SessionID: sessionID, + RunID: runID, + Channel: string(gateway.StreamChannelAll), + }) + bindSuccess := buildSuccessResponse("req-bind-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionBindStream, + RequestID: "req-bind-1", + SessionID: sessionID, + RunID: runID, + Payload: map[string]any{ + "message": "stream binding updated", + "channel": string(gateway.StreamChannelAll), + }, + }) + bindFailure := buildFailureResponse( + "req-bind-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeInvalidAction.String()), + "invalid bind_stream channel", + gateway.ErrorCodeInvalidAction.String(), + ) + + runRequest := buildRequest(runRequestID, protocol.MethodGatewayRun, protocol.RunParams{ + SessionID: sessionID, + RunID: runID, + InputText: "请分析这段代码并给出改进建议", + InputParts: []protocol.RunInputPart{ + { + Type: "text", + Text: "补充一段上下文描述", + }, + { + Type: "image", + Media: &protocol.RunInputMedia{ + URI: "file:///tmp/screenshot.png", + MimeType: "image/png", + FileName: "screenshot.png", + }, + }, + }, + Workdir: "C:/workspace/demo", + }) + runSuccess := buildSuccessResponse(runRequestID, gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionRun, + RequestID: runRequestID, + SessionID: sessionID, + RunID: runID, + Payload: map[string]string{ + "message": "run accepted", + }, + }) + runFailure := buildFailureResponse( + runRequestID, + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeInvalidMultimodalPayload.String()), + "input_parts[image] requires media.uri", + gateway.ErrorCodeInvalidMultimodalPayload.String(), + ) + + compactRequest := buildRequest("req-compact-1", protocol.MethodGatewayCompact, protocol.CompactParams{ + SessionID: sessionID, + RunID: runID, + }) + compactSuccess := buildSuccessResponse("req-compact-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionCompact, + RequestID: "req-compact-1", + SessionID: sessionID, + RunID: runID, + Payload: gateway.CompactResult{ + Applied: true, + BeforeChars: 12345, + AfterChars: 4567, + SavedRatio: 0.63, + TriggerMode: "manual", + TranscriptID: "compact-demo-1", + TranscriptPath: ".neocode/transcripts/compact-demo-1.md", + }, + }) + compactFailure := buildFailureResponse( + "req-compact-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeTimeout.String()), + "compact timed out", + gateway.ErrorCodeTimeout.String(), + ) + + cancelRequest := buildRequest("req-cancel-1", protocol.MethodGatewayCancel, protocol.CancelParams{ + SessionID: sessionID, + RunID: runID, + }) + cancelSuccess := buildSuccessResponse("req-cancel-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionCancel, + RequestID: "req-cancel-1", + Payload: map[string]any{ + "canceled": true, + "run_id": runID, + }, + }) + cancelFailure := buildFailureResponse( + "req-cancel-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeResourceNotFound.String()), + "cancel target not found", + gateway.ErrorCodeResourceNotFound.String(), + ) + + listSessionsRequest := buildRequest("req-list-1", protocol.MethodGatewayListSessions, nil) + listSessionsSuccess := buildSuccessResponse("req-list-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionListSessions, + RequestID: "req-list-1", + Payload: map[string]any{ + "sessions": []gateway.SessionSummary{ + { + ID: sessionID, + Title: "gateway 文档联调", + CreatedAt: mustParseTime("2026-04-22T09:00:00Z"), + UpdatedAt: mustParseTime("2026-04-22T09:10:00Z"), + }, + }, + }, + }) + listSessionsFailure := buildFailureResponse( + "req-list-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeUnauthorized.String()), + "unauthorized", + gateway.ErrorCodeUnauthorized.String(), + ) + + loadSessionRequest := buildRequest("req-load-1", protocol.MethodGatewayLoadSession, protocol.LoadSessionParams{ + SessionID: sessionID, + }) + loadSessionSuccess := buildSuccessResponse("req-load-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionLoadSession, + RequestID: "req-load-1", + SessionID: sessionID, + Payload: gateway.Session{ + ID: sessionID, + Title: "gateway 文档联调", + CreatedAt: mustParseTime("2026-04-22T09:00:00Z"), + UpdatedAt: mustParseTime("2026-04-22T09:10:00Z"), + Workdir: "C:/workspace/demo", + Messages: []gateway.SessionMessage{}, + }, + }) + loadSessionFailure := buildFailureResponse( + "req-load-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeAccessDenied.String()), + "load_session access denied", + gateway.ErrorCodeAccessDenied.String(), + ) + + resolvePermissionRequest := buildRequest( + "req-permission-1", + protocol.MethodGatewayResolvePermission, + protocol.ResolvePermissionParams{ + RequestID: "perm-request-1", + Decision: string(gateway.PermissionResolutionAllowOnce), + }, + ) + resolvePermissionSuccess := buildSuccessResponse("req-permission-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionResolvePermission, + RequestID: "req-permission-1", + Payload: map[string]any{ + "request_id": "perm-request-1", + "decision": string(gateway.PermissionResolutionAllowOnce), + "message": "permission resolved", + }, + }) + resolvePermissionFailure := buildFailureResponse( + "req-permission-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeInvalidAction.String()), + "invalid resolve_permission decision", + gateway.ErrorCodeInvalidAction.String(), + ) + + wakeRequest := buildRequest("req-wake-1", protocol.MethodWakeOpenURL, protocol.WakeIntent{ + Action: protocol.WakeActionReview, + SessionID: sessionID, + Workdir: "C:/workspace/demo", + Params: map[string]string{ + "path": "README.md", + }, + RawURL: "neocode://review?path=README.md", + }) + wakeSuccess := buildSuccessResponse("req-wake-1", gateway.MessageFrame{ + Type: gateway.FrameTypeAck, + Action: gateway.FrameActionWakeOpenURL, + RequestID: "req-wake-1", + SessionID: sessionID, + Payload: map[string]any{ + "message": "wake intent accepted", + "action": protocol.WakeActionReview, + "params": map[string]string{ + "path": "README.md", + }, + }, + }) + wakeFailure := buildFailureResponse( + "req-wake-1", + protocol.MapGatewayCodeToJSONRPCCode(gateway.ErrorCodeMissingRequiredField.String()), + "missing required field: params.path", + gateway.ErrorCodeMissingRequiredField.String(), + ) + + eventNotification := protocol.NewJSONRPCNotification(protocol.MethodGatewayEvent, gateway.MessageFrame{ + Type: gateway.FrameTypeEvent, + Action: gateway.FrameActionRun, + SessionID: sessionID, + RunID: runID, + Payload: map[string]any{ + "event_type": string(gateway.RuntimeEventTypeRunProgress), + "payload": map[string]any{ + "runtime_event_type": "agent_chunk", + "turn": 3, + "phase": "reasoning", + "timestamp": "2026-04-22T09:01:02.123456789Z", + "payload_version": controlplane.PayloadVersion, + "payload": map[string]any{ + "delta": "正在分析请求...", + }, + }, + }, + }) + + examples := []methodExample{ + { + Method: protocol.MethodGatewayAuthenticate, + Request: authenticateRequest, + Success: authenticateSuccess, + Failure: authenticateFailure, + }, + { + Method: protocol.MethodGatewayPing, + Request: pingRequest, + Success: pingSuccess, + Failure: pingFailure, + }, + { + Method: protocol.MethodGatewayBindStream, + Request: bindRequest, + Success: bindSuccess, + Failure: bindFailure, + Notes: []string{ + "`bindStream` 仅建立订阅绑定,后续运行事件通过 `gateway.event` 推送。", + }, + }, + { + Method: protocol.MethodGatewayRun, + Request: runRequest, + Success: runSuccess, + Failure: runFailure, + Notes: []string{ + "`Success Response` 只代表受理成功,不代表运行完成。", + "运行完成或失败需要通过 `gateway.event` 的 `run_done/run_error` 判断。", + }, + }, + { + Method: protocol.MethodGatewayCompact, + Request: compactRequest, + Success: compactSuccess, + Failure: compactFailure, + }, + { + Method: protocol.MethodGatewayCancel, + Request: cancelRequest, + Success: cancelSuccess, + Failure: cancelFailure, + }, + { + Method: protocol.MethodGatewayListSessions, + Request: listSessionsRequest, + Success: listSessionsSuccess, + Failure: listSessionsFailure, + }, + { + Method: protocol.MethodGatewayLoadSession, + Request: loadSessionRequest, + Success: loadSessionSuccess, + Failure: loadSessionFailure, + }, + { + Method: protocol.MethodGatewayResolvePermission, + Request: resolvePermissionRequest, + Success: resolvePermissionSuccess, + Failure: resolvePermissionFailure, + }, + { + Method: protocol.MethodWakeOpenURL, + Request: wakeRequest, + Success: wakeSuccess, + Failure: wakeFailure, + }, + { + Method: protocol.MethodGatewayEvent, + Notification: eventNotification, + }, + } + + return examples, nil +} + +// buildRequest 构建标准 JSON-RPC 请求对象。 +func buildRequest(requestID, method string, params any) protocol.JSONRPCRequest { + request := protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: quotedID(requestID), + Method: strings.TrimSpace(method), + } + if params != nil { + request.Params = marshalRawJSON(params) + } + return request +} + +// buildSuccessResponse 基于 MessageFrame 生成 JSON-RPC 成功响应。 +func buildSuccessResponse(requestID string, frame gateway.MessageFrame) protocol.JSONRPCResponse { + response, err := protocol.NewJSONRPCResultResponse(quotedID(requestID), frame) + if err != nil { + panic(fmt.Errorf("构造成功响应失败: %v", err)) + } + return response +} + +// buildFailureResponse 构建 JSON-RPC 失败响应。 +func buildFailureResponse(requestID string, code int, message, gatewayCode string) protocol.JSONRPCResponse { + return protocol.NewJSONRPCErrorResponse( + quotedID(requestID), + protocol.NewJSONRPCError(code, strings.TrimSpace(message), strings.TrimSpace(gatewayCode)), + ) +} + +// replaceGeneratedBlock 将文档中标记区块替换为新生成的文本内容。 +func replaceGeneratedBlock(documentContent, generatedContent string) (string, error) { + startIndex := strings.Index(documentContent, beginMarker) + if startIndex < 0 { + return "", fmt.Errorf("未找到自动生成起始标记 %q", beginMarker) + } + contentStart := startIndex + len(beginMarker) + endOffset := strings.Index(documentContent[contentStart:], endMarker) + if endOffset < 0 { + return "", fmt.Errorf("未找到自动生成结束标记 %q", endMarker) + } + endIndex := contentStart + endOffset + if endIndex < startIndex { + return "", fmt.Errorf("自动生成区块标记顺序非法") + } + + replaced := documentContent[:contentStart] + "\n" + generatedContent + "\n" + documentContent[endIndex:] + return replaced, nil +} + +// quotedID 将请求 ID 转换为 JSON-RPC 合法的 RawMessage 字面量。 +func quotedID(requestID string) json.RawMessage { + return json.RawMessage(strconv.Quote(strings.TrimSpace(requestID))) +} + +// marshalRawJSON 将任意结构体编码为 RawMessage。 +func marshalRawJSON(value any) json.RawMessage { + raw, err := json.Marshal(value) + if err != nil { + panic(fmt.Errorf("编码 RawMessage 失败: %w", err)) + } + return json.RawMessage(raw) +} + +// mustPrettyJSON 将对象编码为缩进 JSON 字符串。 +func mustPrettyJSON(value any) string { + raw, err := json.MarshalIndent(value, "", " ") + if err != nil { + panic(fmt.Errorf("编码 JSON 示例失败: %w", err)) + } + return string(raw) +} + +// mustParseTime 将 RFC3339 时间文本解析为 time.Time,失败时直接 panic。 +func mustParseTime(raw string) time.Time { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + panic("时间文本不能为空") + } + parsed, err := time.Parse(time.RFC3339, trimmed) + if err != nil { + panic(fmt.Errorf("解析时间失败: %w", err)) + } + return parsed +} + +// exitWithError 统一输出错误并结束进程。 +func exitWithError(message string, err error) { + _, _ = fmt.Fprintf(os.Stderr, "%s: %v\n", message, err) + os.Exit(1) } From 487b323459dfbf2aa7fbfdadcf63712998086517 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 12:32:21 +0000 Subject: [PATCH 08/15] fix(conflict): align docs reference api file with origin main Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- docs/reference/gateway-rpc-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/gateway-rpc-api.md b/docs/reference/gateway-rpc-api.md index 6082aaee..e379f896 100644 --- a/docs/reference/gateway-rpc-api.md +++ b/docs/reference/gateway-rpc-api.md @@ -1319,7 +1319,7 @@ Notification: "payload": { "delta": "正在分析请求..." }, - "payload_version": 4, + "payload_version": 2, "phase": "reasoning", "runtime_event_type": "agent_chunk", "timestamp": "2026-04-22T09:01:02.123456789Z", From 891823bd83976f96fa583ffcfe16dde8f957d6fe Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 12:53:55 +0000 Subject: [PATCH 09/15] fix(docs): regenerate gateway rpc reference examples Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- docs/reference/gateway-rpc-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/gateway-rpc-api.md b/docs/reference/gateway-rpc-api.md index e379f896..6082aaee 100644 --- a/docs/reference/gateway-rpc-api.md +++ b/docs/reference/gateway-rpc-api.md @@ -1319,7 +1319,7 @@ Notification: "payload": { "delta": "正在分析请求..." }, - "payload_version": 2, + "payload_version": 4, "phase": "reasoning", "runtime_event_type": "agent_chunk", "timestamp": "2026-04-22T09:01:02.123456789Z", From f461648cec76e1e5c7dc30c1cc154c3b61aae4a4 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 13:11:18 +0000 Subject: [PATCH 10/15] test(gateway): improve patch coverage for launcher entry paths Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- cmd/neocode-gateway/main_test.go | 48 +++++++++++++++++++ .../adapters/urlscheme/dispatcher_test.go | 28 +++++++++++ internal/gateway/launcher/launcher_test.go | 18 +++++++ 3 files changed, 94 insertions(+) create mode 100644 cmd/neocode-gateway/main_test.go diff --git a/cmd/neocode-gateway/main_test.go b/cmd/neocode-gateway/main_test.go new file mode 100644 index 00000000..f61c0799 --- /dev/null +++ b/cmd/neocode-gateway/main_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "bytes" + "errors" + "os" + "os/exec" + "strings" + "testing" +) + +func TestMainHelpPathDoesNotExit(t *testing.T) { + originalArgs := os.Args + defer func() { + os.Args = originalArgs + }() + + os.Args = []string{"neocode-gateway", "--help"} + main() +} + +func TestMainReturnsExitCodeOneOnCommandError(t *testing.T) { + if os.Getenv("NEOCODE_GATEWAY_MAIN_HELPER") == "1" { + os.Args = []string{"neocode-gateway", "--log-level", "trace"} + main() + return + } + + command := exec.Command(os.Args[0], "-test.run=TestMainReturnsExitCodeOneOnCommandError") + command.Env = append(os.Environ(), "NEOCODE_GATEWAY_MAIN_HELPER=1") + var stderr bytes.Buffer + command.Stderr = &stderr + + err := command.Run() + if err == nil { + t.Fatal("expected subprocess to exit with non-zero status") + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("error type = %T, want *exec.ExitError", err) + } + if exitErr.ExitCode() != 1 { + t.Fatalf("exit code = %d, want %d", exitErr.ExitCode(), 1) + } + if !strings.Contains(stderr.String(), "neocode-gateway:") { + t.Fatalf("stderr = %q, want contains %q", stderr.String(), "neocode-gateway:") + } +} diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 8f25a456..7ea71347 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -8,6 +8,7 @@ import ( "io" "log" "net" + "os" "strings" "testing" "time" @@ -557,6 +558,33 @@ func TestDispatcherResolveAddressUsesTransportResolver(t *testing.T) { } } +func TestNewDispatcherResolveLaunchSpecUsesEnvAndAuthMode(t *testing.T) { + executablePath, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable() error = %v", err) + } + t.Setenv(launcher.EnvGatewayBinary, executablePath) + + dispatcher := NewDispatcher() + spec, err := dispatcher.resolveLaunchSpecFn() + if err != nil { + t.Fatalf("resolve launch spec: %v", err) + } + if spec.LaunchMode != launcher.LaunchModeExplicitPath { + t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, launcher.LaunchModeExplicitPath) + } + if strings.TrimSpace(spec.Executable) == "" { + t.Fatal("resolved executable should not be empty") + } + + if got := resolveAuthMode(" "); got != "disabled" { + t.Fatalf("resolveAuthMode(disabled) = %q, want %q", got, "disabled") + } + if got := resolveAuthMode("token-1"); got != "required" { + t.Fatalf("resolveAuthMode(required) = %q, want %q", got, "required") + } +} + func TestApplyDispatchDeadlineAndToDispatchError(t *testing.T) { stubConn := &stubDispatchConn{} before := time.Now() diff --git a/internal/gateway/launcher/launcher_test.go b/internal/gateway/launcher/launcher_test.go index f40f27d6..6268f8c5 100644 --- a/internal/gateway/launcher/launcher_test.go +++ b/internal/gateway/launcher/launcher_test.go @@ -120,6 +120,24 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { }) } +func TestResolveGatewayLaunchSpec(t *testing.T) { + executablePath, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable() error = %v", err) + } + + spec, err := ResolveGatewayLaunchSpec(ResolveOptions{ExplicitBinary: executablePath}) + if err != nil { + t.Fatalf("ResolveGatewayLaunchSpec() error = %v", err) + } + if spec.LaunchMode != LaunchModeExplicitPath { + t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, LaunchModeExplicitPath) + } + if spec.Executable == "" { + t.Fatal("executable should not be empty") + } +} + func TestStartDetachedGateway(t *testing.T) { t.Run("empty executable rejected", func(t *testing.T) { err := StartDetachedGateway(LaunchSpec{}) From 387ce5c1a2ae09350202e0ac130a761acad8914e Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 13:24:40 +0000 Subject: [PATCH 11/15] test(gateway): improve dispatcher path coverage with branch tests Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- .../adapters/urlscheme/dispatcher_test.go | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 7ea71347..8dbbcd6e 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -1179,6 +1179,366 @@ func TestDispatcherJSONRPCHelpers(t *testing.T) { } } +func TestDispatcherDispatchErrorFrameBranches(t *testing.T) { + t.Run("error frame missing error payload", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString( + `{"jsonrpc":"2.0","id":"wake-err-1","result":{"type":"error","action":"wake.openUrl","request_id":"wake-err-1"}}` + "\n", + ), + }, nil + }, + requestIDFn: func() string { return "wake-err-1" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{RawURL: "neocode://review?path=README.md"}) + if err == nil || !strings.Contains(err.Error(), "missing error payload") { + t.Fatalf("expected missing error payload error, got %v", err) + } + }) + + t.Run("error frame propagates gateway code and message", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{ + readBuffer: bytes.NewBufferString( + `{"jsonrpc":"2.0","id":"wake-err-2","result":{"type":"error","action":"wake.openUrl","request_id":"wake-err-2","error":{"code":"unauthorized","message":"denied"}}}` + "\n", + ), + }, nil + }, + requestIDFn: func() string { return "wake-err-2" }, + } + + _, err := dispatcher.Dispatch(context.Background(), DispatchRequest{RawURL: "neocode://review?path=README.md"}) + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != "unauthorized" { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, "unauthorized") + } + if dispatchErr.Message != "denied" { + t.Fatalf("error message = %q, want %q", dispatchErr.Message, "denied") + } + }) +} + +func TestDispatcherLaunchGatewayBranches(t *testing.T) { + t.Run("context canceled before launch", func(t *testing.T) { + dispatcher := &Dispatcher{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := dispatcher.launchGateway(ctx, "stub://gateway", "wake-launch-1", "") + if !errors.Is(err, context.Canceled) { + t.Fatalf("launchGateway error = %v, want context canceled", err) + } + }) + + t.Run("missing resolve launch function", func(t *testing.T) { + dispatcher := &Dispatcher{ + startGatewayFn: func(launcher.LaunchSpec) error { return nil }, + } + + err := dispatcher.launchGateway(context.Background(), "stub://gateway", "wake-launch-2", "") + if err == nil || !strings.Contains(err.Error(), "launcher is unavailable") { + t.Fatalf("expected launcher unavailable error, got %v", err) + } + }) + + t.Run("missing start gateway function", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveLaunchSpecFn: func() (launcher.LaunchSpec, error) { + return launcher.LaunchSpec{LaunchMode: launcher.LaunchModePathBinary, Executable: "/tmp/neocode-gateway"}, nil + }, + } + + err := dispatcher.launchGateway(context.Background(), "stub://gateway", "wake-launch-3", "") + if err == nil || !strings.Contains(err.Error(), "start function is unavailable") { + t.Fatalf("expected start function unavailable error, got %v", err) + } + }) + + t.Run("resolve launch spec failed and emits failure log", func(t *testing.T) { + buffer := &bytes.Buffer{} + dispatcher := &Dispatcher{ + resolveLaunchSpecFn: func() (launcher.LaunchSpec, error) { + return launcher.LaunchSpec{}, errors.New("resolve failed") + }, + startGatewayFn: func(launcher.LaunchSpec) error { return nil }, + logger: log.New(buffer, "", 0), + } + + err := dispatcher.launchGateway(context.Background(), "stub://gateway", "wake-launch-4", "token") + if err == nil || !strings.Contains(err.Error(), "resolve failed") { + t.Fatalf("expected resolve failed error, got %v", err) + } + if !strings.Contains(buffer.String(), `"status":"launch_failed"`) { + t.Fatalf("expected launch_failed log, got %q", buffer.String()) + } + }) + + t.Run("start gateway failed", func(t *testing.T) { + dispatcher := &Dispatcher{ + resolveLaunchSpecFn: func() (launcher.LaunchSpec, error) { + return launcher.LaunchSpec{LaunchMode: launcher.LaunchModePathBinary, Executable: "/tmp/neocode-gateway"}, nil + }, + startGatewayFn: func(launcher.LaunchSpec) error { + return errors.New("start failed") + }, + } + + err := dispatcher.launchGateway(context.Background(), "stub://gateway", "wake-launch-5", "") + if err == nil || !strings.Contains(err.Error(), "start failed") { + t.Fatalf("expected start failed error, got %v", err) + } + }) +} + +func TestDispatcherWaitGatewayReadyBranches(t *testing.T) { + t.Run("uses default now and sleep functions", func(t *testing.T) { + dispatcher := &Dispatcher{ + dialFn: func(string) (net.Conn, error) { + return &stubDispatchConn{}, nil + }, + } + if err := dispatcher.waitGatewayReady(context.Background(), "stub://gateway"); err != nil { + t.Fatalf("waitGatewayReady() error = %v", err) + } + }) + + t.Run("context deadline short-circuits retry window", func(t *testing.T) { + base := time.Unix(300, 0) + now := base + sleepCalls := 0 + dispatcher := &Dispatcher{ + dialFn: func(string) (net.Conn, error) { + return nil, errors.New("unreachable") + }, + nowFn: func() time.Time { + current := now + now = now.Add(50 * time.Millisecond) + return current + }, + sleepFn: func(time.Duration) { + sleepCalls++ + }, + } + + ctx, cancel := context.WithDeadline(context.Background(), base.Add(40*time.Millisecond)) + defer cancel() + err := dispatcher.waitGatewayReady(ctx, "stub://gateway") + if err == nil { + t.Fatal("expected timeout-related error") + } + if !strings.Contains(err.Error(), "did not become reachable") && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected timeout-related error, got %v", err) + } + if sleepCalls != 0 { + t.Fatalf("sleepCalls = %d, want %d", sleepCalls, 0) + } + }) + + t.Run("retries once then succeeds and sleeps", func(t *testing.T) { + base := time.Unix(400, 0) + now := base + dialCalls := 0 + sleepCalls := 0 + dispatcher := &Dispatcher{ + dialFn: func(string) (net.Conn, error) { + dialCalls++ + if dialCalls == 1 { + return nil, errors.New("not ready") + } + return &stubDispatchConn{}, nil + }, + nowFn: func() time.Time { + current := now + now = now.Add(10 * time.Millisecond) + return current + }, + sleepFn: func(time.Duration) { + sleepCalls++ + }, + } + + if err := dispatcher.waitGatewayReady(context.Background(), "stub://gateway"); err != nil { + t.Fatalf("waitGatewayReady() error = %v", err) + } + if dialCalls != 2 { + t.Fatalf("dialCalls = %d, want %d", dialCalls, 2) + } + if sleepCalls != 1 { + t.Fatalf("sleepCalls = %d, want %d", sleepCalls, 1) + } + }) +} + +func TestDispatcherCallRPCAdditionalBranches(t *testing.T) { + t.Run("context canceled before encode", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + dispatcher := &Dispatcher{} + _, err := dispatcher.callRPC(ctx, &stubDispatchConn{}, protocol.JSONRPCRequest{}) + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + }) + + t.Run("encode error with context canceled during write", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + conn := &cancelOnWriteErrorConn{cancel: cancel} + dispatcher := &Dispatcher{} + + _, err := dispatcher.callRPC(ctx, conn, protocol.JSONRPCRequest{JSONRPC: protocol.JSONRPCVersion}) + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + if !strings.Contains(dispatchErr.Message, context.Canceled.Error()) { + t.Fatalf("error message = %q, want contains %q", dispatchErr.Message, context.Canceled.Error()) + } + }) + + t.Run("context canceled after encode before decode", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + conn := &cancelAfterWriteConn{cancel: cancel} + dispatcher := &Dispatcher{} + + _, err := dispatcher.callRPC(ctx, conn, protocol.JSONRPCRequest{JSONRPC: protocol.JSONRPCVersion}) + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + if !strings.Contains(dispatchErr.Message, context.Canceled.Error()) { + t.Fatalf("error message = %q, want contains %q", dispatchErr.Message, context.Canceled.Error()) + } + }) + + t.Run("decode error with canceled context", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + conn := &cancelOnReadErrorConn{cancel: cancel} + dispatcher := &Dispatcher{} + + _, err := dispatcher.callRPC(ctx, conn, protocol.JSONRPCRequest{JSONRPC: protocol.JSONRPCVersion}) + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != ErrorCodeInternal { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeInternal) + } + }) +} + +func TestDispatcherAuthenticateAdditionalBranches(t *testing.T) { + t.Run("auth response version mismatch", func(t *testing.T) { + dispatcher := &Dispatcher{ + requestIDFn: func() string { return "wake-auth-extra-1" }, + } + conn := &stubDispatchConn{ + readBuffer: bytes.NewBufferString(`{"jsonrpc":"1.0","id":"wake-auth-extra-1-auth","result":{}}` + "\n"), + } + + err := dispatcher.authenticate(context.Background(), conn, "token") + if err == nil || !strings.Contains(err.Error(), "jsonrpc version") { + t.Fatalf("expected auth version mismatch, got %v", err) + } + }) + + t.Run("auth id mismatch", func(t *testing.T) { + dispatcher := &Dispatcher{ + requestIDFn: func() string { return "wake-auth-extra-2" }, + } + conn := &stubDispatchConn{ + readBuffer: bytes.NewBufferString(`{"jsonrpc":"2.0","id":"other-auth-id","result":{}}` + "\n"), + } + + err := dispatcher.authenticate(context.Background(), conn, "token") + if err == nil || !strings.Contains(err.Error(), "auth id mismatch") { + t.Fatalf("expected auth id mismatch, got %v", err) + } + }) + + t.Run("decode auth response frame failed", func(t *testing.T) { + dispatcher := &Dispatcher{ + requestIDFn: func() string { return "wake-auth-extra-3" }, + } + conn := &stubDispatchConn{ + readBuffer: bytes.NewBufferString(`{"jsonrpc":"2.0","id":"wake-auth-extra-3-auth","result":"bad-frame"}` + "\n"), + } + + err := dispatcher.authenticate(context.Background(), conn, "token") + if err == nil || !strings.Contains(err.Error(), "decode auth response frame") { + t.Fatalf("expected decode auth frame failure, got %v", err) + } + }) +} + +func TestDispatcherEmitLaunchDecisionLogNilGuards(t *testing.T) { + var dispatcher *Dispatcher + dispatcher.emitLaunchDecisionLog(launchDecisionLogEntry{}) + + dispatcher = &Dispatcher{} + dispatcher.emitLaunchDecisionLog(launchDecisionLogEntry{}) +} + +type cancelOnWriteErrorConn struct { + stubDispatchConn + cancel context.CancelFunc +} + +func (c *cancelOnWriteErrorConn) Write(_ []byte) (int, error) { + c.cancel() + return 0, errors.New("write failed") +} + +func (c *cancelOnWriteErrorConn) Read(_ []byte) (int, error) { + return 0, io.EOF +} + +type cancelAfterWriteConn struct { + stubDispatchConn + cancel context.CancelFunc +} + +func (c *cancelAfterWriteConn) Write(payload []byte) (int, error) { + c.cancel() + return len(payload), nil +} + +func (c *cancelAfterWriteConn) Read(_ []byte) (int, error) { + return 0, io.EOF +} + +type cancelOnReadErrorConn struct { + stubDispatchConn + cancel context.CancelFunc +} + +func (c *cancelOnReadErrorConn) Write(payload []byte) (int, error) { + return len(payload), nil +} + +func (c *cancelOnReadErrorConn) Read(_ []byte) (int, error) { + c.cancel() + return 0, io.EOF +} + type stubDispatchConn struct { readBuffer *bytes.Buffer writeErr error From 0dd60a90230485e9b22702e07d0013bcf79b3b22 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 02:57:26 +0000 Subject: [PATCH 12/15] fix(gateway): align launcher fallback contract and dispatch launch args Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- Makefile | 6 +-- README.md | 2 +- docs/gateway-detailed-design.md | 2 +- docs/gateway-rpc-api.md | 24 ++++++------ .../gateway/adapters/urlscheme/dispatcher.go | 14 ++++++- .../adapters/urlscheme/dispatcher_test.go | 26 ++++++++++++- internal/gateway/launcher/launcher.go | 17 +++------ internal/gateway/launcher/launcher_test.go | 37 ++++++++----------- 8 files changed, 77 insertions(+), 51 deletions(-) diff --git a/Makefile b/Makefile index c7548ef1..3b5260e6 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,8 @@ install-skills: @./scripts/install_skills.sh docs-gateway: - @go run ./scripts/generate_gateway_rpc_examples + @go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go docs-gateway-check: - @go run ./scripts/generate_gateway_rpc_examples - @git diff --exit-code -- docs/reference/gateway-rpc-api.md + @go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go + @git diff --exit-code -- docs/generated/gateway-rpc-examples.json diff --git a/README.md b/README.md index 36c9df35..aa52a792 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ go run ./cmd/neocode url-dispatch --url "neocode://review?path=README.md" 1. `NEOCODE_GATEWAY_BIN` 显式路径 2. `PATH` 中 `neocode-gateway` -3. 回退当前可执行 `neocode gateway` +3. `PATH` 中 `neocode` 并追加子命令 `gateway` ## 安装脚本 diff --git a/docs/gateway-detailed-design.md b/docs/gateway-detailed-design.md index b8ef0a08..afd78666 100644 --- a/docs/gateway-detailed-design.md +++ b/docs/gateway-detailed-design.md @@ -73,7 +73,7 @@ stateDiagram-v2 1. `NEOCODE_GATEWAY_BIN` 显式路径 2. `PATH` 中的 `neocode-gateway` -3. 当前可执行回退 `neocode gateway` +3. `PATH` 中的 `neocode` 并追加子命令 `gateway` 约束:仅允许一次受控回退,失败后返回确定性错误。 diff --git a/docs/gateway-rpc-api.md b/docs/gateway-rpc-api.md index 8d0549ba..ccededf9 100644 --- a/docs/gateway-rpc-api.md +++ b/docs/gateway-rpc-api.md @@ -106,12 +106,12 @@ type BindStreamParams struct { { "jsonrpc": "2.0", "id": "bind-1", - "result": { - "type": "ack", - "action": "bind_stream", - "request_id": "bind-1", - "session_id": "sess-1", - "run_id": "run-1", + "result": { + "type": "ack", + "action": "bind_stream", + "request_id": "bind-1", + "session_id": "sess-1", + "run_id": "run-1", "payload": { "message": "stream binding updated", "channel": "ws" @@ -182,12 +182,12 @@ type RunParams struct { { "jsonrpc": "2.0", "id": "run-req-1", - "result": { - "type": "ack", - "action": "run", - "request_id": "run-req-1", - "session_id": "sess-1", - "run_id": "run-1", + "result": { + "type": "ack", + "action": "run", + "request_id": "run-req-1", + "session_id": "sess-1", + "run_id": "run-1", "payload": { "message": "run accepted" } diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 14ec9ffb..95495a41 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -370,7 +370,9 @@ func (d *Dispatcher) launchGateway(ctx context.Context, listenAddress string, re LaunchMode: spec.LaunchMode, ResolvedExec: spec.Executable, }) - if err := startGatewayFn(spec); err != nil { + launchSpec := spec + launchSpec.Args = buildGatewayLaunchArgs(spec.Args, listenAddress) + if err := startGatewayFn(launchSpec); err != nil { d.emitLaunchDecisionLog(launchDecisionLogEntry{ RequestID: requestID, Method: string(protocol.MethodWakeOpenURL), @@ -415,6 +417,16 @@ func (d *Dispatcher) launchGateway(ctx context.Context, listenAddress string, re return nil } +// buildGatewayLaunchArgs 构造自动拉起参数,确保子进程监听地址与调度重拨地址一致。 +func buildGatewayLaunchArgs(baseArgs []string, listenAddress string) []string { + args := append([]string(nil), baseArgs...) + normalizedListenAddress := strings.TrimSpace(listenAddress) + if normalizedListenAddress == "" { + return args + } + return append(args, "--listen", normalizedListenAddress) +} + // waitGatewayReady 在单次回退窗口内轮询网关连通性,超时后返回确定性错误。 func (d *Dispatcher) waitGatewayReady(ctx context.Context, listenAddress string) error { nowFn := d.nowFn diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 8dbbcd6e..d193a867 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -9,6 +9,7 @@ import ( "log" "net" "os" + "reflect" "strings" "testing" "time" @@ -1282,11 +1283,13 @@ func TestDispatcherLaunchGatewayBranches(t *testing.T) { }) t.Run("start gateway failed", func(t *testing.T) { + var capturedSpec launcher.LaunchSpec dispatcher := &Dispatcher{ resolveLaunchSpecFn: func() (launcher.LaunchSpec, error) { return launcher.LaunchSpec{LaunchMode: launcher.LaunchModePathBinary, Executable: "/tmp/neocode-gateway"}, nil }, - startGatewayFn: func(launcher.LaunchSpec) error { + startGatewayFn: func(spec launcher.LaunchSpec) error { + capturedSpec = spec return errors.New("start failed") }, } @@ -1295,6 +1298,9 @@ func TestDispatcherLaunchGatewayBranches(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "start failed") { t.Fatalf("expected start failed error, got %v", err) } + if !reflect.DeepEqual(capturedSpec.Args, []string{"--listen", "stub://gateway"}) { + t.Fatalf("launch args = %#v, want %#v", capturedSpec.Args, []string{"--listen", "stub://gateway"}) + } }) } @@ -1497,6 +1503,24 @@ func TestDispatcherEmitLaunchDecisionLogNilGuards(t *testing.T) { dispatcher.emitLaunchDecisionLog(launchDecisionLogEntry{}) } +func TestBuildGatewayLaunchArgs(t *testing.T) { + t.Run("appends listen argument when provided", func(t *testing.T) { + got := buildGatewayLaunchArgs([]string{"gateway"}, " unix:///tmp/neocode.sock ") + want := []string{"gateway", "--listen", "unix:///tmp/neocode.sock"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildGatewayLaunchArgs() = %#v, want %#v", got, want) + } + }) + + t.Run("keeps base args when listen is empty", func(t *testing.T) { + got := buildGatewayLaunchArgs([]string{"gateway"}, " ") + want := []string{"gateway"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildGatewayLaunchArgs() = %#v, want %#v", got, want) + } + }) +} + type cancelOnWriteErrorConn struct { stubDispatchConn cancel context.CancelFunc diff --git a/internal/gateway/launcher/launcher.go b/internal/gateway/launcher/launcher.go index 0f4ea54d..f29b7a89 100644 --- a/internal/gateway/launcher/launcher.go +++ b/internal/gateway/launcher/launcher.go @@ -14,7 +14,7 @@ const ( LaunchModeExplicitPath = "explicit_path" // LaunchModePathBinary 表示命中 PATH 中的 neocode-gateway。 LaunchModePathBinary = "path_neocode_gateway" - // LaunchModeFallbackSubcommand 表示回退到当前可执行的 gateway 子命令。 + // LaunchModeFallbackSubcommand 表示回退到 PATH 中 neocode 的 gateway 子命令。 LaunchModeFallbackSubcommand = "fallback_neocode_gateway_subcommand" ) @@ -31,9 +31,9 @@ type ResolveOptions struct { } // ResolveGatewayLaunchSpec 解析网关可执行发现顺序: -// 显式路径(NEOCODE_GATEWAY_BIN) > PATH(neocode-gateway) > 当前可执行 + gateway 子命令。 +// 显式路径(NEOCODE_GATEWAY_BIN) > PATH(neocode-gateway) > PATH(neocode) + gateway 子命令。 func ResolveGatewayLaunchSpec(options ResolveOptions) (LaunchSpec, error) { - return resolveGatewayLaunchSpecWithDeps(options, exec.LookPath, os.Executable) + return resolveGatewayLaunchSpecWithDeps(options, exec.LookPath) } // StartDetachedGateway 以非阻塞方式拉起网关进程并释放父进程句柄。 @@ -55,7 +55,6 @@ func StartDetachedGateway(spec LaunchSpec) error { func resolveGatewayLaunchSpecWithDeps( options ResolveOptions, lookPathFn func(string) (string, error), - executableFn func() (string, error), ) (LaunchSpec, error) { resolveByLookup := func(binary string) (string, error) { resolved, err := lookPathFn(strings.TrimSpace(binary)) @@ -84,18 +83,14 @@ func resolveGatewayLaunchSpecWithDeps( }, nil } - currentExecutable, err := executableFn() + resolvedFallbackExecutable, err := resolveByLookup("neocode") if err != nil { - return LaunchSpec{}, fmt.Errorf("resolve current executable: %w", err) - } - trimmedCurrentExecutable := strings.TrimSpace(currentExecutable) - if trimmedCurrentExecutable == "" { - return LaunchSpec{}, fmt.Errorf("resolve current executable: empty executable path") + return LaunchSpec{}, err } return LaunchSpec{ LaunchMode: LaunchModeFallbackSubcommand, - Executable: trimmedCurrentExecutable, + Executable: resolvedFallbackExecutable, Args: []string{"gateway"}, }, nil } diff --git a/internal/gateway/launcher/launcher_test.go b/internal/gateway/launcher/launcher_test.go index 6268f8c5..9738cf5e 100644 --- a/internal/gateway/launcher/launcher_test.go +++ b/internal/gateway/launcher/launcher_test.go @@ -20,9 +20,6 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { } return "", errors.New("unexpected lookup") }, - func() (string, error) { - return "/usr/local/bin/neocode", nil - }, ) if err != nil { t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) @@ -47,9 +44,6 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { } return "", errors.New("unexpected lookup") }, - func() (string, error) { - return "/usr/local/bin/neocode", nil - }, ) if err != nil { t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) @@ -65,14 +59,18 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { } }) - t.Run("fallback to current executable subcommand", func(t *testing.T) { + t.Run("fallback to neocode subcommand", func(t *testing.T) { spec, err := resolveGatewayLaunchSpecWithDeps( ResolveOptions{}, - func(string) (string, error) { - return "", errors.New("not found") - }, - func() (string, error) { - return "/usr/local/bin/neocode", nil + func(binary string) (string, error) { + switch binary { + case "neocode-gateway": + return "", errors.New("not found") + case "neocode": + return "/usr/local/bin/neocode", nil + default: + return "", errors.New("unexpected lookup") + } }, ) if err != nil { @@ -95,23 +93,20 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { func(string) (string, error) { return "", errors.New("missing") }, - func() (string, error) { - return "/usr/local/bin/neocode", nil - }, ) if err == nil { t.Fatal("expected explicit lookup error") } }) - t.Run("fallback fails when current executable unavailable", func(t *testing.T) { + t.Run("fallback fails when neocode is unavailable", func(t *testing.T) { _, err := resolveGatewayLaunchSpecWithDeps( ResolveOptions{}, - func(string) (string, error) { - return "", errors.New("not found") - }, - func() (string, error) { - return "", errors.New("unavailable") + func(binary string) (string, error) { + if binary == "neocode-gateway" || binary == "neocode" { + return "", errors.New("not found") + } + return "", errors.New("unexpected lookup") }, ) if err == nil { From 9cf7099852698eb40daac8016f39c70d486817ee Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 05:05:17 +0000 Subject: [PATCH 13/15] fix(gateway): resolve remaining review risks and tighten checks Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- Makefile | 7 +- .../gateway/adapters/urlscheme/dispatcher.go | 221 ++++++++++-------- .../adapters/urlscheme/dispatcher_test.go | 136 +++++------ internal/gateway/launcher/launcher.go | 101 ++++++-- internal/gateway/launcher/launcher_test.go | 117 +++++++--- scripts/check_gateway_docs/main.go | 112 +++++++++ scripts/check_gateway_docs/main_test.go | 112 +++++++++ 7 files changed, 593 insertions(+), 213 deletions(-) create mode 100644 scripts/check_gateway_docs/main.go create mode 100644 scripts/check_gateway_docs/main_test.go diff --git a/Makefile b/Makefile index 3b5260e6..6cea1f23 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,14 @@ .PHONY: install-skills docs-gateway docs-gateway-check +GATEWAY_DOCS_GENERATOR := go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go + install-skills: @./scripts/install_skills.sh docs-gateway: - @go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go + @$(GATEWAY_DOCS_GENERATOR) docs-gateway-check: - @go run -tags gatewaydocgen ./scripts/generate_gateway_rpc_examples.go + @$(GATEWAY_DOCS_GENERATOR) + @go run ./scripts/check_gateway_docs @git diff --exit-code -- docs/generated/gateway-rpc-examples.json diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 95495a41..81444a88 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -166,31 +166,17 @@ func (d *Dispatcher) Dispatch(ctx context.Context, request DispatchRequest) (Dis if err != nil { return DispatchResult{}, err } - if strings.TrimSpace(rpcResponse.JSONRPC) != protocol.JSONRPCVersion { - return DispatchResult{}, newDispatchError( - ErrorCodeUnexpectedResponse, - "unexpected response jsonrpc version", - ) - } - if !rawJSONMessageEqual(rpcResponse.ID, rpcRequest.ID) { - return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, "rpc correlation failed: id mismatch") - } - if rpcResponse.Error != nil && rpcResponse.Result != nil { - return DispatchResult{}, newDispatchError( - ErrorCodeUnexpectedResponse, - "unexpected response payload: both result and error are present", - ) - } - if rpcResponse.Error != nil { - return DispatchResult{}, toDispatchErrorFromJSONRPC(rpcResponse.Error) - } - if rpcResponse.Result == nil { - return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, "gateway response missing result payload") - } - - responseFrame, err := decodeResponseFrameResult(rpcResponse.Result) + responseFrame, err := validateRPCFrameResponse( + rpcResponse, + rpcRequest.ID, + "unexpected response jsonrpc version", + "rpc correlation failed: id mismatch", + "unexpected response payload: both result and error are present", + "gateway response missing result payload", + "decode response frame: %v", + ) if err != nil { - return DispatchResult{}, newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("decode response frame: %v", err)) + return DispatchResult{}, err } if responseFrame.Action != requestFrame.Action || responseFrame.RequestID != requestFrame.RequestID { return DispatchResult{}, newDispatchError( @@ -238,21 +224,17 @@ func (d *Dispatcher) authenticate(ctx context.Context, conn net.Conn, token stri if err != nil { return err } - if strings.TrimSpace(authResponse.JSONRPC) != protocol.JSONRPCVersion { - return newDispatchError(ErrorCodeUnexpectedResponse, "unexpected auth response jsonrpc version") - } - if !rawJSONMessageEqual(authResponse.ID, authRequest.ID) { - return newDispatchError(ErrorCodeUnexpectedResponse, "rpc correlation failed: auth id mismatch") - } - if authResponse.Error != nil { - return toDispatchErrorFromJSONRPC(authResponse.Error) - } - if authResponse.Result == nil { - return newDispatchError(ErrorCodeUnexpectedResponse, "gateway auth response missing result payload") - } - frame, err := decodeResponseFrameResult(authResponse.Result) + frame, err := validateRPCFrameResponse( + authResponse, + authRequest.ID, + "unexpected auth response jsonrpc version", + "rpc correlation failed: auth id mismatch", + "unexpected response payload: both result and error are present", + "gateway auth response missing result payload", + "decode auth response frame: %v", + ) if err != nil { - return newDispatchError(ErrorCodeUnexpectedResponse, fmt.Sprintf("decode auth response frame: %v", err)) + return err } if frame.Type != gateway.FrameTypeAck || frame.Action != gateway.FrameActionAuthenticate || frame.RequestID != authRequestID { return newDispatchError(ErrorCodeUnexpectedResponse, "unexpected auth response frame") @@ -347,76 +329,79 @@ func (d *Dispatcher) launchGateway(ctx context.Context, listenAddress string, re spec, err := resolveLaunchSpecFn() if err != nil { - d.emitLaunchDecisionLog(launchDecisionLogEntry{ - RequestID: requestID, - Method: string(protocol.MethodWakeOpenURL), - Source: "url-dispatch", - Status: "launch_failed", - GatewayCode: ErrorCodeGatewayUnavailable, - ListenAddress: listenAddress, - AuthMode: resolveAuthMode(authToken), - Message: err.Error(), - }) + d.emitLaunchFailureLog(requestID, listenAddress, authToken, launcher.LaunchSpec{}, err) return err } - d.emitLaunchDecisionLog(launchDecisionLogEntry{ - RequestID: requestID, - Method: string(protocol.MethodWakeOpenURL), - Source: "url-dispatch", - Status: "launch_attempt", - ListenAddress: listenAddress, - AuthMode: resolveAuthMode(authToken), - LaunchMode: spec.LaunchMode, - ResolvedExec: spec.Executable, - }) + d.emitLaunchDecisionLog(newLaunchDecisionLogEntry( + requestID, + listenAddress, + authToken, + "launch_attempt", + "", + spec, + "", + )) launchSpec := spec launchSpec.Args = buildGatewayLaunchArgs(spec.Args, listenAddress) if err := startGatewayFn(launchSpec); err != nil { - d.emitLaunchDecisionLog(launchDecisionLogEntry{ - RequestID: requestID, - Method: string(protocol.MethodWakeOpenURL), - Source: "url-dispatch", - Status: "launch_failed", - GatewayCode: ErrorCodeGatewayUnavailable, - ListenAddress: listenAddress, - AuthMode: resolveAuthMode(authToken), - LaunchMode: spec.LaunchMode, - ResolvedExec: spec.Executable, - Message: err.Error(), - }) + d.emitLaunchFailureLog(requestID, listenAddress, authToken, spec, err) return err } if err := d.waitGatewayReady(ctx, listenAddress); err != nil { - d.emitLaunchDecisionLog(launchDecisionLogEntry{ - RequestID: requestID, - Method: string(protocol.MethodWakeOpenURL), - Source: "url-dispatch", - Status: "launch_failed", - GatewayCode: ErrorCodeGatewayUnavailable, - ListenAddress: listenAddress, - AuthMode: resolveAuthMode(authToken), - LaunchMode: spec.LaunchMode, - ResolvedExec: spec.Executable, - Message: err.Error(), - }) + d.emitLaunchFailureLog(requestID, listenAddress, authToken, spec, err) return err } - d.emitLaunchDecisionLog(launchDecisionLogEntry{ - RequestID: requestID, - Method: string(protocol.MethodWakeOpenURL), - Source: "url-dispatch", - Status: "launch_ready", - ListenAddress: listenAddress, - AuthMode: resolveAuthMode(authToken), - LaunchMode: spec.LaunchMode, - ResolvedExec: spec.Executable, - }) + d.emitLaunchDecisionLog(newLaunchDecisionLogEntry( + requestID, + listenAddress, + authToken, + "launch_ready", + "", + spec, + "", + )) return nil } +// validateRPCFrameResponse 统一校验 JSON-RPC 基础字段并解码结果帧,保持调度与鉴权分支一致。 +func validateRPCFrameResponse( + response protocol.JSONRPCResponse, + expectedID json.RawMessage, + versionMismatchMessage string, + idMismatchMessage string, + dualPayloadMessage string, + missingResultMessage string, + decodeFrameMessageFormat string, +) (gateway.MessageFrame, error) { + if strings.TrimSpace(response.JSONRPC) != protocol.JSONRPCVersion { + return gateway.MessageFrame{}, newDispatchError(ErrorCodeUnexpectedResponse, versionMismatchMessage) + } + if !rawJSONMessageEqual(response.ID, expectedID) { + return gateway.MessageFrame{}, newDispatchError(ErrorCodeUnexpectedResponse, idMismatchMessage) + } + if response.Error != nil && response.Result != nil { + return gateway.MessageFrame{}, newDispatchError(ErrorCodeUnexpectedResponse, dualPayloadMessage) + } + if response.Error != nil { + return gateway.MessageFrame{}, toDispatchErrorFromJSONRPC(response.Error) + } + if response.Result == nil { + return gateway.MessageFrame{}, newDispatchError(ErrorCodeUnexpectedResponse, missingResultMessage) + } + + frame, err := decodeResponseFrameResult(response.Result) + if err != nil { + return gateway.MessageFrame{}, newDispatchError( + ErrorCodeUnexpectedResponse, + fmt.Sprintf(decodeFrameMessageFormat, err), + ) + } + return frame, nil +} + // buildGatewayLaunchArgs 构造自动拉起参数,确保子进程监听地址与调度重拨地址一致。 func buildGatewayLaunchArgs(baseArgs []string, listenAddress string) []string { args := append([]string(nil), baseArgs...) @@ -438,12 +423,17 @@ func (d *Dispatcher) waitGatewayReady(ctx context.Context, listenAddress string) sleepFn = time.Sleep } - deadline := nowFn().Add(defaultGatewayLaunchTimeout) + startTime := nowFn() + deadline := startTime.Add(defaultGatewayLaunchTimeout) if ctx != nil { if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { deadline = ctxDeadline } } + effectiveTimeout := deadline.Sub(startTime) + if effectiveTimeout < 0 { + effectiveTimeout = 0 + } for { if err := ensureDispatchContextActive(ctx); err != nil { @@ -455,7 +445,7 @@ func (d *Dispatcher) waitGatewayReady(ctx context.Context, listenAddress string) return nil } if !nowFn().Before(deadline) { - return fmt.Errorf("gateway did not become reachable within %s", defaultGatewayLaunchTimeout) + return fmt.Errorf("gateway did not become reachable within %s", effectiveTimeout) } sleepFn(defaultGatewayLaunchRetryInterval) } @@ -474,6 +464,49 @@ func (d *Dispatcher) emitLaunchDecisionLog(entry launchDecisionLogEntry) { d.logger.Print(string(raw)) } +// newLaunchDecisionLogEntry 构造统一的网关拉起日志字段,避免各分支重复拼装。 +func newLaunchDecisionLogEntry( + requestID string, + listenAddress string, + authToken string, + status string, + gatewayCode string, + spec launcher.LaunchSpec, + message string, +) launchDecisionLogEntry { + return launchDecisionLogEntry{ + RequestID: requestID, + Method: string(protocol.MethodWakeOpenURL), + Source: "url-dispatch", + Status: status, + GatewayCode: gatewayCode, + ListenAddress: listenAddress, + AuthMode: resolveAuthMode(authToken), + LaunchMode: spec.LaunchMode, + ResolvedExec: spec.Executable, + Message: message, + } +} + +// emitLaunchFailureLog 输出统一的启动失败日志,保持失败分支字段稳定。 +func (d *Dispatcher) emitLaunchFailureLog( + requestID string, + listenAddress string, + authToken string, + spec launcher.LaunchSpec, + err error, +) { + d.emitLaunchDecisionLog(newLaunchDecisionLogEntry( + requestID, + listenAddress, + authToken, + "launch_failed", + ErrorCodeGatewayUnavailable, + spec, + err.Error(), + )) +} + // resolveAuthMode 归一化调度鉴权模式,便于日志与兼容性测试稳定断言。 func resolveAuthMode(authToken string) string { if strings.TrimSpace(authToken) == "" { diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index d193a867..9057e264 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -20,6 +20,43 @@ import ( "neo-code/internal/gateway/transport" ) +// newStubDispatcher 创建测试用调度器,统一默认依赖并允许按需覆盖。 +func newStubDispatcher(overrides func(*Dispatcher)) *Dispatcher { + dispatcher := &Dispatcher{ + resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, + dialFn: func(string) (net.Conn, error) { return &stubDispatchConn{}, nil }, + requestIDFn: func() string { return "wake-test" }, + } + if overrides != nil { + overrides(dispatcher) + } + return dispatcher +} + +// assertDispatchErrorCode 校验错误会被映射为指定的 DispatchError 码。 +func assertDispatchErrorCode(t *testing.T, err error, wantCode string) *DispatchError { + t.Helper() + + var dispatchErr *DispatchError + if !errors.As(err, &dispatchErr) { + t.Fatalf("error type = %T, want *DispatchError", err) + } + if dispatchErr.Code != wantCode { + t.Fatalf("error code = %q, want %q", dispatchErr.Code, wantCode) + } + return dispatchErr +} + +// assertDispatchErrorMessageContains 校验结构化错误包含预期消息片段。 +func assertDispatchErrorMessageContains(t *testing.T, err error, wantCode string, wantMessage string) { + t.Helper() + + dispatchErr := assertDispatchErrorCode(t, err, wantCode) + if !strings.Contains(dispatchErr.Message, wantMessage) { + t.Fatalf("error message = %q, want contains %q", dispatchErr.Message, wantMessage) + } +} + func TestDispatcherDispatchSuccess(t *testing.T) { serverConn, clientConn := net.Pipe() t.Cleanup(func() { @@ -27,17 +64,14 @@ func TestDispatcherDispatchSuccess(t *testing.T) { _ = clientConn.Close() }) - dispatcher := &Dispatcher{ - resolveListenAddressFn: func(string) (string, error) { - return "stub://gateway", nil - }, - dialFn: func(string) (net.Conn, error) { + dispatcher := newStubDispatcher(func(dispatcher *Dispatcher) { + dispatcher.dialFn = func(string) (net.Conn, error) { return clientConn, nil - }, - requestIDFn: func() string { + } + dispatcher.requestIDFn = func() string { return "wake-1" - }, - } + } + }) done := make(chan struct{}) go func() { @@ -115,11 +149,10 @@ func TestDispatcherDispatchReturnsGatewayError(t *testing.T) { _ = clientConn.Close() }) - dispatcher := &Dispatcher{ - resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, - dialFn: func(string) (net.Conn, error) { return clientConn, nil }, - requestIDFn: func() string { return "wake-2" }, - } + dispatcher := newStubDispatcher(func(dispatcher *Dispatcher) { + dispatcher.dialFn = func(string) (net.Conn, error) { return clientConn, nil } + dispatcher.requestIDFn = func() string { return "wake-2" } + }) go func() { decoder := json.NewDecoder(serverConn) @@ -144,13 +177,7 @@ func TestDispatcherDispatchReturnsGatewayError(t *testing.T) { t.Fatal("expected gateway error") } - var dispatchErr *DispatchError - if !errors.As(err, &dispatchErr) { - t.Fatalf("error type = %T, want *DispatchError", err) - } - if dispatchErr.Code != gateway.ErrorCodeInvalidAction.String() { - t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeInvalidAction.String()) - } + assertDispatchErrorCode(t, err, gateway.ErrorCodeInvalidAction.String()) } func TestDispatcherDispatchReturnsUnexpectedResponseError(t *testing.T) { @@ -160,11 +187,10 @@ func TestDispatcherDispatchReturnsUnexpectedResponseError(t *testing.T) { _ = clientConn.Close() }) - dispatcher := &Dispatcher{ - resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, - dialFn: func(string) (net.Conn, error) { return clientConn, nil }, - requestIDFn: func() string { return "wake-3" }, - } + dispatcher := newStubDispatcher(func(dispatcher *Dispatcher) { + dispatcher.dialFn = func(string) (net.Conn, error) { return clientConn, nil } + dispatcher.requestIDFn = func() string { return "wake-3" } + }) go func() { decoder := json.NewDecoder(serverConn) @@ -188,13 +214,7 @@ func TestDispatcherDispatchReturnsUnexpectedResponseError(t *testing.T) { if err == nil { t.Fatal("expected unexpected response error") } - var dispatchErr *DispatchError - if !errors.As(err, &dispatchErr) { - t.Fatalf("error type = %T, want *DispatchError", err) - } - if dispatchErr.Code != ErrorCodeUnexpectedResponse { - t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) - } + assertDispatchErrorCode(t, err, ErrorCodeUnexpectedResponse) } func TestDispatcherDispatchReturnsCorrelationMismatchError(t *testing.T) { @@ -204,11 +224,10 @@ func TestDispatcherDispatchReturnsCorrelationMismatchError(t *testing.T) { _ = clientConn.Close() }) - dispatcher := &Dispatcher{ - resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, - dialFn: func(string) (net.Conn, error) { return clientConn, nil }, - requestIDFn: func() string { return "wake-9" }, - } + dispatcher := newStubDispatcher(func(dispatcher *Dispatcher) { + dispatcher.dialFn = func(string) (net.Conn, error) { return clientConn, nil } + dispatcher.requestIDFn = func() string { return "wake-9" } + }) go func() { decoder := json.NewDecoder(serverConn) @@ -232,26 +251,16 @@ func TestDispatcherDispatchReturnsCorrelationMismatchError(t *testing.T) { if err == nil { t.Fatal("expected correlation mismatch error") } - var dispatchErr *DispatchError - if !errors.As(err, &dispatchErr) { - t.Fatalf("error type = %T, want *DispatchError", err) - } - if dispatchErr.Code != ErrorCodeUnexpectedResponse { - t.Fatalf("error code = %q, want %q", dispatchErr.Code, ErrorCodeUnexpectedResponse) - } - if !strings.Contains(dispatchErr.Message, "frame correlation failed") { - t.Fatalf("error message = %q, want correlation failure", dispatchErr.Message) - } + assertDispatchErrorMessageContains(t, err, ErrorCodeUnexpectedResponse, "frame correlation failed") } func TestDispatcherDispatchInputAndDialErrors(t *testing.T) { - dispatcher := &Dispatcher{ - resolveListenAddressFn: func(string) (string, error) { return "stub://gateway", nil }, - dialFn: func(string) (net.Conn, error) { + dispatcher := newStubDispatcher(func(dispatcher *Dispatcher) { + dispatcher.dialFn = func(string) (net.Conn, error) { return nil, errors.New("dial failed") - }, - requestIDFn: func() string { return "wake-4" }, - } + } + dispatcher.requestIDFn = func() string { return "wake-4" } + }) _, parseErr := dispatcher.Dispatch(context.Background(), DispatchRequest{ RawURL: "http://review?path=README.md", @@ -259,13 +268,7 @@ func TestDispatcherDispatchInputAndDialErrors(t *testing.T) { if parseErr == nil { t.Fatal("expected parse error") } - var parseDispatchErr *DispatchError - if !errors.As(parseErr, &parseDispatchErr) { - t.Fatalf("parse error type = %T, want *DispatchError", parseErr) - } - if parseDispatchErr.Code != "invalid_scheme" { - t.Fatalf("parse error code = %q, want %q", parseDispatchErr.Code, "invalid_scheme") - } + assertDispatchErrorCode(t, parseErr, "invalid_scheme") _, dialErr := dispatcher.Dispatch(context.Background(), DispatchRequest{ RawURL: "neocode://review?path=README.md", @@ -273,13 +276,7 @@ func TestDispatcherDispatchInputAndDialErrors(t *testing.T) { if dialErr == nil { t.Fatal("expected dial error") } - var dialDispatchErr *DispatchError - if !errors.As(dialErr, &dialDispatchErr) { - t.Fatalf("dial error type = %T, want *DispatchError", dialErr) - } - if dialDispatchErr.Code != ErrorCodeGatewayUnavailable { - t.Fatalf("dial error code = %q, want %q", dialDispatchErr.Code, ErrorCodeGatewayUnavailable) - } + assertDispatchErrorCode(t, dialErr, ErrorCodeGatewayUnavailable) } func TestDispatcherDialGatewayWithSingleLaunchFallback(t *testing.T) { @@ -1343,6 +1340,9 @@ func TestDispatcherWaitGatewayReadyBranches(t *testing.T) { if !strings.Contains(err.Error(), "did not become reachable") && !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("expected timeout-related error, got %v", err) } + if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "40ms") { + t.Fatalf("error = %v, want contains %q when timeout message is returned", err, "40ms") + } if sleepCalls != 0 { t.Fatalf("sleepCalls = %d, want %d", sleepCalls, 0) } diff --git a/internal/gateway/launcher/launcher.go b/internal/gateway/launcher/launcher.go index f29b7a89..b0b846d9 100644 --- a/internal/gateway/launcher/launcher.go +++ b/internal/gateway/launcher/launcher.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" ) @@ -56,41 +57,97 @@ func resolveGatewayLaunchSpecWithDeps( options ResolveOptions, lookPathFn func(string) (string, error), ) (LaunchSpec, error) { - resolveByLookup := func(binary string) (string, error) { - resolved, err := lookPathFn(strings.TrimSpace(binary)) - if err != nil { - return "", fmt.Errorf("resolve executable %q: %w", strings.TrimSpace(binary), err) - } - return strings.TrimSpace(resolved), nil - } - explicitBinary := strings.TrimSpace(options.ExplicitBinary) if explicitBinary != "" { - resolved, err := resolveByLookup(explicitBinary) + if err := validateExplicitGatewayBinary(explicitBinary); err != nil { + return LaunchSpec{}, err + } + spec, err := resolveLaunchSpecCandidate( + lookPathFn, + explicitBinary, + LaunchModeExplicitPath, + nil, + "explicit gateway binary", + ) if err != nil { return LaunchSpec{}, err } - return LaunchSpec{ - LaunchMode: LaunchModeExplicitPath, - Executable: resolved, - }, nil + return spec, nil } - if resolved, err := resolveByLookup("neocode-gateway"); err == nil { - return LaunchSpec{ - LaunchMode: LaunchModePathBinary, - Executable: resolved, - }, nil + resolvedPathBinary, err := resolveExecutablePath(lookPathFn, "neocode-gateway") + if err == nil { + return resolveLaunchSpecFromResolvedPath( + resolvedPathBinary, + LaunchModePathBinary, + nil, + "PATH neocode-gateway", + ) } - resolvedFallbackExecutable, err := resolveByLookup("neocode") + return resolveLaunchSpecCandidate( + lookPathFn, + "neocode", + LaunchModeFallbackSubcommand, + []string{"gateway"}, + "PATH neocode", + ) +} + +// resolveLaunchSpecCandidate 统一处理可执行查找、绝对路径校验与 LaunchSpec 构造。 +func resolveLaunchSpecCandidate( + lookPathFn func(string) (string, error), + binary string, + launchMode string, + args []string, + source string, +) (LaunchSpec, error) { + resolvedPath, err := resolveExecutablePath(lookPathFn, binary) if err != nil { return LaunchSpec{}, err } + return resolveLaunchSpecFromResolvedPath(resolvedPath, launchMode, args, source) +} +// resolveLaunchSpecFromResolvedPath 基于已解析的路径构造启动规格,并保留绝对路径校验。 +func resolveLaunchSpecFromResolvedPath( + resolvedPath string, + launchMode string, + args []string, + source string, +) (LaunchSpec, error) { + if err := validateResolvedExecutablePath(resolvedPath, source); err != nil { + return LaunchSpec{}, err + } return LaunchSpec{ - LaunchMode: LaunchModeFallbackSubcommand, - Executable: resolvedFallbackExecutable, - Args: []string{"gateway"}, + LaunchMode: launchMode, + Executable: resolvedPath, + Args: append([]string(nil), args...), }, nil } + +// resolveExecutablePath 统一处理可执行路径查找与空白归一化。 +func resolveExecutablePath(lookPathFn func(string) (string, error), binary string) (string, error) { + trimmedBinary := strings.TrimSpace(binary) + resolvedPath, err := lookPathFn(trimmedBinary) + if err != nil { + return "", fmt.Errorf("resolve executable %q: %w", trimmedBinary, err) + } + return strings.TrimSpace(resolvedPath), nil +} + +// validateExplicitGatewayBinary 校验显式配置的网关二进制路径,禁止使用相对路径降低 PATH 劫持风险。 +func validateExplicitGatewayBinary(explicitBinary string) error { + if !filepath.IsAbs(explicitBinary) { + return fmt.Errorf("explicit gateway binary must be an absolute path: %q", explicitBinary) + } + return nil +} + +// validateResolvedExecutablePath 校验解析后的可执行路径必须为绝对路径,避免执行不受控相对路径目标。 +func validateResolvedExecutablePath(resolvedPath string, source string) error { + if !filepath.IsAbs(resolvedPath) { + return fmt.Errorf("resolved executable from %s is not an absolute path: %q", source, resolvedPath) + } + return nil +} diff --git a/internal/gateway/launcher/launcher_test.go b/internal/gateway/launcher/launcher_test.go index 9738cf5e..afa22c31 100644 --- a/internal/gateway/launcher/launcher_test.go +++ b/internal/gateway/launcher/launcher_test.go @@ -6,10 +6,26 @@ import ( "path/filepath" "reflect" "runtime" + "strings" "testing" "time" ) +// assertLaunchSpecEqual 校验解析出的启动规格,保持测试断言结构一致。 +func assertLaunchSpecEqual(t *testing.T, spec LaunchSpec, want LaunchSpec) { + t.Helper() + + if spec.LaunchMode != want.LaunchMode { + t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, want.LaunchMode) + } + if spec.Executable != want.Executable { + t.Fatalf("executable = %q, want %q", spec.Executable, want.Executable) + } + if !reflect.DeepEqual(spec.Args, want.Args) { + t.Fatalf("args = %#v, want %#v", spec.Args, want.Args) + } +} + func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { t.Run("explicit binary has highest priority", func(t *testing.T) { spec, err := resolveGatewayLaunchSpecWithDeps( @@ -24,15 +40,10 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { if err != nil { t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) } - if spec.LaunchMode != LaunchModeExplicitPath { - t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, LaunchModeExplicitPath) - } - if spec.Executable != "/opt/tools/neocode-gateway" { - t.Fatalf("executable = %q, want %q", spec.Executable, "/opt/tools/neocode-gateway") - } - if len(spec.Args) != 0 { - t.Fatalf("args = %#v, want empty", spec.Args) - } + assertLaunchSpecEqual(t, spec, LaunchSpec{ + LaunchMode: LaunchModeExplicitPath, + Executable: "/opt/tools/neocode-gateway", + }) }) t.Run("path binary preferred over fallback", func(t *testing.T) { @@ -48,15 +59,10 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { if err != nil { t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) } - if spec.LaunchMode != LaunchModePathBinary { - t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, LaunchModePathBinary) - } - if spec.Executable != "/usr/local/bin/neocode-gateway" { - t.Fatalf("executable = %q, want %q", spec.Executable, "/usr/local/bin/neocode-gateway") - } - if len(spec.Args) != 0 { - t.Fatalf("args = %#v, want empty", spec.Args) - } + assertLaunchSpecEqual(t, spec, LaunchSpec{ + LaunchMode: LaunchModePathBinary, + Executable: "/usr/local/bin/neocode-gateway", + }) }) t.Run("fallback to neocode subcommand", func(t *testing.T) { @@ -76,15 +82,11 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { if err != nil { t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) } - if spec.LaunchMode != LaunchModeFallbackSubcommand { - t.Fatalf("launch mode = %q, want %q", spec.LaunchMode, LaunchModeFallbackSubcommand) - } - if spec.Executable != "/usr/local/bin/neocode" { - t.Fatalf("executable = %q, want %q", spec.Executable, "/usr/local/bin/neocode") - } - if !reflect.DeepEqual(spec.Args, []string{"gateway"}) { - t.Fatalf("args = %#v, want %#v", spec.Args, []string{"gateway"}) - } + assertLaunchSpecEqual(t, spec, LaunchSpec{ + LaunchMode: LaunchModeFallbackSubcommand, + Executable: "/usr/local/bin/neocode", + Args: []string{"gateway"}, + }) }) t.Run("explicit binary lookup failure returns error", func(t *testing.T) { @@ -99,6 +101,67 @@ func TestResolveGatewayLaunchSpecWithDeps(t *testing.T) { } }) + t.Run("explicit binary must be absolute path", func(t *testing.T) { + lookupCalled := false + _, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{ExplicitBinary: "neocode-gateway"}, + func(string) (string, error) { + lookupCalled = true + return "", nil + }, + ) + if err == nil { + t.Fatal("expected explicit path validation error") + } + if lookupCalled { + t.Fatal("lookPath should not be called for invalid explicit path") + } + }) + + t.Run("path binary resolution rejects non-absolute path", func(t *testing.T) { + _, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{}, + func(binary string) (string, error) { + switch binary { + case "neocode-gateway": + return "neocode-gateway", nil + case "neocode": + return "/usr/local/bin/neocode", nil + default: + return "", errors.New("unexpected lookup") + } + }, + ) + if err == nil { + t.Fatal("expected non-absolute path resolution error") + } + if !strings.Contains(err.Error(), "not an absolute path") { + t.Fatalf("error = %v, want contains %q", err, "not an absolute path") + } + }) + + t.Run("fallback binary resolution rejects non-absolute path", func(t *testing.T) { + _, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{}, + func(binary string) (string, error) { + switch binary { + case "neocode-gateway": + return "", errors.New("not found") + case "neocode": + return "neocode", nil + default: + return "", errors.New("unexpected lookup") + } + }, + ) + if err == nil { + t.Fatal("expected non-absolute fallback path resolution error") + } + if !strings.Contains(err.Error(), "not an absolute path") { + t.Fatalf("error = %v, want contains %q", err, "not an absolute path") + } + }) + t.Run("fallback fails when neocode is unavailable", func(t *testing.T) { _, err := resolveGatewayLaunchSpecWithDeps( ResolveOptions{}, diff --git a/scripts/check_gateway_docs/main.go b/scripts/check_gateway_docs/main.go new file mode 100644 index 00000000..960d0705 --- /dev/null +++ b/scripts/check_gateway_docs/main.go @@ -0,0 +1,112 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + gatewayExamplesPath = "docs/generated/gateway-rpc-examples.json" + gatewayRPCDocPath = "docs/gateway-rpc-api.md" +) + +// main 执行 Gateway RPC 文档一致性校验,确保生成示例与主文档的关键方法声明不漂移。 +func main() { + if err := checkGatewayRPCDocConsistency(gatewayExamplesPath, gatewayRPCDocPath); err != nil { + fmt.Fprintf(os.Stderr, "gateway docs consistency check failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("verified gateway docs consistency: %s <-> %s\n", gatewayExamplesPath, gatewayRPCDocPath) +} + +// checkGatewayRPCDocConsistency 校验示例 JSON 中的 gateway 方法在主文档中均有对应 Method 小节。 +func checkGatewayRPCDocConsistency(examplesPath, docPath string) error { + examples, err := readGatewayExamples(examplesPath) + if err != nil { + return err + } + + docContent, err := readGatewayRPCDoc(docPath) + if err != nil { + return err + } + if !containsAnyPathReference(docContent, pathReferenceCandidates(examplesPath)) { + return fmt.Errorf("rpc doc %q must reference generated examples file %q", docPath, examplesPath) + } + + missingSections := collectMissingMethodSections(docContent, collectGatewayMethods(examples)) + if len(missingSections) > 0 { + return fmt.Errorf("rpc doc %q is missing sections for generated methods: %s", docPath, strings.Join(missingSections, ", ")) + } + return nil +} + +// readGatewayExamples 读取并解析生成的示例文件,统一错误包装。 +func readGatewayExamples(examplesPath string) (map[string]json.RawMessage, error) { + rawExamples, err := os.ReadFile(examplesPath) + if err != nil { + return nil, fmt.Errorf("read examples file %q: %w", examplesPath, err) + } + + var examples map[string]json.RawMessage + if err := json.Unmarshal(rawExamples, &examples); err != nil { + return nil, fmt.Errorf("decode examples file %q: %w", examplesPath, err) + } + return examples, nil +} + +// readGatewayRPCDoc 读取 Gateway RPC 主文档内容。 +func readGatewayRPCDoc(docPath string) (string, error) { + rawDoc, err := os.ReadFile(docPath) + if err != nil { + return "", fmt.Errorf("read rpc doc %q: %w", docPath, err) + } + return string(rawDoc), nil +} + +// pathReferenceCandidates 返回示例文件可能出现的文档引用形式,兼容绝对路径与仓库相对路径。 +func pathReferenceCandidates(examplesPath string) []string { + normalizedInput := filepath.ToSlash(examplesPath) + return []string{ + normalizedInput, + filepath.ToSlash(filepath.Join("docs", "generated", filepath.Base(examplesPath))), + } +} + +// containsAnyPathReference 判断文档是否包含任意一个合法引用路径。 +func containsAnyPathReference(content string, candidates []string) bool { + for _, candidate := range candidates { + if strings.Contains(content, candidate) { + return true + } + } + return false +} + +// collectMissingMethodSections 收集文档中缺失的方法小节标题,便于稳定输出错误信息。 +func collectMissingMethodSections(docContent string, methods []string) []string { + missingSections := make([]string, 0) + for _, method := range methods { + heading := "## Method: " + method + if !strings.Contains(docContent, heading) { + missingSections = append(missingSections, heading) + } + } + return missingSections +} + +// collectGatewayMethods 从生成示例键中提取 gateway.* 方法名并排序,便于稳定校验与报错。 +func collectGatewayMethods(examples map[string]json.RawMessage) []string { + methods := make([]string, 0, len(examples)) + for key := range examples { + if strings.HasPrefix(key, "gateway.") { + methods = append(methods, key) + } + } + sort.Strings(methods) + return methods +} diff --git a/scripts/check_gateway_docs/main_test.go b/scripts/check_gateway_docs/main_test.go new file mode 100644 index 00000000..8632e015 --- /dev/null +++ b/scripts/check_gateway_docs/main_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeGatewayDocFixtures 写入文档校验测试所需的示例与文档文件。 +func writeGatewayDocFixtures(t *testing.T, examples string, doc string) (string, string) { + t.Helper() + + tempDir := t.TempDir() + examplesPath := filepath.Join(tempDir, "gateway-rpc-examples.json") + docPath := filepath.Join(tempDir, "gateway-rpc-api.md") + if err := os.WriteFile(examplesPath, []byte(examples), 0o644); err != nil { + t.Fatalf("write examples: %v", err) + } + if err := os.WriteFile(docPath, []byte(doc), 0o644); err != nil { + t.Fatalf("write doc: %v", err) + } + return examplesPath, docPath +} + +func TestCheckGatewayRPCDocConsistency(t *testing.T) { + t.Run("success when methods and generated path are in doc", func(t *testing.T) { + examples := `{ + "gateway.bindStream": {}, + "gateway.run": {}, + "common.error": {} +} +` + doc := strings.Join([]string{ + "# Gateway RPC API", + "", + "产物:docs/generated/gateway-rpc-examples.json", + "", + "## Method: gateway.bindStream", + "", + "## Method: gateway.run", + }, "\n") + examplesPath, docPath := writeGatewayDocFixtures(t, examples, doc) + + if err := checkGatewayRPCDocConsistency(examplesPath, docPath); err != nil { + t.Fatalf("checkGatewayRPCDocConsistency() error = %v", err) + } + }) + + t.Run("fails when doc misses generated path reference", func(t *testing.T) { + examples := `{"gateway.run":{}}` + doc := "## Method: gateway.run\n" + examplesPath, docPath := writeGatewayDocFixtures(t, examples, doc) + + err := checkGatewayRPCDocConsistency(examplesPath, docPath) + if err == nil { + t.Fatal("expected generated path reference error") + } + if !strings.Contains(err.Error(), "must reference generated examples file") { + t.Fatalf("error = %v, want contains %q", err, "must reference generated examples file") + } + }) + + t.Run("fails when doc misses method sections", func(t *testing.T) { + examples := `{"gateway.bindStream":{},"gateway.run":{}}` + doc := strings.Join([]string{ + "docs/generated/gateway-rpc-examples.json", + "## Method: gateway.run", + }, "\n") + examplesPath, docPath := writeGatewayDocFixtures(t, examples, doc) + + err := checkGatewayRPCDocConsistency(examplesPath, docPath) + if err == nil { + t.Fatal("expected missing method section error") + } + if !strings.Contains(err.Error(), "## Method: gateway.bindStream") { + t.Fatalf("error = %v, want contains %q", err, "## Method: gateway.bindStream") + } + }) +} + +func TestCollectGatewayMethods(t *testing.T) { + methods := collectGatewayMethods(map[string]json.RawMessage{ + "common.error": nil, + "gateway.run": nil, + "gateway.bindStream": nil, + }) + + want := []string{"gateway.bindStream", "gateway.run"} + if len(methods) != len(want) { + t.Fatalf("len(methods) = %d, want %d", len(methods), len(want)) + } + for index := range want { + if methods[index] != want[index] { + t.Fatalf("methods[%d] = %q, want %q", index, methods[index], want[index]) + } + } +} + +func TestCollectMissingMethodSections(t *testing.T) { + missing := collectMissingMethodSections("## Method: gateway.run", []string{"gateway.bindStream", "gateway.run"}) + want := []string{"## Method: gateway.bindStream"} + if len(missing) != len(want) { + t.Fatalf("len(missing) = %d, want %d", len(missing), len(want)) + } + for index := range want { + if missing[index] != want[index] { + t.Fatalf("missing[%d] = %q, want %q", index, missing[index], want[index]) + } + } +} From 57c314c77cc5703c74b3c25d7848800784fff9c8 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 07:28:08 +0000 Subject: [PATCH 14/15] fix(conflict): resolve README merge conflict with main Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index aa52a792..d665f5e8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,22 @@ ## 快速开始 -### 1) 从源码运行 +### 能力概览 + +- 终端原生 TUI 交互体验(Bubble Tea) +- Agent 可调用内置工具完成文件与命令相关任务 +- 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`、`modelscope`) +- 支持上下文压缩(`/compact`),帮助长会话保持可用 +- 支持工作区隔离(`--workdir`、`/cwd`) +- 会话持久化与恢复,降低重复沟通成本 +- 支持持久记忆查看、显式写入与后台自动提取,保留跨会话偏好与项目事实 + +### 1) 环境要求 + +- Go `1.25+` +- 可用的 API Key(如 OpenAI、Gemini、OpenLL、Qiniu、ModelScope) + +### 2) 从源码运行 ```bash git clone https://github.com/1024XEngineer/neo-code.git @@ -21,7 +36,7 @@ cd neo-code go run ./cmd/neocode ``` -### 2) 启动网关(两种等价方式) +### 3) 启动网关(两种等价方式) ```bash go run ./cmd/neocode gateway --http-listen 127.0.0.1:8080 @@ -31,7 +46,7 @@ go run ./cmd/neocode gateway --http-listen 127.0.0.1:8080 go run ./cmd/neocode-gateway --http-listen 127.0.0.1:8080 ``` -### 3) URL 唤醒分发 +### 4) URL 唤醒分发 ```bash go run ./cmd/neocode url-dispatch --url "neocode://review?path=README.md" @@ -58,18 +73,46 @@ bash ./scripts/install.sh --flavor full bash ./scripts/install.sh --flavor gateway ``` -Dry-run(仅输出资产 URL / checksum URL): +Dry-run(仅输出资产 URL / checksum URL,不执行下载与安装): ```bash bash ./scripts/install.sh --flavor gateway --dry-run ``` +Provider API Key 示例(按使用 provider 选择): + +```bash +export OPENAI_API_KEY="your_key_here" +export GEMINI_API_KEY="your_key_here" +export AI_API_KEY="your_key_here" +export QINIU_API_KEY="your_key_here" +export MODELSCOPE_API_KEY="your_key_here" +``` + ### Windows PowerShell ```powershell irm https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.ps1 | iex ``` +可选 flavor 与 dry-run: + +```powershell +.\scripts\install.ps1 -Flavor full +.\scripts\install.ps1 -Flavor gateway +.\scripts\install.ps1 -Flavor gateway -DryRun +``` + +Provider API Key 示例(按使用 provider 选择): + +```powershell +$env:OPENAI_API_KEY = "your_key_here" +$env:GEMINI_API_KEY = "your_key_here" +$env:AI_API_KEY = "your_key_here" +$env:QINIU_API_KEY = "your_key_here" +$env:MODELSCOPE_API_KEY = "your_key_here" +``` + Gateway 转发与自动拉起说明: - `neocode` 默认通过本地 Gateway(优先 IPC)转发 runtime 请求与事件流 @@ -99,14 +142,6 @@ Gateway 转发与自动拉起说明: 帮我在 internal/runtime 下定位与 tool result 回灌相关逻辑 ``` -可选 flavor 与 dry-run: - -```powershell -.\scripts\install.ps1 -Flavor full -.\scripts\install.ps1 -Flavor gateway -.\scripts\install.ps1 -Flavor gateway -DryRun -``` - ## 部署拓扑建议 1. 本地内嵌(默认):`neocode` 进程内通过 `gateway` 子命令管理网关。 @@ -120,6 +155,14 @@ Gateway 转发与自动拉起说明: 2. 再验证 `/rpc` 最小请求(含未鉴权失败路径)。 3. 如异常,回滚到上一个已验证版本的二进制与配置。 +## 内部结构补充 + +- `internal/context`:负责消费仓库/运行时事实并组装主会话 system prompt、动态上下文注入与消息裁剪。 +- `internal/repository`:负责仓库级事实发现与裁剪,统一提供 repo summary、changed-files context 与 targeted retrieval。 +- `internal/runtime`:负责 ReAct 主循环、tool 调用编排、compact 触发与 reminder 注入时机。 +- `internal/subagent`:负责子代理角色策略、执行约束与输出契约。 +- `internal/promptasset`:负责受版本管理的静态 prompt 模板资产,使用 `go:embed` 编译进程序,供 `context`、`runtime`、`subagent` 读取。 + ## 文档索引 - [Gateway 详细设计 RFC](docs/gateway-detailed-design.md) @@ -132,10 +175,12 @@ Gateway 转发与自动拉起说明: - [Runtime/Provider 事件流](docs/runtime-provider-event-flow.md) - [Session 持久化设计](docs/session-persistence-design.md) - [Context Compact 说明](docs/context-compact.md) +- [Repository 模块设计](docs/repository-design.md) - [Tools 与 TUI 集成](docs/tools-and-tui-integration.md) - [Skills 设计与使用](docs/skills-system-design.md) - [MCP 配置指南](docs/guides/mcp-configuration.md) -- [更新指南](docs/guides/update.md) +- [ModelScope 半引导配置](docs/guides/modelscope-provider-setup.md) +- [更新指南(更新与升级)](docs/guides/update.md) ## 如何参与 From f32b04b32551b57c20dde6b04586c02d4618706a Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 07:31:21 +0000 Subject: [PATCH 15/15] fix(conflict): align README with main and retain RFC#420 notes Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- README.md | 223 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 146 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index d665f5e8..17fc4837 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,28 @@ # NeoCode -基于 Go + Bubble Tea 的本地 AI Coding Agent,主链路为: +> 基于 Go + Bubble Tea 的本地 Coding Agent -`用户输入(TUI) -> Gateway -> Runtime -> Tools -> 结果回传 -> UI 展示` +## NeoCode 是什么? +NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-Act-Observe)循环模式,围绕以下主链路工作: -## 产物形态 +`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` -本项目提供双产物发布: +它适合希望在本地工作流中完成代码理解、修改、调试与自动化操作的开发者。 -1. `neocode`:默认完整客户端入口(含 `gateway` 子命令)。 -2. `neocode-gateway`:Gateway-Only 服务端入口(不含 TUI 主入口语义)。 +## 项目介绍页 -## 快速开始 - -### 能力概览 +- 仓库内置了基于 VitePress 的 GitHub Pages 站点源码,目录为 `www/` +- 启用仓库的 GitHub Pages 并选择 `GitHub Actions` 后,站点将发布到: + `https://<仓库拥有者>.github.io/neo-code/` +- 本地预览站点可使用: + ```bash + cd www + pnpm install + pnpm docs:dev + ``` +- 开发服务器启动后,默认从 `http://localhost:5173/neo-code/` 访问首页 +## 有什么能力? - 终端原生 TUI 交互体验(Bubble Tea) - Agent 可调用内置工具完成文件与命令相关任务 - 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`、`modelscope`) @@ -23,63 +31,66 @@ - 会话持久化与恢复,降低重复沟通成本 - 支持持久记忆查看、显式写入与后台自动提取,保留跨会话偏好与项目事实 -### 1) 环境要求 +## 怎么用(快速开始) +### 1) 环境要求 - Go `1.25+` - 可用的 API Key(如 OpenAI、Gemini、OpenLL、Qiniu、ModelScope) -### 2) 从源码运行 +### 2) 一键安装 +macOS / Linux: +```bash +curl -fsSL https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.sh | bash +``` +Windows PowerShell: +```powershell +irm https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.ps1 | iex +``` + +### 3) 从源码运行 ```bash git clone https://github.com/1024XEngineer/neo-code.git cd neo-code go run ./cmd/neocode ``` -### 3) 启动网关(两种等价方式) - -```bash -go run ./cmd/neocode gateway --http-listen 127.0.0.1:8080 -``` +Gateway 子命令(Step 1 骨架): ```bash -go run ./cmd/neocode-gateway --http-listen 127.0.0.1:8080 +go run ./cmd/neocode gateway ``` -### 4) URL 唤醒分发 +指定网络访问面监听地址(默认 `127.0.0.1:8080`,仅允许 Loopback): ```bash -go run ./cmd/neocode url-dispatch --url "neocode://review?path=README.md" +go run ./cmd/neocode gateway --http-listen 127.0.0.1:8080 ``` -当网关不可达时,`url-dispatch` 会按固定发现顺序尝试自动拉起: - -1. `NEOCODE_GATEWAY_BIN` 显式路径 -2. `PATH` 中 `neocode-gateway` -3. `PATH` 中 `neocode` 并追加子命令 `gateway` +网络访问面骨架端点(EPIC-GW-04): -## 安装脚本 +- `POST /rpc`:单次 JSON-RPC 请求入口 +- `GET /ws`:WebSocket 流式入口(含心跳) +- `GET /sse`:SSE 流式入口(MVP 默认触发 `gateway.ping`,含心跳) -### Linux / macOS +安全限制:为防止跨站攻击,网关网络面默认开启严格的 Origin 校验。当前仅允许 +`http://localhost`、`http://127.0.0.1`、`http://[::1]` 以及 `app://` 前缀来源连入; +非允许来源的跨域调用会被拦截并返回 `403`。 -```bash -curl -fsSL https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.sh | bash -``` +注:上述白名单机制仅针对携带 `Origin` 头的浏览器跨站请求生效。若请求不携带 `Origin` 头 +(例如 `curl`、Postman 或本地后端脚本直连),网关默认放行。 -可选 flavor: +URL Scheme 派发骨架命令(EPIC-GW-02A): ```bash -bash ./scripts/install.sh --flavor full -bash ./scripts/install.sh --flavor gateway +go run ./cmd/neocode url-dispatch --url "neocode://review?path=README.md" ``` -Dry-run(仅输出资产 URL / checksum URL,不执行下载与安装): - -```bash -bash ./scripts/install.sh --flavor gateway --dry-run -``` +> `url-dispatch` 会将 `neocode://` URL 转发到本地 Gateway,并输出结构化响应。 +> +> 注意:当前 MVP 版本仅支持 `review` 动作,且必须携带 `path` 参数(如 `neocode://review?path=README.md`);其余动作会在网关侧被拦截拒绝。 -Provider API Key 示例(按使用 provider 选择): +设置 API Key 示例(按你使用的 provider 选择): ```bash export OPENAI_API_KEY="your_key_here" @@ -89,22 +100,7 @@ export QINIU_API_KEY="your_key_here" export MODELSCOPE_API_KEY="your_key_here" ``` -### Windows PowerShell - -```powershell -irm https://raw.githubusercontent.com/1024XEngineer/neo-code/main/scripts/install.ps1 | iex -``` - -可选 flavor 与 dry-run: - -```powershell -.\scripts\install.ps1 -Flavor full -.\scripts\install.ps1 -Flavor gateway -.\scripts\install.ps1 -Flavor gateway -DryRun -``` - -Provider API Key 示例(按使用 provider 选择): - +Windows PowerShell: ```powershell $env:OPENAI_API_KEY = "your_key_here" $env:GEMINI_API_KEY = "your_key_here" @@ -113,14 +109,19 @@ $env:QINIU_API_KEY = "your_key_here" $env:MODELSCOPE_API_KEY = "your_key_here" ``` +按工作区启动(仅当前进程生效): + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + Gateway 转发与自动拉起说明: - `neocode` 默认通过本地 Gateway(优先 IPC)转发 runtime 请求与事件流 - 启动时会先探测本地网关;若未运行会自动后台拉起并等待就绪(无感) - 若自动拉起后仍不可达或握手失败,会直接报错退出(Fail Fast) -### 常用命令 - +### 4) 首次使用与常用命令 - `/help`:查看命令帮助 - `/provider`:打开 provider 选择器 - `/model`:打开 model 选择器 @@ -136,24 +137,23 @@ Gateway 转发与自动拉起说明: - `/skill active`:查看当前会话已激活 skills 示例输入: - ```text 请先阅读当前项目目录结构并给出模块职责摘要 帮我在 internal/runtime 下定位与 tool result 回灌相关逻辑 ``` -## 部署拓扑建议 +## 配置入口 -1. 本地内嵌(默认):`neocode` 进程内通过 `gateway` 子命令管理网关。 -2. 独立网关服务:使用 `neocode-gateway` 作为可审计、可独立运维的网关进程。 +- 主配置文件:`~/.neocode/config.yaml` +- 自定义 Provider:`~/.neocode/providers//provider.yaml` -默认监听保持回环地址(`127.0.0.1`);对外暴露必须显式配置并补齐鉴权与 ACL。 +配置原则(用户侧重点): -## 升级与回滚(最小流程) +- API Key 通过环境变量注入,不写入 `config.yaml` +- `--workdir` 只影响当前运行,不会回写到配置文件 +- TUI 默认通过 Gateway 连接 runtime,启动时会自动探测并在必要时后台拉起网关 -1. 升级后先验证 `GET /healthz`。 -2. 再验证 `/rpc` 最小请求(含未鉴权失败路径)。 -3. 如异常,回滚到上一个已验证版本的二进制与配置。 +详细配置请参考:[docs/guides/configuration.md](docs/guides/configuration.md) ## 内部结构补充 @@ -163,13 +163,8 @@ Gateway 转发与自动拉起说明: - `internal/subagent`:负责子代理角色策略、执行约束与输出契约。 - `internal/promptasset`:负责受版本管理的静态 prompt 模板资产,使用 `go:embed` 编译进程序,供 `context`、`runtime`、`subagent` 读取。 -## 文档索引 +## 文档导航 -- [Gateway 详细设计 RFC](docs/gateway-detailed-design.md) -- [Gateway 第三方接入协作指南](docs/guides/gateway-integration-guide.md) -- [Gateway RPC API(XGO 风格)](docs/gateway-rpc-api.md) -- [Gateway 错误字典](docs/gateway-error-catalog.md) -- [Gateway 兼容性策略](docs/gateway-compatibility.md) - [配置指南](docs/guides/configuration.md) - [扩展 Provider](docs/guides/adding-providers.md) - [Runtime/Provider 事件流](docs/runtime-provider-event-flow.md) @@ -180,7 +175,7 @@ Gateway 转发与自动拉起说明: - [Skills 设计与使用](docs/skills-system-design.md) - [MCP 配置指南](docs/guides/mcp-configuration.md) - [ModelScope 半引导配置](docs/guides/modelscope-provider-setup.md) -- [更新指南(更新与升级)](docs/guides/update.md) +- [更新与升级](docs/guides/update.md) ## 如何参与 @@ -199,7 +194,6 @@ Gateway 转发与自动拉起说明: 5. 提交 PR 到主仓库并说明变更目的、影响范围和验证方式。 提交前请确认: - - 不提交明文密钥、个人配置或会话数据 - 不提交无关改动与临时文件 @@ -211,14 +205,89 @@ Gateway 转发与自动拉起说明: - `issue-rfc-architecture`(架构类,RFC 风格) - `issue-rfc-implementation`(实现类,执行单风格) -## 开发与验证 +先安装 skills 到仓库内常见 AI Coding 工具目录: ```bash -go build ./... -go test ./... -gofmt -w ./cmd ./internal +make install-skills ``` +默认会安装到以下目录(均在仓库内): + +- `.codex/skills` +- `.claude/skills` +- `.cursor/skills` +- `.windsurf/skills` + +如需自定义安装目标,可设置环境变量 `SKILL_INSTALL_TARGETS`(冒号分隔目录): + +```bash +SKILL_INSTALL_TARGETS=".codex/skills:.claude/skills" make install-skills +``` + +Skill 内部调用脚本 `scripts/create_issue.sh` 创建 issue。你也可以直接执行脚本: + +```bash +./scripts/create_issue.sh --type proposal --title "统一会话中断恢复语义" +./scripts/create_issue.sh --type architecture --title "Runtime 与 Session 账本边界梳理" +./scripts/create_issue.sh --type implementation --title "补齐流式中断持久化" --labels "bug,priority-high" +``` + +脚本可选参数: + +- `--repo `:指定目标仓库(默认自动识别当前仓库) +- `--body-file `:自定义 issue 正文文件(不传则使用内置模板) +- `--labels `:追加标签(逗号分隔) + +## 网关运维与安全(GW-06) + +- 静默认证(Silent Auth): + - 启动 `neocode gateway` 时会自动读取 `~/.neocode/auth.json`。 + - 若凭证不存在或损坏,会自动生成高强度 token 并写回该文件。 + - `url-dispatch` 会自动读取同一 token 并先发送 `gateway.authenticate`,再发送业务请求。 +- 认证与授权顺序:`Auth -> ACL -> Dispatch`。 + - 未认证返回 `unauthorized`。 + - 已认证但不允许的方法返回 `access_denied`。 +- 运维端点: + - 免鉴权:`GET /healthz`、`GET /version` + - 需鉴权:`GET /metrics`、`GET /metrics.json`(`Authorization: Bearer `) +- 关键默认治理参数(可通过 `config.yaml` 的 `gateway.*` 配置): + - `max_frame_bytes=1MiB` + - `ipc_max_connections=128` + - `http_max_request_bytes=1MiB` + - `http_max_stream_connections=128` + - `ipc_read/write_sec=30/30` + - `http_read/write/shutdown_sec=15/15/2` + +详细设计文档:[`docs/gateway-detailed-design.md`](docs/gateway-detailed-design.md) + +### Gateway JSON-RPC 方法清单(当前实现) + +- `gateway.authenticate`:连接级鉴权握手 +- `gateway.ping`:探活 +- `gateway.bindStream`:会话流绑定 +- `gateway.run`:发起一次运行(Accepted-ACK,异步执行) +- `gateway.compact`:触发会话压缩 +- `gateway.cancel`:按 `run_id` 精确取消目标运行(`run_id` 必填) +- `gateway.listSessions`:查询会话摘要列表 +- `gateway.loadSession`:加载单个会话详情 +- `gateway.resolvePermission`:提交权限审批结果 +- `wake.openUrl`:处理 `neocode://` 唤醒请求 +- `gateway.event`:网关推送通知事件(notification) + +## 双产物与启动兼容(RFC#420) + +- 发布产物: + - `neocode`(完整客户端,含 `gateway` 子命令) + - `neocode-gateway`(Gateway-Only 入口) +- `url-dispatch` 网关不可达时的拉起优先级固定为: + - `NEOCODE_GATEWAY_BIN` + - `PATH` 中 `neocode-gateway` + - `neocode gateway` +- 第三方接入与协议文档见: + - [`docs/guides/gateway-integration-guide.md`](docs/guides/gateway-integration-guide.md) + - [`docs/gateway-rpc-api.md`](docs/gateway-rpc-api.md) + - [`docs/gateway-error-catalog.md`](docs/gateway-error-catalog.md) + ## License MIT