mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
feat(examples): 内置插件外置化 —— cmd/localuse/mcp 迁入 example + files 新增按行号精确编辑
## 背景 主仓按「只有必须使用内部 SDK 能力的插件才内置」的判定标准(见主仓 ARCHITECTURE「内置插件的判定标准」)把纯公开 SDK 插件移出编译期内置清单。 本仓为它们提供外部插件工程(example/ 下,hmapdev 可直接构建)。 ★ 终审翻案:kbtree 与 multimodal **不**在本次外置之列 —— 逐方法对照 公开 SDK 后发现它们依赖内部专有能力(kbtree 要 KnowledgeAPI 的 Subtree/CategoryCounts/SearchIn/Categories 与树视图类型;multimodal 要 LLMAPI.Chat 与多模态消息类型),公开面没有,已加回主仓内置清单。 ## 新增工程(全部对齐当前模板 + 随用随起契约) | 工程 | 工具 | auto_start | 说明 | |---|---|---|---| | cmd | cmd_run | false(按需) | 沙箱执行 shell 命令 | | localuse | screensee/screensue/camerasue/speakeruse/clipboardsee/clipboardsue/computeruse | false(按需) | 本机外设 | | mcp | mcp_add/list/remove/restart_server | false(按需) | MCP 服务器接入(stdio/SSE) | 每工程:plg.json(含 sdk 字段声明)+ skill/<name>-tools/SKILL.md + README。按 auto_start 契约(sdk/autostart.go 的 OnDemandNeedsSkill), **auto_start=false 必须配 skill**,否则工具不出现在初始清单 ⇒ 模型不知道 它存在 ⇒ 永远不会被拉起,等于没装还占地方。 ## 移植要点 - 注册:内置版 init()+RegisterFactory → 外部版导出 NewPluginFactory; RegisterPluginMeta 的显示名进 plg.json/plugin.json。 - mcp:本地 rpcRequest/rpcResponse/rpcError 与 hmapdev 生成的 z_proc_gen.go 同名冲突(构建期生成物与本目录源码同包编译), 本地类型改名 mcpRPC*;internal/safeclose 内联为 safeCloseChan (外部插件不能引 internal 包,close-of-closed-channel 纪律随之带过来)。 - module 名:cmd 的 go.mod 用 homeagent-cmd(避免与标准库 cmd 混淆); name_en 对齐插件名,hmapdev 以 toSnake(name_en) 命名产物 (此前误写 Command/Local,产物成了 command_/local_)。 ## files:新增 files_edit_line(按行号精确编辑) files_edit 要求 old 文本全文件唯一,改「第三个 case 的 break」这类 非唯一文本时只能退化为整文件重写(有损)。files_edit_line 按行号 定位,op=replace/insert/delete,支持: - 多条 edit 从后往前应用(前面行号不漂移) - expect 安全闸:行内容与预期不符则整体拒绝(防行号过期改错位置) - CRLF/末尾换行保真 skill 同步补 edit_line 行。测试 5 条(含 expect 拒绝路径)。 ## 验证 hmapdev build 四工程全过:cmd/localuse/mcp/files 产物齐; go test files 全绿。
This commit is contained in:
302
example/cmd/plugin.go
Normal file
302
example/cmd/plugin.go
Normal file
@ -0,0 +1,302 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||
)
|
||||
|
||||
// isWindows 缓存运行时检测结果
|
||||
var isWindows = runtime.GOOS == "windows"
|
||||
|
||||
// shellUnquote 拆解命令字符串,处理单引号/双引号包裹的参数
|
||||
func shellUnquote(s string) []string {
|
||||
var args []string
|
||||
var cur strings.Builder
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '\'' && !inDouble:
|
||||
inSingle = !inSingle
|
||||
case c == '"' && !inSingle:
|
||||
inDouble = !inDouble
|
||||
case (c == ' ' || c == '\t') && !inSingle && !inDouble:
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
}
|
||||
|
||||
type cmdRecord struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Command string `json:"command"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Workdir string `json:"workdir"`
|
||||
Timeout string `json:"timeout"`
|
||||
Duration string `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
const maxHistory = 100
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
defaultTimeout string
|
||||
maxOutput int
|
||||
mu sync.Mutex
|
||||
history []cmdRecord
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{name: name}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "default_timeout", Type: "string", DisplayName: "默认命令超时",
|
||||
Description: "命令执行的默认超时时间,例如 30s, 1m, 5m(默认 30s)",
|
||||
Default: "30s",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "max_output_bytes", Type: "int", DisplayName: "最大输出字节数",
|
||||
Description: "命令输出的最大字节数,超出部分将被截断(默认 32000)",
|
||||
Default: "32000",
|
||||
})
|
||||
p.maxOutput = 32000
|
||||
if v, _ := s.Settings().Get("max_output_bytes"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
|
||||
}
|
||||
}
|
||||
}
|
||||
p.defaultTimeout = "30s"
|
||||
if v, _ := s.Settings().Get("default_timeout"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
p.defaultTimeout = s
|
||||
}
|
||||
}
|
||||
|
||||
s.RegisterTool("cmd_run", sdk.ToolDef{
|
||||
Name: "cmd_run",
|
||||
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。",
|
||||
NoMemory: true,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"command": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要执行的命令",
|
||||
},
|
||||
"timeout": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "超时时间,例如 10s, 1m, 30s(默认 30s)",
|
||||
},
|
||||
"workdir": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "工作目录(可选,默认由 core.agent.workdir 配置决定)",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
// 执行体只依赖入参;唯一共享 p.history 由 recordCmd 加 p.mu 保护
|
||||
ParallelSafe: true,
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
command, _ := args["command"].(string)
|
||||
if command == "" {
|
||||
return map[string]interface{}{"error": "command is required"}, nil
|
||||
}
|
||||
|
||||
timeoutStr, _ := args["timeout"].(string)
|
||||
if timeoutStr == "" {
|
||||
timeoutStr = p.defaultTimeout
|
||||
}
|
||||
timeout, err := time.ParseDuration(timeoutStr)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("invalid timeout %q: %v", timeoutStr, err)}, nil
|
||||
}
|
||||
|
||||
workdir, _ := args["workdir"].(string)
|
||||
if workdir == "" {
|
||||
if sett := s.Settings(); sett != nil {
|
||||
if v, _ := sett.GetCore("core.agent.workdir"); v != nil {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
workdir = str
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tStart := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Linux/Unix 经 /bin/sh -c 执行完整 shell 语法:管道、分号、&&、
|
||||
// 命令替换、heredoc、重定向全部可用。旧实现 shellUnquote 拆词后
|
||||
// 直接 exec,导致 pwd; ls / 变成执行名为 "pwd;" 的程序(exit -1)、
|
||||
// heredoc 被截断——agent 多次反馈"命令解析奇怪"即此。
|
||||
var cmd *exec.Cmd
|
||||
if isWindows {
|
||||
execCmd := "chcp 65001>nul & " + command
|
||||
cmd = exec.CommandContext(ctx, "cmd.exe", "/d", "/c", execCmd)
|
||||
} else {
|
||||
shell := "/bin/sh"
|
||||
if _, err := os.Stat("/bin/bash"); err == nil {
|
||||
shell = "/bin/bash" // bash 支持更完整的语法(数组、[[ ]] 等)
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, shell, "-c", command)
|
||||
}
|
||||
if workdir != "" {
|
||||
cmd.Dir = workdir
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
rec := cmdRecord{Timestamp: tStart, Command: command, Workdir: workdir, Timeout: timeoutStr}
|
||||
exitCode := -1
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
rec.Status = "timeout"
|
||||
rec.Stdout = p.truncateOutput(stdout.String())
|
||||
rec.Stderr = p.truncateOutput(stderr.String())
|
||||
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
|
||||
p.recordCmd(rec)
|
||||
return map[string]interface{}{
|
||||
"status": "timeout",
|
||||
"stdout": rec.Stdout,
|
||||
"stderr": rec.Stderr,
|
||||
"error": fmt.Sprintf("命令执行超时(%s)", timeoutStr),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
if cmd.ProcessState != nil {
|
||||
exitCode = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
|
||||
rec.Status = "ok"
|
||||
rec.Stdout = p.truncateOutput(stdout.String())
|
||||
rec.Stderr = p.truncateOutput(stderr.String())
|
||||
rec.ExitCode = exitCode
|
||||
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
|
||||
p.recordCmd(rec)
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"stdout": rec.Stdout,
|
||||
"stderr": rec.Stderr,
|
||||
"exit_code": exitCode,
|
||||
"command": command,
|
||||
}, nil
|
||||
})
|
||||
|
||||
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
|
||||
if len(ctx.ToolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
tc := ctx.ToolCalls[0]
|
||||
if tc.Name != "cmd_run" {
|
||||
return nil
|
||||
}
|
||||
command, _ := tc.Arguments["command"].(string)
|
||||
if command == "" {
|
||||
return nil
|
||||
}
|
||||
if blocked, reason := isDangerousCommand(command); blocked {
|
||||
ctx.Lock()
|
||||
ctx.Response = &reason
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}, sdk.StageScopeOwnTools)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) recordCmd(r cmdRecord) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.history = append(p.history, r)
|
||||
if len(p.history) > maxHistory {
|
||||
p.history = p.history[len(p.history)-maxHistory:]
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) truncateOutput(s string) string {
|
||||
maxLen := p.maxOutput
|
||||
if maxLen <= 0 {
|
||||
maxLen = 32000
|
||||
}
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s))
|
||||
}
|
||||
return strings.TrimRight(s, "\n")
|
||||
}
|
||||
|
||||
func isDangerousCommand(command string) (bool, string) {
|
||||
pid := os.Getpid()
|
||||
pidStr := fmt.Sprintf("%d", pid)
|
||||
lower := strings.ToLower(command)
|
||||
|
||||
selfTargets := []string{"homed", pidStr}
|
||||
killCmds := []string{"kill ", "killall ", "pkill ", "kill -", "kill -9"}
|
||||
serviceCmds := []string{
|
||||
"systemctl stop homeagent",
|
||||
"systemctl restart homeagent",
|
||||
"systemctl kill homeagent",
|
||||
"service homeagent stop",
|
||||
"service homeagent restart",
|
||||
}
|
||||
|
||||
for _, kp := range killCmds {
|
||||
if !strings.Contains(lower, kp) {
|
||||
continue
|
||||
}
|
||||
for _, t := range selfTargets {
|
||||
if strings.Contains(lower, strings.ToLower(t)) {
|
||||
return true, fmt.Sprintf("命令被拦截:禁止向 homed 进程(pid=%s)发送信号", pidStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sc := range serviceCmds {
|
||||
if strings.Contains(lower, sc) {
|
||||
return true, fmt.Sprintf("命令被拦截:禁止操作 homed 服务进程(pid=%s)", pidStr)
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
Reference in New Issue
Block a user