diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3c47642..bac60180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,9 +37,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/Makefile b/Makefile index c7548ef1..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 ./scripts/generate_gateway_rpc_examples + @$(GATEWAY_DOCS_GENERATOR) docs-gateway-check: - @go run ./scripts/generate_gateway_rpc_examples - @git diff --exit-code -- docs/reference/gateway-rpc-api.md + @$(GATEWAY_DOCS_GENERATOR) + @go run ./scripts/check_gateway_docs + @git diff --exit-code -- docs/generated/gateway-rpc-examples.json diff --git a/README.md b/README.md index c3b440ce..17fc4837 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-A ## 有什么能力? - 终端原生 TUI 交互体验(Bubble Tea) - Agent 可调用内置工具完成文件与命令相关任务 -- 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`) +- 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`、`modelscope`) - 支持上下文压缩(`/compact`),帮助长会话保持可用 - 支持工作区隔离(`--workdir`、`/cwd`) - 会话持久化与恢复,降低重复沟通成本 @@ -35,7 +35,7 @@ NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-A ### 1) 环境要求 - Go `1.25+` -- 可用的 API Key(如 OpenAI、Gemini、OpenLL、Qiniu) +- 可用的 API Key(如 OpenAI、Gemini、OpenLL、Qiniu、ModelScope) ### 2) 一键安装 macOS / Linux: @@ -97,6 +97,7 @@ 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: @@ -105,6 +106,7 @@ $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" ``` 按工作区启动(仅当前进程生效): @@ -155,7 +157,8 @@ Gateway 转发与自动拉起说明: ## 内部结构补充 -- `internal/context`:负责主会话 system prompt 的 section 组装、动态上下文注入与消息裁剪。 +- `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` 读取。 @@ -167,9 +170,11 @@ 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) +- [ModelScope 半引导配置](docs/guides/modelscope-provider-setup.md) - [更新与升级](docs/guides/update.md) ## 如何参与 @@ -269,6 +274,20 @@ Skill 内部调用脚本 `scripts/create_issue.sh` 创建 issue。你也可以 - `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 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/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/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..afd78666 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. `PATH` 中的 `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..ccededf9 --- /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/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..252f61c2 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -102,13 +102,28 @@ 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", "", "workdir override for this gateway process") + 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 +131,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 +142,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, @@ -199,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) @@ -322,7 +341,7 @@ type gatewayIdleShutdownController struct { timer *time.Timer } -// newGatewayIdleShutdownController 创建网关空闲自退控制器:连接数归零后延迟退出,有连接恢复则取消退出。 +// newGatewayIdleShutdownController 创建网关空闲退出控制器:连接归零后延迟退出,连接恢复则取消退出。 func newGatewayIdleShutdownController(logger *log.Logger, cancel context.CancelFunc) *gatewayIdleShutdownController { return &gatewayIdleShutdownController{ logger: logger, @@ -445,7 +464,7 @@ func defaultNewGatewayServer(options gateway.ServerOptions) (gatewayServer, erro return gateway.NewServer(options) } -// defaultNewGatewayNetworkServer 创建默认网关网络访问面服务实例,供命令层启动流程调用。 +// defaultNewGatewayNetworkServer 创建默认网关网络访问服务实例,供命令层启动流程调用。 func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) { return gateway.NewNetworkServer(options) } @@ -530,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 { 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..81444a88 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, @@ -142,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( @@ -214,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") @@ -263,6 +269,252 @@ 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.emitLaunchFailureLog(requestID, listenAddress, authToken, launcher.LaunchSpec{}, err) + return err + } + + d.emitLaunchDecisionLog(newLaunchDecisionLogEntry( + requestID, + listenAddress, + authToken, + "launch_attempt", + "", + spec, + "", + )) + launchSpec := spec + launchSpec.Args = buildGatewayLaunchArgs(spec.Args, listenAddress) + if err := startGatewayFn(launchSpec); err != nil { + d.emitLaunchFailureLog(requestID, listenAddress, authToken, spec, err) + return err + } + + if err := d.waitGatewayReady(ctx, listenAddress); err != nil { + d.emitLaunchFailureLog(requestID, listenAddress, authToken, spec, err) + return err + } + + 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...) + 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 + if nowFn == nil { + nowFn = time.Now + } + sleepFn := d.sleepFn + if sleepFn == nil { + sleepFn = time.Sleep + } + + 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 { + 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", effectiveTimeout) + } + 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)) +} + +// 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) == "" { + 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..9057e264 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -6,16 +6,57 @@ import ( "encoding/json" "errors" "io" + "log" "net" + "os" + "reflect" "strings" "testing" "time" "neo-code/internal/gateway" + "neo-code/internal/gateway/launcher" "neo-code/internal/gateway/protocol" "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() { @@ -23,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() { @@ -111,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) @@ -140,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) { @@ -156,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) @@ -184,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) { @@ -200,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) @@ -228,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", @@ -255,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", @@ -269,13 +276,160 @@ 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) { + 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) { @@ -402,6 +556,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() @@ -996,6 +1177,392 @@ 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) { + var capturedSpec launcher.LaunchSpec + dispatcher := &Dispatcher{ + resolveLaunchSpecFn: func() (launcher.LaunchSpec, error) { + return launcher.LaunchSpec{LaunchMode: launcher.LaunchModePathBinary, Executable: "/tmp/neocode-gateway"}, nil + }, + startGatewayFn: func(spec launcher.LaunchSpec) error { + capturedSpec = spec + 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) + } + if !reflect.DeepEqual(capturedSpec.Args, []string{"--listen", "stub://gateway"}) { + t.Fatalf("launch args = %#v, want %#v", capturedSpec.Args, []string{"--listen", "stub://gateway"}) + } + }) +} + +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 !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) + } + }) + + 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{}) +} + +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 +} + +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 diff --git a/internal/gateway/launcher/launcher.go b/internal/gateway/launcher/launcher.go new file mode 100644 index 00000000..b0b846d9 --- /dev/null +++ b/internal/gateway/launcher/launcher.go @@ -0,0 +1,153 @@ +package launcher + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + // EnvGatewayBinary 定义显式网关可执行路径的环境变量名。 + EnvGatewayBinary = "NEOCODE_GATEWAY_BIN" + // LaunchModeExplicitPath 表示命中显式路径配置。 + LaunchModeExplicitPath = "explicit_path" + // LaunchModePathBinary 表示命中 PATH 中的 neocode-gateway。 + LaunchModePathBinary = "path_neocode_gateway" + // LaunchModeFallbackSubcommand 表示回退到 PATH 中 neocode 的 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) > PATH(neocode) + gateway 子命令。 +func ResolveGatewayLaunchSpec(options ResolveOptions) (LaunchSpec, error) { + return resolveGatewayLaunchSpecWithDeps(options, exec.LookPath) +} + +// 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), +) (LaunchSpec, error) { + explicitBinary := strings.TrimSpace(options.ExplicitBinary) + if 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 spec, nil + } + + resolvedPathBinary, err := resolveExecutablePath(lookPathFn, "neocode-gateway") + if err == nil { + return resolveLaunchSpecFromResolvedPath( + resolvedPathBinary, + LaunchModePathBinary, + nil, + "PATH neocode-gateway", + ) + } + + 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: 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 new file mode 100644 index 00000000..afa22c31 --- /dev/null +++ b/internal/gateway/launcher/launcher_test.go @@ -0,0 +1,235 @@ +package launcher + +import ( + "errors" + "os" + "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( + 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") + }, + ) + if err != nil { + t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) + } + assertLaunchSpecEqual(t, spec, LaunchSpec{ + LaunchMode: LaunchModeExplicitPath, + Executable: "/opt/tools/neocode-gateway", + }) + }) + + 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") + }, + ) + if err != nil { + t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) + } + assertLaunchSpecEqual(t, spec, LaunchSpec{ + LaunchMode: LaunchModePathBinary, + Executable: "/usr/local/bin/neocode-gateway", + }) + }) + + t.Run("fallback to neocode subcommand", func(t *testing.T) { + spec, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{}, + 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 { + t.Fatalf("resolveGatewayLaunchSpecWithDeps() error = %v", err) + } + 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) { + _, err := resolveGatewayLaunchSpecWithDeps( + ResolveOptions{ExplicitBinary: "/missing/neocode-gateway"}, + func(string) (string, error) { + return "", errors.New("missing") + }, + ) + if err == nil { + t.Fatal("expected explicit lookup error") + } + }) + + 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{}, + func(binary string) (string, error) { + if binary == "neocode-gateway" || binary == "neocode" { + return "", errors.New("not found") + } + return "", errors.New("unexpected lookup") + }, + ) + if err == nil { + t.Fatal("expected fallback resolution error") + } + }) +} + +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{}) + 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..eeae0c2f --- /dev/null +++ b/internal/gateway/protocol/docgen_generate.go @@ -0,0 +1,3 @@ +package protocol + +//go:generate go run -tags gatewaydocgen ../../../scripts/generate_gateway_rpc_examples.go 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]) + } + } +} diff --git a/scripts/generate_gateway_rpc_examples.go b/scripts/generate_gateway_rpc_examples.go new file mode 100644 index 00000000..af1886e6 --- /dev/null +++ b/scripts/generate_gateway_rpc_examples.go @@ -0,0 +1,158 @@ +//go:build gatewaydocgen +// +build gatewaydocgen + +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}."