// Command localsim 模拟桌面端本地执行 runner(联调工具,无需起真 Wails 桌面端): // 连 gateway 的 /api/v1/local/runner WS,注册为当前用户的本地执行器, // 在指定工作目录内响应 local_list_dir / local_read_file(只读,与桌面端同协议)。 // // 用法:LOCALSIM_TOKEN= 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)} } }