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 @@
-
-
+
+
-
+
@@ -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 @@
-
-
+
+
-
+
-
+
+
文档
·
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)
+ }
+}