Skip to content

Commit 287c224

Browse files
Cai-Tang-wwwclaude
andcommitted
feat(runner): 实现 Phase 2 本机 Runner 安全执行通道 (#555)
实现从"飞书消息 → 云端 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 <noreply@anthropic.com>
1 parent ef9922a commit 287c224

25 files changed

Lines changed: 1563 additions & 7 deletions

README.en.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ Core loop:
5555
- Skills system for task-specific behaviors.
5656
- MCP integration via stdio servers.
5757
- Gateway mode with local JSON-RPC / SSE / WebSocket access.
58+
- Feishu Adapter: Webhook and SDK long-connection ingress with live status card updates.
59+
- Local Runner: execute tools on your local machine via WebSocket connection to a cloud Gateway — no inbound ports needed.
5860

5961
---
6062

@@ -126,14 +128,25 @@ neocode --workdir /path/to/your/project
126128

127129
---
128130

129-
## Gateway / MCP / Skills
131+
## Gateway / MCP / Skills / Runner
130132

131133
Detailed docs are intentionally split out. README keeps entry links:
132134

133135
- Gateway integration and protocol: `docs/guides/gateway-integration-guide.md`
134136
- MCP configuration: `docs/guides/mcp-configuration.md`
135137
- Skills design: `docs/skills-system-design.md`
136138
- Runtime event flow: `docs/runtime-provider-event-flow.md`
139+
- Feishu remote setup: `www/guide/feishu-remote-setup.md`
140+
141+
### CLI Quick Reference
142+
143+
```bash
144+
# Start local runner daemon (connects to cloud Gateway for remote tool execution)
145+
neocode runner --gateway-address "your-gateway.com:8080" --token-file ~/.neocode/auth.json
146+
147+
# Start feishu adapter (SDK mode, no public network required)
148+
neocode feishu-adapter --ingress sdk --gateway-listen "127.0.0.1:8080"
149+
```
137150

138151
---
139152

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ NeoCode 是一个运行在本地开发环境中的 AI Coding Agent。
5656
- MCP 接入:通过 MCP stdio server 扩展外部工具能力。
5757
- Gateway 模式:通过本地 JSON-RPC / SSE / WebSocket 接口连接桌面端、脚本和第三方客户端。
5858
- Feishu Adapter:支持 Webhook 与 SDK 长连接接入,并用单张状态卡片持续回传 run 状态。
59+
- Local Runner:`neocode runner` 在本机执行工具,通过 WebSocket 主动连接云端 Gateway,无需开放入站端口。
5960

6061
---
6162

@@ -176,6 +177,21 @@ neocode use <provider> --model <model-id>
176177
neocode use openai --model gpt-4.1
177178
```
178179

180+
#### Local Runner
181+
182+
在本机启动执行守护进程,主动连接云端 Gateway 接收工具执行请求。
183+
184+
```bash
185+
# 启动 runner(默认连接 127.0.0.1:8080)
186+
neocode runner
187+
188+
# 指定远程 Gateway 地址和 token
189+
neocode runner --gateway-address "your-gateway.com:8080" --token-file ~/.neocode/auth.json
190+
191+
# 指定 Runner 名称与工作目录
192+
neocode runner --runner-name "我的本机" --workdir /path/to/project
193+
```
194+
179195
### 6. Shell 诊断代理
180196

181197
用于进入代理 shell、初始化 shell integration、手动触发诊断和控制自动诊断模式。

docs/guides/feishu-adapter.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
- 会话与运行 ID 保持实现一致:
1717
- `session_id = "feishu_" + stableHash(chat_id)`
1818
- `run_id = "feishu_" + stableHash(message_id)`
19-
- #557 只新增 SDK 入站,不包含 #555 Local Runner 主动长连。
19+
- #557 新增 SDK 入站#555 新增 Local Runner 主动长连(工具在 Runner 本机执行)
2020

2121
## 2. 事件执行顺序
2222

@@ -105,3 +105,50 @@ SDK 模式下不要求公网回调地址,不要求 `adapter.listen/event_path/
105105
- 默认启用签名校验(Webhook);
106106
- 日志不会输出 `app_secret`、签名密钥、gateway token、Authorization 等敏感信息;
107107
- 用户侧只回关键状态(受理、权限请求、完成、失败),不暴露内部堆栈和控制面细节。
108+
109+
## 9. Local Runner 远程工具执行(#555
110+
111+
Runner 是部署在用户本机的执行守护进程,通过 WebSocket 主动连接云端 Gateway,接收工具执行请求并在本机完成。
112+
113+
```
114+
飞书消息 -> Feishu Adapter (cloud) -> Gateway (cloud) -> WebSocket -> Local Runner (本机)
115+
↑ 主动出站连接
116+
```
117+
118+
### 9.1 启动 Runner
119+
120+
```bash
121+
neocode runner \
122+
--gateway-address "your-gateway:8080" \
123+
--token-file ~/.neocode/auth.json \
124+
--runner-name "我的本机" \
125+
--workdir /path/to/project
126+
```
127+
128+
Runner 启动后会主动连接 Gateway,注册自身并保持心跳。当飞书消息触发工具调用时,Gateway 将工具请求推送到 Runner 本机执行。
129+
130+
### 9.2 参数说明
131+
132+
| 参数 | 必填 | 默认值 | 说明 |
133+
|------|:---:|--------|------|
134+
| `--gateway-address` || `127.0.0.1:8080` | Gateway WebSocket 地址 |
135+
| `--token-file` ||| Gateway 认证 token 文件路径 |
136+
| `--runner-id` || 本机 hostname | Runner 唯一标识 |
137+
| `--runner-name` ||| 人类可读的 Runner 名称 |
138+
| `--workdir` || 当前目录 | Runner 工作目录 |
139+
140+
### 9.3 安全模型
141+
142+
- Runner 端验证 CapabilityToken(HMAC-SHA256 签名、TTL、AllowedTools、AllowedPaths)
143+
- 支持 Workdir Allowlist 限制可访问路径
144+
- 所有工具在 Runner 本机执行,结果通过 Gateway 回传飞书
145+
146+
### 9.4 错误翻译
147+
148+
当 Runner 不可用或权限不足时,Feishu Adapter 会将错误码翻译为用户可读消息:
149+
150+
| 错误码 | 飞书消息 |
151+
|--------|----------|
152+
| `runner_offline` | 本机 Runner 未连接,请在电脑上启动 `neocode runner` |
153+
| `capability_denied` | 权限不足:当前能力令牌不允许此操作 |
154+
| `tool_execution_failed` | 工具执行失败:{详情} |

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ func NewRootCommand() *cobra.Command {
9999
cmd.AddCommand(
100100
newGatewayCommand(),
101101
newFeishuAdapterCommand(),
102+
newRunnerCommand(),
102103
newWebCommand(),
103104
newDaemonCommand(),
104105
newShellCommand(),

internal/cli/runner_command.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"strings"
9+
"syscall"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
14+
"neo-code/internal/runner"
15+
)
16+
17+
var runRunnerCommandFn = defaultRunRunner
18+
19+
type runnerCommandOptions struct {
20+
GatewayAddress string
21+
TokenFile string
22+
RunnerID string
23+
RunnerName string
24+
Workdir string
25+
}
26+
27+
func newRunnerCommand() *cobra.Command {
28+
options := &runnerCommandOptions{}
29+
cmd := &cobra.Command{
30+
Use: "runner",
31+
Short: "Start local runner daemon for remote task execution",
32+
SilenceUsage: true,
33+
Args: cobra.NoArgs,
34+
Annotations: map[string]string{
35+
commandAnnotationSkipGlobalPreload: "true",
36+
commandAnnotationSkipSilentUpdateCheck: "true",
37+
},
38+
RunE: func(cmd *cobra.Command, args []string) error {
39+
return runRunnerCommandFn(cmd.Context(), *options)
40+
},
41+
}
42+
43+
cmd.Flags().StringVar(&options.GatewayAddress, "gateway-address", "", "gateway WebSocket address (e.g. 127.0.0.1:8080)")
44+
cmd.Flags().StringVar(&options.TokenFile, "token-file", "", "gateway token file path")
45+
cmd.Flags().StringVar(&options.RunnerID, "runner-id", "", "runner identifier (default: hostname)")
46+
cmd.Flags().StringVar(&options.RunnerName, "runner-name", "", "human-readable runner name")
47+
cmd.Flags().StringVar(&options.Workdir, "workdir", "", "runner working directory (default: current dir)")
48+
49+
return cmd
50+
}
51+
52+
func defaultRunRunner(ctx context.Context, options runnerCommandOptions) error {
53+
gatewayAddress := strings.TrimSpace(options.GatewayAddress)
54+
if gatewayAddress == "" {
55+
gatewayAddress = "127.0.0.1:8080"
56+
}
57+
58+
workdir := strings.TrimSpace(options.Workdir)
59+
if workdir == "" {
60+
if wd, err := os.Getwd(); err == nil {
61+
workdir = wd
62+
}
63+
}
64+
65+
runnerID := strings.TrimSpace(options.RunnerID)
66+
if runnerID == "" {
67+
if hostname, err := os.Hostname(); err == nil {
68+
runnerID = hostname
69+
} else {
70+
runnerID = "local-runner"
71+
}
72+
}
73+
74+
token := ""
75+
if options.TokenFile != "" {
76+
data, err := os.ReadFile(options.TokenFile)
77+
if err != nil {
78+
return fmt.Errorf("read token file: %w", err)
79+
}
80+
token = strings.TrimSpace(string(data))
81+
}
82+
83+
r, err := runner.New(runner.Config{
84+
RunnerID: runnerID,
85+
RunnerName: strings.TrimSpace(options.RunnerName),
86+
GatewayAddress: gatewayAddress,
87+
Token: token,
88+
Workdir: workdir,
89+
HeartbeatInterval: 10 * time.Second,
90+
ReconnectBackoffMin: 500 * time.Millisecond,
91+
ReconnectBackoffMax: 10 * time.Second,
92+
RequestTimeout: 30 * time.Second,
93+
})
94+
if err != nil {
95+
return fmt.Errorf("create runner: %w", err)
96+
}
97+
98+
runCtx, cancel := context.WithCancel(ctx)
99+
defer cancel()
100+
101+
sigCh := make(chan os.Signal, 1)
102+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
103+
go func() {
104+
<-sigCh
105+
fmt.Fprintln(os.Stderr, "\nshutting down runner...")
106+
r.Stop()
107+
cancel()
108+
}()
109+
110+
fmt.Fprintf(os.Stderr, "runner %s connecting to %s...\n", runnerID, gatewayAddress)
111+
if err := r.Run(runCtx); err != nil && err != context.Canceled {
112+
return fmt.Errorf("runner: %w", err)
113+
}
114+
return nil
115+
}

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type Config struct {
2929
Memo MemoConfig `yaml:"memo,omitempty"`
3030
Gateway GatewayConfig `yaml:"gateway,omitempty"`
3131
Feishu FeishuConfig `yaml:"feishu,omitempty"`
32+
Runner RunnerConfig `yaml:"runner,omitempty"`
3233
}
3334

3435
// StaticDefaults 返回 config 层负责的静态默认值骨架,不包含 provider 装配和选择状态修复。
@@ -47,6 +48,7 @@ func StaticDefaults() *Config {
4748
Memo: defaultMemoConfig(),
4849
Gateway: defaultGatewayConfig(),
4950
Feishu: defaultFeishuConfig(),
51+
Runner: defaultRunnerConfig(),
5052
}
5153
}
5254

@@ -63,6 +65,7 @@ func (c *Config) Clone() Config {
6365
clone.Memo = c.Memo.Clone()
6466
clone.Gateway = c.Gateway.Clone()
6567
clone.Feishu = c.Feishu.Clone()
68+
clone.Runner = c.Runner.Clone()
6669
return clone
6770
}
6871

@@ -90,6 +93,7 @@ func (c *Config) applyStaticDefaults(defaults Config) {
9093
c.Memo.ApplyDefaults(defaults.Memo)
9194
c.Gateway.ApplyDefaults(defaults.Gateway)
9295
c.Feishu.ApplyDefaults(defaults.Feishu)
96+
c.Runner.ApplyDefaults(defaults.Runner)
9397

9498
c.Workdir = normalizeWorkdir(c.Workdir)
9599
}
@@ -158,6 +162,9 @@ func (c *Config) ValidateSnapshot() error {
158162
if err := c.Feishu.Validate(); err != nil {
159163
return fmt.Errorf("config: feishu: %w", err)
160164
}
165+
if err := c.Runner.Validate(); err != nil {
166+
return fmt.Errorf("config: runner: %w", err)
167+
}
161168

162169
return nil
163170
}

internal/config/loader.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type persistedConfig struct {
3333
Memo persistedMemoConfig `yaml:"memo,omitempty"`
3434
Gateway GatewayConfig `yaml:"gateway,omitempty"`
3535
Feishu FeishuConfig `yaml:"feishu,omitempty"`
36+
Runner RunnerConfig `yaml:"runner,omitempty"`
3637
}
3738

3839
type persistedContextConfig struct {
@@ -238,6 +239,7 @@ func parseCurrentConfig(data []byte, contextDefaults ContextConfig, memoDefaults
238239
Memo: fromPersistedMemoConfig(file.Memo, memoDefaults),
239240
Gateway: file.Gateway,
240241
Feishu: file.Feishu,
242+
Runner: file.Runner,
241243
}
242244

243245
return cfg, nil
@@ -256,6 +258,7 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) {
256258
Memo: newPersistedMemoConfig(snapshot.Memo),
257259
Gateway: snapshot.Gateway,
258260
Feishu: snapshot.Feishu,
261+
Runner: snapshot.Runner,
259262
}
260263

261264
data, err := yaml.Marshal(&file)

0 commit comments

Comments
 (0)