Files
sundynix-agentix/sundynix-gateway/cmd/localsim/main.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

147 lines
4.3 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.
// Command localsim 模拟桌面端本地执行 runner(联调工具,无需起真 Wails 桌面端):
// 连 gateway 的 /api/v1/local/runner WS,注册为当前用户的本地执行器,
// 在指定工作目录内响应 local_list_dir / local_read_file(只读,与桌面端同协议)。
//
// 用法:LOCALSIM_TOKEN=<jwt> go run ./cmd/localsim [-gw ws://localhost:8080] [-dir /path/to/workdir]
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gorilla/websocket"
)
type req struct {
ID string `json:"id"`
Tool string `json:"tool"`
Args map[string]any `json:"args,omitempty"`
}
type resp struct {
ID string `json:"id"`
OK bool `json:"ok"`
Content string `json:"content,omitempty"`
Error string `json:"error,omitempty"`
Workdir string `json:"workdir,omitempty"`
}
func main() {
gw := flag.String("gw", "ws://localhost:8080", "gateway WS 基址")
dir := flag.String("dir", ".", "沙箱工作目录")
flag.Parse()
token := os.Getenv("LOCALSIM_TOKEN")
if token == "" {
log.Fatal("缺 LOCALSIM_TOKEN(用户 JWT")
}
root, err := filepath.Abs(*dir)
if err != nil {
log.Fatal(err)
}
url := strings.TrimRight(*gw, "/") + "/api/v1/local/runner?token=" + token
// 断线自动重连(对齐真桌面端 runner 的 5s 退避):网关重启时联调不用手动重拉。
for {
if err := serveOnce(url, root); err != nil {
log.Printf("连接断开(5s 后重连): %v", err)
}
time.Sleep(5 * time.Second)
}
}
func serveOnce(url, root string) error {
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return err
}
defer conn.Close()
log.Printf("已注册为本地执行器 workdir=%s", root)
hello, _ := json.Marshal(resp{ID: "hello", OK: true, Workdir: root})
_ = conn.WriteMessage(websocket.TextMessage, hello)
for {
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var r req
if json.Unmarshal(data, &r) != nil {
continue
}
log.Printf("收到调用 tool=%s args=%v", r.Tool, r.Args)
out, _ := json.Marshal(handle(root, &r))
if err := conn.WriteMessage(websocket.TextMessage, out); err != nil {
return err
}
}
}
func handle(root string, r *req) *resp {
rel, _ := r.Args["path"].(string)
p := filepath.Join(root, filepath.Clean("/"+rel)) // 简版沙箱(联调工具;正式沙箱在桌面端)
switch r.Tool {
case "local_list_dir":
ents, err := os.ReadDir(p)
if err != nil {
return &resp{ID: r.ID, OK: false, Error: err.Error()}
}
var names []string
for _, e := range ents {
n := e.Name()
if e.IsDir() {
n += "/"
}
names = append(names, n)
}
data, _ := json.Marshal(map[string]any{"dir": rel, "entries": names})
return &resp{ID: r.ID, OK: true, Content: string(data)}
case "local_read_file":
b, err := os.ReadFile(p)
if err != nil {
return &resp{ID: r.ID, OK: false, Error: err.Error()}
}
if len(b) > 64*1024 {
b = b[:64*1024]
}
return &resp{ID: r.ID, OK: true, Content: string(b)}
case "local_write_file":
// 联调工具无 GUI:自动批准(真桌面端会弹原生确认框,见 desktop/localexec.go)。
content, _ := r.Args["content"].(string)
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
return &resp{ID: r.ID, OK: false, Error: err.Error()}
}
log.Printf(" [sim] 已写入 %s(%d 字节,真桌面端此处会先弹确认框)", p, len(content))
return &resp{ID: r.ID, OK: true, Content: fmt.Sprintf("已写入 %s%d 字节)", rel, len(content))}
case "local_exec":
cmdStr, _ := r.Args["command"].(string)
log.Printf(" [sim] 执行命令: %s(真桌面端此处会先弹确认框 + 走黑名单)", cmdStr)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := exec.CommandContext(ctx, "/bin/sh", "-c", cmdStr)
c.Dir = root
out, err := c.CombinedOutput()
text := string(out)
if len(text) > 16*1024 {
text = text[:16*1024] + "\n…(截断)"
}
if err != nil {
return &resp{ID: r.ID, OK: true, Content: fmt.Sprintf("退出码非零(%v)。输出:\n%s", err, text)}
}
if strings.TrimSpace(text) == "" {
text = "(成功,无输出)"
}
return &resp{ID: r.ID, OK: true, Content: text}
default:
return &resp{ID: r.ID, OK: false, Error: fmt.Sprintf("不支持: %s", r.Tool)}
}
}