Skip to content

Commit 9b7b8a3

Browse files
authored
Merge pull request #570 from Cai-Tang-www/feat/feishu-2
feat(runner): 实现 Phase 2 本机 Runner 安全执行通道 (#555)
2 parents f6571d5 + f0ef669 commit 9b7b8a3

49 files changed

Lines changed: 4169 additions & 134 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.en.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
<a href="https://go.dev/">
99
<img src="https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white" alt="Go Version" />
1010
</a>
11-
<a href="https://github.com/1024XEngineer/neo-code/actions/workflows/ci.yml">
12-
<img src="https://img.shields.io/github/actions/workflow/status/1024XEngineer/neo-code/ci.yml?branch=main&label=CI" alt="CI Status" />
11+
<a href="https://github.com/1024XEngineer/neo-code">
12+
<img src="https://codecov.io/gh/1024XEngineer/neo-code/branch/main/graph/badge.svg" alt="Codecov Coverage" />
1313
</a>
1414
<a href="https://github.com/1024XEngineer/neo-code/blob/main/LICENSE">
15-
<img src="https://img.shields.io/github/license/1024XEngineer/neo-code?color=97CA00" alt="License" />
15+
<img src="https://img.shields.io/badge/License-MIT-purple?logo=opensourceinitiative&logoColor=white" alt="License MIT" />
1616
</a>
1717
<a href="https://neocode-docs.pages.dev/">
1818
<img src="https://img.shields.io/badge/Docs-Official-1677FF?logo=readthedocs&logoColor=white" alt="Docs" />
@@ -22,6 +22,7 @@
2222
</a>
2323
</p>
2424

25+
2526
<p align="center">
2627
<a href="https://neocode-docs.pages.dev/en/">Docs</a>
2728
·
@@ -55,6 +56,8 @@ Core loop:
5556
- Skills system for task-specific behaviors.
5657
- MCP integration via stdio servers.
5758
- Gateway mode with local JSON-RPC / SSE / WebSocket access.
59+
- Feishu Adapter: Webhook and SDK long-connection ingress with live status card updates.
60+
- Local Runner: execute tools on your local machine via WebSocket connection to a cloud Gateway — no inbound ports needed.
5861

5962
---
6063

@@ -126,14 +129,25 @@ neocode --workdir /path/to/your/project
126129

127130
---
128131

129-
## Gateway / MCP / Skills
132+
## Gateway / MCP / Skills / Runner
130133

131134
Detailed docs are intentionally split out. README keeps entry links:
132135

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

138152
---
139153

README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,21 @@
88
<a href="https://go.dev/">
99
<img src="https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white" alt="Go Version" />
1010
</a>
11-
<a href="https://github.com/1024XEngineer/neo-code/actions/workflows/ci.yml">
12-
<img src="https://img.shields.io/github/actions/workflow/status/1024XEngineer/neo-code/ci.yml?branch=main&label=CI" alt="CI Status" />
11+
<a href="https://github.com/1024XEngineer/neo-code">
12+
<img src="https://codecov.io/gh/1024XEngineer/neo-code/branch/main/graph/badge.svg" alt="Codecov Coverage" />
1313
</a>
1414
<a href="https://github.com/1024XEngineer/neo-code/blob/main/LICENSE">
15-
<img src="https://img.shields.io/github/license/1024XEngineer/neo-code?color=97CA00" alt="License" />
15+
<img src="https://img.shields.io/badge/License-MIT-purple?logo=opensourceinitiative&logoColor=white" alt="License MIT" />
1616
</a>
1717
<a href="https://neocode-docs.pages.dev/">
1818
<img src="https://img.shields.io/badge/Docs-Official-1677FF?logo=readthedocs&logoColor=white" alt="Docs" />
1919
</a>
20-
<a href="https://neocode-docs.pages.dev/guide/install">
20+
<a href="https://neocode-docs.pages.dev/en/guide/install">
2121
<img src="https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-4EAA25" alt="Platform" />
2222
</a>
2323
</p>
2424

25+
2526
<p align="center">
2627
<a href="https://neocode-docs.pages.dev/">文档</a>
2728
·
@@ -56,6 +57,7 @@ NeoCode 是一个运行在本地开发环境中的 AI Coding Agent。
5657
- MCP 接入:通过 MCP stdio server 扩展外部工具能力。
5758
- Gateway 模式:通过本地 JSON-RPC / SSE / WebSocket 接口连接桌面端、脚本和第三方客户端。
5859
- Feishu Adapter:支持 Webhook 与 SDK 长连接接入,并用单张状态卡片持续回传 run 状态。
60+
- Local Runner:`neocode runner` 在本机执行工具,通过 WebSocket 主动连接云端 Gateway,无需开放入站端口。
5961

6062
---
6163

@@ -176,6 +178,21 @@ neocode use <provider> --model <model-id>
176178
neocode use openai --model gpt-4.1
177179
```
178180

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

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

docs/guides/feishu-adapter.md

Lines changed: 62 additions & 6 deletions
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

@@ -45,23 +45,32 @@
4545
### 4.1 Webhook 模式(#554
4646

4747
```bash
48+
# 开发模式 (go run)
49+
go run ./cmd/neocode feishu-adapter --ingress webhook
50+
51+
# 安装模式 (neocode)
4852
neocode feishu-adapter --ingress webhook
4953
```
5054

5155
通常还会覆盖地址参数:
5256

5357
```bash
54-
neocode feishu-adapter \
55-
--ingress webhook \
56-
--listen 127.0.0.1:18080 \
57-
--event-path /feishu/events \
58-
--card-path /feishu/cards
58+
# 开发模式 (go run)
59+
go run ./cmd/neocode feishu-adapter --ingress webhook --listen 127.0.0.1:18080 --event-path /feishu/events --card-path /feishu/cards
60+
61+
# 安装模式 (neocode)
62+
neocode feishu-adapter --ingress webhook --listen 127.0.0.1:18080 --event-path /feishu/events --card-path /feishu/cards
5963
```
6064

6165
### 4.2 SDK 模式(#557,本地无公网)
6266

6367
```bash
6468
export FEISHU_APP_SECRET="cli_secret_xxx"
69+
70+
# 开发模式 (go run)
71+
go run ./cmd/neocode feishu-adapter --ingress sdk
72+
73+
# 安装模式 (neocode)
6574
neocode feishu-adapter --ingress sdk
6675
```
6776

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

docs/guides/modelscope-provider-setup.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
2. 打开登录页:<https://www.modelscope.cn/>
1515
3. 打开 Token 页:<https://www.modelscope.cn/my/access/token>
1616
4. 在 TUI 引导面板粘贴 token 并提交校验
17+
5. 打开阿里云绑定页完成账号绑定:<https://www.modelscope.cn/my/settings/account>
1718

18-
如果返回认证或权限类错误,会自动回退并打开阿里云认证页:
19-
<https://www.modelscope.cn/my/settings/account>
19+
> **注意**:步骤 5 的阿里云账号绑定是必须步骤。ModelScope API 依赖阿里云账号体系进行鉴权与计费,
20+
> 未绑定将导致 API 调用返回认证错误。如果 token 校验时提前检测到认证问题,
21+
> TUI 会自动打开绑定页引导完成。
2022
2123
## 安全说明
2224

internal/cli/feishu_adapter_command_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,10 @@ func (s *stubFeishuGatewayClient) Close() error {
228228
type stubFeishuMessenger struct{}
229229

230230
func (stubFeishuMessenger) SendText(context.Context, string, string) error { return nil }
231-
func (stubFeishuMessenger) SendPermissionCard(context.Context, string, feishuadapter.PermissionCardPayload) error {
231+
func (stubFeishuMessenger) SendPermissionCard(context.Context, string, feishuadapter.PermissionCardPayload) (string, error) {
232+
return "", nil
233+
}
234+
func (stubFeishuMessenger) UpdatePermissionCard(context.Context, string, feishuadapter.ResolvedPermissionCardPayload) error {
232235
return nil
233236
}
234237
func (stubFeishuMessenger) SendStatusCard(context.Context, string, feishuadapter.StatusCardPayload) (string, error) {

internal/cli/gateway_commands.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"neo-code/internal/config"
2020
"neo-code/internal/gateway"
2121
gatewayauth "neo-code/internal/gateway/auth"
22+
agentruntime "neo-code/internal/runtime"
2223
"neo-code/internal/webassets"
2324
)
2425

@@ -238,6 +239,15 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat
238239
Metrics: metrics,
239240
})
240241

242+
runnerRegistry := gateway.NewRunnerRegistry(logger)
243+
runnerToolManager := gateway.NewRunnerToolManager(
244+
runnerRegistry,
245+
relay,
246+
nil, // capability signer: nil allows execution without token for MVP
247+
30*time.Second,
248+
logger,
249+
)
250+
241251
runtimePort, closeRuntimePort, err := buildGatewayRuntimePort(signalContext, options.Workdir)
242252
if err != nil {
243253
return fmt.Errorf("initialize gateway runtime: %w", err)
@@ -248,6 +258,9 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat
248258
}
249259
}()
250260

261+
// 注入 Runner 工具分发器到 runtime,使 ReAct 循环中的工具调用可以通过 runner 执行
262+
injectRunnerDispatcherIntoRuntime(runtimePort, runnerToolManager)
263+
251264
idleCloser := newGatewayIdleShutdownController(logger, cancelRuntime)
252265
defer idleCloser.close()
253266

@@ -294,6 +307,8 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat
294307
AllowedOrigins: gatewayConfig.Security.AllowOrigins,
295308
StaticFileDir: staticFileDir,
296309
StaticFileFS: staticFileFS,
310+
RunnerRegistry: runnerRegistry,
311+
RunnerToolManager: runnerToolManager,
297312
ConnectionCountChanged: func(active int) {
298313
idleCloser.observe(active)
299314
},
@@ -479,6 +494,33 @@ func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatew
479494
return gateway.NewNetworkServer(options)
480495
}
481496

497+
// injectRunnerDispatcherIntoRuntime 将 RunnerToolManager 注入到多工作区 runtime 的所有 bundle 中,
498+
// 使 ReAct 循环中的工具调用可以通过 runner 远程执行。
499+
func injectRunnerDispatcherIntoRuntime(runtimePort gateway.RuntimePort, runnerToolManager *gateway.RunnerToolManager) {
500+
if runtimePort == nil || runnerToolManager == nil {
501+
return
502+
}
503+
504+
mw, ok := runtimePort.(*gateway.MultiWorkspaceRuntime)
505+
if !ok {
506+
return
507+
}
508+
509+
dispatcher := gateway.NewRunnerToolDispatcher(runnerToolManager)
510+
511+
mw.InjectRunnerDispatcher(func(port gateway.RuntimePort) {
512+
bridge, ok := port.(*gatewayRuntimePortBridge)
513+
if !ok {
514+
return
515+
}
516+
svc, ok := bridge.runtime.(*agentruntime.Service)
517+
if !ok {
518+
return
519+
}
520+
svc.SetRunnerToolDispatcher(dispatcher)
521+
})
522+
}
523+
482524
// encodeJSONLine 将对象编码为单行 JSON,并写入目标输出流。
483525
func encodeJSONLine(writer io.Writer, payload any) error {
484526
encoder := json.NewEncoder(writer)

internal/cli/gateway_commands_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package cli
22

33
import (
4+
"reflect"
45
"testing"
56
"time"
67

78
"github.com/spf13/cobra"
9+
10+
"neo-code/internal/gateway"
11+
agentruntime "neo-code/internal/runtime"
812
)
913

1014
func TestNormalizeGatewayLogLevel(t *testing.T) {
@@ -69,6 +73,29 @@ func TestMustReadInheritedWorkdir(t *testing.T) {
6973
})
7074
}
7175

76+
func TestInjectRunnerDispatcherIntoRuntime(t *testing.T) {
77+
injectRunnerDispatcherIntoRuntime(nil, nil)
78+
injectRunnerDispatcherIntoRuntime(&gatewayRuntimePortBridge{}, nil)
79+
injectRunnerDispatcherIntoRuntime(&gatewayRuntimePortBridge{}, &gateway.RunnerToolManager{})
80+
81+
nonServiceBridge := &gatewayRuntimePortBridge{runtime: &runtimeStub{}}
82+
multiNonService := gateway.NewMultiWorkspaceRuntime(nil, "", nil)
83+
multiNonService.PreloadWorkspaceBundle("non-service", nonServiceBridge, func() error { return nil })
84+
injectRunnerDispatcherIntoRuntime(multiNonService, &gateway.RunnerToolManager{})
85+
86+
service := &agentruntime.Service{}
87+
bridge := &gatewayRuntimePortBridge{runtime: service}
88+
multi := gateway.NewMultiWorkspaceRuntime(nil, "", nil)
89+
multi.PreloadWorkspaceBundle("default", bridge, func() error { return nil })
90+
91+
injectRunnerDispatcherIntoRuntime(multi, &gateway.RunnerToolManager{})
92+
93+
field := reflect.ValueOf(service).Elem().FieldByName("runnerToolDispatcher")
94+
if !field.IsValid() || field.IsNil() {
95+
t.Fatal("runnerToolDispatcher was not injected")
96+
}
97+
}
98+
7299
func TestNewGatewayIdleShutdownControllerUsesExpectedDefaultTimeout(t *testing.T) {
73100
controller := newGatewayIdleShutdownController(nil, nil)
74101
if controller.idleTimeout != 5*time.Minute {

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(),

0 commit comments

Comments
 (0)