Files
sundynix-agentix/sundynix-gateway/internal/handler/local_runner.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

166 lines
5.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 handler
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/sundynix/sundynix-shared/contract"
)
// 本地执行 runner 接入(JARVIS P4「本地的手」,LOCAL_AGENT_DESIGN 档 A):
// 桌面端 Go host 登录后连本 WS,把自己注册成该用户的本地执行器。local_* 工具调用
// 经 NATS sundynix.local.exec.<uid>request-reply,本连接队列组订阅)路由到这里,
// 转发给 runner 执行、结果原路回。runner 掉线即退订——调用方拿到"不在线"而非干等。
//
// 安全:执行发生在**用户自己的机器**、以用户 OS 身份、锁在用户自选的工作目录(沙箱在
// runner 侧强制);服务端只做路由。归属天然成立:subject 按 uid 分桶,工具 inject user_id。
var runnerUpgrader = websocket.Upgrader{
ReadBufferSize: 4096, WriteBufferSize: 4096,
CheckOrigin: func(*http.Request) bool { return true }, // 鉴权由 token 把关
}
const (
// 等 runner 回执行结果。写文件/执行命令要等用户在桌面端点原生确认框(最长 60s)
// 再真跑(最长 60s),所以给到 140s;只读操作瞬时返回,不受影响。
runnerCallTimeout = 140 * time.Second
runnerWriteWait = 10 * time.Second
)
// runnerReq / runnerResp 是 gateway ↔ 桌面 runner 的 WS 消息(JSON 文本帧)。
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"` // hello 帧带:runner 声明的沙箱根(仅展示/日志)
}
// LocalRunner: GET /api/v1/local/runner —— 桌面端注册本地执行器(WS,?token= 鉴权)。
func (h *Handler) LocalRunner(c *gin.Context) {
uid := userID(c)
if uid == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
return
}
conn, err := runnerUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
r := &runnerConn{conn: conn, pending: map[string]chan *runnerResp{}}
// 队列组订阅本用户执行主题:多台桌面在线时 NATS 队列组自动挑一台执行。
drain, err := h.bus.ServeTool(contract.LocalExecSubject(uid), "local-"+uid, r.forward)
if err != nil {
log.Printf("[local] runner 订阅失败 uid=%s: %v", uid, err)
return
}
log.Printf("[local] 本地执行器上线 uid=%s", uid)
// 读循环:分发 runner 的执行回执;连接断开即退出。
for {
_, data, rerr := conn.ReadMessage()
if rerr != nil {
break
}
var resp runnerResp
if json.Unmarshal(data, &resp) != nil {
continue
}
if resp.ID == "hello" {
log.Printf("[local] runner 声明工作目录 uid=%s dir=%s", uid, resp.Workdir)
continue
}
r.deliver(&resp)
}
dctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
drain(dctx)
cancel()
r.failAll("本地执行器已断开")
log.Printf("[local] 本地执行器下线 uid=%s", uid)
}
// runnerConn 一条 runner 连接:串行化写 + 按请求 id 关联回执。
type runnerConn struct {
conn *websocket.Conn
writeMu sync.Mutex
mu sync.Mutex
pending map[string]chan *runnerResp
seq atomic.Int64
}
// forward 把一次 local_* 工具调用转发给 runner,等回执(NATS ServeTool 的 handler)。
func (r *runnerConn) forward(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
id := fmt.Sprintf("r%d", r.seq.Add(1))
ch := make(chan *runnerResp, 1)
r.mu.Lock()
r.pending[id] = ch
r.mu.Unlock()
defer func() {
r.mu.Lock()
delete(r.pending, id)
r.mu.Unlock()
}()
data, _ := json.Marshal(runnerReq{ID: id, Tool: call.Tool, Args: call.Args})
r.writeMu.Lock()
_ = r.conn.SetWriteDeadline(time.Now().Add(runnerWriteWait))
werr := r.conn.WriteMessage(websocket.TextMessage, data)
r.writeMu.Unlock()
if werr != nil {
return &contract.ToolResult{OK: false, Error: "转发本地执行失败: " + werr.Error()}
}
select {
case resp := <-ch:
return &contract.ToolResult{OK: resp.OK, Content: resp.Content, Error: resp.Error}
case <-time.After(runnerCallTimeout):
return &contract.ToolResult{OK: false, Error: "本地执行超时(桌面端未响应)"}
case <-ctx.Done():
return &contract.ToolResult{OK: false, Error: "本地执行取消: " + ctx.Err().Error()}
}
}
// deliver 把 runner 回执投递给等待中的 forward。
func (r *runnerConn) deliver(resp *runnerResp) {
r.mu.Lock()
ch := r.pending[resp.ID]
r.mu.Unlock()
if ch != nil {
select {
case ch <- resp:
default:
}
}
}
// failAll 连接断开时让所有在途调用立即失败(别干等超时)。
func (r *runnerConn) failAll(msg string) {
r.mu.Lock()
defer r.mu.Unlock()
for id, ch := range r.pending {
select {
case ch <- &runnerResp{ID: id, OK: false, Error: msg}:
default:
}
}
}