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

414 lines
20 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"
"sort"
"strconv"
"strings"
"time"
"github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/contract"
)
// 平台工具族(JARVIS 大脑中枢,见 JARVIS_BRAIN_DESIGN.md §2.1):gateway 自己作为第三个工具
// 提供方(与 mcp-go/mcp-py 同协议)。平台操作的权威——提交关卡 preflightCore/launchCore、
// 归属校验、计费——都在 gateway,工具就长在权威所在地,而不是让 mcp-go 绕一圈回来调。
// dispatcher 经 list_tools 动态发现,加工具只改这里的注册表。
//
// 安全铁律:①一律 inject user_id + 服务端归属校验,模型不可指定别人的资源;
// ②会烧钱的提交(gen_report)必须走 preflightCore(预算/暂停/积分硬拦截)同一关卡。
// platParam 工具参数声明(与 mcp-go paramSpec 同构,list_tools JSON 契约一致)。
type platParam struct {
Name string `json:"name"`
Type string `json:"type"`
Desc string `json:"desc"`
Required bool `json:"required"`
}
// platTool 一个平台工具:元信息 + 处理函数(注册表 = 唯一事实源,dispatch 与 list_tools 共用)。
// timeoutSec:自报超时预算(秒,0=用 dispatcher 默认 3 秒)。本地执行类要等用户点确认框
// (人的反应时间)+ 真跑命令,必须自报更长预算,否则 3 秒必超时。
type platTool struct {
cn string
desc string
params []platParam
inject []string
timeoutSec int
handler func(context.Context, *contract.ToolCall) *contract.ToolResult
}
// platformRegistry 平台工具注册表。想加新工具(run_orchestration/search_kb/usage_today…)只改这里。
func (h *Handler) platformRegistry() map[string]platTool {
return map[string]platTool{
"platform_recent_tasks": {
cn: "最近任务", desc: "查询当前用户最近的任务运行列表(状态/主题/时间)。用户问“我最近的任务怎么样了/都有什么任务”时调用。",
inject: []string{"user_id"}, handler: h.platRecentTasks,
},
"platform_task_status": {
cn: "任务状态", desc: "查询某个任务的当前状态与输出摘要。用户问“那个任务/报告跑完了吗、结果是什么”时调用。",
params: []platParam{{Name: "task_id", Type: "string", Desc: "任务 ID", Required: true}},
inject: []string{"user_id"}, handler: h.platTaskStatus,
},
"platform_gen_report": {
cn: "生成报告", desc: "提交一个报告生成任务(异步跑,规划→分章→成稿)。用户要“写/生成一份 XX 报告”时调用。返回 task_id 后告诉用户已开跑、可稍后询问进度,不要原地等待结果。",
params: []platParam{
{Name: "topic", Type: "string", Desc: "报告主题", Required: true},
{Name: "kb", Type: "string", Desc: "参考知识库名(可选,用户提到才填)"},
},
inject: []string{"user_id", "tenant_id", "session_id"}, handler: h.platGenReport,
},
"platform_open_view": {
cn: "打开界面", desc: "把用户的客户端界面切到某个页面。用户说“打开/带我去/看看 运行页、报告页、知识库”等时调用。view 取值:home(工作台)/studio(编排)/kb(知识库)/runs(运行)/report(报告)/memory(记忆)/usage(用量)。",
params: []platParam{
{Name: "view", Type: "string", Desc: "目标页面:home/studio/kb/runs/report/memory/usage", Required: true},
{Name: "task_id", Type: "string", Desc: "可选:聚焦的任务 ID(配合 runs 页)"},
},
inject: []string{"user_id"}, handler: h.platOpenView,
},
// —— 本地的手(P4,只读起步):执行发生在用户自己的桌面(沙箱工作目录内),服务端只路由。
// 桌面端不在线/没开本地访问时工具会明确报不可用——如实转告用户即可。
"local_list_dir": {
cn: "看本地目录", desc: "列出用户电脑上某个目录里的文件。**path 留空会返回用户授权你访问的目录清单**——不知道该看哪时先这样调一次。用户问“我某个文件夹里有什么/桌面上有什么”时调用。仅用户桌面端在线且开启本地访问才可用。",
params: []platParam{{Name: "path", Type: "string", Desc: "目录的绝对路径(如 /Users/xx/Desktop);留空=返回授权目录清单"}},
inject: []string{"user_id"}, handler: h.platLocalExec,
},
"local_read_file": {
cn: "读本地文件", desc: "读取用户电脑上某个文件的文本内容(大文件截断)。用户让“看看/读一下 某个文件”时调用。仅用户桌面端在线且开启本地访问才可用。",
params: []platParam{{Name: "path", Type: "string", Desc: "文件的绝对路径", Required: true}},
inject: []string{"user_id"}, handler: h.platLocalExec,
},
// —— 能动的手:写文件 / 执行命令。桌面端会弹原生确认框让用户逐次批准(默认拒绝),
// 且命中安全黑名单的命令直接拒绝。被拒时如实告诉用户,别重试绕路。
"local_write_file": {
cn: "写本地文件", desc: "在用户电脑上写入/覆盖一个文本文件(只能写进用户授权的目录)。用户要求“写个文件/保存到本地/生成脚本”时调用。用户会在桌面端收到确认框,需其批准才真正写入。",
params: []platParam{
{Name: "path", Type: "string", Desc: "文件的绝对路径(须在授权目录内)", Required: true},
{Name: "content", Type: "string", Desc: "完整文件内容(会覆盖原文件)", Required: true},
},
inject: []string{"user_id"}, timeoutSec: 100, handler: h.platLocalExec,
},
"local_exec": {
cn: "操作电脑", desc: `在用户的 Mac 上执行一条 shell 命令并返回输出(超时 60 秒)。这是你操作这台电脑的主要手段,能干的远不止跑脚本:
- 文件整理:mv / cp / mkdir / 批量重命名(find + mv
- 找东西:mdfind "关键词"Spotlight 全盘搜索)、grep -rn、ls
- 开应用/开文件:open -a "Safari"、open /path/to/file、open https://...
- 控制 AppAppleScript):osascript -e 'tell application "Music" to play'、暂停播放、发邮件、建日历事件、取当前播放歌曲等
- 触发快捷指令:shortcuts run "指令名"(用户在「快捷指令」App 里建的自动化,可含智能家居场景);shortcuts list 可先列出有哪些
- 系统信息:df -h、uptime、pmset -g batt(电量)
用户说“打开某某/放首歌/把这些文件整理一下/找找那个文档在哪”都用这个工具。
用户会在桌面端收到确认框,需其批准才执行;删库、提权、写系统路径等危险命令会被直接拒绝。一次只提交一条命令。`,
params: []platParam{
{Name: "command", Type: "string", Desc: "要执行的 shell 命令", Required: true},
{Name: "cwd", Type: "string", Desc: "在哪个授权目录里执行(绝对路径,可选;默认第一个授权目录)"},
},
inject: []string{"user_id"}, timeoutSec: 160, handler: h.platLocalExec,
},
// —— 调度:让用户能说「每天早上九点帮我看看昨天的任务」。到点由 JARVIS 自己按指令办事并播报。
"platform_schedule_create": {
cn: "建定时任务", desc: "创建一个定时/周期执行的任务。用户说“每天/每小时/几点钟 帮我做某事”“过 N 分钟提醒我”时调用。prompt 写清到点要做什么(就像用户当面对你说的那句话)。到点会自动执行并主动播报结果给用户。",
params: []platParam{
{Name: "title", Type: "string", Desc: "任务名,如「每日任务巡检」", Required: true},
{Name: "prompt", Type: "string", Desc: "到点要执行的指令原话,如「看看我昨天的任务都什么状态,有失败的告诉我」", Required: true},
{Name: "first_delay_sec", Type: "integer", Desc: "距首次执行的秒数。如「10 分钟后」填 600;如「明早9点」自己按当前时间算出秒数", Required: true},
{Name: "interval_sec", Type: "integer", Desc: "重复周期秒数(每天=86400,每小时=3600);只跑一次填 0"},
},
inject: []string{"user_id", "tenant_id", "session_id"}, handler: h.platScheduleCreate,
},
"platform_schedule_list": {
cn: "看定时任务", desc: "列出当前用户的定时任务(含下次执行时间、是否启用)。用户问“我有哪些定时任务/提醒”时调用。",
inject: []string{"user_id"}, handler: h.platScheduleList,
},
"platform_schedule_cancel": {
cn: "取消定时任务", desc: "停用一条定时任务。用户说“取消/停掉那个定时任务”时调用;不知道 id 就先用 platform_schedule_list 查。",
params: []platParam{{Name: "id", Type: "string", Desc: "定时任务 ID", Required: true}},
inject: []string{"user_id"}, handler: h.platScheduleCancel,
},
}
}
// platScheduleCreate 建定时任务。首次执行时间由模型按"多少秒后"给出(它拿得到当前时间工具)。
func (h *Handler) platScheduleCreate(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
title, _ := call.Args["title"].(string)
prompt, _ := call.Args["prompt"].(string)
if strings.TrimSpace(title) == "" || strings.TrimSpace(prompt) == "" {
return &contract.ToolResult{OK: false, Error: "缺少任务名或执行指令"}
}
firstDelay := argInt(call.Args["first_delay_sec"])
interval := argInt(call.Args["interval_sec"])
if firstDelay < 10 {
firstDelay = 10 // 兜底:别让模型算出个立刻/过去的时刻
}
if interval > 0 && interval < 60 {
interval = 60 // 最短周期 1 分钟,防刷爆
}
tid, _ := call.Args["tenant_id"].(string)
sid, _ := call.Args["session_id"].(string)
s := &store.Schedule{
Owner: uid, TenantID: tid, SessionID: sid,
Title: title, Prompt: prompt,
IntervalSec: int64(interval), Enabled: true,
NextRunAt: time.Now().Add(time.Duration(firstDelay) * time.Second),
}
if err := h.db.CreateSchedule(ctx, s); err != nil {
return &contract.ToolResult{OK: false, Error: "创建定时任务失败: " + err.Error()}
}
every := "只执行一次"
if interval > 0 {
every = fmt.Sprintf("每 %d 分钟重复", interval/60)
}
data, _ := json.Marshal(map[string]string{
"id": s.ID, "title": title, "next_run": s.NextRunAt.Format("01-02 15:04"), "repeat": every,
"msg": "已建好定时任务。告诉用户首次执行时间即可,到点会自动执行并主动播报结果。",
})
return &contract.ToolResult{OK: true, Content: string(data)}
}
func (h *Handler) platScheduleList(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
rows := h.db.ListSchedules(ctx, uid)
if len(rows) == 0 {
return &contract.ToolResult{OK: true, Content: "当前没有任何定时任务。"}
}
type item struct {
ID string `json:"id"`
Title string `json:"title"`
Next string `json:"next_run,omitempty"`
Repeat string `json:"repeat"`
Enabled bool `json:"enabled"`
Runs int64 `json:"run_count"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
rep := "一次性"
if r.IntervalSec > 0 {
rep = fmt.Sprintf("每 %d 分钟", r.IntervalSec/60)
}
it := item{ID: r.ID, Title: r.Title, Repeat: rep, Enabled: r.Enabled, Runs: r.RunCount}
if r.Enabled {
it.Next = r.NextRunAt.Format("01-02 15:04")
}
out = append(out, it)
}
data, _ := json.Marshal(out)
return &contract.ToolResult{OK: true, Content: string(data)}
}
func (h *Handler) platScheduleCancel(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
id, _ := call.Args["id"].(string)
if uid == "" || strings.TrimSpace(id) == "" {
return &contract.ToolResult{OK: false, Error: "缺少定时任务 ID"}
}
// CancelSchedule 的 WHERE 带 owner,天然拦住改别人的任务。
if err := h.db.CancelSchedule(ctx, uid, id); err != nil {
return &contract.ToolResult{OK: false, Error: "取消失败: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: "已停用该定时任务(若 ID 不属于当前用户则无事发生)。"}
}
// argInt 把模型给的数字参数(JSON 里可能是 float64/string)转成 int。
func argInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case string:
i, _ := strconv.Atoi(strings.TrimSpace(n))
return i
}
return 0
}
// platLocalExec 把 local_* 调用经 NATS 路由到该用户的桌面 runner(见 local_runner.go)。
// 无 runner 在线时 NATS 无应答 → 明确报"不在线",绝不挂起任务。
func (h *Handler) platLocalExec(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
// 超时链必须外松内紧:dispatcher(160s) > 这里(150s) > runner 转发(140s) > 桌面端审批60s+执行60s。
// 任一层比内层短,用户还在看确认框就被判超时。
cctx, cancel := context.WithTimeout(ctx, 150*time.Second)
defer cancel()
res, err := h.bus.CallTool(cctx, contract.LocalExecSubject(uid), call)
if err != nil {
return &contract.ToolResult{OK: true, Content: "本地执行器不在线:用户桌面端未运行或未开启本地文件访问。请如实告知用户。"}
}
return res
}
// ServePlatformTools 以队列组订阅 sundynix.tools.platform.>,返回 drain 供优雅停机。
func (h *Handler) ServePlatformTools() (func(context.Context), error) {
reg := h.platformRegistry()
names := make([]string, 0, len(reg))
for n := range reg {
names = append(names, n)
}
sort.Strings(names)
drain, err := h.bus.ServeTool(contract.SubjectToolsPlatformAll, contract.QueueToolsPlatform,
func(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
return h.platformDispatch(reg, ctx, call)
})
if err != nil {
return nil, err
}
log.Printf("[platform] 平台工具就绪 %s (queue=%s): %s",
contract.SubjectToolsPlatformAll, contract.QueueToolsPlatform, strings.Join(names, ", "))
return drain, nil
}
func (h *Handler) platformDispatch(reg map[string]platTool, ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
log.Printf("[platform] tool=%s task=%s", call.Tool, call.TaskID)
if call.Tool == "list_tools" {
return platListTools(reg)
}
td, ok := reg[call.Tool]
if !ok {
return &contract.ToolResult{OK: false, Error: "unknown platform tool: " + call.Tool}
}
return td.handler(ctx, call)
}
// platListTools 自省:JSON 契约与 mcp-go listTools 一致(dispatcher toolCatalogEntry 同一解析)。
func platListTools(reg map[string]platTool) *contract.ToolResult {
type info struct {
Name string `json:"name"`
CN string `json:"cn"`
Desc string `json:"desc"`
Agent bool `json:"agent_exposed"`
Params []platParam `json:"params,omitempty"`
Inject []string `json:"inject,omitempty"`
Timeout int `json:"timeout_sec,omitempty"`
}
out := make([]info, 0, len(reg))
for name, td := range reg {
out = append(out, info{
Name: name, CN: td.cn, Desc: td.desc, Agent: true,
Params: td.params, Inject: td.inject, Timeout: td.timeoutSec,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
data, _ := json.Marshal(map[string]any{"service": "gateway-platform", "tools": out})
return &contract.ToolResult{OK: true, Content: string(data)}
}
// ---- 工具实现 ----
// platRecentTasks 最近任务列表(owner 隔离:只看自己的)。
func (h *Handler) platRecentTasks(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
rows := h.db.RecentRuns(ctx, uid, 10)
type item struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
Topic string `json:"topic,omitempty"`
At string `json:"at"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
out = append(out, item{TaskID: r.TaskID, Status: r.Status, Topic: r.Topic, At: r.At.Format("01-02 15:04")})
}
if len(out) == 0 {
return &contract.ToolResult{OK: true, Content: "没有任何任务运行记录。"}
}
data, _ := json.Marshal(out)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// platTaskStatus 单任务状态 + 输出摘要。归属校验:不是本人的任务一律说不存在(不泄露存在性)。
func (h *Handler) platTaskStatus(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
taskID, _ := call.Args["task_id"].(string)
if uid == "" || strings.TrimSpace(taskID) == "" {
return &contract.ToolResult{OK: false, Error: "缺少 task_id"}
}
owner := h.db.TaskOwner(ctx, taskID)
if owner == "" || owner != uid {
return &contract.ToolResult{OK: true, Content: "没有找到这个任务(ID 不存在或不属于当前用户)。"}
}
status, detail := h.db.GetTaskStatus(ctx, taskID)
output, _ := h.db.GetRunDetail(ctx, taskID)
if rs := []rune(output); len(rs) > 600 {
output = string(rs[:600]) + "…(已截断)"
}
data, _ := json.Marshal(map[string]string{
"task_id": taskID, "status": status, "detail": detail, "output_preview": output,
})
return &contract.ToolResult{OK: true, Content: string(data)}
}
// platOpenView 界面动作(P2 动作通道):经语音事件通道把 navigate 发到该用户的语音会话,
// 由客户端按白名单执行。视图合法性双重校验(此处 + 会话 onVoiceEvent + 客户端),宁可不动。
func (h *Handler) platOpenView(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
view, _ := call.Args["view"].(string)
taskID, _ := call.Args["task_id"].(string)
if uid == "" || view == "" {
return &contract.ToolResult{OK: false, Error: "缺少 view"}
}
if !navigateViews[view] {
return &contract.ToolResult{OK: false, Error: "未知页面: " + view + "(可用 home/studio/kb/runs/report/memory/usage"}
}
if err := h.bus.PublishVoiceEvent(uid, &contract.VoiceEvent{Action: "navigate", View: view, TaskID: taskID}); err != nil {
return &contract.ToolResult{OK: false, Error: "下发界面动作失败: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: "已让客户端切到 " + view + " 页。告诉用户已打开即可。"}
}
// platGenReport 提交报告任务——与 HTTP GenerateReport 同一条关卡与发射流程
// preflightCore:预算/暂停/计费租户/积分硬拦截;launchCore:落库+录像+Publish)。
func (h *Handler) platGenReport(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
topic, _ := call.Args["topic"].(string)
kb, _ := call.Args["kb"].(string)
tid, _ := call.Args["tenant_id"].(string) // 发起任务的活跃租户(计费口径跟 HTTP 提交一致)
sid, _ := call.Args["session_id"].(string) // 挂回发起会话(语音会话可续聊“报告好了吗”)
topic = strings.TrimSpace(topic)
if uid == "" || topic == "" {
return &contract.ToolResult{OK: false, Error: "缺少报告主题"}
}
billingTenant, block := h.preflightCore(ctx, uid, tid)
if block != nil {
return &contract.ToolResult{OK: true, Content: "无法提交:" + block.message()}
}
id := newReportID()
graph, _ := json.Marshal(map[string]any{"topic": topic})
task := &contract.Task{
ID: id,
Graph: graph,
Meta: map[string]any{
contract.MetaIntent: contract.IntentReport,
contract.MetaTopic: topic,
contract.MetaKB: kb,
contract.MetaUserID: uid,
contract.MetaTenantID: billingTenant,
contract.MetaSessionID: sid,
},
}
if err := h.launchCore(ctx, uid, task); err != nil {
return &contract.ToolResult{OK: false, Error: "报告任务提交失败: " + err.Error()}
}
data, _ := json.Marshal(map[string]string{
"task_id": id, "msg": "报告任务已提交开跑,请告知用户任务号并说明完成后可询问进度。",
})
return &contract.ToolResult{OK: true, Content: string(data)}
}