Files
sundynix-agentix/sundynix-desktop/localrunner.go
T
Blizzard 348f1e0249 feat(jarvis): 能动的手(写文件/执行命令,三道闸) + 定时任务调度
此前 JARVIS 只能看不能动(本地工具纯只读)、也不会调度,补齐这两块。

【能动的手】local_write_file / local_exec,在用户自选工作目录内动手:
- 独立开关:只开只读访问不给这能力,须单独勾「允许写文件/执行命令」
- 原生确认框逐次审批:展示命令原文,默认按钮=拒绝,60s 无人应答按拒绝
  (防无人值守被静默批准);可选「本次会话都允许」,关开关即失效
- 硬黑名单:删库/提权/管道下载执行/写系统路径/装开机项/摸凭据等,
  用户点同意也不执行,连审批框都不弹。20 条危险命令 + 10 条正常命令单测
- 命令 cwd 锁沙箱根、60s 超时、输出 16KB 截断;非零退出不算失败(编译/测试
  错误对模型是有用信息)

【定时任务】sundynix_schedule + leader 锁 ticker(30s 扫) + 三个平台工具:
- 存自然语言指令而非编排图,到点走语音同一条关卡(preflightCore/launchCore)
  执行,跑完经语音事件主动播报结果
- 先推进 NextRunAt 再提交:提交失败也不会下轮重复捞起反复烧钱
- 停机期间错过的不补跑(补一堆历史提醒是骚扰),直接顺推到下一个未来时刻

【顺带修一个必崩的 bug】dispatcher 工具超时硬编码 3 秒,而审批要等人点
(60s)+执行(60s)——local_exec 100% 超时。改成工具在 list_tools 自报
timeout_sec(不在 dispatcher 硬编码工具名),超时链外松内紧:
dispatcher 160s > 网关 150s > runner 转发 140s > 桌面端 60+60s。

live 验证:①「写个 hello.sh 打印日期然后跑一下」→ 写+执行两步,文件真落磁盘
②「建个定时任务 35 秒后跑 wc -l」→ 到点自动触发 → 自主调 local_exec → 出结果

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:49 +08:00

252 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/coder/websocket"
)
// 本地执行 runnerJARVIS「本地的手」,LOCAL_AGENT_DESIGN 档 A / JARVIS_BRAIN_DESIGN P4):
// 桌面端 Go host 连 gateway 的 /api/v1/local/runner WS,把自己注册成本用户的本地执行器;
// 服务端把 local_* 工具调用转发过来,这里在**用户自选工作目录的沙箱内**执行并回结果。
//
// 安全铁律(P1 只读起步):
// - 只实现 list_dir / read_file,无写无 exec
// - 一切路径锁死在用户显式选择的 workdir 根下(清洗 + 软链解析后前缀校验,越界即拒);
// - 用户不点"开启",runner 永不连接——本地访问是显式授权,不是默认能力。
type runnerReq struct {
ID string `json:"id"`
Tool string `json:"tool"`
Args map[string]any `json:"args,omitempty"`
}
type runnerResp struct {
ID string `json:"id"`
OK bool `json:"ok"`
Content string `json:"content,omitempty"`
Error string `json:"error,omitempty"`
Workdir string `json:"workdir,omitempty"`
}
// LocalRunner 管一条 runner 连接的生命周期(App 持有单例)。
type LocalRunner struct {
mu sync.Mutex
cancel context.CancelFunc
workdir string
status string // offline / connecting / online
}
// StartLocalRunner 开启本地文件访问:以 workdir 为沙箱根连接 gateway 注册执行器。
// 幂等:重复调用先停旧连接。断线自动重连(5s 退避)直到 StopLocalRunner。
func (a *App) StartLocalRunner(gatewayURL, token, workdir string) error {
abs, err := filepath.Abs(strings.TrimSpace(workdir))
if err != nil {
return fmt.Errorf("工作目录无效: %w", err)
}
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
return fmt.Errorf("工作目录不存在或不是目录: %s", abs)
}
a.runner.stop()
ctx, cancel := context.WithCancel(context.Background())
a.runner.mu.Lock()
a.runner.cancel = cancel
a.runner.workdir = abs
a.runner.status = "connecting"
a.runner.mu.Unlock()
wsURL := strings.Replace(strings.TrimRight(gatewayURL, "/"), "http", "ws", 1) +
"/api/v1/local/runner?token=" + token
go a.runner.loop(ctx, wsURL, abs)
return nil
}
// StopLocalRunner 关闭本地文件访问(幂等)。
func (a *App) StopLocalRunner() { a.runner.stop() }
// LocalRunnerStatus 返回 "offline" / "connecting" / "online:<workdir>"(前端状态显示)。
func (a *App) LocalRunnerStatus() string {
a.runner.mu.Lock()
defer a.runner.mu.Unlock()
if a.runner.status == "online" {
return "online:" + a.runner.workdir
}
return a.runner.status
}
func (r *LocalRunner) stop() {
r.mu.Lock()
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
r.status = "offline"
r.mu.Unlock()
}
func (r *LocalRunner) setStatus(s string) {
r.mu.Lock()
r.status = s
r.mu.Unlock()
}
// loop 连接→服务→断线重连(5s 退避),直到 ctx 取消。
func (r *LocalRunner) loop(ctx context.Context, wsURL, root string) {
for {
if err := r.serve(ctx, wsURL, root); err != nil && ctx.Err() == nil {
r.setStatus("connecting")
}
select {
case <-ctx.Done():
r.setStatus("offline")
return
case <-time.After(5 * time.Second):
}
}
}
// serve 一条连接的会话:发 hello → 循环收请求、沙箱内执行、回结果。
func (r *LocalRunner) serve(ctx context.Context, wsURL, root string) error {
dctx, dcancel := context.WithTimeout(ctx, 10*time.Second)
conn, _, err := websocket.Dial(dctx, wsURL, nil)
dcancel()
if err != nil {
return err
}
defer conn.Close(websocket.StatusNormalClosure, "bye")
conn.SetReadLimit(1 << 20)
r.setStatus("online")
hello, _ := json.Marshal(runnerResp{ID: "hello", OK: true, Workdir: root})
if err := conn.Write(ctx, websocket.MessageText, hello); err != nil {
return err
}
for {
_, data, err := conn.Read(ctx)
if err != nil {
return err
}
var req runnerReq
if json.Unmarshal(data, &req) != nil {
continue
}
resp := execLocal(root, &req)
out, _ := json.Marshal(resp)
if err := conn.Write(ctx, websocket.MessageText, out); err != nil {
return err
}
}
}
// ---- 沙箱执行(只读)----
const (
maxReadBytes = 64 * 1024 // read_file 上限:64KB,超出截断(语音/对话场景足够)
maxDirEntries = 200 // list_dir 上限条数
)
// resolveInRoot 把相对路径解析进沙箱根:清洗 + 软链解析后必须仍在 root 下,越界即错。
func resolveInRoot(root, rel string) (string, error) {
p := filepath.Join(root, filepath.Clean("/"+rel)) // 前置 "/" 再 Clean:吃掉 ../ 逃逸
// 软链解析(目标可能不存在:解析其父目录)
resolved, err := filepath.EvalSymlinks(p)
if err != nil {
if !os.IsNotExist(err) {
return "", err
}
resolved = p
}
rootR, err := filepath.EvalSymlinks(root)
if err != nil {
rootR = root
}
if resolved != rootR && !strings.HasPrefix(resolved, rootR+string(filepath.Separator)) {
return "", fmt.Errorf("路径越出工作目录沙箱")
}
return resolved, nil
}
func execLocal(root string, req *runnerReq) *runnerResp {
fail := func(msg string) *runnerResp { return &runnerResp{ID: req.ID, OK: false, Error: msg} }
rel, _ := req.Args["path"].(string)
switch req.Tool {
case "local_list_dir":
p, err := resolveInRoot(root, rel)
if err != nil {
return fail(err.Error())
}
ents, err := os.ReadDir(p)
if err != nil {
return fail("读目录失败: " + err.Error())
}
sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() })
type item struct {
Name string `json:"name"`
Dir bool `json:"dir"`
Size int64 `json:"size,omitempty"`
}
out := make([]item, 0, len(ents))
for i, e := range ents {
if i >= maxDirEntries {
break
}
it := item{Name: e.Name(), Dir: e.IsDir()}
if fi, err := e.Info(); err == nil && !e.IsDir() {
it.Size = fi.Size()
}
out = append(out, it)
}
data, _ := json.Marshal(map[string]any{"dir": rel, "entries": out, "truncated": len(ents) > maxDirEntries})
return &runnerResp{ID: req.ID, OK: true, Content: string(data)}
case "local_read_file":
if strings.TrimSpace(rel) == "" {
return fail("缺少文件路径")
}
p, err := resolveInRoot(root, rel)
if err != nil {
return fail(err.Error())
}
fi, err := os.Stat(p)
if err != nil {
return fail("文件不存在: " + rel)
}
if fi.IsDir() {
return fail("这是目录不是文件: " + rel)
}
f, err := os.Open(p)
if err != nil {
return fail("打开失败: " + err.Error())
}
defer f.Close()
buf := make([]byte, maxReadBytes+1)
n, _ := f.Read(buf)
content := string(buf[:min(n, maxReadBytes)])
if n > maxReadBytes {
content += "\n…(文件过大,已截断到 64KB"
}
return &runnerResp{ID: req.ID, OK: true, Content: content}
// 能动的手(localexec.go):各自带独立开关 + 黑名单 + 原生审批框。
case "local_write_file":
return execWrite(root, req)
case "local_exec":
return execCommand(root, req)
default:
return fail("本地执行器不支持该操作: " + req.Tool)
}
}