Files
sundynix-agentix/sundynix-gateway/cmd/localsim/main.go
T
Blizzard a16229573b feat(jarvis): 本地沙箱改多目录白名单 + 让它知道自己能操作这台电脑
单目录沙箱做不了"操作我电脑"——只能在一个文件夹里打转,跨目录整理直接没戏。
且模型压根不知道 shell 除了跑脚本还能开 App、控 App、触发快捷指令。

沙箱:单根 → 多根白名单
- 用户授权多个目录(设置里每行一个,可一键填入桌面/下载/文档),其余一律拒
- 路径改绝对路径(多根之下相对路径没有唯一含义),支持 ~ 展开,相对路径兜底按首个根解释
- local_list_dir 留空 path = 返回授权目录清单 → 模型据此自己发现"我能访问哪儿"
- local_exec 可指定 cwd(须在授权目录内)
- 防逃逸不变:软链解析后必须落在某个根内,越界即拒(单测覆盖 ../ 与软链逃逸)

告诉模型它能干什么
- local_exec 描述展开:文件整理(mv/cp/find)、mdfind 全盘搜、open 开应用/文件/网址、
  osascript 控制 Mac App、shortcuts run/list 触发快捷指令、系统信息
- 语音系统提示词把 JARVIS 定位成"这台电脑的操作者",并要求先想清用哪个工具再动手

live 验证(两个授权目录):
① "你能访问哪些目录,里面有什么" → 自主先查授权清单、再逐个列举,答全对
② "把下载里的图片挪到桌面" → 自主 mv,文件真的跨目录移动了

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:42:58 +08:00

178 lines
5.2 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")
}
var roots []string
for _, d := range strings.Split(*dir, ",") {
if d = strings.TrimSpace(d); d == "" {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
log.Fatal(err)
}
roots = append(roots, abs)
}
if len(roots) == 0 {
log.Fatal("至少一个目录")
}
url := strings.TrimRight(*gw, "/") + "/api/v1/local/runner?token=" + token
// 断线自动重连(对齐真桌面端 runner 的 5s 退避):网关重启时联调不用手动重拉。
for {
if err := serveOnce(url, roots); err != nil {
log.Printf("连接断开(5s 后重连): %v", err)
}
time.Sleep(5 * time.Second)
}
}
func serveOnce(url string, roots []string) error {
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return err
}
defer conn.Close()
log.Printf("已注册为本地执行器 授权目录=%s", strings.Join(roots, "、"))
hello, _ := json.Marshal(resp{ID: "hello", OK: true, Workdir: strings.Join(roots, "、")})
_ = 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(roots, &r))
if err := conn.WriteMessage(websocket.TextMessage, out); err != nil {
return err
}
}
}
func handle(roots []string, r *req) *resp {
root := roots[0]
rel, _ := r.Args["path"].(string)
// 简版沙箱(联调工具;正式沙箱在桌面端 resolveAllowed):绝对路径须落在某个授权根内。
p := rel
if p == "" {
p = root
} else if !filepath.IsAbs(p) {
p = filepath.Join(root, filepath.Clean("/"+p))
}
p = filepath.Clean(p)
inRoot := false
for _, rt := range roots {
if p == rt || strings.HasPrefix(p, rt+string(filepath.Separator)) {
inRoot = true
break
}
}
if !inRoot {
return &resp{ID: r.ID, OK: false, Error: "路径不在授权目录内: " + p}
}
switch r.Tool {
case "local_list_dir":
if strings.TrimSpace(rel) == "" {
data, _ := json.Marshal(map[string]any{"authorized_dirs": roots})
return &resp{ID: r.ID, OK: true, Content: string(data)}
}
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)}
}
}