Files
sundynix-agentix/sundynix-desktop/localrunner.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

319 lines
9.5 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_* 工具调用转发过来,这里在**用户授权目录的沙箱内**执行并回结果。
//
// 安全铁律:
// - 多目录白名单:用户授权哪些目录(桌面/下载/文档/项目…),就只能动这些目录,其余一律拒;
// - 防逃逸靠软链解析后前缀校验(软链指向白名单外也拒);
// - 写文件/执行命令另有独立开关 + 硬黑名单 + 逐次原生确认框(见 localexec.go);
// - 用户不点"开启",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
roots []string // 已授权目录白名单(绝对路径)
status string // offline / connecting / online
}
// StartLocalRunner 开启本地访问:dirs 是换行分隔的授权目录列表(沙箱白名单)。
// 幂等:重复调用先停旧连接。断线自动重连(5s 退避)直到 StopLocalRunner。
func (a *App) StartLocalRunner(gatewayURL, token, dirs string) error {
var roots []string
for _, line := range strings.Split(dirs, "\n") {
d := strings.TrimSpace(line)
if d == "" {
continue
}
if d == "~" || strings.HasPrefix(d, "~/") {
if home, err := os.UserHomeDir(); err == nil {
d = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(d, "~"), "/"))
}
}
abs, err := filepath.Abs(d)
if err != nil {
return fmt.Errorf("目录无效: %s", d)
}
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
return fmt.Errorf("目录不存在或不是目录: %s", abs)
}
roots = append(roots, abs)
}
if len(roots) == 0 {
return fmt.Errorf("至少要授权一个目录")
}
a.runner.stop()
ctx, cancel := context.WithCancel(context.Background())
a.runner.mu.Lock()
a.runner.cancel = cancel
a.runner.roots = roots
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, roots)
return nil
}
// DefaultLocalDirs 返回建议授权的常用目录(桌面/下载/文档),供设置界面一键填入。
func (a *App) DefaultLocalDirs() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
var out []string
for _, name := range []string{"Desktop", "Downloads", "Documents"} {
p := filepath.Join(home, name)
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
out = append(out, p)
}
}
return strings.Join(out, "\n")
}
// 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:" + strings.Join(a.runner.roots, "\n")
}
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 string, roots []string) {
for {
if err := r.serve(ctx, wsURL, roots); 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 string, roots []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: strings.Join(roots, "、")})
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(roots, &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 上限条数
)
// resolveAllowed 把一个路径解析并校验是否落在**任一**授权目录内。
// 单目录沙箱做不了"操作我电脑"(只能在一个文件夹里打转),改成多目录白名单:
// 用户授权哪些目录(桌面/下载/文档/项目目录…),agent 就只能在这些目录里动手。
// 路径用绝对路径(多根之下相对路径没有唯一含义),支持 ~ 展开。
// 防逃逸仍靠"软链解析后前缀校验"——软链指向白名单之外一律拒。
func resolveAllowed(roots []string, p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", fmt.Errorf("缺少路径")
}
if p == "~" || strings.HasPrefix(p, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
p = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
}
if !filepath.IsAbs(p) {
// 相对路径按第一个授权目录解释(兜底:模型偶尔会给相对路径)
if len(roots) == 0 {
return "", fmt.Errorf("没有已授权的目录")
}
p = filepath.Join(roots[0], filepath.Clean("/"+p))
}
p = filepath.Clean(p)
// 软链解析(目标可能不存在:那就用清洗后的路径本身比对)
resolved, err := filepath.EvalSymlinks(p)
if err != nil {
if !os.IsNotExist(err) {
return "", err
}
resolved = p
}
for _, root := range roots {
rootR, err := filepath.EvalSymlinks(root)
if err != nil {
rootR = root
}
if resolved == rootR || strings.HasPrefix(resolved, rootR+string(filepath.Separator)) {
return resolved, nil
}
}
return "", fmt.Errorf("路径 %s 不在已授权目录内(当前授权:%s)", p, strings.Join(roots, "、"))
}
func execLocal(roots []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":
// 空路径 = 列出所有已授权目录(多根之下没有唯一"根")。
// 这也是模型发现"我能访问哪些地方"的入口。
if strings.TrimSpace(rel) == "" {
data, _ := json.Marshal(map[string]any{
"authorized_dirs": roots,
"hint": "这些是用户授权可访问的目录。要看某个目录内容,把它的绝对路径作为 path 再调一次。",
})
return &runnerResp{ID: req.ID, OK: true, Content: string(data)}
}
p, err := resolveAllowed(roots, 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 := resolveAllowed(roots, 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(roots, req)
case "local_exec":
return execCommand(roots, req)
default:
return fail("本地执行器不支持该操作: " + req.Tool)
}
}