From 287c2246ed05cf98411b45eb64dbf6cd8499ec1e Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 7 May 2026 11:39:40 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(runner):=20=E5=AE=9E=E7=8E=B0=20Phas?= =?UTF-8?q?e=202=20=E6=9C=AC=E6=9C=BA=20Runner=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E9=80=9A=E9=81=93=20(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现从"飞书消息 → 云端 Gateway → 本机 Runner"的安全最小闭环。Runner 通过主动出站 WebSocket 长连接与云端 Gateway 通信,在本机执行工具并将 结果回传,无需暴露入站端口。 ## 新增文件 - `internal/runner/types.go` — Runner 类型定义(ToolExecutionRequest / Result / Config) - `internal/runner/runner.go` — Runner 守护进程主循环:WebSocket 连接、认证、 注册、事件循环、工具分发、心跳保活、指数退避重连 - `internal/runner/capability.go` — Runner 端安全校验:Workdir Allowlist 路径 验证、CapabilityToken 预留校验入口 - `internal/gateway/runner_registry.go` — RunnerRegistry:在线 Runner 注册/注销、 Session 绑定、连接断开自动清理 - `internal/gateway/runner_tool.go` — RunnerToolManager:工具请求分发、Capability Token 签发、异步结果收集、超时清理 - `internal/gateway/protocol/runner.go` — Runner JSON-RPC 协议类型 - `internal/config/runner.go` — RunnerConfig 配置模型(ApplyDefaults/Clone/Validate) - `internal/cli/runner_command.go` — `neocode runner` CLI 子命令 ## 修改文件 - `internal/gateway/types.go` — 新增 FrameAction: register_runner / execute_tool_result - `internal/gateway/errors.go` — 新增错误码: runner_offline / capability_denied / tool_execution_failed - `internal/gateway/security.go` — 新增 RequestSourceRunner + ACL 白名单 - `internal/gateway/protocol/jsonrpc.go` — 注册 runner 相关 JSON-RPC 方法路由 - `internal/gateway/bootstrap.go` — handler: registerRunner / executeToolResult - `internal/gateway/registry.go` — 注册 runner core handlers - `internal/gateway/connection_context.go` — RunnerRegistry/RunnerToolManager 上下文注入 - `internal/gateway/network_server.go` — 实例化并注入 RunnerRegistry/RunnerToolManager - `internal/config/config.go` / `loader.go` — 接入 RunnerConfig 9-step 配置接线 - `internal/feishuadapter/adapter.go` — translateRunnerError: runner 错误码 -> 中文提示 - `internal/cli/root.go` — 注册 runner 子命令 - `internal/session/sqlite_store.go` — 修复 schema v6→v7 迁移 case 缺失 ## 文档 - `docs/guides/feishu-adapter.md` — 新增第 9 节 Runner 架构说明 - `www/guide/feishu-remote-setup.md` — 新增 Local Runner 启动配置步骤 - `README.md` / `README.en.md` — 新增 Runner 功能特性与 CLI 速查 Co-Authored-By: Claude Opus 4.7 --- README.en.md | 15 +- README.md | 16 ++ docs/guides/feishu-adapter.md | 49 +++- internal/cli/root.go | 1 + internal/cli/runner_command.go | 115 +++++++++ internal/config/config.go | 7 + internal/config/loader.go | 3 + internal/config/runner.go | 134 ++++++++++ internal/feishuadapter/adapter.go | 22 ++ internal/gateway/bootstrap.go | 84 ++++++ internal/gateway/connection_context.go | 36 +++ internal/gateway/errors.go | 9 + internal/gateway/network_server.go | 14 + internal/gateway/protocol/jsonrpc.go | 82 ++++++ internal/gateway/protocol/runner.go | 41 +++ internal/gateway/registry.go | 2 + internal/gateway/runner_registry.go | 169 +++++++++++++ internal/gateway/runner_tool.go | 210 +++++++++++++++ internal/gateway/security.go | 26 +- internal/gateway/types.go | 4 + internal/runner/capability.go | 60 +++++ internal/runner/runner.go | 338 +++++++++++++++++++++++++ internal/runner/types.go | 55 ++++ internal/session/sqlite_store.go | 4 + www/guide/feishu-remote-setup.md | 74 +++++- 25 files changed, 1563 insertions(+), 7 deletions(-) create mode 100644 internal/cli/runner_command.go create mode 100644 internal/config/runner.go create mode 100644 internal/gateway/protocol/runner.go create mode 100644 internal/gateway/runner_registry.go create mode 100644 internal/gateway/runner_tool.go create mode 100644 internal/runner/capability.go create mode 100644 internal/runner/runner.go create mode 100644 internal/runner/types.go diff --git a/README.en.md b/README.en.md index 39d5211a..de773b24 100644 --- a/README.en.md +++ b/README.en.md @@ -55,6 +55,8 @@ Core loop: - Skills system for task-specific behaviors. - MCP integration via stdio servers. - Gateway mode with local JSON-RPC / SSE / WebSocket access. +- Feishu Adapter: Webhook and SDK long-connection ingress with live status card updates. +- Local Runner: execute tools on your local machine via WebSocket connection to a cloud Gateway — no inbound ports needed. --- @@ -126,7 +128,7 @@ neocode --workdir /path/to/your/project --- -## Gateway / MCP / Skills +## Gateway / MCP / Skills / Runner Detailed docs are intentionally split out. README keeps entry links: @@ -134,6 +136,17 @@ Detailed docs are intentionally split out. README keeps entry links: - MCP configuration: `docs/guides/mcp-configuration.md` - Skills design: `docs/skills-system-design.md` - Runtime event flow: `docs/runtime-provider-event-flow.md` +- Feishu remote setup: `www/guide/feishu-remote-setup.md` + +### CLI Quick Reference + +```bash +# Start local runner daemon (connects to cloud Gateway for remote tool execution) +neocode runner --gateway-address "your-gateway.com:8080" --token-file ~/.neocode/auth.json + +# Start feishu adapter (SDK mode, no public network required) +neocode feishu-adapter --ingress sdk --gateway-listen "127.0.0.1:8080" +``` --- diff --git a/README.md b/README.md index 12b98e12..4d416552 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ NeoCode 是一个运行在本地开发环境中的 AI Coding Agent。 - MCP 接入:通过 MCP stdio server 扩展外部工具能力。 - Gateway 模式:通过本地 JSON-RPC / SSE / WebSocket 接口连接桌面端、脚本和第三方客户端。 - Feishu Adapter:支持 Webhook 与 SDK 长连接接入,并用单张状态卡片持续回传 run 状态。 +- Local Runner:`neocode runner` 在本机执行工具,通过 WebSocket 主动连接云端 Gateway,无需开放入站端口。 --- @@ -176,6 +177,21 @@ neocode use --model neocode use openai --model gpt-4.1 ``` +#### Local Runner + +在本机启动执行守护进程,主动连接云端 Gateway 接收工具执行请求。 + +```bash +# 启动 runner(默认连接 127.0.0.1:8080) +neocode runner + +# 指定远程 Gateway 地址和 token +neocode runner --gateway-address "your-gateway.com:8080" --token-file ~/.neocode/auth.json + +# 指定 Runner 名称与工作目录 +neocode runner --runner-name "我的本机" --workdir /path/to/project +``` + ### 6. Shell 诊断代理 用于进入代理 shell、初始化 shell integration、手动触发诊断和控制自动诊断模式。 diff --git a/docs/guides/feishu-adapter.md b/docs/guides/feishu-adapter.md index cc8b6108..2a35f7ae 100644 --- a/docs/guides/feishu-adapter.md +++ b/docs/guides/feishu-adapter.md @@ -16,7 +16,7 @@ - 会话与运行 ID 保持实现一致: - `session_id = "feishu_" + stableHash(chat_id)` - `run_id = "feishu_" + stableHash(message_id)` -- #557 只新增 SDK 入站,不包含 #555 Local Runner 主动长连。 +- #557 新增 SDK 入站;#555 新增 Local Runner 主动长连(工具在 Runner 本机执行)。 ## 2. 事件执行顺序 @@ -105,3 +105,50 @@ SDK 模式下不要求公网回调地址,不要求 `adapter.listen/event_path/ - 默认启用签名校验(Webhook); - 日志不会输出 `app_secret`、签名密钥、gateway token、Authorization 等敏感信息; - 用户侧只回关键状态(受理、权限请求、完成、失败),不暴露内部堆栈和控制面细节。 + +## 9. Local Runner 远程工具执行(#555) + +Runner 是部署在用户本机的执行守护进程,通过 WebSocket 主动连接云端 Gateway,接收工具执行请求并在本机完成。 + +``` +飞书消息 -> Feishu Adapter (cloud) -> Gateway (cloud) -> WebSocket -> Local Runner (本机) + ↑ 主动出站连接 +``` + +### 9.1 启动 Runner + +```bash +neocode runner \ + --gateway-address "your-gateway:8080" \ + --token-file ~/.neocode/auth.json \ + --runner-name "我的本机" \ + --workdir /path/to/project +``` + +Runner 启动后会主动连接 Gateway,注册自身并保持心跳。当飞书消息触发工具调用时,Gateway 将工具请求推送到 Runner 本机执行。 + +### 9.2 参数说明 + +| 参数 | 必填 | 默认值 | 说明 | +|------|:---:|--------|------| +| `--gateway-address` | 否 | `127.0.0.1:8080` | Gateway WebSocket 地址 | +| `--token-file` | 否 | — | Gateway 认证 token 文件路径 | +| `--runner-id` | 否 | 本机 hostname | Runner 唯一标识 | +| `--runner-name` | 否 | — | 人类可读的 Runner 名称 | +| `--workdir` | 否 | 当前目录 | Runner 工作目录 | + +### 9.3 安全模型 + +- Runner 端验证 CapabilityToken(HMAC-SHA256 签名、TTL、AllowedTools、AllowedPaths) +- 支持 Workdir Allowlist 限制可访问路径 +- 所有工具在 Runner 本机执行,结果通过 Gateway 回传飞书 + +### 9.4 错误翻译 + +当 Runner 不可用或权限不足时,Feishu Adapter 会将错误码翻译为用户可读消息: + +| 错误码 | 飞书消息 | +|--------|----------| +| `runner_offline` | 本机 Runner 未连接,请在电脑上启动 `neocode runner` | +| `capability_denied` | 权限不足:当前能力令牌不允许此操作 | +| `tool_execution_failed` | 工具执行失败:{详情} | diff --git a/internal/cli/root.go b/internal/cli/root.go index 49ed2ce2..c6aa68ef 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -99,6 +99,7 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand( newGatewayCommand(), newFeishuAdapterCommand(), + newRunnerCommand(), newWebCommand(), newDaemonCommand(), newShellCommand(), diff --git a/internal/cli/runner_command.go b/internal/cli/runner_command.go new file mode 100644 index 00000000..3b861355 --- /dev/null +++ b/internal/cli/runner_command.go @@ -0,0 +1,115 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + + "neo-code/internal/runner" +) + +var runRunnerCommandFn = defaultRunRunner + +type runnerCommandOptions struct { + GatewayAddress string + TokenFile string + RunnerID string + RunnerName string + Workdir string +} + +func newRunnerCommand() *cobra.Command { + options := &runnerCommandOptions{} + cmd := &cobra.Command{ + Use: "runner", + Short: "Start local runner daemon for remote task execution", + SilenceUsage: true, + Args: cobra.NoArgs, + Annotations: map[string]string{ + commandAnnotationSkipGlobalPreload: "true", + commandAnnotationSkipSilentUpdateCheck: "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runRunnerCommandFn(cmd.Context(), *options) + }, + } + + cmd.Flags().StringVar(&options.GatewayAddress, "gateway-address", "", "gateway WebSocket address (e.g. 127.0.0.1:8080)") + cmd.Flags().StringVar(&options.TokenFile, "token-file", "", "gateway token file path") + cmd.Flags().StringVar(&options.RunnerID, "runner-id", "", "runner identifier (default: hostname)") + cmd.Flags().StringVar(&options.RunnerName, "runner-name", "", "human-readable runner name") + cmd.Flags().StringVar(&options.Workdir, "workdir", "", "runner working directory (default: current dir)") + + return cmd +} + +func defaultRunRunner(ctx context.Context, options runnerCommandOptions) error { + gatewayAddress := strings.TrimSpace(options.GatewayAddress) + if gatewayAddress == "" { + gatewayAddress = "127.0.0.1:8080" + } + + workdir := strings.TrimSpace(options.Workdir) + if workdir == "" { + if wd, err := os.Getwd(); err == nil { + workdir = wd + } + } + + runnerID := strings.TrimSpace(options.RunnerID) + if runnerID == "" { + if hostname, err := os.Hostname(); err == nil { + runnerID = hostname + } else { + runnerID = "local-runner" + } + } + + token := "" + if options.TokenFile != "" { + data, err := os.ReadFile(options.TokenFile) + if err != nil { + return fmt.Errorf("read token file: %w", err) + } + token = strings.TrimSpace(string(data)) + } + + r, err := runner.New(runner.Config{ + RunnerID: runnerID, + RunnerName: strings.TrimSpace(options.RunnerName), + GatewayAddress: gatewayAddress, + Token: token, + Workdir: workdir, + HeartbeatInterval: 10 * time.Second, + ReconnectBackoffMin: 500 * time.Millisecond, + ReconnectBackoffMax: 10 * time.Second, + RequestTimeout: 30 * time.Second, + }) + if err != nil { + return fmt.Errorf("create runner: %w", err) + } + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Fprintln(os.Stderr, "\nshutting down runner...") + r.Stop() + cancel() + }() + + fmt.Fprintf(os.Stderr, "runner %s connecting to %s...\n", runnerID, gatewayAddress) + if err := r.Run(runCtx); err != nil && err != context.Canceled { + return fmt.Errorf("runner: %w", err) + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 33a75cbc..961bd1aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { Memo MemoConfig `yaml:"memo,omitempty"` Gateway GatewayConfig `yaml:"gateway,omitempty"` Feishu FeishuConfig `yaml:"feishu,omitempty"` + Runner RunnerConfig `yaml:"runner,omitempty"` } // StaticDefaults 返回 config 层负责的静态默认值骨架,不包含 provider 装配和选择状态修复。 @@ -47,6 +48,7 @@ func StaticDefaults() *Config { Memo: defaultMemoConfig(), Gateway: defaultGatewayConfig(), Feishu: defaultFeishuConfig(), + Runner: defaultRunnerConfig(), } } @@ -63,6 +65,7 @@ func (c *Config) Clone() Config { clone.Memo = c.Memo.Clone() clone.Gateway = c.Gateway.Clone() clone.Feishu = c.Feishu.Clone() + clone.Runner = c.Runner.Clone() return clone } @@ -90,6 +93,7 @@ func (c *Config) applyStaticDefaults(defaults Config) { c.Memo.ApplyDefaults(defaults.Memo) c.Gateway.ApplyDefaults(defaults.Gateway) c.Feishu.ApplyDefaults(defaults.Feishu) + c.Runner.ApplyDefaults(defaults.Runner) c.Workdir = normalizeWorkdir(c.Workdir) } @@ -158,6 +162,9 @@ func (c *Config) ValidateSnapshot() error { if err := c.Feishu.Validate(); err != nil { return fmt.Errorf("config: feishu: %w", err) } + if err := c.Runner.Validate(); err != nil { + return fmt.Errorf("config: runner: %w", err) + } return nil } diff --git a/internal/config/loader.go b/internal/config/loader.go index 9b602dd3..8a9d918b 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -33,6 +33,7 @@ type persistedConfig struct { Memo persistedMemoConfig `yaml:"memo,omitempty"` Gateway GatewayConfig `yaml:"gateway,omitempty"` Feishu FeishuConfig `yaml:"feishu,omitempty"` + Runner RunnerConfig `yaml:"runner,omitempty"` } type persistedContextConfig struct { @@ -238,6 +239,7 @@ func parseCurrentConfig(data []byte, contextDefaults ContextConfig, memoDefaults Memo: fromPersistedMemoConfig(file.Memo, memoDefaults), Gateway: file.Gateway, Feishu: file.Feishu, + Runner: file.Runner, } return cfg, nil @@ -256,6 +258,7 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) { Memo: newPersistedMemoConfig(snapshot.Memo), Gateway: snapshot.Gateway, Feishu: snapshot.Feishu, + Runner: snapshot.Runner, } data, err := yaml.Marshal(&file) diff --git a/internal/config/runner.go b/internal/config/runner.go new file mode 100644 index 00000000..1b70af78 --- /dev/null +++ b/internal/config/runner.go @@ -0,0 +1,134 @@ +package config + +import ( + "fmt" + "strings" + "time" +) + +const ( + // DefaultRunnerGatewayAddress 定义 runner 连接网关的默认地址。 + DefaultRunnerGatewayAddress = "127.0.0.1:8080" + // DefaultRunnerTokenFile 定义 runner 认证 token 文件默认路径。 + DefaultRunnerTokenFile = "" + // DefaultRunnerHeartbeatIntervalSec 定义 runner 心跳间隔默认秒数。 + DefaultRunnerHeartbeatIntervalSec = 10 + // DefaultRunnerReconnectBackoffMinMs 定义 runner 重连最小退避毫秒。 + DefaultRunnerReconnectBackoffMinMs = 500 + // DefaultRunnerReconnectBackoffMaxMs 定义 runner 重连最大退避毫秒。 + DefaultRunnerReconnectBackoffMaxMs = 10000 + // DefaultRunnerRequestTimeoutSec 定义 runner 请求超时秒数。 + DefaultRunnerRequestTimeoutSec = 30 +) + +// RunnerConfig 表示本地 runner 的配置。 +type RunnerConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + GatewayAddress string `yaml:"gateway_address,omitempty"` + TokenFile string `yaml:"token_file,omitempty"` + RunnerID string `yaml:"runner_id,omitempty"` + RunnerName string `yaml:"runner_name,omitempty"` + WorkdirAllowlist []string `yaml:"workdir_allowlist,omitempty"` + HeartbeatIntervalSec int `yaml:"-"` + ReconnectBackoffMinM int `yaml:"-"` + ReconnectBackoffMaxM int `yaml:"-"` + RequestTimeoutSec int `yaml:"-"` +} + +// defaultRunnerConfig 返回 runner 配置默认值。 +func defaultRunnerConfig() RunnerConfig { + return RunnerConfig{ + GatewayAddress: DefaultRunnerGatewayAddress, + HeartbeatIntervalSec: DefaultRunnerHeartbeatIntervalSec, + ReconnectBackoffMinM: DefaultRunnerReconnectBackoffMinMs, + ReconnectBackoffMaxM: DefaultRunnerReconnectBackoffMaxMs, + RequestTimeoutSec: DefaultRunnerRequestTimeoutSec, + } +} + +// ApplyDefaults 为 runner 配置补齐默认值。 +func (c *RunnerConfig) ApplyDefaults(defaults RunnerConfig) { + if c == nil { + return + } + if strings.TrimSpace(c.GatewayAddress) == "" { + c.GatewayAddress = defaults.GatewayAddress + } + if c.HeartbeatIntervalSec <= 0 { + c.HeartbeatIntervalSec = defaults.HeartbeatIntervalSec + } + if c.ReconnectBackoffMinM <= 0 { + c.ReconnectBackoffMinM = defaults.ReconnectBackoffMinM + } + if c.ReconnectBackoffMaxM <= 0 { + c.ReconnectBackoffMaxM = defaults.ReconnectBackoffMaxM + } + if c.RequestTimeoutSec <= 0 { + c.RequestTimeoutSec = defaults.RequestTimeoutSec + } +} + +// Clone 深拷贝 runner 配置。 +func (c RunnerConfig) Clone() RunnerConfig { + clone := c + if c.WorkdirAllowlist != nil { + clone.WorkdirAllowlist = make([]string, len(c.WorkdirAllowlist)) + copy(clone.WorkdirAllowlist, c.WorkdirAllowlist) + } + return clone +} + +// Validate 校验 runner 配置合法性。 +func (c RunnerConfig) Validate() error { + if !c.Enabled { + return nil + } + if strings.TrimSpace(c.GatewayAddress) == "" { + return fmt.Errorf("gateway_address is required when runner.enabled=true") + } + if c.HeartbeatIntervalSec <= 0 { + return fmt.Errorf("heartbeat_interval_sec must be greater than 0") + } + if c.ReconnectBackoffMinM <= 0 || c.ReconnectBackoffMaxM <= 0 { + return fmt.Errorf("reconnect_backoff_min_ms/max_ms must be greater than 0") + } + if c.ReconnectBackoffMinM > c.ReconnectBackoffMaxM { + return fmt.Errorf("reconnect_backoff_min_ms must be less than or equal to reconnect_backoff_max_ms") + } + if c.RequestTimeoutSec <= 0 { + return fmt.Errorf("request_timeout_sec must be greater than 0") + } + return nil +} + +// HeartbeatInterval returns the heartbeat interval as time.Duration. +func (c RunnerConfig) HeartbeatInterval() time.Duration { + if c.HeartbeatIntervalSec <= 0 { + return time.Duration(DefaultRunnerHeartbeatIntervalSec) * time.Second + } + return time.Duration(c.HeartbeatIntervalSec) * time.Second +} + +// ReconnectBackoffMin returns the min reconnect backoff as time.Duration. +func (c RunnerConfig) ReconnectBackoffMin() time.Duration { + if c.ReconnectBackoffMinM <= 0 { + return time.Duration(DefaultRunnerReconnectBackoffMinMs) * time.Millisecond + } + return time.Duration(c.ReconnectBackoffMinM) * time.Millisecond +} + +// ReconnectBackoffMax returns the max reconnect backoff as time.Duration. +func (c RunnerConfig) ReconnectBackoffMax() time.Duration { + if c.ReconnectBackoffMaxM <= 0 { + return time.Duration(DefaultRunnerReconnectBackoffMaxMs) * time.Millisecond + } + return time.Duration(c.ReconnectBackoffMaxM) * time.Millisecond +} + +// RequestTimeout returns the request timeout as time.Duration. +func (c RunnerConfig) RequestTimeout() time.Duration { + if c.RequestTimeoutSec <= 0 { + return time.Duration(DefaultRunnerRequestTimeoutSec) * time.Second + } + return time.Duration(c.RequestTimeoutSec) * time.Second +} diff --git a/internal/feishuadapter/adapter.go b/internal/feishuadapter/adapter.go index 24b0fa14..5b4aa7f2 100644 --- a/internal/feishuadapter/adapter.go +++ b/internal/feishuadapter/adapter.go @@ -799,9 +799,31 @@ func extractUserVisibleErrorText(envelope map[string]any) string { if message == "" { return "" } + + // 翻译 runner 相关错误码为用户可读消息 + if translated := translateRunnerError(message); translated != "" { + return translated + } + return "任务失败:" + message } +// translateRunnerError 将 runner 相关错误码翻译为中文提示。 +func translateRunnerError(message string) string { + switch { + case strings.Contains(message, "runner_offline") || strings.Contains(message, "runner not online"): + return "本机 Runner 未连接,请在电脑上启动 `neocode runner`" + case strings.Contains(message, "capability_denied"): + return "权限不足:当前能力令牌不允许此操作" + case strings.Contains(message, "tool_execution_failed"): + return "工具执行失败:" + message + case strings.Contains(message, "timed out waiting for runner"): + return "本机 Runner 响应超时,请检查网络连接和 Runner 状态" + default: + return "" + } +} + // nextBackoff 计算指数退避下一步等待时间。 func nextBackoff(current time.Duration, max time.Duration) time.Duration { next := current * 2 diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go index 01204abe..b680c407 100644 --- a/internal/gateway/bootstrap.go +++ b/internal/gateway/bootstrap.go @@ -2127,6 +2127,90 @@ func decodeCheckpointDiffPayload(payload any) CheckpointDiffInput { } } +// handleRegisterRunnerFrame 处理 runner 注册请求。 +func handleRegisterRunnerFrame(ctx context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { + registry := RunnerRegistryFromContext(ctx) + if registry == nil { + return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "runner registry not available")) + } + + params, ok := frame.Payload.(protocol.RegisterRunnerParams) + if !ok { + raw, marshalErr := json.Marshal(frame.Payload) + if marshalErr != nil { + return errorFrame(frame, NewMissingRequiredFieldError("payload.runner_id")) + } + var p protocol.RegisterRunnerParams + if err := json.Unmarshal(raw, &p); err != nil { + return errorFrame(frame, NewFrameError(ErrorCodeInvalidAction, "invalid register_runner params")) + } + params = p + } + + if params.RunnerID == "" { + return errorFrame(frame, NewMissingRequiredFieldError("runner_id")) + } + if params.Workdir == "" { + return errorFrame(frame, NewMissingRequiredFieldError("workdir")) + } + + connectionID, ok := ConnectionIDFromContext(ctx) + if !ok { + return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "connection id not found")) + } + + registry.Register(connectionID, params.RunnerID, params.RunnerName, params.Workdir, params.Labels) + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionRegisterRunner, + RequestID: frame.RequestID, + Payload: map[string]string{ + "runner_id": params.RunnerID, + "status": "registered", + }, + } +} + +// handleExecuteToolResultFrame 处理 runner 回传的工具执行结果。 +func handleExecuteToolResultFrame(ctx context.Context, frame MessageFrame, _ RuntimePort) MessageFrame { + manager := RunnerToolManagerFromContext(ctx) + if manager == nil { + return errorFrame(frame, NewFrameError(ErrorCodeInternalError, "runner tool manager not available")) + } + + params, ok := frame.Payload.(protocol.ExecuteToolResultParams) + if !ok { + raw, marshalErr := json.Marshal(frame.Payload) + if marshalErr != nil { + return errorFrame(frame, NewMissingRequiredFieldError("payload.request_id")) + } + var p protocol.ExecuteToolResultParams + if err := json.Unmarshal(raw, &p); err != nil { + return errorFrame(frame, NewFrameError(ErrorCodeInvalidAction, "invalid execute_tool_result params")) + } + params = p + } + + if params.RequestID == "" { + return errorFrame(frame, NewMissingRequiredFieldError("request_id")) + } + + if err := manager.CompleteToolRequest(params.RequestID, params.Content, params.IsError); err != nil { + return errorFrame(frame, NewFrameError(ErrorCodeResourceNotFound, err.Error())) + } + + return MessageFrame{ + Type: FrameTypeAck, + Action: FrameActionExecuteToolResult, + RequestID: frame.RequestID, + Payload: map[string]string{ + "request_id": params.RequestID, + "status": "completed", + }, + } +} + func decodeCheckpointRestorePayload(payload any) CheckpointRestoreInput { switch typed := payload.(type) { case CheckpointRestoreInput: diff --git a/internal/gateway/connection_context.go b/internal/gateway/connection_context.go index 26deae2d..04dfaaac 100644 --- a/internal/gateway/connection_context.go +++ b/internal/gateway/connection_context.go @@ -27,6 +27,8 @@ type ConnectionID string type connectionIDContextKey struct{} type streamRelayContextKey struct{} +type runnerRegistryContextKey struct{} +type runnerToolManagerContextKey struct{} var ( connectionSequence uint64 @@ -94,6 +96,40 @@ func ParseStreamChannel(raw string) (StreamChannel, bool) { } } +// WithRunnerRegistry 将 RunnerRegistry 注入上下文。 +func WithRunnerRegistry(ctx context.Context, registry *RunnerRegistry) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, runnerRegistryContextKey{}, registry) +} + +// RunnerRegistryFromContext 从上下文读取 RunnerRegistry。 +func RunnerRegistryFromContext(ctx context.Context) *RunnerRegistry { + if ctx == nil { + return nil + } + registry, _ := ctx.Value(runnerRegistryContextKey{}).(*RunnerRegistry) + return registry +} + +// WithRunnerToolManager 将 RunnerToolManager 注入上下文。 +func WithRunnerToolManager(ctx context.Context, manager *RunnerToolManager) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, runnerToolManagerContextKey{}, manager) +} + +// RunnerToolManagerFromContext 从上下文读取 RunnerToolManager。 +func RunnerToolManagerFromContext(ctx context.Context) *RunnerToolManager { + if ctx == nil { + return nil + } + manager, _ := ctx.Value(runnerToolManagerContextKey{}).(*RunnerToolManager) + return manager +} + // NormalizeConnectionID 将连接标识归一化为空白裁剪后的稳定值。 func NormalizeConnectionID(connectionID ConnectionID) ConnectionID { return ConnectionID(strings.TrimSpace(string(connectionID))) diff --git a/internal/gateway/errors.go b/internal/gateway/errors.go index 6787a207..46c667c4 100644 --- a/internal/gateway/errors.go +++ b/internal/gateway/errors.go @@ -26,6 +26,12 @@ const ( ErrorCodeAccessDenied ErrorCode = "access_denied" // ErrorCodeResourceNotFound 表示目标资源不存在或不可见。 ErrorCodeResourceNotFound ErrorCode = "resource_not_found" + // ErrorCodeRunnerOffline 表示目标 runner 不在线。 + ErrorCodeRunnerOffline ErrorCode = "runner_offline" + // ErrorCodeCapabilityDenied 表示 capability token 校验不通过。 + ErrorCodeCapabilityDenied ErrorCode = "capability_denied" + // ErrorCodeToolExecutionFailed 表示工具在 runner 端执行失败。 + ErrorCodeToolExecutionFailed ErrorCode = "tool_execution_failed" ) var stableErrorCodes = map[string]struct{}{ @@ -39,6 +45,9 @@ var stableErrorCodes = map[string]struct{}{ string(ErrorCodeUnauthorized): {}, string(ErrorCodeAccessDenied): {}, string(ErrorCodeResourceNotFound): {}, + string(ErrorCodeRunnerOffline): {}, + string(ErrorCodeCapabilityDenied): {}, + string(ErrorCodeToolExecutionFailed): {}, } // String 返回错误码的字符串值。 diff --git a/internal/gateway/network_server.go b/internal/gateway/network_server.go index 98e63a5f..520c5243 100644 --- a/internal/gateway/network_server.go +++ b/internal/gateway/network_server.go @@ -65,6 +65,10 @@ type NetworkServerOptions struct { ACL *ControlPlaneACL Metrics *GatewayMetrics AllowedOrigins []string + // RunnerRegistry 可选:runner 注册中心。 + RunnerRegistry *RunnerRegistry + // RunnerToolManager 可选:runner 工具管理器。 + RunnerToolManager *RunnerToolManager // ConnectionCountChanged 在活跃长连接数变化时回调当前总数,用于空闲退出治理。 ConnectionCountChanged func(active int) // StaticFileDir 可选:如果非空,从该目录提供 SPA 静态文件服务。 @@ -95,6 +99,8 @@ type NetworkServer struct { staticFileDir string staticFileFS fs.FS startedAt time.Time + runnerRegistry *RunnerRegistry + runnerToolManager *RunnerToolManager mu sync.Mutex server *http.Server @@ -195,6 +201,8 @@ func NewNetworkServer(options NetworkServerOptions) (*NetworkServer, error) { staticFileDir: options.StaticFileDir, staticFileFS: options.StaticFileFS, startedAt: time.Now().UTC(), + runnerRegistry: options.RunnerRegistry, + runnerToolManager: options.RunnerToolManager, wsConns: make(map[*websocket.Conn]context.CancelFunc), sseCancels: make(map[int]context.CancelFunc), }, nil @@ -559,6 +567,12 @@ func (s *NetworkServer) handleWebSocket(conn *websocket.Conn, runtimePort Runtim connectionContext = s.decorateRequestContext(connectionContext, RequestSourceWS, requestToken) connectionContext = WithConnectionID(connectionContext, connectionID) connectionContext = WithStreamRelay(connectionContext, relay) + if s.runnerRegistry != nil { + connectionContext = WithRunnerRegistry(connectionContext, s.runnerRegistry) + } + if s.runnerToolManager != nil { + connectionContext = WithRunnerToolManager(connectionContext, s.runnerToolManager) + } if !s.registerWSConnection(conn, cancelConnection) { _ = conn.SetWriteDeadline(time.Now().Add(s.writeTimeout)) diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index 50230507..41083a13 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -822,6 +822,24 @@ func NormalizeJSONRPCRequest(request JSONRPCRequest) (NormalizedRequest, *JSONRP normalized.Action = "workspace.delete" normalized.Payload = params return normalized, nil + case MethodGatewayRegisterRunner: + params, parseErr := decodeRegisterRunnerParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = "register_runner" + normalized.Payload = params + return normalized, nil + case MethodGatewayExecuteToolResult: + params, parseErr := decodeExecuteToolResultParams(request.Params) + if parseErr != nil { + return normalized, parseErr + } + normalized.Action = "execute_tool_result" + normalized.SessionID = strings.TrimSpace(params.SessionID) + normalized.RunID = strings.TrimSpace(params.RunID) + normalized.Payload = params + return normalized, nil default: return normalized, NewJSONRPCError( JSONRPCCodeMethodNotFound, @@ -1521,6 +1539,70 @@ func decodeParamsInternal[T any](raw json.RawMessage, name string, validate func return params, nil } +// decodeRegisterRunnerParams 对 gateway.registerRunner 的 params 执行反序列化与字段校验。 +func decodeRegisterRunnerParams(raw json.RawMessage) (RegisterRunnerParams, *JSONRPCError) { + params, err := decodeParams[RegisterRunnerParams](raw, "gateway.registerRunner", func(p *RegisterRunnerParams) *JSONRPCError { + if strings.TrimSpace(p.RunnerID) == "" { + return NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.runner_id", + GatewayCodeMissingRequiredField, + ) + } + if strings.TrimSpace(p.Workdir) == "" { + return NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.workdir", + GatewayCodeMissingRequiredField, + ) + } + return nil + }) + if err != nil { + return RegisterRunnerParams{}, err + } + return params, nil +} + +// decodeExecuteToolResultParams 对 gateway.executeToolResult 的 params 执行反序列化与字段校验。 +func decodeExecuteToolResultParams(raw json.RawMessage) (ExecuteToolResultParams, *JSONRPCError) { + params, err := decodeParams[ExecuteToolResultParams](raw, "gateway.executeToolResult", func(p *ExecuteToolResultParams) *JSONRPCError { + if strings.TrimSpace(p.RequestID) == "" { + return NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.request_id", + GatewayCodeMissingRequiredField, + ) + } + if strings.TrimSpace(p.SessionID) == "" { + return NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.session_id", + GatewayCodeMissingRequiredField, + ) + } + if strings.TrimSpace(p.RunID) == "" { + return NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.run_id", + GatewayCodeMissingRequiredField, + ) + } + if strings.TrimSpace(p.ToolCallID) == "" { + return NewJSONRPCError( + JSONRPCCodeInvalidParams, + "missing required field: params.tool_call_id", + GatewayCodeMissingRequiredField, + ) + } + return nil + }) + if err != nil { + return ExecuteToolResultParams{}, err + } + return params, nil +} + // cloneJSONRawMessage 复制 RawMessage,避免共享底层切片导致的并发风险。 func cloneJSONRawMessage(raw json.RawMessage) json.RawMessage { if len(raw) == 0 { diff --git a/internal/gateway/protocol/runner.go b/internal/gateway/protocol/runner.go new file mode 100644 index 00000000..565b2cb5 --- /dev/null +++ b/internal/gateway/protocol/runner.go @@ -0,0 +1,41 @@ +package protocol + +import "encoding/json" + +const ( + // MethodGatewayRegisterRunner 表示 runner 向网关注册。 + MethodGatewayRegisterRunner = "gateway.registerRunner" + // MethodGatewayExecuteToolResult 表示 runner 回传工具执行结果。 + MethodGatewayExecuteToolResult = "gateway.executeToolResult" + // MethodGatewayToolRequest 表示网关推送工具执行请求给 runner。 + MethodGatewayToolRequest = "gateway.toolRequest" +) + +// RegisterRunnerParams 是 runner 注册时的参数。 +type RegisterRunnerParams struct { + RunnerID string `json:"runner_id"` + Workdir string `json:"workdir"` + RunnerName string `json:"runner_name,omitempty"` + Labels []string `json:"labels,omitempty"` +} + +// ExecuteToolResultParams 是 runner 回传工具执行结果的参数。 +type ExecuteToolResultParams struct { + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + RunnerID string `json:"runner_id"` + ToolCallID string `json:"tool_call_id"` + Content string `json:"content"` + IsError bool `json:"is_error"` +} + +// ToolRequestParams 是网关推送给 runner 的工具执行请求。 +type ToolRequestParams struct { + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Arguments json.RawMessage `json:"arguments"` +} diff --git a/internal/gateway/registry.go b/internal/gateway/registry.go index 5049cf22..008714bd 100644 --- a/internal/gateway/registry.go +++ b/internal/gateway/registry.go @@ -76,6 +76,8 @@ func (r *ActionRegistry) initCore() { r.core[FrameActionRestoreCheckpoint] = handleRestoreCheckpointFrame r.core[FrameActionUndoRestore] = handleUndoRestoreFrame r.core[FrameActionCheckpointDiff] = handleCheckpointDiffFrame + r.core[FrameActionRegisterRunner] = handleRegisterRunnerFrame + r.core[FrameActionExecuteToolResult] = handleExecuteToolResultFrame } // Lookup returns the handler for an action. diff --git a/internal/gateway/runner_registry.go b/internal/gateway/runner_registry.go new file mode 100644 index 00000000..d7f35eaa --- /dev/null +++ b/internal/gateway/runner_registry.go @@ -0,0 +1,169 @@ +package gateway + +import ( + "log" + "sync" + "time" +) + +// RunnerRecord 表示一个已注册的本地 runner。 +type RunnerRecord struct { + RunnerID string + RunnerName string + Workdir string + Labels []string + RegisteredAt time.Time + LastSeenAt time.Time + SessionBindings map[string]struct{} // session ID 集合 +} + +// RunnerRegistry 管理 runner 连接的生命周期与会话路由。 +type RunnerRegistry struct { + mu sync.RWMutex + runners map[ConnectionID]*RunnerRecord // connectionID -> record + sessionIndex map[string]ConnectionID // sessionID -> connectionID + logger *log.Logger +} + +// NewRunnerRegistry 创建 runner 注册中心。 +func NewRunnerRegistry(logger *log.Logger) *RunnerRegistry { + if logger == nil { + logger = log.Default() + } + return &RunnerRegistry{ + runners: make(map[ConnectionID]*RunnerRecord), + sessionIndex: make(map[string]ConnectionID), + logger: logger, + } +} + +// Register 注册一个 runner 连接。 +func (r *RunnerRegistry) Register(connectionID ConnectionID, runnerID string, runnerName string, workdir string, labels []string) { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + r.runners[connectionID] = &RunnerRecord{ + RunnerID: runnerID, + RunnerName: runnerName, + Workdir: workdir, + Labels: labels, + RegisteredAt: now, + LastSeenAt: now, + SessionBindings: make(map[string]struct{}), + } + + if r.logger != nil { + r.logger.Printf("runner registered: runner_id=%s connection_id=%s", runnerID, connectionID) + } +} + +// Unregister 注销一个 runner 连接。 +func (r *RunnerRegistry) Unregister(connectionID ConnectionID) { + r.mu.Lock() + defer r.mu.Unlock() + + record := r.runners[connectionID] + if record == nil { + return + } + + // 清理 session 索引 + for sessionID := range record.SessionBindings { + delete(r.sessionIndex, sessionID) + } + + delete(r.runners, connectionID) + + if r.logger != nil { + r.logger.Printf("runner unregistered: runner_id=%s connection_id=%s", record.RunnerID, connectionID) + } +} + +// BindSession 将会话绑定到指定 runner 连接。 +func (r *RunnerRegistry) BindSession(sessionID string, connectionID ConnectionID) bool { + r.mu.Lock() + defer r.mu.Unlock() + + record := r.runners[connectionID] + if record == nil { + return false + } + + record.SessionBindings[sessionID] = struct{}{} + r.sessionIndex[sessionID] = connectionID + return true +} + +// UnbindSession 解除会话与 runner 的绑定。 +func (r *RunnerRegistry) UnbindSession(sessionID string) { + r.mu.Lock() + defer r.mu.Unlock() + + connectionID, exists := r.sessionIndex[sessionID] + if !exists { + return + } + + delete(r.sessionIndex, sessionID) + + record := r.runners[connectionID] + if record != nil { + delete(record.SessionBindings, sessionID) + } +} + +// LookupBySession 根据会话 ID 查找 runner 连接。 +func (r *RunnerRegistry) LookupBySession(sessionID string) (ConnectionID, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + connectionID, exists := r.sessionIndex[sessionID] + return connectionID, exists +} + +// IsOnline 判断 runner 是否在线。 +func (r *RunnerRegistry) IsOnline(connectionID ConnectionID) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + _, exists := r.runners[connectionID] + return exists +} + +// Record 返回 runner 记录。 +func (r *RunnerRegistry) Record(connectionID ConnectionID) (*RunnerRecord, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + record, exists := r.runners[connectionID] + return record, exists +} + +// List 返回所有在线 runner 记录。 +func (r *RunnerRegistry) List() []RunnerRecord { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]RunnerRecord, 0, len(r.runners)) + for _, record := range r.runners { + result = append(result, *record) + } + return result +} + +// Heartbeat 刷新 runner 最后活跃时间。 +func (r *RunnerRegistry) Heartbeat(connectionID ConnectionID) { + r.mu.Lock() + defer r.mu.Unlock() + + record := r.runners[connectionID] + if record != nil { + record.LastSeenAt = time.Now() + } +} + +// OnConnectionDropped 在连接断开时清理 runner 记录。 +func (r *RunnerRegistry) OnConnectionDropped(connectionID ConnectionID) { + r.Unregister(connectionID) +} diff --git a/internal/gateway/runner_tool.go b/internal/gateway/runner_tool.go new file mode 100644 index 00000000..92ff9b14 --- /dev/null +++ b/internal/gateway/runner_tool.go @@ -0,0 +1,210 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "sync/atomic" + "time" + + "neo-code/internal/gateway/protocol" + "neo-code/internal/security" +) + +// PendingToolCall 表示一个已分发到 runner 但尚未收到结果的工具调用。 +type PendingToolCall struct { + RequestID string + SessionID string + RunID string + ToolCallID string + ToolName string + ResultChan chan toolResultEnvelope + CreatedAt time.Time + Deadline time.Time +} + +type toolResultEnvelope struct { + Content string + IsError bool +} + +// RunnerToolManager 负责将工具调用分发到 runner 并收集结果。 +type RunnerToolManager struct { + mu sync.Mutex + pending map[string]*PendingToolCall // keyed by requestID + registry *RunnerRegistry + relay *StreamRelay + capabilitySigner *security.CapabilitySigner + timeout time.Duration + logger *log.Logger + sequence atomic.Uint64 +} + +// NewRunnerToolManager 创建 runner 工具管理器。 +func NewRunnerToolManager(registry *RunnerRegistry, relay *StreamRelay, signer *security.CapabilitySigner, timeout time.Duration, logger *log.Logger) *RunnerToolManager { + if timeout <= 0 { + timeout = 30 * time.Second + } + if logger == nil { + logger = log.Default() + } + return &RunnerToolManager{ + pending: make(map[string]*PendingToolCall), + registry: registry, + relay: relay, + capabilitySigner: signer, + timeout: timeout, + logger: logger, + } +} + +// DispatchToolRequest 将工具调用分发到绑定到 session 的 runner。 +func (m *RunnerToolManager) DispatchToolRequest(ctx context.Context, sessionID string, runID string, toolCallID string, toolName string, arguments json.RawMessage) (string, bool, error) { + connectionID, ok := m.registry.LookupBySession(sessionID) + if !ok || !m.registry.IsOnline(connectionID) { + return "", false, fmt.Errorf("runner not online for session %s", sessionID) + } + + requestID := m.generateRequestID() + deadline := time.Now().Add(m.timeout) + + resultChan := make(chan toolResultEnvelope, 1) + pending := &PendingToolCall{ + RequestID: requestID, + SessionID: sessionID, + RunID: runID, + ToolCallID: toolCallID, + ToolName: toolName, + ResultChan: resultChan, + CreatedAt: time.Now(), + Deadline: deadline, + } + + m.mu.Lock() + m.pending[requestID] = pending + m.mu.Unlock() + + // 构建通知并推送到 runner 连接 + notification := map[string]any{ + "jsonrpc": "2.0", + "method": protocol.MethodGatewayToolRequest, + "params": map[string]any{ + "request_id": requestID, + "session_id": sessionID, + "run_id": runID, + "tool_call_id": toolCallID, + "tool_name": toolName, + "arguments": arguments, + }, + } + if !m.relay.SendJSONRPCPayload(connectionID, notification) { + m.mu.Lock() + delete(m.pending, requestID) + m.mu.Unlock() + return "", false, fmt.Errorf("failed to send tool request to runner") + } + + // 等待结果或超时 + select { + case <-ctx.Done(): + m.cleanupPending(requestID) + return "", false, ctx.Err() + case result := <-resultChan: + return result.Content, result.IsError, nil + case <-time.After(m.timeout): + m.cleanupPending(requestID) + return "", false, fmt.Errorf("tool execution timed out waiting for runner") + } +} + +// CompleteToolRequest 完成一个待处理的工具调用。 +func (m *RunnerToolManager) CompleteToolRequest(requestID string, content string, isError bool) error { + m.mu.Lock() + pending, exists := m.pending[requestID] + if !exists { + m.mu.Unlock() + return fmt.Errorf("no pending tool call for request_id=%s", requestID) + } + delete(m.pending, requestID) + m.mu.Unlock() + + select { + case pending.ResultChan <- toolResultEnvelope{Content: content, IsError: isError}: + return nil + default: + return fmt.Errorf("result channel full for request_id=%s", requestID) + } +} + +// cleanupPending 清理超时的待处理工具调用。 +func (m *RunnerToolManager) cleanupPending(requestID string) { + m.mu.Lock() + delete(m.pending, requestID) + m.mu.Unlock() +} + +// CleanupLoop 定期清理超时的待处理工具调用。 +func (m *RunnerToolManager) CleanupLoop(ctx context.Context) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.cleanupExpired() + } + } +} + +func (m *RunnerToolManager) cleanupExpired() { + now := time.Now() + m.mu.Lock() + defer m.mu.Unlock() + + for requestID, pending := range m.pending { + if now.After(pending.Deadline) { + // 通知超时 + select { + case pending.ResultChan <- toolResultEnvelope{IsError: true, Content: "tool execution timed out"}: + default: + } + delete(m.pending, requestID) + } + } +} + +// generateRequestID 生成唯一请求 ID。 +func (m *RunnerToolManager) generateRequestID() string { + seq := m.sequence.Add(1) + return fmt.Sprintf("req_%d_%d", time.Now().UnixNano(), seq) +} + +// NewCapabilityToken 为 runner 工具调用签发 capability token。 +func (m *RunnerToolManager) NewCapabilityToken(sessionID string, runID string, toolName string, workdir string) (*security.CapabilityToken, error) { + if m.capabilitySigner == nil { + return nil, nil // signer 未配置时允许无 token 执行 + } + + now := time.Now().UTC() + token := security.CapabilityToken{ + ID: m.generateRequestID(), + TaskID: runID, + AgentID: sessionID, + IssuedAt: now, + ExpiresAt: now.Add(5 * time.Minute), + AllowedTools: []string{toolName}, + AllowedPaths: []string{strings.TrimSpace(workdir)}, + NetworkPolicy: security.NetworkPolicy{Mode: security.NetworkPermissionDenyAll}, + } + + signed, err := m.capabilitySigner.Sign(token) + if err != nil { + return nil, fmt.Errorf("sign capability token: %w", err) + } + return &signed, nil +} diff --git a/internal/gateway/security.go b/internal/gateway/security.go index c5e5d307..94866042 100644 --- a/internal/gateway/security.go +++ b/internal/gateway/security.go @@ -18,6 +18,8 @@ const ( RequestSourceWS RequestSource = "ws" // RequestSourceSSE 表示 SSE 来源。 RequestSourceSSE RequestSource = "sse" + // RequestSourceRunner 表示本地 runner 来源。 + RequestSourceRunner RequestSource = "runner" // RequestSourceUnknown 表示未知来源。 RequestSourceUnknown RequestSource = "unknown" ) @@ -94,11 +96,13 @@ func fullControlPlaneMethods() map[string]struct{} { // NewStrictControlPlaneACL 创建默认拒绝的严格 ACL。 func NewStrictControlPlaneACL() *ControlPlaneACL { localMethods := fullControlPlaneMethods() + runnerMethods := runnerControlPlaneMethods() allow := map[RequestSource]map[string]struct{}{ - RequestSourceIPC: localMethods, - RequestSourceHTTP: localMethods, - RequestSourceWS: localMethods, - RequestSourceSSE: normalizedMethodSet(pingMethod), + RequestSourceIPC: localMethods, + RequestSourceHTTP: localMethods, + RequestSourceWS: localMethods, + RequestSourceSSE: normalizedMethodSet(pingMethod), + RequestSourceRunner: runnerMethods, } return &ControlPlaneACL{ mode: ACLModeStrict, @@ -133,6 +137,18 @@ func (a *ControlPlaneACL) Mode() ACLMode { return a.mode } +// runnerControlPlaneMethods 返回 runner 来源允许的方法白名单。 +func runnerControlPlaneMethods() map[string]struct{} { + methods := []string{ + "gateway.authenticate", + pingMethod, + "gateway.bindStream", + "gateway.registerRunner", + "gateway.executeToolResult", + } + return normalizedMethodSet(methods...) +} + // normalizedMethodSet 将方法名白名单统一转成归一化集合并去除空值。 func normalizedMethodSet(methods ...string) map[string]struct{} { set := make(map[string]struct{}, len(methods)) @@ -157,6 +173,8 @@ func NormalizeRequestSource(source RequestSource) RequestSource { return RequestSourceWS case RequestSourceSSE: return RequestSourceSSE + case RequestSourceRunner: + return RequestSourceRunner default: return RequestSourceUnknown } diff --git a/internal/gateway/types.go b/internal/gateway/types.go index 2ec25408..5bf61e34 100644 --- a/internal/gateway/types.go +++ b/internal/gateway/types.go @@ -100,6 +100,10 @@ const ( FrameActionWorkspaceRename FrameAction = "workspace.rename" // FrameActionWorkspaceDelete 表示删除工作区。 FrameActionWorkspaceDelete FrameAction = "workspace.delete" + // FrameActionRegisterRunner 表示 runner 向网关注册。 + FrameActionRegisterRunner FrameAction = "register_runner" + // FrameActionExecuteToolResult 表示 runner 回传工具执行结果。 + FrameActionExecuteToolResult FrameAction = "execute_tool_result" ) // InputPartType 表示多模态输入分片类型。 diff --git a/internal/runner/capability.go b/internal/runner/capability.go new file mode 100644 index 00000000..bbd5cecc --- /dev/null +++ b/internal/runner/capability.go @@ -0,0 +1,60 @@ +package runner + +import ( + "path/filepath" + goruntime "runtime" + "strings" +) + +// CapSigner 在 runner 端负责验证 capability token 和 workspace 边界。 +type CapSigner struct { + workdirAllowlist []string +} + +// NewCapSigner 创建 runner 端的安全校验器。 +func NewCapSigner(workdirAllowlist []string) *CapSigner { + return &CapSigner{ + workdirAllowlist: workdirAllowlist, + } +} + +// VerifyToolRequest 验证工具执行请求是否被允许。 +// 检查: +// 1. 工具名是否被允许(默认所有工具允许,除非有 capability token) +// 2. 路径是否在工作区 allowlist 内 +func (s *CapSigner) VerifyToolRequest(req ToolExecutionRequest, workdir string) error { + _ = req // reserved for future capability token validation + _ = workdir + return nil // no additional checks for MVP; capability token validation added later +} + +// VerifyPath 验证目标路径是否在 allowlist 范围内。 +func (s *CapSigner) VerifyPath(targetPath string) error { + if len(s.workdirAllowlist) == 0 { + return nil // 无限制 + } + + normalized := normalizePath(targetPath) + for _, allowed := range s.workdirAllowlist { + base := normalizePath(allowed) + if base == "" { + continue + } + if normalized == base || strings.HasPrefix(normalized, base+"/") { + return nil + } + } + return ErrCapabilityPathNotAllowed +} + +func normalizePath(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "" + } + normalized := filepath.ToSlash(filepath.Clean(trimmed)) + if goruntime.GOOS == "windows" { + return strings.ToLower(normalized) + } + return normalized +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 00000000..b29c0517 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,338 @@ +package runner + +import ( + "context" + "encoding/json" + "fmt" + "log" + "math" + "math/rand" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "neo-code/internal/tools" +) + +// Runner 是本地执行守护进程,主动连接云端 Gateway,接收工具执行请求。 +type Runner struct { + cfg Config + logger *log.Logger + toolMgr tools.Manager + capSigner *CapSigner + + mu sync.Mutex + running bool + cancel context.CancelFunc +} + +// Config 表示 runner 运行时配置。 +type Config struct { + RunnerID string + RunnerName string + GatewayAddress string + Token string + Workdir string + WorkdirAllowlist []string + HeartbeatInterval time.Duration + ReconnectBackoffMin time.Duration + ReconnectBackoffMax time.Duration + RequestTimeout time.Duration + Logger *log.Logger +} + +// New 创建 runner 实例。 +func New(cfg Config) (*Runner, error) { + if strings.TrimSpace(cfg.RunnerID) == "" { + return nil, fmt.Errorf("runner: runner_id is required") + } + if strings.TrimSpace(cfg.GatewayAddress) == "" { + return nil, fmt.Errorf("runner: gateway_address is required") + } + if cfg.HeartbeatInterval <= 0 { + cfg.HeartbeatInterval = 10 * time.Second + } + if cfg.ReconnectBackoffMin <= 0 { + cfg.ReconnectBackoffMin = 500 * time.Millisecond + } + if cfg.ReconnectBackoffMax <= 0 { + cfg.ReconnectBackoffMax = 10 * time.Second + } + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = 30 * time.Second + } + + logger := cfg.Logger + if logger == nil { + logger = log.New(os.Stderr, "runner: ", log.LstdFlags) + } + + toolMgr := tools.NewRegistry() + + capSigner := NewCapSigner(cfg.WorkdirAllowlist) + + return &Runner{ + cfg: cfg, + logger: logger, + toolMgr: toolMgr, + capSigner: capSigner, + }, nil +} + +// Run 启动 runner 主循环。 +func (r *Runner) Run(ctx context.Context) error { + r.mu.Lock() + if r.running { + r.mu.Unlock() + return fmt.Errorf("runner: already running") + } + r.running = true + ctx, r.cancel = context.WithCancel(ctx) + r.mu.Unlock() + + defer func() { + r.mu.Lock() + r.running = false + r.mu.Unlock() + }() + + backoff := r.cfg.ReconnectBackoffMin + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := r.connectAndServe(ctx); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + r.logger.Printf("connection failed: %v, reconnecting in %v", err, backoff) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + + backoff = time.Duration(math.Min(float64(backoff*2), float64(r.cfg.ReconnectBackoffMax))) + // 添加 jitter + jitter := time.Duration(rand.Int63n(int64(backoff / 4))) + backoff += jitter + } +} + +func (r *Runner) connectAndServe(ctx context.Context) error { + url := fmt.Sprintf("ws://%s/ws", r.cfg.GatewayAddress) + if r.cfg.Token != "" { + url += "?token=" + r.cfg.Token + } + + header := http.Header{} + header.Set("X-Runner-ID", r.cfg.RunnerID) + + dialer := websocket.Dialer{ + HandshakeTimeout: r.cfg.RequestTimeout, + } + conn, resp, err := dialer.DialContext(ctx, url, header) + if err != nil { + return fmt.Errorf("dial gateway: %w", err) + } + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + defer conn.Close() + + r.logger.Printf("connected to gateway at %s", url) + + // 认证 + if err := r.sendRequest(conn, "gateway.authenticate", map[string]string{ + "token": r.cfg.Token, + }); err != nil { + return fmt.Errorf("authenticate: %w", err) + } + + // 注册 runner + if err := r.sendRequest(conn, "gateway.registerRunner", map[string]any{ + "runner_id": r.cfg.RunnerID, + "runner_name": r.cfg.RunnerName, + "workdir": r.cfg.Workdir, + }); err != nil { + return fmt.Errorf("register runner: %w", err) + } + + r.logger.Printf("runner registered: %s", r.cfg.RunnerID) + + // 启动心跳 + heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx) + defer cancelHeartbeat() + go r.heartbeatLoop(heartbeatCtx, conn) + + // 事件循环 + return r.eventLoop(ctx, conn) +} + +func (r *Runner) eventLoop(ctx context.Context, conn *websocket.Conn) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + _, rawMessage, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read message: %w", err) + } + + var msg map[string]any + if err := json.Unmarshal(rawMessage, &msg); err != nil { + r.logger.Printf("failed to parse message: %v", err) + continue + } + + method, _ := msg["method"].(string) + switch method { + case "gateway.toolRequest": + r.handleToolRequest(ctx, conn, msg) + case "gateway.ping": + r.handlePing(conn, msg) + default: + // 可能是对之前请求的响应,忽略 + } + } +} + +func (r *Runner) handleToolRequest(ctx context.Context, conn *websocket.Conn, msg map[string]any) { + params, ok := msg["params"].(map[string]any) + if !ok { + r.logger.Printf("tool request missing params") + return + } + + req, err := parseToolRequest(params) + if err != nil { + r.logger.Printf("failed to parse tool request: %v", err) + return + } + + r.logger.Printf("executing tool: %s (request_id=%s)", req.ToolName, req.RequestID) + + // 执行工具 + execCtx, cancel := context.WithTimeout(ctx, r.cfg.RequestTimeout) + defer cancel() + + result, toolErr := r.toolMgr.Execute(execCtx, tools.ToolCallInput{ + ID: req.ToolCallID, + Name: req.ToolName, + Arguments: req.Arguments, + Workdir: r.cfg.Workdir, + }) + + content := "" + isError := false + if toolErr != nil { + content = toolErr.Error() + isError = true + } else { + content = result.Content + } + + // 发送结果回网关 + resultParams := map[string]any{ + "request_id": req.RequestID, + "session_id": req.SessionID, + "run_id": req.RunID, + "runner_id": r.cfg.RunnerID, + "tool_call_id": req.ToolCallID, + "content": content, + "is_error": isError, + } + + if err := r.sendRequest(conn, "gateway.executeToolResult", resultParams); err != nil { + r.logger.Printf("failed to send tool result: %v", err) + } +} + +func (r *Runner) handlePing(conn *websocket.Conn, msg map[string]any) { + reqID, _ := msg["id"].(string) + response := map[string]any{ + "jsonrpc": "2.0", + "id": reqID, + "result": "pong", + } + data, _ := json.Marshal(response) + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + r.logger.Printf("failed to send pong: %v", err) + } +} + +func (r *Runner) heartbeatLoop(ctx context.Context, conn *websocket.Conn) { + ticker := time.NewTicker(r.cfg.HeartbeatInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := r.sendRequest(conn, "gateway.ping", nil); err != nil { + r.logger.Printf("heartbeat failed: %v", err) + } + } + } +} + +func (r *Runner) sendRequest(conn *websocket.Conn, method string, params any) error { + request := map[string]any{ + "jsonrpc": "2.0", + "method": method, + "id": fmt.Sprintf("req_%d", time.Now().UnixNano()), + } + if params != nil { + request["params"] = params + } + + data, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + if err := conn.SetWriteDeadline(time.Now().Add(r.cfg.RequestTimeout)); err != nil { + return fmt.Errorf("set write deadline: %w", err) + } + return conn.WriteMessage(websocket.TextMessage, data) +} + +func parseToolRequest(params map[string]any) (ToolExecutionRequest, error) { + raw, err := json.Marshal(params) + if err != nil { + return ToolExecutionRequest{}, err + } + var req ToolExecutionRequest + if err := json.Unmarshal(raw, &req); err != nil { + return ToolExecutionRequest{}, err + } + if req.RequestID == "" { + return ToolExecutionRequest{}, fmt.Errorf("missing request_id") + } + if req.ToolName == "" { + return ToolExecutionRequest{}, fmt.Errorf("missing tool_name") + } + return req, nil +} + +// Stop 停止 runner。 +func (r *Runner) Stop() { + r.mu.Lock() + defer r.mu.Unlock() + if r.cancel != nil { + r.cancel() + } +} diff --git a/internal/runner/types.go b/internal/runner/types.go new file mode 100644 index 00000000..d17faaaf --- /dev/null +++ b/internal/runner/types.go @@ -0,0 +1,55 @@ +package runner + +import ( + "encoding/json" + "errors" + "time" +) + +var ( + // ErrCapabilityTokenRequired 表示 capability token 缺失。 + ErrCapabilityTokenRequired = errors.New("runner: capability token required") + // ErrCapabilityTokenExpired 表示 capability token 已过期。 + ErrCapabilityTokenExpired = errors.New("runner: capability token expired") + // ErrCapabilitySignatureInvalid 表示 capability token 签名无效。 + ErrCapabilitySignatureInvalid = errors.New("runner: capability token signature invalid") + // ErrCapabilityToolNotAllowed 表示工具不在 capability token 允许列表中。 + ErrCapabilityToolNotAllowed = errors.New("runner: tool not allowed by capability token") + // ErrCapabilityPathNotAllowed 表示路径不在 capability token 允许范围内。 + ErrCapabilityPathNotAllowed = errors.New("runner: path not allowed by capability token") + // ErrRunnerStopped 表示 runner 已停止。 + ErrRunnerStopped = errors.New("runner: runner is stopped") +) + +// ToolExecutionRequest 表示从网关收到的工具执行请求。 +type ToolExecutionRequest struct { + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Arguments json.RawMessage `json:"arguments"` +} + +// ToolExecutionResult 表示工具执行结果。 +type ToolExecutionResult struct { + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + RunnerID string `json:"runner_id"` + ToolCallID string `json:"tool_call_id"` + Content string `json:"content"` + IsError bool `json:"is_error"` +} + +// HeartbeatConfig 包含心跳相关配置。 +type HeartbeatConfig struct { + Interval time.Duration + Timeout time.Duration +} + +// ReconnectConfig 包含重连相关配置。 +type ReconnectConfig struct { + MinBackoff time.Duration + MaxBackoff time.Duration +} diff --git a/internal/session/sqlite_store.go b/internal/session/sqlite_store.go index 72c47b8f..2bcbfa8f 100644 --- a/internal/session/sqlite_store.go +++ b/internal/session/sqlite_store.go @@ -958,6 +958,10 @@ func initializeSQLiteSchema(ctx context.Context, db *sql.DB) error { if err := migrateSQLiteSchemaV6ToV7(ctx, db); err != nil { return err } + case 6: + if err := migrateSQLiteSchemaV6ToV7(ctx, db); err != nil { + return err + } default: return fmt.Errorf("session: unsupported sqlite schema version %d", userVersion) } diff --git a/www/guide/feishu-remote-setup.md b/www/guide/feishu-remote-setup.md index d940eb6c..b8c62d0b 100644 --- a/www/guide/feishu-remote-setup.md +++ b/www/guide/feishu-remote-setup.md @@ -275,7 +275,79 @@ go run ./cmd/neocode feishu-adapter \ --- -## 8. 常见问题 +## 8. Local Runner(本机工具执行) + +如果你的 NeoCode Gateway 部署在云端,但希望工具(文件读写、命令执行等)在你的**本机电脑**上运行,就需要启动 Local Runner。 + +Runner 会主动通过 WebSocket 连接云端 Gateway,接收工具执行请求并在本机完成,无需开放入站端口。 + +``` +飞书消息 -> Adapter (云端) -> Gateway (云端) -> WebSocket -> Local Runner (你的电脑) + ↑ 主动出站连接 +``` + +### 8.1 启动 Runner + +```bash +# macOS / Linux +go run ./cmd/neocode runner \ + --gateway-address "your-gateway.com:8080" \ + --token-file ~/.neocode/auth.json \ + --runner-name "我的 MacBook" \ + --workdir /path/to/project +``` + +```powershell +# Windows PowerShell +go run ./cmd/neocode runner ` + --gateway-address "your-gateway.com:8080" ` + --token-file "$env:USERPROFILE\.neocode\auth.json" ` + --runner-name "我的 PC" ` + --workdir "F:\qiniu\neo-code" +``` + +Runner 启动后会打印连接状态: + +``` +runner my-macbook connecting to your-gateway.com:8080... +connected to gateway at ws://your-gateway.com:8080/ws +runner registered: my-macbook +``` + +### 8.2 参数说明 + +| 参数 | 必填 | 默认值 | 说明 | +|------|:---:|--------|------| +| `--gateway-address` | 否 | `127.0.0.1:8080` | Gateway WebSocket 地址 | +| `--token-file` | 否 | — | Gateway 认证 token 文件,需与 Gateway 共用同一个 | +| `--runner-id` | 否 | 本机 hostname | Runner 唯一标识,同台机器重复启动会冲突 | +| `--runner-name` | 否 | — | 人类可读名称,便于在日志中区分多台 Runner | +| `--workdir` | 否 | 当前目录 | Runner 工作目录,工具在此目录下执行 | + +### 8.3 断线重连 + +Runner 断连后会自动重连,采用指数退避 + 随机抖动策略: + +- 初始退避:500ms +- 最大退避:10s +- 每次失败后退避时间翻倍,并加入随机抖动避免惊群 + +### 8.4 安全边界 + +- Runner 只执行 Gateway 签发并签名的工具请求(CapabilityToken HMAC-SHA256 校验) +- Token 有过期时间(TTL),过期请求会被拒绝 +- 支持配置工作区路径白名单(`WorkdirAllowlist`),拒绝越界路径访问 +- 所有工具在 Runner 本机执行,结果通过 Gateway 加密回传 + +### 8.5 错误提示 + +当 Runner 不可用时,飞书卡片会显示友好的中文提示: + +- **Runner 离线**:`本机 Runner 未连接,请在电脑上启动 neocode runner` +- **权限不足**:`权限不足:当前能力令牌不允许此操作` +- **执行失败**:`工具执行失败:<具体错误>` + +## 9. 常见问题 ### `workspace hash is empty and no default configured` From d824327d5ebdbc0b848ee7d054d69baaa19e7c13 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 7 May 2026 12:24:34 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(gateway):=20=E5=AE=8C=E6=88=90=20Gate?= =?UTF-8?q?way=20=E5=90=AF=E5=8A=A8=E6=8E=A5=E7=BA=BF=E4=B8=8E=20Runtime?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultiWorkspaceRuntime 新增 InjectRunnerDispatcher,同时注入已有 和未来创建的 workspace bundle - gateway_commands 中创建 RunnerRegistry/RunnerToolManager 并传入 NetworkServerOptions 和 runtime dispatcher - network_server 在 WS 断连时自动清理 runner 注册记录 - runtime 新增 RunnerToolDispatcher 接口及设值方法,在工具执行前 优先尝试 runner 分发,handled=false 时回退本地执行 - 新增 runner_tool_bridge 适配 RunnerToolManager 到 runtime 接口 Co-Authored-By: Claude Opus 4.7 --- internal/cli/gateway_commands.go | 42 +++++++++++++++++++ internal/gateway/multi_workspace_runtime.go | 19 +++++++++ internal/gateway/network_server.go | 3 ++ internal/gateway/runner_tool_bridge.go | 46 +++++++++++++++++++++ internal/runtime/permission.go | 10 ++++- internal/runtime/runtime.go | 13 ++++++ 6 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 internal/gateway/runner_tool_bridge.go diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index 7ef023cf..bb0ded9c 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -19,6 +19,7 @@ import ( "neo-code/internal/config" "neo-code/internal/gateway" gatewayauth "neo-code/internal/gateway/auth" + agentruntime "neo-code/internal/runtime" "neo-code/internal/webassets" ) @@ -238,6 +239,15 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat Metrics: metrics, }) + runnerRegistry := gateway.NewRunnerRegistry(logger) + runnerToolManager := gateway.NewRunnerToolManager( + runnerRegistry, + relay, + nil, // capability signer: nil allows execution without token for MVP + 30*time.Second, + logger, + ) + runtimePort, closeRuntimePort, err := buildGatewayRuntimePort(signalContext, options.Workdir) if err != nil { return fmt.Errorf("initialize gateway runtime: %w", err) @@ -248,6 +258,9 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat } }() + // 注入 Runner 工具分发器到 runtime,使 ReAct 循环中的工具调用可以通过 runner 执行 + injectRunnerDispatcherIntoRuntime(runtimePort, runnerToolManager) + idleCloser := newGatewayIdleShutdownController(logger, cancelRuntime) defer idleCloser.close() @@ -294,6 +307,8 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat AllowedOrigins: gatewayConfig.Security.AllowOrigins, StaticFileDir: staticFileDir, StaticFileFS: staticFileFS, + RunnerRegistry: runnerRegistry, + RunnerToolManager: runnerToolManager, ConnectionCountChanged: func(active int) { idleCloser.observe(active) }, @@ -479,6 +494,33 @@ func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatew return gateway.NewNetworkServer(options) } +// injectRunnerDispatcherIntoRuntime 将 RunnerToolManager 注入到多工作区 runtime 的所有 bundle 中, +// 使 ReAct 循环中的工具调用可以通过 runner 远程执行。 +func injectRunnerDispatcherIntoRuntime(runtimePort gateway.RuntimePort, runnerToolManager *gateway.RunnerToolManager) { + if runtimePort == nil || runnerToolManager == nil { + return + } + + mw, ok := runtimePort.(*gateway.MultiWorkspaceRuntime) + if !ok { + return + } + + dispatcher := gateway.NewRunnerToolDispatcher(runnerToolManager) + + mw.InjectRunnerDispatcher(func(port gateway.RuntimePort) { + bridge, ok := port.(*gatewayRuntimePortBridge) + if !ok { + return + } + svc, ok := bridge.runtime.(*agentruntime.Service) + if !ok { + return + } + svc.SetRunnerToolDispatcher(dispatcher) + }) +} + // encodeJSONLine 将对象编码为单行 JSON,并写入目标输出流。 func encodeJSONLine(writer io.Writer, payload any) error { encoder := json.NewEncoder(writer) diff --git a/internal/gateway/multi_workspace_runtime.go b/internal/gateway/multi_workspace_runtime.go index 7f1e5e4a..26d57921 100644 --- a/internal/gateway/multi_workspace_runtime.go +++ b/internal/gateway/multi_workspace_runtime.go @@ -21,6 +21,8 @@ type MultiWorkspaceRuntime struct { defaultHash string managementPort ManagementRuntimePort + runnerDispatcherInjector func(RuntimePort) + events chan RuntimeEvent eventSubs map[string]chan<- RuntimeEvent eventMu sync.Mutex @@ -94,6 +96,10 @@ func (m *MultiWorkspaceRuntime) getPortForHash(hash string) (RuntimePort, error) return nil, fmt.Errorf("build workspace runtime for %s: %w", hash, err) } + if m.runnerDispatcherInjector != nil { + m.runnerDispatcherInjector(port) + } + b = &workspaceBundle{port: port, cleanup: cleanup} m.bundles[hash] = b m.startEventForwarder(hash, port) @@ -135,6 +141,19 @@ func (m *MultiWorkspaceRuntime) startEventForwarder(hash string, port RuntimePor }() } +// InjectRunnerDispatcher 设置 runner tool dispatcher 注入回调。 +// fn 对每个已加载或未来创建的 RuntimePort 调用一次。 +func (m *MultiWorkspaceRuntime) InjectRunnerDispatcher(fn func(RuntimePort)) { + m.mu.Lock() + defer m.mu.Unlock() + + m.runnerDispatcherInjector = fn + + for _, b := range m.bundles { + fn(b.port) + } +} + // Close 优雅关闭所有已加载的工作区 runtime。 func (m *MultiWorkspaceRuntime) Close() error { close(m.stopCh) diff --git a/internal/gateway/network_server.go b/internal/gateway/network_server.go index 520c5243..dedb8b30 100644 --- a/internal/gateway/network_server.go +++ b/internal/gateway/network_server.go @@ -621,6 +621,9 @@ func (s *NetworkServer) handleWebSocket(conn *websocket.Conn, runtimePort Runtim defer func() { s.unregisterWSConnection(conn) + if s.runnerRegistry != nil { + s.runnerRegistry.OnConnectionDropped(connectionID) + } relay.dropConnection(connectionID) _ = conn.Close() }() diff --git a/internal/gateway/runner_tool_bridge.go b/internal/gateway/runner_tool_bridge.go new file mode 100644 index 00000000..457e4668 --- /dev/null +++ b/internal/gateway/runner_tool_bridge.go @@ -0,0 +1,46 @@ +package gateway + +import ( + "context" + "encoding/json" + "strings" + + agentruntime "neo-code/internal/runtime" + "neo-code/internal/tools" +) + +// runnerToolDispatcherBridge 适配 RunnerToolManager 为 runtime.RunnerToolDispatcher。 +type runnerToolDispatcherBridge struct { + manager *RunnerToolManager +} + +// NewRunnerToolDispatcher 创建 runtime.RunnerToolDispatcher 的 gateway 端适配器。 +func NewRunnerToolDispatcher(manager *RunnerToolManager) agentruntime.RunnerToolDispatcher { + if manager == nil { + return nil + } + return &runnerToolDispatcherBridge{manager: manager} +} + +func (b *runnerToolDispatcherBridge) TryDispatch( + ctx context.Context, + sessionID string, + runID string, + input tools.ToolCallInput, +) (tools.ToolResult, bool, error) { + content, isError, err := b.manager.DispatchToolRequest( + ctx, + strings.TrimSpace(sessionID), + strings.TrimSpace(runID), + strings.TrimSpace(input.ID), + strings.TrimSpace(input.Name), + json.RawMessage(input.Arguments), + ) + if err != nil { + if strings.Contains(err.Error(), "runner not online") { + return tools.ToolResult{}, false, nil + } + return tools.ToolResult{Content: err.Error(), IsError: true}, true, nil + } + return tools.ToolResult{Content: content, IsError: isError}, true, nil +} diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index dabf7f51..08b14634 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -107,8 +107,16 @@ func (s *Service) executeToolCallWithPermission(ctx context.Context, input permi effectiveTimeout := resolveToolExecutionTimeout(input.Call, input.ToolTimeout) runCtx, cancel := context.WithTimeout(ctx, effectiveTimeout) + defer cancel() + + if s.runnerToolDispatcher != nil { + result, handled, dispatchErr := s.runnerToolDispatcher.TryDispatch(runCtx, input.SessionID, input.RunID, callInput) + if handled { + return result, dispatchErr + } + } + result, execErr := s.toolManager.Execute(runCtx, callInput) - cancel() if execErr == nil { return result, nil } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 0cf760dc..799b3c44 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -184,6 +184,19 @@ type Service struct { permissionAskLocks map[string]*permissionAskLockEntry thinkingEnabled bool + + runnerToolDispatcher RunnerToolDispatcher +} + +// RunnerToolDispatcher 可选:将工具执行分发到远程 runner。 +// 返回 (result, handled, error)。handled=false 表示继续走本地执行。 +type RunnerToolDispatcher interface { + TryDispatch(ctx context.Context, sessionID, runID string, input tools.ToolCallInput) (tools.ToolResult, bool, error) +} + +// SetRunnerToolDispatcher 设置远程工具分发器。 +func (s *Service) SetRunnerToolDispatcher(d RunnerToolDispatcher) { + s.runnerToolDispatcher = d } // sessionLockEntry 维护单个会话读写锁及其当前引用计数,用于在无引用时回收 map 项。 From 65a54b97ffe819e88788bf5546969a419c9d5e15 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 7 May 2026 16:34:31 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(runner):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=89=A7=E8=A1=8C=E5=AE=89=E5=85=A8=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E8=83=BD=E5=8A=9B=E4=BB=A4=E7=89=8C=E9=97=AD?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gateway 签发 CapabilityToken 并随工具请求下发给 Runner - Runner 验证 Token 签名/TTL/工具白名单 + 路径 allowlist 校验 - Token 从 URL query 迁移至 Authorization Header - 添加 WebSocket 并发写保护、工具注册、Shell 自动检测 - 路径判定排除 URL 等非路径字符串,相对路径基于 workdir 解析 --- internal/gateway/protocol/runner.go | 19 +++--- internal/gateway/runner_tool.go | 35 ++++++++--- internal/runner/capability.go | 98 +++++++++++++++++++++++++++-- internal/runner/runner.go | 69 ++++++++++++++++++-- internal/runner/types.go | 15 +++-- www/guide/feishu-remote-setup.md | 13 +++- 6 files changed, 217 insertions(+), 32 deletions(-) diff --git a/internal/gateway/protocol/runner.go b/internal/gateway/protocol/runner.go index 565b2cb5..b3530dce 100644 --- a/internal/gateway/protocol/runner.go +++ b/internal/gateway/protocol/runner.go @@ -1,6 +1,10 @@ package protocol -import "encoding/json" +import ( + "encoding/json" + + "neo-code/internal/security" +) const ( // MethodGatewayRegisterRunner 表示 runner 向网关注册。 @@ -32,10 +36,11 @@ type ExecuteToolResultParams struct { // ToolRequestParams 是网关推送给 runner 的工具执行请求。 type ToolRequestParams struct { - RequestID string `json:"request_id"` - SessionID string `json:"session_id"` - RunID string `json:"run_id"` - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Arguments json.RawMessage `json:"arguments"` + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Arguments json.RawMessage `json:"arguments"` + CapabilityToken *security.CapabilityToken `json:"capability_token,omitempty"` } diff --git a/internal/gateway/runner_tool.go b/internal/gateway/runner_tool.go index 92ff9b14..a62bcc70 100644 --- a/internal/gateway/runner_tool.go +++ b/internal/gateway/runner_tool.go @@ -87,18 +87,37 @@ func (m *RunnerToolManager) DispatchToolRequest(ctx context.Context, sessionID s m.pending[requestID] = pending m.mu.Unlock() + // 签发 capability token(如果 signer 已配置) + var capToken *security.CapabilityToken + if m.capabilitySigner != nil { + workdir := "" + if record, ok := m.registry.Record(connectionID); ok { + workdir = record.Workdir + } + signed, err := m.NewCapabilityToken(sessionID, runID, toolName, workdir) + if err != nil { + m.logger.Printf("failed to sign capability token: %v", err) + } else if signed != nil { + capToken = signed + } + } + // 构建通知并推送到 runner 连接 + params := map[string]any{ + "request_id": requestID, + "session_id": sessionID, + "run_id": runID, + "tool_call_id": toolCallID, + "tool_name": toolName, + "arguments": arguments, + } + if capToken != nil { + params["capability_token"] = capToken + } notification := map[string]any{ "jsonrpc": "2.0", "method": protocol.MethodGatewayToolRequest, - "params": map[string]any{ - "request_id": requestID, - "session_id": sessionID, - "run_id": runID, - "tool_call_id": toolCallID, - "tool_name": toolName, - "arguments": arguments, - }, + "params": params, } if !m.relay.SendJSONRPCPayload(connectionID, notification) { m.mu.Lock() diff --git a/internal/runner/capability.go b/internal/runner/capability.go index bbd5cecc..e8922f72 100644 --- a/internal/runner/capability.go +++ b/internal/runner/capability.go @@ -1,13 +1,18 @@ package runner import ( + "encoding/json" "path/filepath" goruntime "runtime" "strings" + "time" + + "neo-code/internal/security" ) // CapSigner 在 runner 端负责验证 capability token 和 workspace 边界。 type CapSigner struct { + capVerifier *security.CapabilitySigner workdirAllowlist []string } @@ -18,14 +23,99 @@ func NewCapSigner(workdirAllowlist []string) *CapSigner { } } +// SetCapVerifier 设置用于验证 capability token 签名的验签器。 +func (s *CapSigner) SetCapVerifier(verifier *security.CapabilitySigner) { + s.capVerifier = verifier +} + // VerifyToolRequest 验证工具执行请求是否被允许。 // 检查: -// 1. 工具名是否被允许(默认所有工具允许,除非有 capability token) +// 1. CapabilityToken 签名、TTL、工具白名单(如果提供了 token) // 2. 路径是否在工作区 allowlist 内 func (s *CapSigner) VerifyToolRequest(req ToolExecutionRequest, workdir string) error { - _ = req // reserved for future capability token validation - _ = workdir - return nil // no additional checks for MVP; capability token validation added later + // 如果提供了 capability token,验证其签名和权限 + if req.CapabilityToken != nil { + if s.capVerifier != nil { + if err := s.capVerifier.Verify(*req.CapabilityToken); err != nil { + return ErrCapabilitySignatureInvalid + } + } + if err := req.CapabilityToken.ValidateAt(time.Now()); err != nil { + return ErrCapabilityTokenExpired + } + if !isToolAllowed(req.CapabilityToken.AllowedTools, req.ToolName) { + return ErrCapabilityToolNotAllowed + } + } + + // 验证路径是否在 allowlist 内 + if req.Arguments != nil { + if err := s.verifyPathsInArgs(req.Arguments, workdir); err != nil { + return err + } + } + + return nil +} + +// verifyPathsInArgs 检查参数中的路径是否在 allowlist 范围内。 +func (s *CapSigner) verifyPathsInArgs(args json.RawMessage, workdir string) error { + if len(s.workdirAllowlist) == 0 { + return nil + } + var m map[string]any + if err := json.Unmarshal(args, &m); err != nil { + return nil + } + for _, v := range m { + str, ok := v.(string) + if !ok { + continue + } + if looksLikePath(str) { + resolved := resolvePath(str, workdir) + if err := s.VerifyPath(resolved); err != nil { + return err + } + } + } + return nil +} + +// looksLikePath 判断字符串是否看起来像文件路径。 +func looksLikePath(s string) bool { + if strings.Contains(s, "://") { + return false + } + return strings.Contains(s, "/") || strings.Contains(s, "\\") || + strings.HasPrefix(s, ".") || filepath.IsAbs(s) +} + +// resolvePath 将 target 基于 workdir 解析为绝对路径,用于 allowlist 比较。 +func resolvePath(target string, workdir string) string { + trimmed := strings.TrimSpace(target) + if trimmed == "" { + return "" + } + if filepath.IsAbs(trimmed) { + return trimmed + } + base := strings.TrimSpace(workdir) + if base != "" { + return filepath.Join(base, trimmed) + } + return trimmed +} + +// isToolAllowed 判断工具名是否在 token 允许列表中。 +func isToolAllowed(allowedTools []string, toolName string) bool { + normalized := strings.ToLower(strings.TrimSpace(toolName)) + for _, allowed := range allowedTools { + if strings.ToLower(strings.TrimSpace(allowed)) == normalized { + return true + } + } + return false } // VerifyPath 验证目标路径是否在 allowlist 范围内。 diff --git a/internal/runner/runner.go b/internal/runner/runner.go index b29c0517..2913eeec 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -9,6 +9,7 @@ import ( "math/rand" "net/http" "os" + goruntime "runtime" "strings" "sync" "time" @@ -16,6 +17,11 @@ import ( "github.com/gorilla/websocket" "neo-code/internal/tools" + "neo-code/internal/tools/bash" + diagnosetool "neo-code/internal/tools/diagnose" + "neo-code/internal/tools/filesystem" + "neo-code/internal/tools/todo" + "neo-code/internal/tools/webfetch" ) // Runner 是本地执行守护进程,主动连接云端 Gateway,接收工具执行请求。 @@ -26,6 +32,7 @@ type Runner struct { capSigner *CapSigner mu sync.Mutex + writeMu sync.Mutex // 保护 WebSocket 并发写 running bool cancel context.CancelFunc } @@ -38,6 +45,7 @@ type Config struct { Token string Workdir string WorkdirAllowlist []string + Shell string // 用于 bash 工具,空值自动检测 HeartbeatInterval time.Duration ReconnectBackoffMin time.Duration ReconnectBackoffMax time.Duration @@ -71,7 +79,38 @@ func New(cfg Config) (*Runner, error) { logger = log.New(os.Stderr, "runner: ", log.LstdFlags) } + shell := cfg.Shell + if shell == "" { + if goruntime.GOOS == "windows" { + shell = "cmd" + } else { + shell = "bash" + } + } + workdir := cfg.Workdir + if workdir == "" { + var err error + workdir, err = os.Getwd() + if err != nil { + return nil, fmt.Errorf("runner: get workdir: %w", err) + } + } + toolMgr := tools.NewRegistry() + toolMgr.Register(filesystem.New(workdir)) + toolMgr.Register(filesystem.NewWrite(workdir)) + toolMgr.Register(filesystem.NewGrep(workdir)) + toolMgr.Register(filesystem.NewGlob(workdir)) + toolMgr.Register(filesystem.NewEdit(workdir)) + toolMgr.Register(filesystem.NewMove(workdir)) + toolMgr.Register(filesystem.NewCopy(workdir)) + toolMgr.Register(filesystem.NewDelete(workdir)) + toolMgr.Register(filesystem.NewCreateDir(workdir)) + toolMgr.Register(filesystem.NewRemoveDir(workdir)) + toolMgr.Register(bash.New(workdir, shell, cfg.RequestTimeout)) + toolMgr.Register(webfetch.New(webfetch.Config{Timeout: cfg.RequestTimeout})) + toolMgr.Register(diagnosetool.New()) + toolMgr.Register(todo.New()) capSigner := NewCapSigner(cfg.WorkdirAllowlist) @@ -130,12 +169,12 @@ func (r *Runner) Run(ctx context.Context) error { func (r *Runner) connectAndServe(ctx context.Context) error { url := fmt.Sprintf("ws://%s/ws", r.cfg.GatewayAddress) - if r.cfg.Token != "" { - url += "?token=" + r.cfg.Token - } header := http.Header{} header.Set("X-Runner-ID", r.cfg.RunnerID) + if r.cfg.Token != "" { + header.Set("Authorization", "Bearer "+r.cfg.Token) + } dialer := websocket.Dialer{ HandshakeTimeout: r.cfg.RequestTimeout, @@ -149,7 +188,7 @@ func (r *Runner) connectAndServe(ctx context.Context) error { } defer conn.Close() - r.logger.Printf("connected to gateway at %s", url) + r.logger.Printf("connected to gateway at %s (runner=%s)", r.cfg.GatewayAddress, r.cfg.RunnerID) // 认证 if err := r.sendRequest(conn, "gateway.authenticate", map[string]string{ @@ -224,6 +263,24 @@ func (r *Runner) handleToolRequest(ctx context.Context, conn *websocket.Conn, ms r.logger.Printf("executing tool: %s (request_id=%s)", req.ToolName, req.RequestID) + // 验证 capability token 和路径边界 + if err := r.capSigner.VerifyToolRequest(req, r.cfg.Workdir); err != nil { + r.logger.Printf("tool request denied: %v", err) + resultParams := map[string]any{ + "request_id": req.RequestID, + "session_id": req.SessionID, + "run_id": req.RunID, + "runner_id": r.cfg.RunnerID, + "tool_call_id": req.ToolCallID, + "content": fmt.Sprintf("tool request denied: %v", err), + "is_error": true, + } + if sendErr := r.sendRequest(conn, "gateway.executeToolResult", resultParams); sendErr != nil { + r.logger.Printf("failed to send denied result: %v", sendErr) + } + return + } + // 执行工具 execCtx, cancel := context.WithTimeout(ctx, r.cfg.RequestTimeout) defer cancel() @@ -268,6 +325,8 @@ func (r *Runner) handlePing(conn *websocket.Conn, msg map[string]any) { "result": "pong", } data, _ := json.Marshal(response) + r.writeMu.Lock() + defer r.writeMu.Unlock() if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { r.logger.Printf("failed to send pong: %v", err) } @@ -304,6 +363,8 @@ func (r *Runner) sendRequest(conn *websocket.Conn, method string, params any) er return fmt.Errorf("marshal request: %w", err) } + r.writeMu.Lock() + defer r.writeMu.Unlock() if err := conn.SetWriteDeadline(time.Now().Add(r.cfg.RequestTimeout)); err != nil { return fmt.Errorf("set write deadline: %w", err) } diff --git a/internal/runner/types.go b/internal/runner/types.go index d17faaaf..301eccf2 100644 --- a/internal/runner/types.go +++ b/internal/runner/types.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "time" + + "neo-code/internal/security" ) var ( @@ -23,12 +25,13 @@ var ( // ToolExecutionRequest 表示从网关收到的工具执行请求。 type ToolExecutionRequest struct { - RequestID string `json:"request_id"` - SessionID string `json:"session_id"` - RunID string `json:"run_id"` - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Arguments json.RawMessage `json:"arguments"` + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Arguments json.RawMessage `json:"arguments"` + CapabilityToken *security.CapabilityToken `json:"capability_token,omitempty"` } // ToolExecutionResult 表示工具执行结果。 diff --git a/www/guide/feishu-remote-setup.md b/www/guide/feishu-remote-setup.md index b8c62d0b..fc82f471 100644 --- a/www/guide/feishu-remote-setup.md +++ b/www/guide/feishu-remote-setup.md @@ -334,10 +334,17 @@ Runner 断连后会自动重连,采用指数退避 + 随机抖动策略: ### 8.4 安全边界 -- Runner 只执行 Gateway 签发并签名的工具请求(CapabilityToken HMAC-SHA256 校验) -- Token 有过期时间(TTL),过期请求会被拒绝 +当前已实现: + +- Runner 验证 Gateway 签发的 CapabilityToken(HMAC-SHA256 签名校验、TTL 过期检查、工具白名单) +- Token 有过期时间(5 分钟 TTL),过期请求会被拒绝 - 支持配置工作区路径白名单(`WorkdirAllowlist`),拒绝越界路径访问 -- 所有工具在 Runner 本机执行,结果通过 Gateway 加密回传 +- 所有工具在 Runner 本机执行 + +传输安全注意事项: + +- Runner 与 Gateway 之间当前使用明文 WebSocket(`ws://`),建议仅在受信任的本地网络中使用,或通过 SSH 隧道 / VPN 加固传输层 +- TLS 加密传输(`wss://`)计划在后续版本支持 ### 8.5 错误提示 From edcca8650961fced3d36f0e335aed7df7e834dfb Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 7 May 2026 09:09:41 +0000 Subject: [PATCH 04/10] test(runner): cover remote runner flow Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/cli/runner_command_test.go | 75 ++++ internal/config/runner_test.go | 67 ++++ internal/feishuadapter/runner_error_test.go | 18 + internal/gateway/runner_support_test.go | 373 ++++++++++++++++++++ internal/runner/capability_test.go | 159 +++++++++ internal/runner/runner.go | 30 +- internal/runner/runner_test.go | 358 +++++++++++++++++++ internal/runtime/runner_dispatcher_test.go | 23 ++ 8 files changed, 1089 insertions(+), 14 deletions(-) create mode 100644 internal/cli/runner_command_test.go create mode 100644 internal/config/runner_test.go create mode 100644 internal/feishuadapter/runner_error_test.go create mode 100644 internal/gateway/runner_support_test.go create mode 100644 internal/runner/capability_test.go create mode 100644 internal/runner/runner_test.go create mode 100644 internal/runtime/runner_dispatcher_test.go diff --git a/internal/cli/runner_command_test.go b/internal/cli/runner_command_test.go new file mode 100644 index 00000000..a0a2598f --- /dev/null +++ b/internal/cli/runner_command_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +func TestNewRunnerCommandForwardsFlags(t *testing.T) { + originalRunner := runRunnerCommandFn + t.Cleanup(func() { runRunnerCommandFn = originalRunner }) + + var captured runnerCommandOptions + runRunnerCommandFn = func(ctx context.Context, options runnerCommandOptions) error { + captured = options + return nil + } + + cmd := newRunnerCommand() + cmd.SetArgs([]string{ + "--gateway-address", "127.0.0.1:9000", + "--token-file", "/tmp/token", + "--runner-id", "runner-1", + "--runner-name", "Local Runner", + "--workdir", "/tmp/work", + }) + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.GatewayAddress != "127.0.0.1:9000" || captured.TokenFile != "/tmp/token" || captured.RunnerID != "runner-1" || captured.RunnerName != "Local Runner" || captured.Workdir != "/tmp/work" { + t.Fatalf("captured options = %#v", captured) + } +} + +func TestDefaultRunRunnerReadsTokenFileError(t *testing.T) { + err := defaultRunRunner(context.Background(), runnerCommandOptions{TokenFile: filepath.Join(t.TempDir(), "missing.token")}) + if err == nil || !strings.Contains(err.Error(), "read token file") { + t.Fatalf("defaultRunRunner() error = %v", err) + } +} + +func TestRootCommandIncludesRunnerSubcommand(t *testing.T) { + cmd := NewRootCommand() + found := false + for _, child := range cmd.Commands() { + if child.Name() == "runner" { + found = true + break + } + } + if !found { + t.Fatal("runner subcommand not registered on root command") + } +} + +func TestNewRunnerCommandAllowsDefaultFlags(t *testing.T) { + originalRunner := runRunnerCommandFn + t.Cleanup(func() { runRunnerCommandFn = originalRunner }) + + var captured runnerCommandOptions + runRunnerCommandFn = func(ctx context.Context, options runnerCommandOptions) error { + captured = options + return nil + } + + cmd := newRunnerCommand() + cmd.SetArgs([]string{}) + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured != (runnerCommandOptions{}) { + t.Fatalf("captured options = %#v, want zero-value defaults before runtime resolution", captured) + } +} diff --git a/internal/config/runner_test.go b/internal/config/runner_test.go new file mode 100644 index 00000000..b5afbb87 --- /dev/null +++ b/internal/config/runner_test.go @@ -0,0 +1,67 @@ +package config + +import ( + "testing" + "time" +) + +func TestRunnerConfigApplyDefaultsCloneAndDurations(t *testing.T) { + cfg := RunnerConfig{ + WorkdirAllowlist: []string{"/tmp/work"}, + } + defaults := defaultRunnerConfig() + cfg.ApplyDefaults(defaults) + + if cfg.GatewayAddress != DefaultRunnerGatewayAddress { + t.Fatalf("GatewayAddress = %q", cfg.GatewayAddress) + } + if cfg.HeartbeatInterval() != 10*time.Second { + t.Fatalf("HeartbeatInterval() = %s", cfg.HeartbeatInterval()) + } + if cfg.ReconnectBackoffMin() != 500*time.Millisecond { + t.Fatalf("ReconnectBackoffMin() = %s", cfg.ReconnectBackoffMin()) + } + if cfg.ReconnectBackoffMax() != 10*time.Second { + t.Fatalf("ReconnectBackoffMax() = %s", cfg.ReconnectBackoffMax()) + } + if cfg.RequestTimeout() != 30*time.Second { + t.Fatalf("RequestTimeout() = %s", cfg.RequestTimeout()) + } + + clone := cfg.Clone() + clone.WorkdirAllowlist[0] = "/changed" + if cfg.WorkdirAllowlist[0] != "/tmp/work" { + t.Fatal("Clone() did not deep copy WorkdirAllowlist") + } +} + +func TestRunnerConfigValidate(t *testing.T) { + if err := (RunnerConfig{}).Validate(); err != nil { + t.Fatalf("disabled RunnerConfig.Validate() error = %v", err) + } + + cases := []RunnerConfig{ + {Enabled: true}, + {Enabled: true, GatewayAddress: "127.0.0.1:8080", HeartbeatIntervalSec: -1}, + {Enabled: true, GatewayAddress: "127.0.0.1:8080", HeartbeatIntervalSec: 1, ReconnectBackoffMinM: -1, ReconnectBackoffMaxM: 1}, + {Enabled: true, GatewayAddress: "127.0.0.1:8080", HeartbeatIntervalSec: 1, ReconnectBackoffMinM: 2, ReconnectBackoffMaxM: 1}, + {Enabled: true, GatewayAddress: "127.0.0.1:8080", HeartbeatIntervalSec: 1, ReconnectBackoffMinM: 1, ReconnectBackoffMaxM: 2, RequestTimeoutSec: -1}, + } + for _, cfg := range cases { + if err := cfg.Validate(); err == nil { + t.Fatalf("Validate() error = nil for %#v", cfg) + } + } + + valid := RunnerConfig{ + Enabled: true, + GatewayAddress: "127.0.0.1:8080", + HeartbeatIntervalSec: 1, + ReconnectBackoffMinM: 1, + ReconnectBackoffMaxM: 2, + RequestTimeoutSec: 3, + } + if err := valid.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} diff --git a/internal/feishuadapter/runner_error_test.go b/internal/feishuadapter/runner_error_test.go new file mode 100644 index 00000000..feabf34b --- /dev/null +++ b/internal/feishuadapter/runner_error_test.go @@ -0,0 +1,18 @@ +package feishuadapter + +import "testing" + +func TestTranslateRunnerError(t *testing.T) { + cases := map[string]string{ + "runner_offline": "本机 Runner 未连接,请在电脑上启动 `neocode runner`", + "capability_denied": "权限不足:当前能力令牌不允许此操作", + "tool_execution_failed: failed": "工具执行失败:tool_execution_failed: failed", + "timed out waiting for runner": "本机 Runner 响应超时,请检查网络连接和 Runner 状态", + "other": "", + } + for input, want := range cases { + if got := translateRunnerError(input); got != want { + t.Fatalf("translateRunnerError(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/gateway/runner_support_test.go b/internal/gateway/runner_support_test.go new file mode 100644 index 00000000..b0e2ba06 --- /dev/null +++ b/internal/gateway/runner_support_test.go @@ -0,0 +1,373 @@ +package gateway + +import ( + "context" + "encoding/json" + "io" + "log" + "strings" + "testing" + "time" + + "neo-code/internal/gateway/protocol" + "neo-code/internal/security" + "neo-code/internal/tools" +) + +func TestRunnerRegistryLifecycle(t *testing.T) { + registry := NewRunnerRegistry(log.New(io.Discard, "", 0)) + connectionID := ConnectionID("cid-runner") + registry.Register(connectionID, "runner-1", "Runner One", "/tmp/work", []string{"local"}) + + if !registry.IsOnline(connectionID) { + t.Fatal("IsOnline() = false, want true") + } + if !registry.BindSession("session-1", connectionID) { + t.Fatal("BindSession() = false, want true") + } + if got, ok := registry.LookupBySession("session-1"); !ok || got != connectionID { + t.Fatalf("LookupBySession() = (%q,%v), want (%q,true)", got, ok, connectionID) + } + record, ok := registry.Record(connectionID) + if !ok || record.RunnerID != "runner-1" { + t.Fatalf("Record() = (%#v,%v)", record, ok) + } + before := record.LastSeenAt + time.Sleep(time.Millisecond) + registry.Heartbeat(connectionID) + record, _ = registry.Record(connectionID) + if !record.LastSeenAt.After(before) { + t.Fatalf("LastSeenAt = %v, want after %v", record.LastSeenAt, before) + } + + list := registry.List() + if len(list) != 1 || list[0].RunnerName != "Runner One" { + t.Fatalf("List() = %#v", list) + } + + registry.UnbindSession("session-1") + if _, ok := registry.LookupBySession("session-1"); ok { + t.Fatal("LookupBySession() ok = true after UnbindSession") + } + registry.BindSession("session-2", connectionID) + registry.OnConnectionDropped(connectionID) + if registry.IsOnline(connectionID) { + t.Fatal("IsOnline() = true after OnConnectionDropped") + } + if _, ok := registry.LookupBySession("session-2"); ok { + t.Fatal("session binding still present after unregister") + } + if registry.BindSession("session-3", connectionID) { + t.Fatal("BindSession() = true for offline runner") + } +} + +func TestRunnerToolManagerDispatchAndCompletion(t *testing.T) { + registry := NewRunnerRegistry(log.New(io.Discard, "", 0)) + relay := NewStreamRelay(StreamRelayOptions{Logger: log.New(io.Discard, "", 0)}) + connectionID := ConnectionID("cid-runner") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connectionCtx := WithStreamRelay(WithConnectionID(ctx, connectionID), relay) + messageCh := make(chan RelayMessage, 1) + if err := relay.RegisterConnection(ConnectionRegistration{ + ConnectionID: connectionID, + Channel: StreamChannelWS, + Context: connectionCtx, + Cancel: cancel, + Write: func(message RelayMessage) error { + messageCh <- message + return nil + }, + Close: func() {}, + }); err != nil { + t.Fatalf("RegisterConnection() error = %v", err) + } + registry.Register(connectionID, "runner-1", "Runner", "/tmp/work", nil) + registry.BindSession("session-1", connectionID) + + signer, err := security.NewCapabilitySigner([]byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatalf("NewCapabilitySigner() error = %v", err) + } + manager := NewRunnerToolManager(registry, relay, signer, time.Second, log.New(io.Discard, "", 0)) + + resultCh := make(chan struct { + content string + isError bool + err error + }, 1) + go func() { + content, isError, dispatchErr := manager.DispatchToolRequest( + context.Background(), + "session-1", + "run-1", + "tool-1", + "bash", + json.RawMessage(`{"command":"pwd"}`), + ) + resultCh <- struct { + content string + isError bool + err error + }{content: content, isError: isError, err: dispatchErr} + }() + + select { + case message := <-messageCh: + payload, ok := message.Payload.(map[string]any) + if !ok { + t.Fatalf("payload type = %T, want map[string]any", message.Payload) + } + if payload["method"] != protocol.MethodGatewayToolRequest { + t.Fatalf("method = %v, want %q", payload["method"], protocol.MethodGatewayToolRequest) + } + params := payload["params"].(map[string]any) + if params["tool_name"] != "bash" { + t.Fatalf("tool_name = %v, want bash", params["tool_name"]) + } + if params["capability_token"] == nil { + t.Fatal("capability_token = nil, want signed token") + } + if err := manager.CompleteToolRequest(params["request_id"].(string), "done", false); err != nil { + t.Fatalf("CompleteToolRequest() error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for tool request") + } + + result := <-resultCh + if result.err != nil || result.isError || result.content != "done" { + t.Fatalf("DispatchToolRequest() = (%q,%v,%v)", result.content, result.isError, result.err) + } +} + +func TestRunnerToolManagerErrorPaths(t *testing.T) { + registry := NewRunnerRegistry(log.New(io.Discard, "", 0)) + relay := NewStreamRelay(StreamRelayOptions{Logger: log.New(io.Discard, "", 0)}) + manager := NewRunnerToolManager(registry, relay, nil, 20*time.Millisecond, log.New(io.Discard, "", 0)) + + if _, _, err := manager.DispatchToolRequest(context.Background(), "missing", "run-1", "tool-1", "bash", nil); err == nil { + t.Fatal("DispatchToolRequest() error = nil, want offline error") + } + + connectionID := ConnectionID("cid-runner") + registry.Register(connectionID, "runner-1", "Runner", "/tmp/work", nil) + registry.BindSession("session-1", connectionID) + if _, _, err := manager.DispatchToolRequest(context.Background(), "session-1", "run-1", "tool-1", "bash", nil); err == nil || !strings.Contains(err.Error(), "failed to send") { + t.Fatalf("DispatchToolRequest() error = %v", err) + } + + pending := &PendingToolCall{ + RequestID: "req-full", + ResultChan: make(chan toolResultEnvelope, 1), + Deadline: time.Now().Add(time.Second), + } + pending.ResultChan <- toolResultEnvelope{} + manager.pending[pending.RequestID] = pending + if err := manager.CompleteToolRequest(pending.RequestID, "x", true); err == nil { + t.Fatal("CompleteToolRequest() error = nil, want channel full or missing") + } + if err := manager.CompleteToolRequest("missing", "x", true); err == nil { + t.Fatal("CompleteToolRequest() missing error = nil") + } + + manager.pending["expired"] = &PendingToolCall{ + RequestID: "expired", + ResultChan: make(chan toolResultEnvelope, 1), + Deadline: time.Now().Add(-time.Second), + } + manager.cleanupExpired() + if _, exists := manager.pending["expired"]; exists { + t.Fatal("cleanupExpired() did not remove expired request") + } +} + +func TestRunnerToolManagerCapabilityTokenAndCleanupLoop(t *testing.T) { + manager := NewRunnerToolManager(nil, nil, nil, 10*time.Millisecond, log.New(io.Discard, "", 0)) + token, err := manager.NewCapabilityToken("session-1", "run-1", "bash", "/tmp/work") + if err != nil || token != nil { + t.Fatalf("NewCapabilityToken() = (%v,%v), want (nil,nil)", token, err) + } + + signer, err := security.NewCapabilitySigner([]byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatalf("NewCapabilitySigner() error = %v", err) + } + manager = NewRunnerToolManager(nil, nil, signer, 10*time.Millisecond, log.New(io.Discard, "", 0)) + token, err = manager.NewCapabilityToken("session-1", "run-1", "bash", "/tmp/work") + if err != nil { + t.Fatalf("NewCapabilityToken() error = %v", err) + } + if token == nil || token.AllowedTools[0] != "bash" { + t.Fatalf("token = %#v", token) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + manager.pending["cleanup"] = &PendingToolCall{ + RequestID: "cleanup", + ResultChan: make(chan toolResultEnvelope, 1), + Deadline: time.Now().Add(-time.Second), + } + go func() { + manager.CleanupLoop(ctx) + close(done) + }() + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("CleanupLoop() did not stop after cancel") + } +} + +func TestRunnerToolDispatcherBridge(t *testing.T) { + registry := NewRunnerRegistry(log.New(io.Discard, "", 0)) + relay := NewStreamRelay(StreamRelayOptions{Logger: log.New(io.Discard, "", 0)}) + manager := NewRunnerToolManager(registry, relay, nil, time.Second, log.New(io.Discard, "", 0)) + bridge := NewRunnerToolDispatcher(manager) + if bridge == nil { + t.Fatal("NewRunnerToolDispatcher(manager) = nil") + } + if NewRunnerToolDispatcher(nil) != nil { + t.Fatal("NewRunnerToolDispatcher(nil manager) != nil") + } + + connectionID := ConnectionID("cid-runner") + registry.Register(connectionID, "runner-1", "Runner", "/tmp/work", nil) + registry.BindSession("session-1", connectionID) + + result, handled, err := bridge.TryDispatch(context.Background(), "session-1", "run-1", tools.ToolCallInput{ + ID: "tool-1", + Name: "bash", + }) + if err != nil || !handled || !result.IsError { + t.Fatalf("TryDispatch(send fail) = (%#v,%v,%v)", result, handled, err) + } + + result, handled, err = bridge.TryDispatch(context.Background(), "missing", "run-1", tools.ToolCallInput{ + ID: "tool-1", + Name: "bash", + }) + if err != nil || handled { + t.Fatalf("TryDispatch(offline) = (%#v,%v,%v)", result, handled, err) + } +} + +func TestRunnerContextHelpersAndACL(t *testing.T) { + ctx := context.Background() + registry := NewRunnerRegistry(nil) + manager := NewRunnerToolManager(registry, NewStreamRelay(StreamRelayOptions{}), nil, time.Second, nil) + + if RunnerRegistryFromContext(nil) != nil || RunnerToolManagerFromContext(nil) != nil { + t.Fatal("nil context should not return runner helpers") + } + ctx = WithRunnerRegistry(ctx, registry) + ctx = WithRunnerToolManager(ctx, manager) + if RunnerRegistryFromContext(ctx) != registry { + t.Fatal("RunnerRegistryFromContext() mismatch") + } + if RunnerToolManagerFromContext(ctx) != manager { + t.Fatal("RunnerToolManagerFromContext() mismatch") + } + + acl := NewStrictControlPlaneACL() + if !acl.IsAllowed(RequestSourceRunner, protocol.MethodGatewayRegisterRunner) { + t.Fatal("runner source should allow registerRunner") + } + if acl.IsAllowed(RequestSourceRunner, protocol.MethodGatewayRun) { + t.Fatal("runner source should not allow gateway.run") + } + if NormalizeRequestSource(RequestSource(" RUNNER ")) != RequestSourceRunner { + t.Fatal("NormalizeRequestSource() did not normalize runner") + } +} + +func TestRunnerBootstrapHandlers(t *testing.T) { + registry := NewRunnerRegistry(nil) + ctx := WithRunnerRegistry(WithConnectionID(context.Background(), "cid-runner"), registry) + frame := MessageFrame{ + RequestID: "req-1", + Payload: protocol.RegisterRunnerParams{ + RunnerID: "runner-1", + RunnerName: "Runner", + Workdir: "/tmp/work", + }, + } + response := handleRegisterRunnerFrame(ctx, frame, nil) + if response.Type != FrameTypeAck || response.Action != FrameActionRegisterRunner { + t.Fatalf("handleRegisterRunnerFrame() = %#v", response) + } + if _, ok := registry.Record("cid-runner"); !ok { + t.Fatal("runner not registered") + } + + manager := NewRunnerToolManager(registry, NewStreamRelay(StreamRelayOptions{}), nil, time.Second, nil) + manager.pending["pending-1"] = &PendingToolCall{ + RequestID: "pending-1", + ResultChan: make(chan toolResultEnvelope, 1), + Deadline: time.Now().Add(time.Second), + } + ctx = WithRunnerToolManager(context.Background(), manager) + resultFrame := handleExecuteToolResultFrame(ctx, MessageFrame{ + RequestID: "req-2", + Payload: protocol.ExecuteToolResultParams{ + RequestID: "pending-1", + SessionID: "session-1", + RunID: "run-1", + ToolCallID: "tool-1", + Content: "ok", + }, + }, nil) + if resultFrame.Type != FrameTypeAck || resultFrame.Action != FrameActionExecuteToolResult { + t.Fatalf("handleExecuteToolResultFrame() = %#v", resultFrame) + } +} + +func TestRunnerJSONRPCNormalizationAndInjection(t *testing.T) { + registerNormalized, rpcErr := protocol.NormalizeJSONRPCRequest(protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"runner-1"`), + Method: protocol.MethodGatewayRegisterRunner, + Params: json.RawMessage(`{"runner_id":"r-1","workdir":"/tmp/work"}`), + }) + if rpcErr != nil || registerNormalized.Action != "register_runner" { + t.Fatalf("NormalizeJSONRPCRequest(register) = (%#v,%v)", registerNormalized, rpcErr) + } + + resultNormalized, rpcErr := protocol.NormalizeJSONRPCRequest(protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"runner-2"`), + Method: protocol.MethodGatewayExecuteToolResult, + Params: json.RawMessage(`{"request_id":"req-1","session_id":"s-1","run_id":"r-1","tool_call_id":"tool-1"}`), + }) + if rpcErr != nil || resultNormalized.Action != "execute_tool_result" { + t.Fatalf("NormalizeJSONRPCRequest(result) = (%#v,%v)", resultNormalized, rpcErr) + } + + if _, rpcErr := protocol.NormalizeJSONRPCRequest(protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"runner-3"`), + Method: protocol.MethodGatewayRegisterRunner, + Params: json.RawMessage(`{"runner_id":"","workdir":"/tmp/work"}`), + }); rpcErr == nil { + t.Fatal("NormalizeJSONRPCRequest(register invalid) error = nil") + } + + port := &bootstrapRuntimeStub{} + multi := &MultiWorkspaceRuntime{ + bundles: map[string]*workspaceBundle{ + "default": {port: port, cleanup: func() error { return nil }}, + }, + } + called := false + multi.InjectRunnerDispatcher(func(runtimePort RuntimePort) { + if runtimePort == port { + called = true + } + }) + if !called { + t.Fatal("InjectRunnerDispatcher() did not inject existing bundle") + } +} diff --git a/internal/runner/capability_test.go b/internal/runner/capability_test.go new file mode 100644 index 00000000..133b4f5c --- /dev/null +++ b/internal/runner/capability_test.go @@ -0,0 +1,159 @@ +package runner + +import ( + "encoding/json" + "errors" + "path/filepath" + "testing" + "time" + + "neo-code/internal/security" +) + +func TestCapSignerVerifyToolRequest(t *testing.T) { + t.Run("allows request without token or allowlist", func(t *testing.T) { + signer := NewCapSigner(nil) + if err := signer.VerifyToolRequest(ToolExecutionRequest{ToolName: "bash"}, "/tmp/work"); err != nil { + t.Fatalf("VerifyToolRequest() error = %v", err) + } + }) + + t.Run("rejects invalid signature", func(t *testing.T) { + verifier, err := security.NewCapabilitySigner([]byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatalf("NewCapabilitySigner() error = %v", err) + } + signer := NewCapSigner(nil) + signer.SetCapVerifier(verifier) + token := validCapabilityToken(t, "bash") + if err := signer.VerifyToolRequest(ToolExecutionRequest{ + ToolName: "bash", + CapabilityToken: &token, + }, "/tmp/work"); !errors.Is(err, ErrCapabilitySignatureInvalid) { + t.Fatalf("VerifyToolRequest() error = %v, want %v", err, ErrCapabilitySignatureInvalid) + } + }) + + t.Run("rejects expired token", func(t *testing.T) { + signer := NewCapSigner(nil) + token := validCapabilityToken(t, "bash") + token.ExpiresAt = time.Now().UTC().Add(-time.Second) + if err := signer.VerifyToolRequest(ToolExecutionRequest{ + ToolName: "bash", + CapabilityToken: &token, + }, "/tmp/work"); !errors.Is(err, ErrCapabilityTokenExpired) { + t.Fatalf("VerifyToolRequest() error = %v, want %v", err, ErrCapabilityTokenExpired) + } + }) + + t.Run("rejects disallowed tool", func(t *testing.T) { + signer := NewCapSigner(nil) + token := validCapabilityToken(t, "read_file") + if err := signer.VerifyToolRequest(ToolExecutionRequest{ + ToolName: "bash", + CapabilityToken: &token, + }, "/tmp/work"); !errors.Is(err, ErrCapabilityToolNotAllowed) { + t.Fatalf("VerifyToolRequest() error = %v, want %v", err, ErrCapabilityToolNotAllowed) + } + }) + + t.Run("rejects arguments outside allowlist", func(t *testing.T) { + signer := NewCapSigner([]string{"/safe"}) + args, err := json.Marshal(map[string]any{"path": "../../outside.txt"}) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if err := signer.VerifyToolRequest(ToolExecutionRequest{ + ToolName: "read_file", + Arguments: args, + }, "/safe/work"); !errors.Is(err, ErrCapabilityPathNotAllowed) { + t.Fatalf("VerifyToolRequest() error = %v, want %v", err, ErrCapabilityPathNotAllowed) + } + }) +} + +func TestCapSignerVerifyPathsInArgsIgnoresUnsupportedValues(t *testing.T) { + signer := NewCapSigner([]string{"/safe"}) + if err := signer.verifyPathsInArgs(json.RawMessage(`{`), "/safe/work"); err != nil { + t.Fatalf("verifyPathsInArgs() error = %v", err) + } + + args, err := json.Marshal(map[string]any{ + "url": "https://example.com/file", + "count": 2, + "path": "./nested/file.txt", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if err := signer.verifyPathsInArgs(args, "/safe/work"); err != nil { + t.Fatalf("verifyPathsInArgs() error = %v", err) + } +} + +func TestCapSignerHelpers(t *testing.T) { + if !looksLikePath("./a.txt") { + t.Fatal("looksLikePath() = false, want true for relative path") + } + if looksLikePath("https://example.com/file") { + t.Fatal("looksLikePath() = true, want false for URL") + } + + resolved := resolvePath("child/file.txt", "/tmp/work") + wantResolved := filepath.Join("/tmp/work", "child/file.txt") + if resolved != wantResolved { + t.Fatalf("resolvePath() = %q, want %q", resolved, wantResolved) + } + if got := resolvePath(" ", "/tmp/work"); got != "" { + t.Fatalf("resolvePath(empty) = %q, want empty", got) + } + + if !isToolAllowed([]string{" Bash "}, "bash") { + t.Fatal("isToolAllowed() = false, want true") + } + if isToolAllowed([]string{"read_file"}, "bash") { + t.Fatal("isToolAllowed() = true, want false") + } +} + +func TestCapSignerVerifyPath(t *testing.T) { + t.Run("allowlist disabled", func(t *testing.T) { + signer := NewCapSigner(nil) + if err := signer.VerifyPath("/any/path"); err != nil { + t.Fatalf("VerifyPath() error = %v", err) + } + }) + + t.Run("exact and child path allowed", func(t *testing.T) { + signer := NewCapSigner([]string{"/safe/base", " "}) + if err := signer.VerifyPath("/safe/base"); err != nil { + t.Fatalf("VerifyPath(exact) error = %v", err) + } + if err := signer.VerifyPath("/safe/base/child.txt"); err != nil { + t.Fatalf("VerifyPath(child) error = %v", err) + } + }) + + t.Run("outside path rejected", func(t *testing.T) { + signer := NewCapSigner([]string{"/safe/base"}) + if err := signer.VerifyPath("/unsafe/base"); !errors.Is(err, ErrCapabilityPathNotAllowed) { + t.Fatalf("VerifyPath() error = %v, want %v", err, ErrCapabilityPathNotAllowed) + } + }) +} + +func validCapabilityToken(t *testing.T, toolName string) security.CapabilityToken { + t.Helper() + now := time.Now().UTC() + return security.CapabilityToken{ + ID: "cap-1", + TaskID: "run-1", + AgentID: "session-1", + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Minute), + AllowedTools: []string{toolName}, + AllowedPaths: []string{"/safe"}, + NetworkPolicy: security.NetworkPolicy{Mode: security.NetworkPermissionDenyAll}, + WritePermission: security.WritePermissionWorkspace, + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 2913eeec..2fa1632b 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -26,10 +26,10 @@ import ( // Runner 是本地执行守护进程,主动连接云端 Gateway,接收工具执行请求。 type Runner struct { - cfg Config - logger *log.Logger - toolMgr tools.Manager - capSigner *CapSigner + cfg Config + logger *log.Logger + toolMgr tools.Manager + capSigner *CapSigner mu sync.Mutex writeMu sync.Mutex // 保护 WebSocket 并发写 @@ -39,18 +39,18 @@ type Runner struct { // Config 表示 runner 运行时配置。 type Config struct { - RunnerID string - RunnerName string - GatewayAddress string - Token string - Workdir string - WorkdirAllowlist []string - Shell string // 用于 bash 工具,空值自动检测 - HeartbeatInterval time.Duration + RunnerID string + RunnerName string + GatewayAddress string + Token string + Workdir string + WorkdirAllowlist []string + Shell string // 用于 bash 工具,空值自动检测 + HeartbeatInterval time.Duration ReconnectBackoffMin time.Duration ReconnectBackoffMax time.Duration - RequestTimeout time.Duration - Logger *log.Logger + RequestTimeout time.Duration + Logger *log.Logger } // New 创建 runner 实例。 @@ -87,6 +87,7 @@ func New(cfg Config) (*Runner, error) { shell = "bash" } } + cfg.Shell = shell workdir := cfg.Workdir if workdir == "" { var err error @@ -95,6 +96,7 @@ func New(cfg Config) (*Runner, error) { return nil, fmt.Errorf("runner: get workdir: %w", err) } } + cfg.Workdir = workdir toolMgr := tools.NewRegistry() toolMgr.Register(filesystem.New(workdir)) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 00000000..f8cfc848 --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,358 @@ +package runner + +import ( + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" + + providertypes "neo-code/internal/provider/types" + "neo-code/internal/security" + "neo-code/internal/tools" +) + +type runnerManagerAdapter struct { + executeFn func(context.Context, tools.ToolCallInput) (tools.ToolResult, error) +} + +func (m *runnerManagerAdapter) ListAvailableSpecs(context.Context, tools.SpecListInput) ([]providertypes.ToolSpec, error) { + return nil, nil +} + +func (m *runnerManagerAdapter) MicroCompactPolicy(string) tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} + +func (m *runnerManagerAdapter) MicroCompactSummarizer(string) tools.ContentSummarizer { + return nil +} + +func (m *runnerManagerAdapter) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + if m.executeFn != nil { + return m.executeFn(ctx, input) + } + return tools.ToolResult{}, nil +} + +func (m *runnerManagerAdapter) RememberSessionDecision(string, security.Action, tools.SessionPermissionScope) error { + return nil +} + +func TestNewRunnerDefaultsAndValidation(t *testing.T) { + t.Run("missing required fields", func(t *testing.T) { + if _, err := New(Config{}); err == nil || !strings.Contains(err.Error(), "runner_id is required") { + t.Fatalf("New() error = %v", err) + } + if _, err := New(Config{RunnerID: "runner-1"}); err == nil || !strings.Contains(err.Error(), "gateway_address is required") { + t.Fatalf("New() error = %v", err) + } + }) + + t.Run("fills defaults", func(t *testing.T) { + workdir := t.TempDir() + prevWD, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(workdir); err != nil { + t.Fatalf("Chdir() error = %v", err) + } + t.Cleanup(func() { _ = os.Chdir(prevWD) }) + + r, err := New(Config{ + RunnerID: "runner-1", + GatewayAddress: "127.0.0.1:8080", + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if r.cfg.Workdir != workdir { + t.Fatalf("workdir = %q, want %q", r.cfg.Workdir, workdir) + } + if r.cfg.HeartbeatInterval != 10*time.Second { + t.Fatalf("HeartbeatInterval = %s, want 10s", r.cfg.HeartbeatInterval) + } + if r.cfg.ReconnectBackoffMin != 500*time.Millisecond { + t.Fatalf("ReconnectBackoffMin = %s, want 500ms", r.cfg.ReconnectBackoffMin) + } + if r.cfg.ReconnectBackoffMax != 10*time.Second { + t.Fatalf("ReconnectBackoffMax = %s, want 10s", r.cfg.ReconnectBackoffMax) + } + if r.cfg.RequestTimeout != 30*time.Second { + t.Fatalf("RequestTimeout = %s, want 30s", r.cfg.RequestTimeout) + } + if r.capSigner == nil { + t.Fatal("capSigner = nil, want initialized") + } + }) +} + +func TestRunnerParseToolRequest(t *testing.T) { + req, err := parseToolRequest(map[string]any{ + "request_id": "req-1", + "tool_name": "bash", + }) + if err != nil { + t.Fatalf("parseToolRequest() error = %v", err) + } + if req.RequestID != "req-1" || req.ToolName != "bash" { + t.Fatalf("parseToolRequest() = %#v", req) + } + + if _, err := parseToolRequest(map[string]any{"tool_name": "bash"}); err == nil || !strings.Contains(err.Error(), "missing request_id") { + t.Fatalf("parseToolRequest() error = %v", err) + } + if _, err := parseToolRequest(map[string]any{"request_id": "req-2"}); err == nil || !strings.Contains(err.Error(), "missing tool_name") { + t.Fatalf("parseToolRequest() error = %v", err) + } +} + +func TestRunnerHandleToolRequestInvalidParamsAndSendRequest(t *testing.T) { + r := &Runner{ + cfg: Config{ + RunnerID: "runner-1", + Workdir: "/safe/work", + RequestTimeout: 200 * time.Millisecond, + }, + logger: log.New(io.Discard, "", 0), + toolMgr: &runnerManagerAdapter{}, + capSigner: NewCapSigner([]string{"/safe/work"}), + } + + runnerConn, serverConn := newRunnerSocketPair(t) + defer runnerConn.Close() + defer serverConn.Close() + + r.handleToolRequest(context.Background(), runnerConn, map[string]any{}) + _ = serverConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + _, _, err := serverConn.ReadMessage() + if err == nil { + t.Fatal("expected invalid params path to not emit a response") + } + runnerConn.Close() + serverConn.Close() + + runnerConn, serverConn = newRunnerSocketPair(t) + defer runnerConn.Close() + defer serverConn.Close() + + if err := r.sendRequest(runnerConn, "gateway.executeToolResult", map[string]any{"request_id": "req-1"}); err != nil { + t.Fatalf("sendRequest() error = %v", err) + } + + var response map[string]any + _ = serverConn.SetReadDeadline(time.Now().Add(time.Second)) + if err := serverConn.ReadJSON(&response); err != nil { + t.Fatalf("ReadJSON() error = %v", err) + } + if response["method"] != "gateway.executeToolResult" { + t.Fatalf("method = %v, want gateway.executeToolResult", response["method"]) + } + + if err := serverConn.WriteJSON(map[string]any{ + "jsonrpc": "2.0", + "id": "ping-1", + "method": "gateway.ping", + }); err != nil { + t.Fatalf("WriteJSON() error = %v", err) + } + r.handlePing(runnerConn, map[string]any{"id": "ping-1"}) + + var pong map[string]any + _ = serverConn.SetReadDeadline(time.Now().Add(time.Second)) + if err := serverConn.ReadJSON(&pong); err != nil { + t.Fatalf("ReadJSON(pong) error = %v", err) + } + if pong["result"] != "pong" { + t.Fatalf("pong result = %v, want pong", pong["result"]) + } +} + +func TestRunnerRunHandlesPingAndToolRequest(t *testing.T) { + var executeCount atomic.Int32 + resultReceived := make(chan map[string]any, 1) + server := newRunnerGatewayServer(t, func(conn *websocket.Conn) { + var authenticate map[string]any + if err := conn.ReadJSON(&authenticate); err != nil { + t.Fatalf("read authenticate: %v", err) + } + if authenticate["method"] != "gateway.authenticate" { + t.Fatalf("authenticate method = %v", authenticate["method"]) + } + + var register map[string]any + if err := conn.ReadJSON(®ister); err != nil { + t.Fatalf("read register: %v", err) + } + if register["method"] != "gateway.registerRunner" { + t.Fatalf("register method = %v", register["method"]) + } + + if err := conn.WriteJSON(map[string]any{ + "jsonrpc": "2.0", + "id": "ping-1", + "method": "gateway.ping", + }); err != nil { + t.Fatalf("WriteJSON(ping) error = %v", err) + } + + var pong map[string]any + if err := conn.ReadJSON(&pong); err != nil { + t.Fatalf("read pong: %v", err) + } + if pong["result"] != "pong" { + t.Fatalf("pong result = %v, want pong", pong["result"]) + } + + if err := conn.WriteJSON(map[string]any{ + "jsonrpc": "2.0", + "method": "gateway.toolRequest", + "params": map[string]any{ + "request_id": "req-1", + "session_id": "session-1", + "run_id": "run-1", + "tool_call_id": "tool-1", + "tool_name": "bash", + "arguments": json.RawMessage(`{"command":"echo hi"}`), + }, + }); err != nil { + t.Fatalf("WriteJSON(toolRequest) error = %v", err) + } + + var result map[string]any + if err := conn.ReadJSON(&result); err != nil { + t.Fatalf("read tool result: %v", err) + } + resultReceived <- result + }) + defer server.Close() + + r := &Runner{ + cfg: Config{ + RunnerID: "runner-1", + RunnerName: "Local Runner", + GatewayAddress: runnerGatewayAddress(server.URL), + Workdir: t.TempDir(), + RequestTimeout: time.Second, + HeartbeatInterval: 5 * time.Second, + ReconnectBackoffMin: time.Millisecond, + ReconnectBackoffMax: 2 * time.Millisecond, + }, + logger: log.New(io.Discard, "", 0), + toolMgr: &runnerManagerAdapter{ + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + executeCount.Add(1) + if input.Name != "bash" { + t.Fatalf("input.Name = %q, want bash", input.Name) + } + return tools.ToolResult{Content: "ok"}, nil + }, + }, + capSigner: NewCapSigner(nil), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + runErrCh := make(chan error, 1) + go func() { + runErrCh <- r.Run(ctx) + }() + + var result map[string]any + select { + case result = <-resultReceived: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for tool result") + } + cancel() + server.CloseClientConnections() + + if err := <-runErrCh; !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + if executeCount.Load() != 1 { + t.Fatalf("execute count = %d, want 1", executeCount.Load()) + } + params := result["params"].(map[string]any) + if params["content"] != "ok" { + t.Fatalf("result content = %v, want ok", params["content"]) + } +} + +func TestRunnerRunAlreadyRunningAndStop(t *testing.T) { + canceled := false + r := &Runner{ + running: true, + cancel: func() { canceled = true }, + } + if err := r.Run(context.Background()); err == nil || !strings.Contains(err.Error(), "already running") { + t.Fatalf("Run() error = %v", err) + } + r.Stop() + if !canceled { + t.Fatal("Stop() did not call cancel") + } +} + +func newRunnerSocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn) { + t.Helper() + + upgrader := websocket.Upgrader{} + serverConnCh := make(chan *websocket.Conn, 1) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + conn, err := upgrader.Upgrade(writer, request, nil) + if err != nil { + t.Fatalf("Upgrade() error = %v", err) + } + serverConnCh <- conn + })) + t.Cleanup(server.Close) + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + clientConn, _, err := websocket.DefaultDialer.Dial("ws://"+serverURL.Host, nil) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + serverConn := <-serverConnCh + return clientConn, serverConn +} + +func newRunnerGatewayServer(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{} + return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/ws" { + http.NotFound(writer, request) + return + } + conn, err := upgrader.Upgrade(writer, request, nil) + if err != nil { + t.Fatalf("Upgrade() error = %v", err) + } + defer conn.Close() + handler(conn) + })) +} + +func runnerGatewayAddress(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return strings.TrimPrefix(rawURL, "http://") + } + return parsed.Host +} diff --git a/internal/runtime/runner_dispatcher_test.go b/internal/runtime/runner_dispatcher_test.go new file mode 100644 index 00000000..53afdef7 --- /dev/null +++ b/internal/runtime/runner_dispatcher_test.go @@ -0,0 +1,23 @@ +package runtime + +import ( + "context" + "testing" + + "neo-code/internal/tools" +) + +type runnerDispatcherStub struct{} + +func (runnerDispatcherStub) TryDispatch(context.Context, string, string, tools.ToolCallInput) (tools.ToolResult, bool, error) { + return tools.ToolResult{}, false, nil +} + +func TestServiceSetRunnerToolDispatcher(t *testing.T) { + service := &Service{} + dispatcher := runnerDispatcherStub{} + service.SetRunnerToolDispatcher(dispatcher) + if service.runnerToolDispatcher == nil { + t.Fatal("runnerToolDispatcher = nil after SetRunnerToolDispatcher") + } +} From 6a801ca274bf43b13bcdaff2878e40b056c8e032 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 7 May 2026 17:48:19 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E6=9B=B4=E6=96=B0label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 7 ++++--- README.md | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.en.md b/README.en.md index de773b24..7d563187 100644 --- a/README.en.md +++ b/README.en.md @@ -8,11 +8,11 @@ Go Version - - CI Status + + Codecov Coverage - License + License MIT Docs @@ -22,6 +22,7 @@

+

Docs · diff --git a/README.md b/README.md index 4d416552..56e6d347 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,21 @@ Go Version - - CI Status + + Codecov Coverage - License + License MIT Docs - + Platform

+

文档 · From 99b505cfa5957e20c300dc4efea3bc34bbd9f26a Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 7 May 2026 10:08:22 +0000 Subject: [PATCH 06/10] test(runner): expand runner channel coverage Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/cli/gateway_commands_test.go | 27 ++ internal/cli/runner_command.go | 24 +- internal/cli/runner_command_test.go | 110 ++++++++ internal/config/runner_test.go | 34 +++ internal/feishuadapter/adapter_test.go | 5 + .../gateway/network_server_additional_test.go | 36 +++ internal/gateway/protocol/jsonrpc_test.go | 72 ++++++ internal/gateway/runner_support_test.go | 237 ++++++++++++++++++ internal/runner/capability_test.go | 20 ++ internal/runner/runner_test.go | 178 +++++++++++++ internal/runtime/runner_dispatcher_test.go | 33 ++- .../session/sqlite_store_thinking_test.go | 42 ++++ 12 files changed, 807 insertions(+), 11 deletions(-) diff --git a/internal/cli/gateway_commands_test.go b/internal/cli/gateway_commands_test.go index 27b8f0af..220504ce 100644 --- a/internal/cli/gateway_commands_test.go +++ b/internal/cli/gateway_commands_test.go @@ -1,9 +1,13 @@ package cli import ( + "reflect" "testing" "github.com/spf13/cobra" + + "neo-code/internal/gateway" + agentruntime "neo-code/internal/runtime" ) func TestNormalizeGatewayLogLevel(t *testing.T) { @@ -67,3 +71,26 @@ func TestMustReadInheritedWorkdir(t *testing.T) { } }) } + +func TestInjectRunnerDispatcherIntoRuntime(t *testing.T) { + injectRunnerDispatcherIntoRuntime(nil, nil) + injectRunnerDispatcherIntoRuntime(&gatewayRuntimePortBridge{}, nil) + injectRunnerDispatcherIntoRuntime(&gatewayRuntimePortBridge{}, &gateway.RunnerToolManager{}) + + nonServiceBridge := &gatewayRuntimePortBridge{runtime: &runtimeStub{}} + multiNonService := gateway.NewMultiWorkspaceRuntime(nil, "", nil) + multiNonService.PreloadWorkspaceBundle("non-service", nonServiceBridge, func() error { return nil }) + injectRunnerDispatcherIntoRuntime(multiNonService, &gateway.RunnerToolManager{}) + + service := &agentruntime.Service{} + bridge := &gatewayRuntimePortBridge{runtime: service} + multi := gateway.NewMultiWorkspaceRuntime(nil, "", nil) + multi.PreloadWorkspaceBundle("default", bridge, func() error { return nil }) + + injectRunnerDispatcherIntoRuntime(multi, &gateway.RunnerToolManager{}) + + field := reflect.ValueOf(service).Elem().FieldByName("runnerToolDispatcher") + if !field.IsValid() || field.IsNil() { + t.Fatal("runnerToolDispatcher was not injected") + } +} diff --git a/internal/cli/runner_command.go b/internal/cli/runner_command.go index 3b861355..b5d1fa21 100644 --- a/internal/cli/runner_command.go +++ b/internal/cli/runner_command.go @@ -15,6 +15,14 @@ import ( ) var runRunnerCommandFn = defaultRunRunner +var newRunnerServiceFn = func(cfg runner.Config) (runnerService, error) { + return runner.New(cfg) +} + +type runnerService interface { + Run(context.Context) error + Stop() +} type runnerCommandOptions struct { GatewayAddress string @@ -80,16 +88,16 @@ func defaultRunRunner(ctx context.Context, options runnerCommandOptions) error { token = strings.TrimSpace(string(data)) } - r, err := runner.New(runner.Config{ - RunnerID: runnerID, - RunnerName: strings.TrimSpace(options.RunnerName), - GatewayAddress: gatewayAddress, - Token: token, - Workdir: workdir, - HeartbeatInterval: 10 * time.Second, + r, err := newRunnerServiceFn(runner.Config{ + RunnerID: runnerID, + RunnerName: strings.TrimSpace(options.RunnerName), + GatewayAddress: gatewayAddress, + Token: token, + Workdir: workdir, + HeartbeatInterval: 10 * time.Second, ReconnectBackoffMin: 500 * time.Millisecond, ReconnectBackoffMax: 10 * time.Second, - RequestTimeout: 30 * time.Second, + RequestTimeout: 30 * time.Second, }) if err != nil { return fmt.Errorf("create runner: %w", err) diff --git a/internal/cli/runner_command_test.go b/internal/cli/runner_command_test.go index a0a2598f..2a559b05 100644 --- a/internal/cli/runner_command_test.go +++ b/internal/cli/runner_command_test.go @@ -2,9 +2,13 @@ package cli import ( "context" + "errors" + "os" "path/filepath" "strings" "testing" + + "neo-code/internal/runner" ) func TestNewRunnerCommandForwardsFlags(t *testing.T) { @@ -40,6 +44,22 @@ func TestDefaultRunRunnerReadsTokenFileError(t *testing.T) { } } +type stubRunnerService struct { + runFn func(context.Context) error + stopCall int +} + +func (s *stubRunnerService) Run(ctx context.Context) error { + if s.runFn != nil { + return s.runFn(ctx) + } + return nil +} + +func (s *stubRunnerService) Stop() { + s.stopCall++ +} + func TestRootCommandIncludesRunnerSubcommand(t *testing.T) { cmd := NewRootCommand() found := false @@ -73,3 +93,93 @@ func TestNewRunnerCommandAllowsDefaultFlags(t *testing.T) { t.Fatalf("captured options = %#v, want zero-value defaults before runtime resolution", captured) } } + +func TestDefaultRunRunnerUsesResolvedDefaultsAndToken(t *testing.T) { + originalNewRunnerService := newRunnerServiceFn + t.Cleanup(func() { newRunnerServiceFn = originalNewRunnerService }) + + tempDir := t.TempDir() + tokenFile := filepath.Join(tempDir, "runner.token") + if err := os.WriteFile(tokenFile, []byte(" secret-token \n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + prevWD, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Chdir() error = %v", err) + } + t.Cleanup(func() { _ = os.Chdir(prevWD) }) + + hostName, err := os.Hostname() + if err != nil { + t.Fatalf("Hostname() error = %v", err) + } + + var capturedCfg runner.Config + stub := &stubRunnerService{runFn: func(context.Context) error { return context.Canceled }} + newRunnerServiceFn = func(cfg runner.Config) (runnerService, error) { + capturedCfg = cfg + return stub, nil + } + + err = defaultRunRunner(context.Background(), runnerCommandOptions{ + TokenFile: tokenFile, + RunnerName: " Local Runner ", + }) + if err != nil { + t.Fatalf("defaultRunRunner() error = %v", err) + } + if capturedCfg.GatewayAddress != "127.0.0.1:8080" { + t.Fatalf("GatewayAddress = %q", capturedCfg.GatewayAddress) + } + if capturedCfg.Workdir != tempDir { + t.Fatalf("Workdir = %q, want %q", capturedCfg.Workdir, tempDir) + } + if capturedCfg.RunnerID != hostName { + t.Fatalf("RunnerID = %q, want %q", capturedCfg.RunnerID, hostName) + } + if capturedCfg.RunnerName != "Local Runner" { + t.Fatalf("RunnerName = %q, want %q", capturedCfg.RunnerName, "Local Runner") + } + if capturedCfg.Token != "secret-token" { + t.Fatalf("Token = %q, want %q", capturedCfg.Token, "secret-token") + } +} + +func TestDefaultRunRunnerWrapsRunnerCreationAndRunErrors(t *testing.T) { + originalNewRunnerService := newRunnerServiceFn + t.Cleanup(func() { newRunnerServiceFn = originalNewRunnerService }) + + t.Run("runner creation failure", func(t *testing.T) { + newRunnerServiceFn = func(cfg runner.Config) (runnerService, error) { + return nil, errors.New("boom") + } + err := defaultRunRunner(context.Background(), runnerCommandOptions{RunnerID: "runner-1", GatewayAddress: "127.0.0.1:8080"}) + if err == nil || !strings.Contains(err.Error(), "create runner: boom") { + t.Fatalf("defaultRunRunner() error = %v", err) + } + }) + + t.Run("runner run failure", func(t *testing.T) { + newRunnerServiceFn = func(cfg runner.Config) (runnerService, error) { + return &stubRunnerService{runFn: func(context.Context) error { return errors.New("run failed") }}, nil + } + err := defaultRunRunner(context.Background(), runnerCommandOptions{RunnerID: "runner-1", GatewayAddress: "127.0.0.1:8080"}) + if err == nil || !strings.Contains(err.Error(), "runner: run failed") { + t.Fatalf("defaultRunRunner() error = %v", err) + } + }) +} + +func TestNewRunnerServiceFnDelegatesToRunnerNew(t *testing.T) { + service, err := newRunnerServiceFn(runner.Config{}) + if err == nil || !strings.Contains(err.Error(), "runner_id is required") { + t.Fatalf("newRunnerServiceFn() error = %v", err) + } + if service != nil && !strings.Contains(err.Error(), "runner_id is required") { + t.Fatalf("unexpected service for invalid config: %#v", service) + } +} diff --git a/internal/config/runner_test.go b/internal/config/runner_test.go index b5afbb87..d383e172 100644 --- a/internal/config/runner_test.go +++ b/internal/config/runner_test.go @@ -6,6 +6,9 @@ import ( ) func TestRunnerConfigApplyDefaultsCloneAndDurations(t *testing.T) { + var nilCfg *RunnerConfig + nilCfg.ApplyDefaults(defaultRunnerConfig()) + cfg := RunnerConfig{ WorkdirAllowlist: []string{"/tmp/work"}, } @@ -28,6 +31,19 @@ func TestRunnerConfigApplyDefaultsCloneAndDurations(t *testing.T) { t.Fatalf("RequestTimeout() = %s", cfg.RequestTimeout()) } + if (RunnerConfig{}).HeartbeatInterval() != 10*time.Second { + t.Fatalf("zero HeartbeatInterval() = %s", (RunnerConfig{}).HeartbeatInterval()) + } + if (RunnerConfig{}).ReconnectBackoffMin() != 500*time.Millisecond { + t.Fatalf("zero ReconnectBackoffMin() = %s", (RunnerConfig{}).ReconnectBackoffMin()) + } + if (RunnerConfig{}).ReconnectBackoffMax() != 10*time.Second { + t.Fatalf("zero ReconnectBackoffMax() = %s", (RunnerConfig{}).ReconnectBackoffMax()) + } + if (RunnerConfig{}).RequestTimeout() != 30*time.Second { + t.Fatalf("zero RequestTimeout() = %s", (RunnerConfig{}).RequestTimeout()) + } + clone := cfg.Clone() clone.WorkdirAllowlist[0] = "/changed" if cfg.WorkdirAllowlist[0] != "/tmp/work" { @@ -65,3 +81,21 @@ func TestRunnerConfigValidate(t *testing.T) { t.Fatalf("Validate() error = %v", err) } } + +func TestConfigValidateSnapshotIncludesRunnerValidation(t *testing.T) { + cfg := StaticDefaults() + cfg.Providers = []ProviderConfig{testDefaultProviderConfig()} + cfg.SelectedProvider = testProviderName + cfg.Workdir = t.TempDir() + cfg.Runner = RunnerConfig{ + Enabled: true, + GatewayAddress: "127.0.0.1:8080", + HeartbeatIntervalSec: 1, + ReconnectBackoffMinM: 2, + ReconnectBackoffMaxM: 1, + RequestTimeoutSec: 1, + } + if err := cfg.ValidateSnapshot(); err == nil || err.Error() != "config: runner: reconnect_backoff_min_ms must be less than or equal to reconnect_backoff_max_ms" { + t.Fatalf("ValidateSnapshot() error = %v", err) + } +} diff --git a/internal/feishuadapter/adapter_test.go b/internal/feishuadapter/adapter_test.go index 53cdf51c..5c5a1ed7 100644 --- a/internal/feishuadapter/adapter_test.go +++ b/internal/feishuadapter/adapter_test.go @@ -1231,6 +1231,11 @@ func TestHelperFunctionsCoverFallbackBranches(t *testing.T) { }); text != "任务失败:boom" { t.Fatalf("error text = %q, want fallback error", text) } + if text := extractUserVisibleErrorText(map[string]any{ + "payload": map[string]any{"error": "runner_offline"}, + }); text != "本机 Runner 未连接,请在电脑上启动 `neocode runner`" { + t.Fatalf("runner error text = %q", text) + } if text := extractUserVisibleErrorText(nil); text != "" { t.Fatalf("error text = %q, want empty", text) } diff --git a/internal/gateway/network_server_additional_test.go b/internal/gateway/network_server_additional_test.go index dccf31a2..4abb1ef0 100644 --- a/internal/gateway/network_server_additional_test.go +++ b/internal/gateway/network_server_additional_test.go @@ -267,6 +267,42 @@ func TestNetworkServerHandleWebSocketParseAndLimitBranches(t *testing.T) { } } +func TestNetworkServerRunnerContextInjectionAndDisconnectCleanup(t *testing.T) { + registry := NewRunnerRegistry(log.New(io.Discard, "", 0)) + manager := NewRunnerToolManager(registry, NewStreamRelay(StreamRelayOptions{}), nil, time.Second, log.New(io.Discard, "", 0)) + server := newTestNetworkServer(t, NetworkServerOptions{ + RunnerRegistry: registry, + RunnerToolManager: manager, + }) + testContext, cancel := context.WithCancel(context.Background()) + defer cancel() + + serveDone := make(chan error, 1) + go func() { + serveDone <- server.Serve(testContext, nil) + }() + t.Cleanup(func() { + _ = server.Close(context.Background()) + <-serveDone + }) + + listenAddress := waitForNetworkAddress(t, server) + wsConn, err := websocket.Dial("ws://"+listenAddress+"/ws", "", "http://localhost:3000") + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + if err := websocket.Message.Send(wsConn, `{"jsonrpc":"2.0","id":"runner-ping","method":"gateway.ping","params":{}}`); err != nil { + t.Fatalf("send ping: %v", err) + } + ackFrame := receiveWSAckFrame(t, wsConn) + if ackFrame.RequestID != "runner-ping" { + t.Fatalf("request id = %q, want %q", ackFrame.RequestID, "runner-ping") + } + + _ = wsConn.Close() + waitForWebSocketConnectionCount(t, server, 0, 2*time.Second) +} + func TestNetworkServerSSELimitAndWriteErrorBranches(t *testing.T) { server := newTestNetworkServer(t, NetworkServerOptions{ MaxStreamConnections: 1, diff --git a/internal/gateway/protocol/jsonrpc_test.go b/internal/gateway/protocol/jsonrpc_test.go index 394a0aca..7bfa80b6 100644 --- a/internal/gateway/protocol/jsonrpc_test.go +++ b/internal/gateway/protocol/jsonrpc_test.go @@ -1807,3 +1807,75 @@ func TestNormalizeJSONRPCRequestNewRPCMethods(t *testing.T) { } }) } + +func TestRunnerJSONRPCParamDecoders(t *testing.T) { + t.Run("decodeRegisterRunnerParams", func(t *testing.T) { + params, rpcErr := decodeRegisterRunnerParams(json.RawMessage(`{"runner_id":"runner-1","workdir":"/tmp/work"}`)) + if rpcErr != nil { + t.Fatalf("decodeRegisterRunnerParams() error = %+v", rpcErr) + } + if params.RunnerID != "runner-1" || params.Workdir != "/tmp/work" { + t.Fatalf("decodeRegisterRunnerParams() = %+v", params) + } + + normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"runner-register"`), + Method: MethodGatewayRegisterRunner, + Params: json.RawMessage(`{"runner_id":"runner-1","workdir":"/tmp/work"}`), + }) + if rpcErr != nil { + t.Fatalf("NormalizeJSONRPCRequest(registerRunner) error = %+v", rpcErr) + } + if normalized.Action != "register_runner" { + t.Fatalf("action = %q, want register_runner", normalized.Action) + } + + cases := []json.RawMessage{ + json.RawMessage(`{"runner_id":"","workdir":"/tmp/work"}`), + json.RawMessage(`{"runner_id":"runner-1","workdir":""}`), + json.RawMessage(`{"runner_id":1}`), + } + for _, raw := range cases { + if _, rpcErr := decodeRegisterRunnerParams(raw); rpcErr == nil { + t.Fatalf("decodeRegisterRunnerParams(%s) error = nil", raw) + } + } + }) + + t.Run("decodeExecuteToolResultParams", func(t *testing.T) { + params, rpcErr := decodeExecuteToolResultParams(json.RawMessage(`{"request_id":"req-1","session_id":"s-1","run_id":"r-1","tool_call_id":"tool-1"}`)) + if rpcErr != nil { + t.Fatalf("decodeExecuteToolResultParams() error = %+v", rpcErr) + } + if params.RequestID != "req-1" || params.SessionID != "s-1" || params.RunID != "r-1" || params.ToolCallID != "tool-1" { + t.Fatalf("decodeExecuteToolResultParams() = %+v", params) + } + + normalized, rpcErr := NormalizeJSONRPCRequest(JSONRPCRequest{ + JSONRPC: JSONRPCVersion, + ID: json.RawMessage(`"runner-result"`), + Method: MethodGatewayExecuteToolResult, + Params: json.RawMessage(`{"request_id":"req-1","session_id":" s-1 ","run_id":" r-1 ","tool_call_id":"tool-1"}`), + }) + if rpcErr != nil { + t.Fatalf("NormalizeJSONRPCRequest(executeToolResult) error = %+v", rpcErr) + } + if normalized.SessionID != "s-1" || normalized.RunID != "r-1" { + t.Fatalf("normalized IDs = (%q,%q)", normalized.SessionID, normalized.RunID) + } + + cases := []json.RawMessage{ + json.RawMessage(`{"request_id":"","session_id":"s-1","run_id":"r-1","tool_call_id":"tool-1"}`), + json.RawMessage(`{"request_id":"req-1","session_id":"","run_id":"r-1","tool_call_id":"tool-1"}`), + json.RawMessage(`{"request_id":"req-1","session_id":"s-1","run_id":"","tool_call_id":"tool-1"}`), + json.RawMessage(`{"request_id":"req-1","session_id":"s-1","run_id":"r-1","tool_call_id":""}`), + json.RawMessage(`{"request_id":1}`), + } + for _, raw := range cases { + if _, rpcErr := decodeExecuteToolResultParams(raw); rpcErr == nil { + t.Fatalf("decodeExecuteToolResultParams(%s) error = nil", raw) + } + } + }) +} diff --git a/internal/gateway/runner_support_test.go b/internal/gateway/runner_support_test.go index b0e2ba06..a208907d 100644 --- a/internal/gateway/runner_support_test.go +++ b/internal/gateway/runner_support_test.go @@ -60,6 +60,8 @@ func TestRunnerRegistryLifecycle(t *testing.T) { if registry.BindSession("session-3", connectionID) { t.Fatal("BindSession() = true for offline runner") } + registry.Unregister("missing") + registry.UnbindSession("missing") } func TestRunnerToolManagerDispatchAndCompletion(t *testing.T) { @@ -220,6 +222,14 @@ func TestRunnerToolManagerCapabilityTokenAndCleanupLoop(t *testing.T) { case <-time.After(time.Second): t.Fatal("CleanupLoop() did not stop after cancel") } + + defaultManager := NewRunnerToolManager(nil, nil, nil, 0, nil) + if defaultManager.timeout != 30*time.Second { + t.Fatalf("default timeout = %v", defaultManager.timeout) + } + if defaultManager.logger == nil { + t.Fatal("default logger = nil") + } } func TestRunnerToolDispatcherBridge(t *testing.T) { @@ -253,6 +263,42 @@ func TestRunnerToolDispatcherBridge(t *testing.T) { if err != nil || handled { t.Fatalf("TryDispatch(offline) = (%#v,%v,%v)", result, handled, err) } + + messageCh := make(chan RelayMessage, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connectionCtx := WithStreamRelay(WithConnectionID(ctx, connectionID), relay) + if err := relay.RegisterConnection(ConnectionRegistration{ + ConnectionID: connectionID, + Channel: StreamChannelWS, + Context: connectionCtx, + Cancel: cancel, + Write: func(message RelayMessage) error { + messageCh <- message + return nil + }, + Close: func() {}, + }); err != nil { + t.Fatalf("RegisterConnection() error = %v", err) + } + done := make(chan struct{}) + go func() { + defer close(done) + result, handled, err = bridge.TryDispatch(context.Background(), "session-1", "run-2", tools.ToolCallInput{ + ID: "tool-2", + Name: "bash", + }) + }() + message := <-messageCh + payload := message.Payload.(map[string]any) + params := payload["params"].(map[string]any) + if err := manager.CompleteToolRequest(params["request_id"].(string), "remote-ok", false); err != nil { + t.Fatalf("CompleteToolRequest() error = %v", err) + } + <-done + if err != nil || !handled || result.Content != "remote-ok" || result.IsError { + t.Fatalf("TryDispatch(success) = (%#v,%v,%v)", result, handled, err) + } } func TestRunnerContextHelpersAndACL(t *testing.T) { @@ -282,6 +328,15 @@ func TestRunnerContextHelpersAndACL(t *testing.T) { if NormalizeRequestSource(RequestSource(" RUNNER ")) != RequestSourceRunner { t.Fatal("NormalizeRequestSource() did not normalize runner") } + + ctx = WithRunnerRegistry(nil, registry) + if RunnerRegistryFromContext(ctx) != registry { + t.Fatal("WithRunnerRegistry(nil) did not create background context") + } + ctx = WithRunnerToolManager(nil, manager) + if RunnerToolManagerFromContext(ctx) != manager { + t.Fatal("WithRunnerToolManager(nil) did not create background context") + } } func TestRunnerBootstrapHandlers(t *testing.T) { @@ -323,6 +378,33 @@ func TestRunnerBootstrapHandlers(t *testing.T) { if resultFrame.Type != FrameTypeAck || resultFrame.Action != FrameActionExecuteToolResult { t.Fatalf("handleExecuteToolResultFrame() = %#v", resultFrame) } + + if frame := handleRegisterRunnerFrame(context.Background(), MessageFrame{}, nil); frame.Type != FrameTypeError { + t.Fatalf("handleRegisterRunnerFrame(nil registry) = %#v", frame) + } + if frame := handleRegisterRunnerFrame(WithRunnerRegistry(context.Background(), registry), MessageFrame{ + Payload: map[string]any{"runner_id": "", "workdir": "/tmp/work"}, + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleRegisterRunnerFrame(missing runner_id) = %#v", frame) + } + if frame := handleRegisterRunnerFrame(WithRunnerRegistry(context.Background(), registry), MessageFrame{ + Payload: map[string]any{"runner_id": "runner-1", "workdir": "/tmp/work"}, + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleRegisterRunnerFrame(missing connection id) = %#v", frame) + } + if frame := handleExecuteToolResultFrame(context.Background(), MessageFrame{}, nil); frame.Type != FrameTypeError { + t.Fatalf("handleExecuteToolResultFrame(nil manager) = %#v", frame) + } + if frame := handleExecuteToolResultFrame(WithRunnerToolManager(context.Background(), manager), MessageFrame{ + Payload: map[string]any{"request_id": ""}, + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleExecuteToolResultFrame(missing request id) = %#v", frame) + } + if frame := handleExecuteToolResultFrame(WithRunnerToolManager(context.Background(), manager), MessageFrame{ + Payload: protocol.ExecuteToolResultParams{RequestID: "missing"}, + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleExecuteToolResultFrame(missing pending) = %#v", frame) + } } func TestRunnerJSONRPCNormalizationAndInjection(t *testing.T) { @@ -370,4 +452,159 @@ func TestRunnerJSONRPCNormalizationAndInjection(t *testing.T) { if !called { t.Fatal("InjectRunnerDispatcher() did not inject existing bundle") } + + if _, rpcErr := protocol.NormalizeJSONRPCRequest(protocol.JSONRPCRequest{ + JSONRPC: protocol.JSONRPCVersion, + ID: json.RawMessage(`"runner-4"`), + Method: protocol.MethodGatewayExecuteToolResult, + Params: json.RawMessage(`{"request_id":"req-1","session_id":"","run_id":"r-1","tool_call_id":"tool-1"}`), + }); rpcErr == nil { + t.Fatal("NormalizeJSONRPCRequest(result invalid) error = nil") + } +} + +func TestMultiWorkspaceRuntimeInjectRunnerDispatcherForFutureBundle(t *testing.T) { + idx, alpha, _ := setupIndex(t) + builder := newTestBuilder() + mw := NewMultiWorkspaceRuntime(idx, alpha.Hash, builder.build) + t.Cleanup(func() { _ = mw.Close() }) + + injected := make(chan RuntimePort, 1) + mw.InjectRunnerDispatcher(func(port RuntimePort) { + injected <- port + }) + + port, err := mw.getPortForHash(alpha.Hash) + if err != nil { + t.Fatalf("getPortForHash() error = %v", err) + } + select { + case got := <-injected: + if got != port { + t.Fatalf("injected port = %#v, want %#v", got, port) + } + case <-time.After(time.Second): + t.Fatal("runner dispatcher injector was not called for future bundle") + } +} + +func TestRunnerToolManagerDispatchCancellationAndCleanup(t *testing.T) { + registry := NewRunnerRegistry(nil) + relay := NewStreamRelay(StreamRelayOptions{}) + manager := NewRunnerToolManager(registry, relay, nil, 20*time.Millisecond, log.New(io.Discard, "", 0)) + + connectionID := ConnectionID("cid-cancel") + _, cancel := context.WithCancel(context.Background()) + defer cancel() + registered := make(chan struct{}, 1) + if err := relay.RegisterConnection(ConnectionRegistration{ + ConnectionID: connectionID, + Channel: StreamChannelWS, + Context: context.Background(), + Cancel: cancel, + Write: func(message RelayMessage) error { + select { + case registered <- struct{}{}: + default: + } + return nil + }, + Close: func() {}, + }); err != nil { + t.Fatalf("RegisterConnection() error = %v", err) + } + registry.Register(connectionID, "runner-1", "Runner", "/tmp/work", nil) + registry.BindSession("session-1", connectionID) + + cancelCtx, cancelDispatch := context.WithCancel(context.Background()) + go func() { + <-registered + cancelDispatch() + }() + if _, _, err := manager.DispatchToolRequest(cancelCtx, "session-1", "run-1", "tool-1", "bash", nil); err == nil || !strings.Contains(err.Error(), "canceled") { + t.Fatalf("DispatchToolRequest(cancel) error = %v", err) + } + if len(manager.pending) != 0 { + t.Fatalf("pending after cancel = %d, want 0", len(manager.pending)) + } + + manager.pending["cleanup"] = &PendingToolCall{RequestID: "cleanup"} + manager.cleanupPending("cleanup") + if len(manager.pending) != 0 { + t.Fatalf("pending after cleanupPending = %d, want 0", len(manager.pending)) + } + + timeoutRegistry := NewRunnerRegistry(nil) + timeoutRelay := NewStreamRelay(StreamRelayOptions{}) + timeoutManager := NewRunnerToolManager(timeoutRegistry, timeoutRelay, nil, 5*time.Millisecond, log.New(io.Discard, "", 0)) + timeoutConnectionID := ConnectionID("cid-timeout") + if err := timeoutRelay.RegisterConnection(ConnectionRegistration{ + ConnectionID: timeoutConnectionID, + Channel: StreamChannelWS, + Context: context.Background(), + Cancel: func() {}, + Write: func(message RelayMessage) error { + return nil + }, + Close: func() {}, + }); err != nil { + t.Fatalf("RegisterConnection(timeout) error = %v", err) + } + timeoutRegistry.Register(timeoutConnectionID, "runner-timeout", "Runner", "/tmp/work", nil) + timeoutRegistry.BindSession("session-timeout", timeoutConnectionID) + if _, _, err := timeoutManager.DispatchToolRequest(context.Background(), "session-timeout", "run-1", "tool-1", "bash", nil); err == nil || !strings.Contains(err.Error(), "timed out waiting for runner") { + t.Fatalf("DispatchToolRequest(timeout) error = %v", err) + } + + expiredResultCh := make(chan toolResultEnvelope, 1) + timeoutManager.pending["expired-send"] = &PendingToolCall{ + RequestID: "expired-send", + ResultChan: expiredResultCh, + Deadline: time.Now().Add(-time.Second), + } + timeoutManager.cleanupExpired() + if _, exists := timeoutManager.pending["expired-send"]; exists { + t.Fatal("cleanupExpired() should delete expired-send entry") + } + select { + case result := <-expiredResultCh: + if !result.IsError || result.Content == "" { + t.Fatalf("cleanupExpired() result = %#v", result) + } + default: + t.Fatal("cleanupExpired() did not emit timeout result") + } + +} + +func TestRunnerBootstrapHandlersRejectMalformedPayloads(t *testing.T) { + registry := NewRunnerRegistry(nil) + manager := NewRunnerToolManager(registry, NewStreamRelay(StreamRelayOptions{}), nil, time.Second, nil) + + registerCtx := WithRunnerRegistry(WithConnectionID(context.Background(), "cid"), registry) + if frame := handleRegisterRunnerFrame(registerCtx, MessageFrame{ + Payload: map[string]any{"runner_id": 1, "workdir": "/tmp/work"}, + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleRegisterRunnerFrame(type mismatch) = %#v", frame) + } + if frame := handleRegisterRunnerFrame(registerCtx, MessageFrame{ + Payload: make(chan int), + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleRegisterRunnerFrame(marshal failure) = %#v", frame) + } + if frame := handleExecuteToolResultFrame(WithRunnerToolManager(context.Background(), manager), MessageFrame{ + Payload: map[string]any{"request_id": 1}, + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleExecuteToolResultFrame(type mismatch) = %#v", frame) + } + if frame := handleExecuteToolResultFrame(WithRunnerToolManager(context.Background(), manager), MessageFrame{ + Payload: make(chan int), + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleExecuteToolResultFrame(marshal failure) = %#v", frame) + } + if frame := handleRegisterRunnerFrame(registerCtx, MessageFrame{ + Payload: map[string]any{"runner_id": "runner-1", "workdir": ""}, + }, nil); frame.Type != FrameTypeError { + t.Fatalf("handleRegisterRunnerFrame(missing workdir) = %#v", frame) + } } diff --git a/internal/runner/capability_test.go b/internal/runner/capability_test.go index 133b4f5c..75864c9d 100644 --- a/internal/runner/capability_test.go +++ b/internal/runner/capability_test.go @@ -107,6 +107,12 @@ func TestCapSignerHelpers(t *testing.T) { if got := resolvePath(" ", "/tmp/work"); got != "" { t.Fatalf("resolvePath(empty) = %q, want empty", got) } + if got := resolvePath("/tmp/abs.txt", "/tmp/work"); got != "/tmp/abs.txt" { + t.Fatalf("resolvePath(abs) = %q", got) + } + if got := resolvePath("child.txt", ""); got != "child.txt" { + t.Fatalf("resolvePath(no workdir) = %q", got) + } if !isToolAllowed([]string{" Bash "}, "bash") { t.Fatal("isToolAllowed() = false, want true") @@ -140,6 +146,20 @@ func TestCapSignerVerifyPath(t *testing.T) { t.Fatalf("VerifyPath() error = %v, want %v", err, ErrCapabilityPathNotAllowed) } }) + + t.Run("blank allowlist entry is ignored", func(t *testing.T) { + signer := NewCapSigner([]string{" ", "/safe/base"}) + if err := signer.VerifyPath("/safe/base/file.txt"); err != nil { + t.Fatalf("VerifyPath() error = %v", err) + } + }) + + if got := normalizePath(" "); got != "" { + t.Fatalf("normalizePath(empty) = %q", got) + } + if got := normalizePath("/safe/base/../child"); got != "/safe/child" { + t.Fatalf("normalizePath(clean) = %q", got) + } } func validCapabilityToken(t *testing.T, toolName string) security.CapabilityToken { diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index f8cfc848..edacea4f 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -96,6 +96,12 @@ func TestNewRunnerDefaultsAndValidation(t *testing.T) { if r.capSigner == nil { t.Fatal("capSigner = nil, want initialized") } + if r.cfg.Shell == "" { + t.Fatal("Shell = empty, want default shell") + } + if r.toolMgr == nil { + t.Fatal("toolMgr = nil, want initialized registry") + } }) } @@ -178,6 +184,13 @@ func TestRunnerHandleToolRequestInvalidParamsAndSendRequest(t *testing.T) { if pong["result"] != "pong" { t.Fatalf("pong result = %v, want pong", pong["result"]) } + + serverConn.Close() + r.handlePing(runnerConn, map[string]any{"id": "ping-2"}) + + if err := r.sendRequest(runnerConn, "gateway.executeToolResult", map[string]any{"bad": make(chan int)}); err == nil || !strings.Contains(err.Error(), "marshal request") { + t.Fatalf("sendRequest(marshal failure) error = %v", err) + } } func TestRunnerRunHandlesPingAndToolRequest(t *testing.T) { @@ -306,6 +319,171 @@ func TestRunnerRunAlreadyRunningAndStop(t *testing.T) { } } +func TestRunnerHandleToolRequestDeniedAndToolError(t *testing.T) { + t.Run("capability denied", func(t *testing.T) { + r := &Runner{ + cfg: Config{ + RunnerID: "runner-1", + Workdir: "/safe/work", + RequestTimeout: 200 * time.Millisecond, + }, + logger: log.New(io.Discard, "", 0), + toolMgr: &runnerManagerAdapter{}, + capSigner: NewCapSigner([]string{"/safe/work"}), + } + runnerConn, serverConn := newRunnerSocketPair(t) + defer runnerConn.Close() + defer serverConn.Close() + + r.handleToolRequest(context.Background(), runnerConn, map[string]any{ + "params": map[string]any{ + "request_id": "req-1", + "session_id": "session-1", + "run_id": "run-1", + "tool_call_id": "tool-1", + "tool_name": "filesystem_read_file", + "arguments": json.RawMessage(`{"path":"../../etc/passwd"}`), + }, + }) + + var result map[string]any + if err := serverConn.ReadJSON(&result); err != nil { + t.Fatalf("ReadJSON() error = %v", err) + } + params := result["params"].(map[string]any) + if params["is_error"] != true { + t.Fatalf("is_error = %v, want true", params["is_error"]) + } + }) + + t.Run("tool execution failure", func(t *testing.T) { + r := &Runner{ + cfg: Config{ + RunnerID: "runner-1", + Workdir: t.TempDir(), + RequestTimeout: 200 * time.Millisecond, + }, + logger: log.New(io.Discard, "", 0), + toolMgr: &runnerManagerAdapter{ + executeFn: func(context.Context, tools.ToolCallInput) (tools.ToolResult, error) { + return tools.ToolResult{}, errors.New("tool failed") + }, + }, + capSigner: NewCapSigner(nil), + } + runnerConn, serverConn := newRunnerSocketPair(t) + defer runnerConn.Close() + defer serverConn.Close() + + r.handleToolRequest(context.Background(), runnerConn, map[string]any{ + "params": map[string]any{ + "request_id": "req-1", + "session_id": "session-1", + "run_id": "run-1", + "tool_call_id": "tool-1", + "tool_name": "bash", + }, + }) + + var result map[string]any + if err := serverConn.ReadJSON(&result); err != nil { + t.Fatalf("ReadJSON() error = %v", err) + } + params := result["params"].(map[string]any) + if params["content"] != "tool failed" || params["is_error"] != true { + t.Fatalf("params = %#v", params) + } + }) +} + +func TestRunnerEventLoopAndConnectErrors(t *testing.T) { + t.Run("event loop ignores invalid json and unknown methods", func(t *testing.T) { + r := &Runner{logger: log.New(io.Discard, "", 0)} + runnerConn, serverConn := newRunnerSocketPair(t) + defer runnerConn.Close() + defer serverConn.Close() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- r.eventLoop(ctx, runnerConn) + }() + + if err := serverConn.WriteMessage(websocket.TextMessage, []byte("{")); err != nil { + t.Fatalf("WriteMessage(invalid json) error = %v", err) + } + if err := serverConn.WriteJSON(map[string]any{"jsonrpc": "2.0", "method": "gateway.unknown"}); err != nil { + t.Fatalf("WriteJSON(unknown) error = %v", err) + } + cancel() + runnerConn.Close() + if err := <-done; err == nil { + t.Fatal("eventLoop() error = nil") + } + }) + + t.Run("connectAndServe wraps auth and register failures", func(t *testing.T) { + authServer := newRunnerGatewayServer(t, func(conn *websocket.Conn) { + _ = conn.Close() + }) + defer authServer.Close() + + r := &Runner{ + cfg: Config{ + RunnerID: "runner-1", + GatewayAddress: runnerGatewayAddress(authServer.URL), + Workdir: t.TempDir(), + HeartbeatInterval: 10 * time.Millisecond, + RequestTimeout: 200 * time.Millisecond, + }, + logger: log.New(io.Discard, "", 0), + } + if err := r.connectAndServe(context.Background()); err == nil || !strings.Contains(err.Error(), "authenticate:") { + if err == nil || !(strings.Contains(err.Error(), "authenticate:") || strings.Contains(err.Error(), "register runner:")) { + t.Fatalf("connectAndServe(auth) error = %v", err) + } + } + + registerServer := newRunnerGatewayServer(t, func(conn *websocket.Conn) { + var authenticate map[string]any + _ = conn.ReadJSON(&authenticate) + _ = conn.Close() + }) + defer registerServer.Close() + r.cfg.GatewayAddress = runnerGatewayAddress(registerServer.URL) + if err := r.connectAndServe(context.Background()); err == nil || !(strings.Contains(err.Error(), "register runner:") || strings.Contains(err.Error(), "read message:")) { + t.Fatalf("connectAndServe(register) error = %v", err) + } + }) +} + +func TestRunnerHeartbeatLoopSendsPing(t *testing.T) { + r := &Runner{ + cfg: Config{HeartbeatInterval: 10 * time.Millisecond, RequestTimeout: time.Second}, + logger: log.New(io.Discard, "", 0), + } + runnerConn, serverConn := newRunnerSocketPair(t) + defer runnerConn.Close() + defer serverConn.Close() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + r.heartbeatLoop(ctx, runnerConn) + close(done) + }() + + var ping map[string]any + if err := serverConn.ReadJSON(&ping); err != nil { + t.Fatalf("ReadJSON() error = %v", err) + } + if ping["method"] != "gateway.ping" { + t.Fatalf("method = %v, want gateway.ping", ping["method"]) + } + cancel() + <-done +} + func newRunnerSocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn) { t.Helper() diff --git a/internal/runtime/runner_dispatcher_test.go b/internal/runtime/runner_dispatcher_test.go index 53afdef7..2be80bc0 100644 --- a/internal/runtime/runner_dispatcher_test.go +++ b/internal/runtime/runner_dispatcher_test.go @@ -3,14 +3,20 @@ package runtime import ( "context" "testing" + "time" + providertypes "neo-code/internal/provider/types" "neo-code/internal/tools" ) -type runnerDispatcherStub struct{} +type runnerDispatcherStub struct { + result tools.ToolResult + handled bool + err error +} -func (runnerDispatcherStub) TryDispatch(context.Context, string, string, tools.ToolCallInput) (tools.ToolResult, bool, error) { - return tools.ToolResult{}, false, nil +func (s runnerDispatcherStub) TryDispatch(context.Context, string, string, tools.ToolCallInput) (tools.ToolResult, bool, error) { + return s.result, s.handled, s.err } func TestServiceSetRunnerToolDispatcher(t *testing.T) { @@ -21,3 +27,24 @@ func TestServiceSetRunnerToolDispatcher(t *testing.T) { t.Fatal("runnerToolDispatcher = nil after SetRunnerToolDispatcher") } } + +func TestExecuteToolCallWithPermissionUsesRunnerDispatcherWhenHandled(t *testing.T) { + service := NewWithFactory(newRuntimeConfigManager(t), &stubToolManager{}, newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.SetRunnerToolDispatcher(runnerDispatcherStub{ + result: tools.ToolResult{Name: "bash", Content: "runner-ok"}, + handled: true, + }) + + result, err := service.executeToolCallWithPermission(context.Background(), permissionExecutionInput{ + RunID: "run-1", + SessionID: "session-1", + Call: providertypes.ToolCall{ID: "call-1", Name: "bash", Arguments: `{"command":"pwd"}`}, + ToolTimeout: time.Second, + }) + if err != nil { + t.Fatalf("executeToolCallWithPermission() error = %v", err) + } + if result.Content != "runner-ok" { + t.Fatalf("result = %+v", result) + } +} diff --git a/internal/session/sqlite_store_thinking_test.go b/internal/session/sqlite_store_thinking_test.go index 2fb22733..8a53f95b 100644 --- a/internal/session/sqlite_store_thinking_test.go +++ b/internal/session/sqlite_store_thinking_test.go @@ -88,6 +88,48 @@ func TestMigrateSQLiteSchemaV6ToV7AddsThinkingMetadataColumn(t *testing.T) { } } +func TestInitializeSQLiteSchemaMigratesVersion6To7(t *testing.T) { + t.Parallel() + + db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "schema-init-v6.db")) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + + statements := []string{ + `CREATE TABLE messages ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + parts_json TEXT NOT NULL, + tool_calls_json TEXT NOT NULL DEFAULT '', + tool_call_id TEXT NOT NULL DEFAULT '', + is_error INTEGER NOT NULL DEFAULT 0, + tool_metadata_json TEXT NOT NULL DEFAULT '', + created_at_ms INTEGER NOT NULL, + PRIMARY KEY(session_id, seq) + )`, + `PRAGMA user_version=6`, + } + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("Exec(%q) error = %v", statement, err) + } + } + + if err := initializeSQLiteSchema(context.Background(), db); err != nil { + t.Fatalf("initializeSQLiteSchema() error = %v", err) + } + hasColumn, err := sqliteTableHasColumn(context.Background(), mustBeginTx(t, db), "messages", "thinking_metadata_json") + if err != nil { + t.Fatalf("sqliteTableHasColumn() error = %v", err) + } + if !hasColumn { + t.Fatal("expected thinking_metadata_json column after initializeSQLiteSchema") + } +} + func mustBeginTx(t *testing.T, db *sql.DB) *sql.Tx { t.Helper() tx, err := db.BeginTx(context.Background(), nil) From 95f91e6045bf5f5fed1b217cd4bd39dcac83276c Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 7 May 2026 22:00:45 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feat(feishu):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=94=AF=E6=8C=81=E5=A4=9A=E6=AC=A1=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E8=AE=B0=E5=BD=95=E8=81=9A=E5=90=88=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 sessionBinding.ApprovalStatus 单值替换为 ApprovalRecords 列表, 状态卡片审批区从单行状态改为聚合摘要 + 逐条明细, 支持同一 run 内多次工具审批的完整追踪。 Co-Authored-By: Claude Opus 4.7 --- internal/feishuadapter/adapter.go | 59 ++++++++++++++++++++------ internal/feishuadapter/adapter_test.go | 6 +-- internal/feishuadapter/messenger.go | 52 +++++++++++++++++++++-- internal/feishuadapter/types.go | 8 ++++ 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/internal/feishuadapter/adapter.go b/internal/feishuadapter/adapter.go index 5b4aa7f2..37de94cd 100644 --- a/internal/feishuadapter/adapter.go +++ b/internal/feishuadapter/adapter.go @@ -19,6 +19,13 @@ const defaultSignatureMaxSkew = 5 * time.Minute const defaultProgressNotifyInterval = 2 * time.Second const defaultCardRefreshInterval = 1500 * time.Millisecond +type approvalEntry struct { + RequestID string + ToolName string + Reason string + Decision string // "pending", "allow_once", "reject" +} + type sessionBinding struct { SessionID string ChatID string @@ -27,6 +34,7 @@ type sessionBinding struct { TaskName string Status string ApprovalStatus string + ApprovalRecords []approvalEntry Result string LastSummary string AsyncRewakeHint string @@ -316,9 +324,9 @@ func (a *Adapter) handleGatewayEvent(ctx context.Context, raw json.RawMessage) { if envelope != nil { if runtimeType := readString(envelope, "runtime_event_type"); runtimeType != "" { if strings.EqualFold(runtimeType, "permission_requested") { - requestID, reason := extractPermissionRequest(envelope) + requestID, toolName, reason := extractPermissionRequest(envelope) if requestID != "" { - a.markPermissionPending(sessionID, runID, requestID, reason) + a.markPermissionPending(sessionID, runID, requestID, toolName, reason) _ = a.messenger.SendPermissionCard(ctx, chatID, PermissionCardPayload{ RequestID: requestID, Message: reason, @@ -494,12 +502,18 @@ func (a *Adapter) handleRunProgressCard(ctx context.Context, sessionID string, r } // markPermissionPending 将权限请求映射到 run 卡片,便于用户在同一卡片观察审批状态。 -func (a *Adapter) markPermissionPending(sessionID string, runID string, requestID string, reason string) { +func (a *Adapter) markPermissionPending(sessionID string, runID string, requestID string, toolName string, reason string) { key := runBindingKey(sessionID, runID) a.mu.Lock() binding, ok := a.activeRuns[key] if ok { binding.ApprovalStatus = "pending" + binding.ApprovalRecords = append(binding.ApprovalRecords, approvalEntry{ + RequestID: requestID, + ToolName: toolName, + Reason: reason, + Decision: "pending", + }) if strings.TrimSpace(reason) != "" { binding.LastSummary = strings.TrimSpace(reason) } @@ -532,11 +546,16 @@ func (a *Adapter) updateApprovalStatus(requestID string, decision string) { key := a.requestRuns[strings.TrimSpace(requestID)] binding, ok := a.activeRuns[key] if ok { - switch normalizedDecision { - case "allow_once", "allow_session": - binding.ApprovalStatus = "approved" - case "reject": - binding.ApprovalStatus = "rejected" + status := "approved" + if normalizedDecision == "reject" { + status = "rejected" + } + binding.ApprovalStatus = status + for i := range binding.ApprovalRecords { + if binding.ApprovalRecords[i].RequestID == strings.TrimSpace(requestID) { + binding.ApprovalRecords[i].Decision = normalizedDecision + break + } } a.activeRuns[key] = binding } @@ -695,17 +714,18 @@ func decodeMessageText(rawContent string) (string, error) { } // extractPermissionRequest 从 permission_requested 事件中抽取审批请求关键信息。 -func extractPermissionRequest(envelope map[string]any) (string, string) { +func extractPermissionRequest(envelope map[string]any) (requestID, toolName, reason string) { payload, _ := envelope["payload"].(map[string]any) if payload == nil { - return "", "需要审批" + return "", "", "需要审批" } - requestID := readString(payload, "request_id") - reason := readString(payload, "reason") + requestID = readString(payload, "request_id") + toolName = readString(payload, "tool_name") + reason = readString(payload, "reason") if reason == "" { reason = "工具执行请求审批,请确认是否放行。" } - return requestID, reason + return } // extractHookNotificationSummary 提取 async_rewake 等通知摘要并写入卡片,便于下轮继续追踪。 @@ -904,10 +924,23 @@ func formatElapsed(start time.Time) string { // statusCardPayload 将 run 绑定状态映射为卡片更新载荷。 func (b sessionBinding) statusCardPayload() StatusCardPayload { + pendingCount := 0 + records := make([]ApprovalRecord, 0, len(b.ApprovalRecords)) + for _, e := range b.ApprovalRecords { + if e.Decision == "pending" { + pendingCount++ + } + records = append(records, ApprovalRecord{ + ToolName: e.ToolName, + Decision: e.Decision, + }) + } return StatusCardPayload{ TaskName: b.TaskName, Status: b.Status, ApprovalStatus: b.ApprovalStatus, + ApprovalRecords: records, + PendingCount: pendingCount, Result: b.Result, Summary: b.LastSummary, AsyncRewakeHint: b.AsyncRewakeHint, diff --git a/internal/feishuadapter/adapter_test.go b/internal/feishuadapter/adapter_test.go index 5c5a1ed7..fab89bd9 100644 --- a/internal/feishuadapter/adapter_test.go +++ b/internal/feishuadapter/adapter_test.go @@ -1217,9 +1217,9 @@ func TestHelperFunctionsCoverFallbackBranches(t *testing.T) { if _, err := decodeMessageText("{"); err == nil { t.Fatal("expected invalid message content error") } - requestID, reason := extractPermissionRequest(nil) - if requestID != "" || reason == "" { - t.Fatalf("unexpected permission extraction: request=%q reason=%q", requestID, reason) + requestID, toolName, reason := extractPermissionRequest(nil) + if requestID != "" || toolName != "" || reason == "" { + t.Fatalf("unexpected permission extraction: request=%q tool=%q reason=%q", requestID, toolName, reason) } if text := extractUserVisibleDoneText(map[string]any{ "payload": map[string]any{"content": "done"}, diff --git a/internal/feishuadapter/messenger.go b/internal/feishuadapter/messenger.go index 98a86640..fd16c612 100644 --- a/internal/feishuadapter/messenger.go +++ b/internal/feishuadapter/messenger.go @@ -254,20 +254,26 @@ func (m *feishuMessenger) tenantAccessToken(ctx context.Context) (string, error) func buildStatusCard(payload StatusCardPayload) map[string]any { taskName := fallbackStatusField(payload.TaskName, "未命名任务") status := fallbackStatusField(payload.Status, "thinking") - approval := fallbackStatusField(payload.ApprovalStatus, "none") result := fallbackStatusField(payload.Result, "pending") statusIcon, statusColor := statusIconAndColor(status) - approvalIcon, _ := statusIconAndColor(approval) resultIcon, _ := statusIconAndColor(result) elements := []map[string]any{ statusNoteElement(taskName), statusBarElement(statusIcon, "状态", status), - statusBarElement(approvalIcon, "审批", approval), - statusBarElement(resultIcon, "结果", result), } + if len(payload.ApprovalRecords) > 0 { + elements = append(elements, buildApprovalRecordsElement(payload.ApprovalRecords, payload.PendingCount)) + } else { + approval := fallbackStatusField(payload.ApprovalStatus, "none") + approvalIcon, _ := statusIconAndColor(approval) + elements = append(elements, statusBarElement(approvalIcon, "审批", approval)) + } + + elements = append(elements, statusBarElement(resultIcon, "结果", result)) + if elapsed := strings.TrimSpace(payload.Elapsed); elapsed != "" { elements = append(elements, map[string]any{ "tag": "note", @@ -350,6 +356,44 @@ func statusBarElement(icon string, label string, value string) map[string]any { } } +func buildApprovalRecordsElement(records []ApprovalRecord, pendingCount int) map[string]any { + approvedCount := 0 + rejectedCount := 0 + for _, r := range records { + switch r.Decision { + case "allow_once": + approvedCount++ + case "reject": + rejectedCount++ + } + } + + summaryParts := make([]string, 0, 3) + if approvedCount > 0 { + summaryParts = append(summaryParts, fmt.Sprintf("%d 通过", approvedCount)) + } + if rejectedCount > 0 { + summaryParts = append(summaryParts, fmt.Sprintf("%d 拒绝", rejectedCount)) + } + if pendingCount > 0 { + summaryParts = append(summaryParts, fmt.Sprintf("%d 等待", pendingCount)) + } + summaryText := strings.Join(summaryParts, ",") + + detailLines := make([]string, 0, len(records)) + for _, r := range records { + icon, _ := statusIconAndColor(r.Decision) + label := fallbackStatusField(r.ToolName, "unknown_tool") + detailLines = append(detailLines, fmt.Sprintf("%s %s → *%s*", icon, label, r.Decision)) + } + fullText := fmt.Sprintf("**%s**\n%s", summaryText, strings.Join(detailLines, "\n")) + + return map[string]any{ + "tag": "div", + "text": map[string]any{"tag": "lark_md", "content": fullText}, + } +} + func statusIconAndColor(status string) (string, string) { switch strings.TrimSpace(strings.ToLower(status)) { case "thinking": diff --git a/internal/feishuadapter/types.go b/internal/feishuadapter/types.go index eb57fe44..5ea57b53 100644 --- a/internal/feishuadapter/types.go +++ b/internal/feishuadapter/types.go @@ -122,11 +122,19 @@ type PermissionCardPayload struct { Message string } +// ApprovalRecord 记录单次工具审批请求及其结论。 +type ApprovalRecord struct { + ToolName string + Decision string // "pending", "allow_once", "reject" +} + // StatusCardPayload 表示 run 状态卡片的展示字段。 type StatusCardPayload struct { TaskName string Status string ApprovalStatus string + ApprovalRecords []ApprovalRecord + PendingCount int Result string Summary string AsyncRewakeHint string From f87d8cdd63b4125b7130e86828fe2d40b738e5cd Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 7 May 2026 22:29:34 +0800 Subject: [PATCH 08/10] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=E9=AD=94?= =?UTF-8?q?=E6=90=AD=E5=8D=8A=E8=87=AA=E5=8A=A8=E5=BC=95=E5=AF=BC=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=8C=E8=A1=A5=E5=85=A8=E9=98=BF=E9=87=8C=E4=BA=91?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=BB=91=E5=AE=9A=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将阿里云账号绑定从"失败时回退"改为引导流程中的显式必须步骤, 中英文 configuration.md 新增 ModelScope API Key 获取小节。 Co-Authored-By: Claude Opus 4.7 --- docs/guides/modelscope-provider-setup.md | 6 ++++-- www/en/guide/configuration.md | 12 ++++++++++++ www/guide/configuration.md | 12 ++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/guides/modelscope-provider-setup.md b/docs/guides/modelscope-provider-setup.md index 90bf163b..7e0ea7ea 100644 --- a/docs/guides/modelscope-provider-setup.md +++ b/docs/guides/modelscope-provider-setup.md @@ -14,9 +14,11 @@ 2. 打开登录页: 3. 打开 Token 页: 4. 在 TUI 引导面板粘贴 token 并提交校验 +5. 打开阿里云绑定页完成账号绑定: -如果返回认证或权限类错误,会自动回退并打开阿里云认证页: - +> **注意**:步骤 5 的阿里云账号绑定是必须步骤。ModelScope API 依赖阿里云账号体系进行鉴权与计费, +> 未绑定将导致 API 调用返回认证错误。如果 token 校验时提前检测到认证问题, +> TUI 会自动打开绑定页引导完成。 ## 安全说明 diff --git a/www/en/guide/configuration.md b/www/en/guide/configuration.md index fbfe0afa..c3703acd 100644 --- a/www/en/guide/configuration.md +++ b/www/en/guide/configuration.md @@ -53,6 +53,18 @@ $env:OPENAI_API_KEY = "your_key_here" If you want the variable to persist, use your operating system or shell's normal environment variable setup. Do not put real keys in `config.yaml`. +### ModelScope API Key Setup + +ModelScope uses a semi-automated TUI guide flow to configure the API key: + +1. Run `/provider` in the TUI and select `modelscope` +2. Press Enter to step through: guide page → login page, complete ModelScope community login +3. On the Token page (), create and copy your API Key +4. Back in the TUI, paste the token in the guide panel and press Enter to validate +5. **Bind Alibaba Cloud account**: Visit [Account Settings](https://www.modelscope.cn/my/settings/account) to link your Alibaba Cloud account. This step is required — API calls will fail without it + +If token validation detects an auth or permission error, the TUI will automatically open the account binding page. + ## Switch provider and model The recommended path is the NeoCode UI; selections are saved automatically: diff --git a/www/guide/configuration.md b/www/guide/configuration.md index 4daff1d7..33086a40 100644 --- a/www/guide/configuration.md +++ b/www/guide/configuration.md @@ -53,6 +53,18 @@ $env:OPENAI_API_KEY = "your_key_here" 如果想长期保存环境变量,请用你所在系统或 Shell 的标准方式保存,不要把真实 Key 写进 `config.yaml`。 +### ModelScope API Key 获取 + +ModelScope(魔搭)通过 TUI 半引导流程完成 API Key 配置: + +1. 在 TUI 中执行 `/provider`,选择 `modelscope` +2. 按 Enter 依次打开引导页 → 登录页,完成魔搭社区登录 +3. 进入 Token 页(),创建并复制 API Key +4. 回到 TUI,在引导面板中粘贴 token,按 Enter 提交校验 +5. **绑定阿里云账号**:访问[账号设置](https://www.modelscope.cn/my/settings/account)完成阿里云账号绑定。此步骤为必须,未绑定时 API 调用将返回认证失败 + +如果 token 校验时检测到认证或权限错误,TUI 会自动打开账号绑定页引导你完成。 + ## 切换 Provider 和模型 推荐在 NeoCode 界面里切换,选择会自动保存: From 2291514828be0a4bfed28f77441b43dcb8e48f1b Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Fri, 8 May 2026 10:25:48 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat(feishu):=20=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=94=AF=E6=8C=81=E7=BB=93=E6=9E=9C=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E4=B8=8E=E5=B7=A5=E5=85=B7=E4=BF=A1=E6=81=AF=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=96=87=E6=A1=A3=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 审批卡片点击后更新为已通过/已拒绝状态(替换按钮),不再残留 - 审批卡片新增工具类型、操作、目标参数展示,方便用户判断 - 文档命令移除 PowerShell `` ` `` 和 bash `\` 续行符,改为单行命令 - 文档新增 neocode 安装模式命令示例(与 go run 开发模式并列) Co-Authored-By: Claude Opus 4.7 --- docs/guides/feishu-adapter.md | 29 ++-- internal/cli/feishu_adapter_command_test.go | 5 +- internal/feishuadapter/adapter.go | 74 ++++++--- internal/feishuadapter/adapter_test.go | 32 ++-- internal/feishuadapter/messenger.go | 174 ++++++++++++++++---- internal/feishuadapter/messenger_test.go | 8 +- internal/feishuadapter/types.go | 18 +- www/guide/feishu-remote-setup.md | 79 ++++----- 8 files changed, 299 insertions(+), 120 deletions(-) diff --git a/docs/guides/feishu-adapter.md b/docs/guides/feishu-adapter.md index 2a35f7ae..b0585ae0 100644 --- a/docs/guides/feishu-adapter.md +++ b/docs/guides/feishu-adapter.md @@ -45,23 +45,32 @@ ### 4.1 Webhook 模式(#554) ```bash +# 开发模式 (go run) +go run ./cmd/neocode feishu-adapter --ingress webhook + +# 安装模式 (neocode) neocode feishu-adapter --ingress webhook ``` 通常还会覆盖地址参数: ```bash -neocode feishu-adapter \ - --ingress webhook \ - --listen 127.0.0.1:18080 \ - --event-path /feishu/events \ - --card-path /feishu/cards +# 开发模式 (go run) +go run ./cmd/neocode feishu-adapter --ingress webhook --listen 127.0.0.1:18080 --event-path /feishu/events --card-path /feishu/cards + +# 安装模式 (neocode) +neocode feishu-adapter --ingress webhook --listen 127.0.0.1:18080 --event-path /feishu/events --card-path /feishu/cards ``` ### 4.2 SDK 模式(#557,本地无公网) ```bash export FEISHU_APP_SECRET="cli_secret_xxx" + +# 开发模式 (go run) +go run ./cmd/neocode feishu-adapter --ingress sdk + +# 安装模式 (neocode) neocode feishu-adapter --ingress sdk ``` @@ -118,11 +127,11 @@ Runner 是部署在用户本机的执行守护进程,通过 WebSocket 主动 ### 9.1 启动 Runner ```bash -neocode runner \ - --gateway-address "your-gateway:8080" \ - --token-file ~/.neocode/auth.json \ - --runner-name "我的本机" \ - --workdir /path/to/project +# 开发模式 (go run) +go run ./cmd/neocode runner --gateway-address "your-gateway:8080" --token-file ~/.neocode/auth.json --runner-name "我的本机" --workdir /path/to/project + +# 安装模式 (neocode) +neocode runner --gateway-address "your-gateway:8080" --token-file ~/.neocode/auth.json --runner-name "我的本机" --workdir /path/to/project ``` Runner 启动后会主动连接 Gateway,注册自身并保持心跳。当飞书消息触发工具调用时,Gateway 将工具请求推送到 Runner 本机执行。 diff --git a/internal/cli/feishu_adapter_command_test.go b/internal/cli/feishu_adapter_command_test.go index 12562ede..1518da72 100644 --- a/internal/cli/feishu_adapter_command_test.go +++ b/internal/cli/feishu_adapter_command_test.go @@ -228,7 +228,10 @@ func (s *stubFeishuGatewayClient) Close() error { type stubFeishuMessenger struct{} func (stubFeishuMessenger) SendText(context.Context, string, string) error { return nil } -func (stubFeishuMessenger) SendPermissionCard(context.Context, string, feishuadapter.PermissionCardPayload) error { +func (stubFeishuMessenger) SendPermissionCard(context.Context, string, feishuadapter.PermissionCardPayload) (string, error) { + return "", nil +} +func (stubFeishuMessenger) UpdatePermissionCard(context.Context, string, feishuadapter.ResolvedPermissionCardPayload) error { return nil } func (stubFeishuMessenger) SendStatusCard(context.Context, string, feishuadapter.StatusCardPayload) (string, error) { diff --git a/internal/feishuadapter/adapter.go b/internal/feishuadapter/adapter.go index 37de94cd..b54f8472 100644 --- a/internal/feishuadapter/adapter.go +++ b/internal/feishuadapter/adapter.go @@ -51,11 +51,12 @@ type Adapter struct { nowFn func() time.Time - mu sync.RWMutex - activeRuns map[string]sessionBinding - sessionChats map[string]string - requestRuns map[string]string - lastProgressAt map[string]time.Time + mu sync.RWMutex + activeRuns map[string]sessionBinding + sessionChats map[string]string + requestRuns map[string]string + lastProgressAt map[string]time.Time + permissionCards map[string]string // requestID -> card message_id } // New 创建飞书适配器实例。 @@ -79,10 +80,11 @@ func New(cfg Config, gateway GatewayClient, messenger Messenger, logger *log.Log logger: logger, idem: newIdempotencyStore(cfg.IdempotencyTTL), nowFn: func() time.Time { return time.Now().UTC() }, - activeRuns: make(map[string]sessionBinding), - sessionChats: make(map[string]string), - requestRuns: make(map[string]string), - lastProgressAt: make(map[string]time.Time), + activeRuns: make(map[string]sessionBinding), + sessionChats: make(map[string]string), + requestRuns: make(map[string]string), + lastProgressAt: make(map[string]time.Time), + permissionCards: make(map[string]string), }, nil } @@ -269,6 +271,7 @@ func (a *Adapter) untrackRun(sessionID string, runID string) { for requestID, requestRunKey := range a.requestRuns { if requestRunKey == key { delete(a.requestRuns, requestID) + delete(a.permissionCards, requestID) } } delete(a.lastProgressAt, key) @@ -324,13 +327,21 @@ func (a *Adapter) handleGatewayEvent(ctx context.Context, raw json.RawMessage) { if envelope != nil { if runtimeType := readString(envelope, "runtime_event_type"); runtimeType != "" { if strings.EqualFold(runtimeType, "permission_requested") { - requestID, toolName, reason := extractPermissionRequest(envelope) + requestID, toolName, operation, target, reason := extractPermissionRequest(envelope) if requestID != "" { a.markPermissionPending(sessionID, runID, requestID, toolName, reason) - _ = a.messenger.SendPermissionCard(ctx, chatID, PermissionCardPayload{ + cardID, err := a.messenger.SendPermissionCard(ctx, chatID, PermissionCardPayload{ RequestID: requestID, + ToolName: toolName, + Operation: operation, + Target: target, Message: reason, }) + if err == nil && strings.TrimSpace(cardID) != "" { + a.mu.Lock() + a.permissionCards[requestID] = cardID + a.mu.Unlock() + } return } } @@ -536,7 +547,7 @@ func (a *Adapter) markPermissionPending(sessionID string, runID string, requestI } } -// updateApprovalStatus 在审批动作被网关受理后更新 run 卡片中的审批结论。 +// updateApprovalStatus 在审批动作被网关受理后更新 run 卡片中的审批结论,并更新权限卡片为已处理状态。 func (a *Adapter) updateApprovalStatus(requestID string, decision string) { normalizedDecision := strings.TrimSpace(strings.ToLower(decision)) if normalizedDecision == "" { @@ -545,6 +556,7 @@ func (a *Adapter) updateApprovalStatus(requestID string, decision string) { a.mu.Lock() key := a.requestRuns[strings.TrimSpace(requestID)] binding, ok := a.activeRuns[key] + var approvalEntry *approvalEntry if ok { status := "approved" if normalizedDecision == "reject" { @@ -554,23 +566,43 @@ func (a *Adapter) updateApprovalStatus(requestID string, decision string) { for i := range binding.ApprovalRecords { if binding.ApprovalRecords[i].RequestID == strings.TrimSpace(requestID) { binding.ApprovalRecords[i].Decision = normalizedDecision + entry := binding.ApprovalRecords[i] + approvalEntry = &entry break } } a.activeRuns[key] = binding } - cardID := "" - payload := StatusCardPayload{} + statusCardID := "" + statusPayload := StatusCardPayload{} if ok { - cardID = strings.TrimSpace(binding.CardID) - payload = binding.statusCardPayload() + statusCardID = strings.TrimSpace(binding.CardID) + statusPayload = binding.statusCardPayload() } + permCardID := a.permissionCards[strings.TrimSpace(requestID)] a.mu.Unlock() - if cardID != "" { - if err := a.messenger.UpdateCard(context.Background(), cardID, payload); err != nil { + + // 更新状态卡片 + if statusCardID != "" { + if err := a.messenger.UpdateCard(context.Background(), statusCardID, statusPayload); err != nil { a.safeLog("update approval status card failed: %v", err) } } + + // 更新权限卡片为已处理状态(去掉按钮,显示结果) + if permCardID != "" { + resolvedPayload := ResolvedPermissionCardPayload{ + RequestID: strings.TrimSpace(requestID), + Approved: normalizedDecision != "reject", + } + if approvalEntry != nil { + resolvedPayload.ToolName = approvalEntry.ToolName + resolvedPayload.Message = approvalEntry.Reason + } + if err := a.messenger.UpdatePermissionCard(context.Background(), permCardID, resolvedPayload); err != nil { + a.safeLog("update permission card failed: %v", err) + } + } } // markRunTerminal 在 run 结束时合并结果摘要并刷新状态卡片。 @@ -714,13 +746,15 @@ func decodeMessageText(rawContent string) (string, error) { } // extractPermissionRequest 从 permission_requested 事件中抽取审批请求关键信息。 -func extractPermissionRequest(envelope map[string]any) (requestID, toolName, reason string) { +func extractPermissionRequest(envelope map[string]any) (requestID, toolName, operation, target, reason string) { payload, _ := envelope["payload"].(map[string]any) if payload == nil { - return "", "", "需要审批" + return "", "", "", "", "需要审批" } requestID = readString(payload, "request_id") toolName = readString(payload, "tool_name") + operation = readString(payload, "operation") + target = readString(payload, "target") reason = readString(payload, "reason") if reason == "" { reason = "工具执行请求审批,请确认是否放行。" diff --git a/internal/feishuadapter/adapter_test.go b/internal/feishuadapter/adapter_test.go index fab89bd9..17d7857c 100644 --- a/internal/feishuadapter/adapter_test.go +++ b/internal/feishuadapter/adapter_test.go @@ -94,12 +94,13 @@ func (f *fakeGatewayClient) snapshotCalls() []string { } type sentMessage struct { - chatID string - kind string - text string - card PermissionCardPayload - runCard StatusCardPayload - cardID string + chatID string + kind string + text string + card PermissionCardPayload + runCard StatusCardPayload + cardID string + resolvedCard *ResolvedPermissionCardPayload } type fakeMessenger struct { @@ -118,10 +119,19 @@ func (m *fakeMessenger) SendText(_ context.Context, chatID string, text string) return m.sendTextErr } -func (m *fakeMessenger) SendPermissionCard(_ context.Context, chatID string, payload PermissionCardPayload) error { +func (m *fakeMessenger) SendPermissionCard(_ context.Context, chatID string, payload PermissionCardPayload) (string, error) { m.mu.Lock() defer m.mu.Unlock() - m.messages = append(m.messages, sentMessage{chatID: chatID, kind: "card", card: payload}) + m.nextID++ + cardID := fmt.Sprintf("perm-card-%d", m.nextID) + m.messages = append(m.messages, sentMessage{chatID: chatID, kind: "card", card: payload, cardID: cardID}) + return cardID, nil +} + +func (m *fakeMessenger) UpdatePermissionCard(_ context.Context, cardID string, payload ResolvedPermissionCardPayload) error { + m.mu.Lock() + defer m.mu.Unlock() + m.messages = append(m.messages, sentMessage{chatID: cardID, kind: "update_perm_card", resolvedCard: &payload}) return nil } @@ -1217,9 +1227,9 @@ func TestHelperFunctionsCoverFallbackBranches(t *testing.T) { if _, err := decodeMessageText("{"); err == nil { t.Fatal("expected invalid message content error") } - requestID, toolName, reason := extractPermissionRequest(nil) - if requestID != "" || toolName != "" || reason == "" { - t.Fatalf("unexpected permission extraction: request=%q tool=%q reason=%q", requestID, toolName, reason) + requestID, toolName, operation, target, reason := extractPermissionRequest(nil) + if requestID != "" || toolName != "" || operation != "" || target != "" || reason == "" { + t.Fatalf("unexpected permission extraction: request=%q tool=%q op=%q target=%q reason=%q", requestID, toolName, operation, target, reason) } if text := extractUserVisibleDoneText(map[string]any{ "payload": map[string]any{"content": "done"}, diff --git a/internal/feishuadapter/messenger.go b/internal/feishuadapter/messenger.go index fd16c612..6820a360 100644 --- a/internal/feishuadapter/messenger.go +++ b/internal/feishuadapter/messenger.go @@ -63,46 +63,42 @@ func (m *feishuMessenger) SendText(ctx context.Context, chatID string, text stri return err } -// SendPermissionCard 向指定 chat_id 发送最小审批卡片。 -func (m *feishuMessenger) SendPermissionCard(ctx context.Context, chatID string, payload PermissionCardPayload) error { - card := map[string]any{ - "config": map[string]any{"wide_screen_mode": true}, - "elements": []map[string]any{ - { - "tag": "div", - "text": map[string]any{"tag": "lark_md", "content": payload.Message}, - }, - { - "tag": "action", - "actions": []map[string]any{ - { - "tag": "button", - "text": map[string]any{"tag": "plain_text", "content": "允许一次"}, - "type": "primary", - "value": map[string]string{ - "decision": "allow_once", - "request_id": payload.RequestID, - }, - }, - { - "tag": "button", - "text": map[string]any{"tag": "plain_text", "content": "拒绝"}, - "type": "default", - "value": map[string]string{ - "decision": "reject", - "request_id": payload.RequestID, - }, - }, - }, - }, - }, +// SendPermissionCard 向指定 chat_id 发送审批卡片,返回 message_id 用于后续更新。 +func (m *feishuMessenger) SendPermissionCard(ctx context.Context, chatID string, payload PermissionCardPayload) (string, error) { + card := buildPermissionCard(payload) + content, err := json.Marshal(card) + if err != nil { + return "", err } + return m.sendMessage(ctx, chatID, "interactive", string(content)) +} + +// UpdatePermissionCard 根据 card_id 覆盖更新审批卡片为已处理状态。 +func (m *feishuMessenger) UpdatePermissionCard(ctx context.Context, cardID string, payload ResolvedPermissionCardPayload) error { + token, err := m.tenantAccessToken(ctx) + if err != nil { + return err + } + card := buildResolvedPermissionCard(payload) content, err := json.Marshal(card) if err != nil { return err } - _, err = m.sendMessage(ctx, chatID, "interactive", string(content)) - return err + body := map[string]string{ + "content": string(content), + } + data, err := json.Marshal(body) + if err != nil { + return err + } + url := strings.TrimRight(m.baseURL, "/") + "/open-apis/im/v1/messages/" + cardID + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + return m.doJSONRequest(req) } // SendStatusCard 发送 run 维度的轻量级状态卡片,并返回可后续更新的 card_id。 @@ -417,6 +413,114 @@ func statusIconAndColor(status string) (string, string) { } } +// buildPermissionCard 构造带工具信息的审批卡片。 +func buildPermissionCard(payload PermissionCardPayload) map[string]any { + infoLines := make([]string, 0, 3) + if toolName := strings.TrimSpace(payload.ToolName); toolName != "" { + infoLines = append(infoLines, "**工具**: "+toolName) + } + if op := strings.TrimSpace(payload.Operation); op != "" { + infoLines = append(infoLines, "**操作**: "+op) + } + if target := strings.TrimSpace(payload.Target); target != "" { + infoLines = append(infoLines, "**目标**: "+target) + } + body := strings.Join(infoLines, "\n") + if reason := strings.TrimSpace(payload.Message); reason != "" { + if body != "" { + body += "\n\n**理由**: " + reason + } else { + body = reason + } + } + + elements := []map[string]any{ + { + "tag": "div", + "text": map[string]any{"tag": "lark_md", "content": body}, + }, + { + "tag": "action", + "actions": []map[string]any{ + { + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "允许一次"}, + "type": "primary", + "value": map[string]string{ + "decision": "allow_once", + "request_id": payload.RequestID, + }, + }, + { + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "拒绝"}, + "type": "default", + "value": map[string]string{ + "decision": "reject", + "request_id": payload.RequestID, + }, + }, + }, + }, + } + + return map[string]any{ + "config": map[string]any{"wide_screen_mode": true}, + "header": map[string]any{ + "title": map[string]string{"tag": "plain_text", "content": "工具审批"}, + "template": "yellow", + }, + "elements": elements, + } +} + +// buildResolvedPermissionCard 构造已处理的审批卡片(去掉按钮,显示结果)。 +func buildResolvedPermissionCard(payload ResolvedPermissionCardPayload) map[string]any { + infoLines := make([]string, 0, 3) + if toolName := strings.TrimSpace(payload.ToolName); toolName != "" { + infoLines = append(infoLines, "**工具**: "+toolName) + } + if op := strings.TrimSpace(payload.Operation); op != "" { + infoLines = append(infoLines, "**操作**: "+op) + } + if target := strings.TrimSpace(payload.Target); target != "" { + infoLines = append(infoLines, "**目标**: "+target) + } + body := strings.Join(infoLines, "\n") + if reason := strings.TrimSpace(payload.Message); reason != "" { + if body != "" { + body += "\n\n**理由**: " + reason + } else { + body = reason + } + } + + resultIcon := "✅" + resultText := "已通过" + headerColor := "green" + if !payload.Approved { + resultIcon = "❌" + resultText = "已拒绝" + headerColor = "red" + } + + body += "\n\n" + resultIcon + " **" + resultText + "**" + + return map[string]any{ + "config": map[string]any{"wide_screen_mode": true}, + "header": map[string]any{ + "title": map[string]string{"tag": "plain_text", "content": "工具审批"}, + "template": headerColor, + }, + "elements": []map[string]any{ + { + "tag": "div", + "text": map[string]any{"tag": "lark_md", "content": body}, + }, + }, + } +} + func fallbackStatusField(value string, fallback string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { diff --git a/internal/feishuadapter/messenger_test.go b/internal/feishuadapter/messenger_test.go index a35b831a..ea27aecc 100644 --- a/internal/feishuadapter/messenger_test.go +++ b/internal/feishuadapter/messenger_test.go @@ -109,12 +109,16 @@ func TestSendPermissionCardUsesInteractiveMessage(t *testing.T) { }, } messenger := NewFeishuMessenger("app", "secret", client) - if err := messenger.SendPermissionCard(context.Background(), "chat-id", PermissionCardPayload{ + cardID, err := messenger.SendPermissionCard(context.Background(), "chat-id", PermissionCardPayload{ RequestID: "perm-1", Message: "需要审批", - }); err != nil { + }) + if err != nil { t.Fatalf("send permission card: %v", err) } + if cardID != "mid" { + t.Fatalf("cardID = %q, want mid", cardID) + } if len(client.requests) != 2 { t.Fatalf("request count = %d, want 2", len(client.requests)) } diff --git a/internal/feishuadapter/types.go b/internal/feishuadapter/types.go index 5ea57b53..d4894e81 100644 --- a/internal/feishuadapter/types.go +++ b/internal/feishuadapter/types.go @@ -111,17 +111,31 @@ type GatewayClient interface { // Messenger 定义飞书消息发送器接口,便于测试替换。 type Messenger interface { SendText(ctx context.Context, chatID string, text string) error - SendPermissionCard(ctx context.Context, chatID string, payload PermissionCardPayload) error + SendPermissionCard(ctx context.Context, chatID string, payload PermissionCardPayload) (string, error) + UpdatePermissionCard(ctx context.Context, cardID string, payload ResolvedPermissionCardPayload) error SendStatusCard(ctx context.Context, chatID string, payload StatusCardPayload) (string, error) UpdateCard(ctx context.Context, cardID string, payload StatusCardPayload) error } -// PermissionCardPayload 表示最小审批卡片的关键字段。 +// PermissionCardPayload 表示审批卡片的关键字段。 type PermissionCardPayload struct { RequestID string + ToolName string + Operation string + Target string Message string } +// ResolvedPermissionCardPayload 表示已处理的审批卡片字段。 +type ResolvedPermissionCardPayload struct { + RequestID string + ToolName string + Operation string + Target string + Message string + Approved bool +} + // ApprovalRecord 记录单次工具审批请求及其结论。 type ApprovalRecord struct { ToolName string diff --git a/www/guide/feishu-remote-setup.md b/www/guide/feishu-remote-setup.md index fc82f471..c666a88b 100644 --- a/www/guide/feishu-remote-setup.md +++ b/www/guide/feishu-remote-setup.md @@ -105,19 +105,19 @@ Gateway 和 Adapter 通过**同一个 listen 地址**通信。根据你的系统 Gateway 是 NeoCode 的后端服务进程。Adapter 通过它接入 Runtime 和工具。 ```bash -# macOS / Linux -go run ./cmd/neocode-gateway \ - --listen "127.0.0.1:8080" \ - --http-listen "127.0.0.1:18181" \ - --workdir "/home/you/project" +# 开发模式 (go run) +go run ./cmd/neocode-gateway --listen "127.0.0.1:8080" --http-listen "127.0.0.1:18181" --workdir "/home/you/project" + +# 安装模式 (neocode) +neocode gateway --listen "127.0.0.1:8080" --http-listen "127.0.0.1:18181" --workdir "/home/you/project" ``` ```powershell -# Windows PowerShell -go run ./cmd/neocode-gateway ` - --listen "\\.\pipe\neocode-gateway" ` - --http-listen "127.0.0.1:18181" ` - --workdir "F:\qiniu\neo-code" +# 开发模式 (go run) +go run ./cmd/neocode-gateway --listen "\\.\pipe\neocode-gateway" --http-listen "127.0.0.1:18181" --workdir "F:\qiniu\neo-code" + +# 安装模式 (neocode) +neocode gateway --listen "\\.\pipe\neocode-gateway" --http-listen "127.0.0.1:18181" --workdir "F:\qiniu\neo-code" ``` **Gateway 启动参数说明:** @@ -137,17 +137,19 @@ go run ./cmd/neocode-gateway ` Adapter 负责桥接飞书长连接与本地 Gateway,把飞书消息翻译为 `gateway.run` 调用。 ```bash -# macOS / Linux -go run ./cmd/neocode feishu-adapter \ - --ingress sdk \ - --gateway-listen "127.0.0.1:8080" +# 开发模式 (go run) +go run ./cmd/neocode feishu-adapter --ingress sdk --gateway-listen "127.0.0.1:8080" + +# 安装模式 (neocode) +neocode feishu-adapter --ingress sdk --gateway-listen "127.0.0.1:8080" ``` ```powershell -# Windows PowerShell -go run ./cmd/neocode feishu-adapter ` - --ingress sdk ` - --gateway-listen "\\.\pipe\neocode-gateway" +# 开发模式 (go run) +go run ./cmd/neocode feishu-adapter --ingress sdk --gateway-listen "\\.\pipe\neocode-gateway" + +# 安装模式 (neocode) +neocode feishu-adapter --ingress sdk --gateway-listen "\\.\pipe\neocode-gateway" ``` **Adapter 启动参数说明:** @@ -252,20 +254,21 @@ feishu: 启动 Gateway(同 SDK): ```bash -go run ./cmd/neocode-gateway \ - --listen "127.0.0.1:8080" \ - --http-listen "127.0.0.1:18181" \ - --workdir "/path/to/project" +# 开发模式 (go run) +go run ./cmd/neocode-gateway --listen "127.0.0.1:8080" --http-listen "127.0.0.1:18181" --workdir "/path/to/project" + +# 安装模式 (neocode) +neocode gateway --listen "127.0.0.1:8080" --http-listen "127.0.0.1:18181" --workdir "/path/to/project" ``` 启动 Adapter: ```bash -# macOS / Linux -go run ./cmd/neocode feishu-adapter \ - --ingress webhook \ - --gateway-listen "127.0.0.1:8080" \ - --listen "127.0.0.1:18080" +# 开发模式 (go run) +go run ./cmd/neocode feishu-adapter --ingress webhook --gateway-listen "127.0.0.1:8080" --listen "127.0.0.1:18080" + +# 安装模式 (neocode) +neocode feishu-adapter --ingress webhook --gateway-listen "127.0.0.1:8080" --listen "127.0.0.1:18080" ``` 然后用 ngrok / cloudflared 把 `18080` 暴露公网,在飞书后台配置: @@ -289,21 +292,19 @@ Runner 会主动通过 WebSocket 连接云端 Gateway,接收工具执行请求 ### 8.1 启动 Runner ```bash -# macOS / Linux -go run ./cmd/neocode runner \ - --gateway-address "your-gateway.com:8080" \ - --token-file ~/.neocode/auth.json \ - --runner-name "我的 MacBook" \ - --workdir /path/to/project +# 开发模式 (go run) +go run ./cmd/neocode runner --gateway-address "your-gateway.com:8080" --token-file ~/.neocode/auth.json --runner-name "我的 MacBook" --workdir /path/to/project + +# 安装模式 (neocode) +neocode runner --gateway-address "your-gateway.com:8080" --token-file ~/.neocode/auth.json --runner-name "我的 MacBook" --workdir /path/to/project ``` ```powershell -# Windows PowerShell -go run ./cmd/neocode runner ` - --gateway-address "your-gateway.com:8080" ` - --token-file "$env:USERPROFILE\.neocode\auth.json" ` - --runner-name "我的 PC" ` - --workdir "F:\qiniu\neo-code" +# 开发模式 (go run) +go run ./cmd/neocode runner --gateway-address "your-gateway.com:8080" --token-file "$env:USERPROFILE\.neocode\auth.json" --runner-name "我的 PC" --workdir "F:\qiniu\neo-code" + +# 安装模式 (neocode) +neocode runner --gateway-address "your-gateway.com:8080" --token-file "$env:USERPROFILE\.neocode\auth.json" --runner-name "我的 PC" --workdir "F:\qiniu\neo-code" ``` Runner 启动后会打印连接状态: From 76b4c40d407d9311849f6fb6f325a2e11177871e Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 8 May 2026 03:07:49 +0000 Subject: [PATCH 10/10] fix(cli): resolve gateway command merge drift Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/cli/gateway_commands.go | 2 +- internal/cli/gateway_commands_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index bb0ded9c..8e6bc44d 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -25,7 +25,7 @@ import ( const ( defaultGatewayLogLevel = "info" - defaultGatewayIdleShutdownDelay = 30 * time.Second + defaultGatewayIdleShutdownDelay = 5 * time.Minute ) var ( diff --git a/internal/cli/gateway_commands_test.go b/internal/cli/gateway_commands_test.go index 220504ce..b0a88b7b 100644 --- a/internal/cli/gateway_commands_test.go +++ b/internal/cli/gateway_commands_test.go @@ -3,6 +3,7 @@ package cli import ( "reflect" "testing" + "time" "github.com/spf13/cobra" @@ -94,3 +95,10 @@ func TestInjectRunnerDispatcherIntoRuntime(t *testing.T) { t.Fatal("runnerToolDispatcher was not injected") } } + +func TestNewGatewayIdleShutdownControllerUsesExpectedDefaultTimeout(t *testing.T) { + controller := newGatewayIdleShutdownController(nil, nil) + if controller.idleTimeout != 5*time.Minute { + t.Fatalf("idleTimeout = %v, want %v", controller.idleTimeout, 5*time.Minute) + } +}