Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -126,14 +128,25 @@ neocode --workdir /path/to/your/project

---

## Gateway / MCP / Skills
## Gateway / MCP / Skills / Runner

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

- Gateway integration and protocol: `docs/guides/gateway-integration-guide.md`
- 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"
```

---

Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,无需开放入站端口。

---

Expand Down Expand Up @@ -176,6 +177,21 @@ neocode use <provider> --model <model-id>
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、手动触发诊断和控制自动诊断模式。
Expand Down
49 changes: 48 additions & 1 deletion docs/guides/feishu-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 事件执行顺序

Expand Down Expand Up @@ -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` | 工具执行失败:{详情} |
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(
newGatewayCommand(),
newFeishuAdapterCommand(),
newRunnerCommand(),
newWebCommand(),
newDaemonCommand(),
newShellCommand(),
Expand Down
115 changes: 115 additions & 0 deletions internal/cli/runner_command.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 装配和选择状态修复。
Expand All @@ -47,6 +48,7 @@ func StaticDefaults() *Config {
Memo: defaultMemoConfig(),
Gateway: defaultGatewayConfig(),
Feishu: defaultFeishuConfig(),
Runner: defaultRunnerConfig(),
}
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading