feat: clipboardsee/clipboardsue 工具 — agent 读写远程设备剪切板

与 screensue/screensee 同模式配对命名:
- clipboardsee: 读取设备剪切板当前内容(用户最近复制的文字)
- clipboardsue: 写入文字到设备剪切板(用户可直接 Ctrl+V 粘贴)

设计要点:
- 协议: homeagent-clipboardsee / homeagent-clipboardsue <文字>
- clipboardsee 回执 output 字段为剪切板文字, 响应含 content/empty 字段
- clipboardsue 响应含 written 字节数 + preview 截断预览(60 rune)
- 隐私提示写入工具描述: 剪切板可能含密码, 仅在用户明确要求时读取
- 典型组合写入描述: clipboardsue + computeruse keypress ctrl+v 自动粘贴
- 未授权/离线/超时完整错误路径

GUI 配套需求(已发群): executeHomeagentCmd 加 case clipboardsee/clipboardsue,
Electron clipboard.readText()/writeText(text) 一行实现。

测试: 端到端读写+参数校验+未授权拒绝 全通过
This commit is contained in:
JianFeeeee
2026-08-21 16:31:23 +08:00
parent cacd9a8572
commit c0e92cceb1
2 changed files with 232 additions and 0 deletions

View File

@ -459,3 +459,104 @@ func TestComputeruseEndToEnd(t *testing.T) {
t.Fatal("type without text should error")
}
}
// ===== clipboardsee / clipboardsue剪切板读写 =====
func TestClipboardEndToEnd(t *testing.T) {
reg := NewRegistry()
token := "test-token-clip"
reg.SetAcceptToken(func(provided string) bool { return provided == token })
dev := &devicectlDevice{reg: reg}
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"clip-dev","name":"剪贴板机","kind":"computer","caps":["cmd"]}}`))
if _, _, err := cli.readMsg(); err != nil {
t.Fatalf("read hello_ack: %v", err)
}
reg.SetAuthorized("clip-dev", true)
// 设备侧响应剪贴板命令
go func() {
for {
op, payload, err := cli.readMsg()
if err != nil {
return
}
if op != 0x1 {
continue
}
var msg map[string]interface{}
if json.Unmarshal(payload, &msg) != nil {
continue
}
if msg["op"] != "cmd" || msg["cmd_type"] != "homeagent" {
continue
}
cmd, _ := msg["command"].(string)
reqID, _ := msg["req_id"].(string)
switch {
case cmd == "clipboardsee":
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "device_id": "clip-dev",
"status": "ok", "output": "https://example.com/copied-link",
}))
case strings.HasPrefix(cmd, "clipboardsue "):
written := strings.TrimPrefix(cmd, "clipboardsue ")
cli.sendText(mustJSON(map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "device_id": "clip-dev",
"status": "ok", "output": "clipboard set: " + written,
}))
}
}
}()
t.Run("clipboardsee_returns_content", func(t *testing.T) {
res, err := dev.Execute("clipboardsee", map[string]interface{}{"device_id": "clip-dev"})
if err != nil {
t.Fatalf("clipboardsee: %v", err)
}
m := res.(map[string]interface{})
if m["content"] != "https://example.com/copied-link" {
t.Fatalf("unexpected content: %v", m["content"])
}
if m["empty"] == true {
t.Fatal("content should not be empty")
}
})
t.Run("clipboardsue_writes_text", func(t *testing.T) {
long := strings.Repeat("你好", 100) // 200 runes验证 preview 截断
res, err := dev.Execute("clipboardsue", map[string]interface{}{"device_id": "clip-dev", "text": long})
if err != nil {
t.Fatalf("clipboardsue: %v", err)
}
m := res.(map[string]interface{})
if m["written"] != len(long) {
t.Fatalf("written mismatch: %v", m["written"])
}
preview, _ := m["preview"].(string)
if !strings.HasSuffix(preview, "...") || len([]rune(preview)) > 64 {
t.Fatalf("preview should be truncated: %q", preview)
}
})
t.Run("clipboardsue_requires_text", func(t *testing.T) {
if _, err := dev.Execute("clipboardsue", map[string]interface{}{"device_id": "clip-dev"}); err == nil {
t.Fatal("missing text should error")
}
})
t.Run("unauthorized_device_rejected", func(t *testing.T) {
reg.SetAuthorized("clip-dev", false)
defer reg.SetAuthorized("clip-dev", true)
if _, err := dev.Execute("clipboardsee", map[string]interface{}{"device_id": "clip-dev"}); err == nil {
t.Fatal("unauthorized should error")
}
})
}

View File

@ -135,6 +135,36 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef {
"required": []interface{}{"device_id", "action"},
},
},
{
Name: "clipboardsee",
Description: "读取一台已授权设备的剪切板当前内容(用户最近复制/剪切的文字)。" +
"与 clipboardsue 配对clipboardsee 是读clipboardsue 是写。" +
"适用场景:用户说「看看我刚复制的东西」「把我复制的链接打开」。" +
"⚡ 隐私敏感:剪切板可能含密码/隐私,仅在用户明确要求时使用。设备必须已授权且在线。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
},
"required": []interface{}{"device_id"},
},
},
{
Name: "clipboardsue",
Description: "把指定文字写入一台已授权设备的剪切板(用户之后可直接 Ctrl+V 粘贴)。" +
"与 clipboardsee 配对clipboardsee 是读clipboardsue 是写。" +
"适用场景:帮用户准备好要粘贴的长文本/链接/代码,避免 computeruse type 逐字输入慢且易错。" +
"典型组合clipboardsue 写入 → 提示用户 Ctrl+V或 clipboardsue + computeruse keypress ctrl+v 自动粘贴。" +
"设备必须已授权且在线。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
"text": map[string]interface{}{"type": "string", "description": "要写入剪切板的内容"},
},
"required": []interface{}{"device_id", "text"},
},
},
{
Name: "deviceinfo",
Description: "探查一台设备接入网关时声明的详细信息与支持能力。" +
@ -165,6 +195,10 @@ func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (int
return d.screensee(args)
case "computeruse":
return d.computeruse(args)
case "clipboardsee":
return d.clipboardsee(args)
case "clipboardsue":
return d.clipboardsue(args)
case "deviceinfo":
return d.info(args)
default:
@ -457,3 +491,100 @@ func (d *devicectlDevice) computeruse(args map[string]interface{}) (interface{},
d.reg.SaveResult(reqID, out)
return out, nil
}
// clipboardCheck 检查设备可操作性(存在/已授权/在线),返回错误或 nil。
func (d *devicectlDevice) clipboardCheck(id, verb string) error {
if id == "" {
return fmt.Errorf("device_id required")
}
m, ok := d.reg.Get(id)
if !ok {
return fmt.Errorf("device %s 不存在", id)
}
if !m.Authorized {
return fmt.Errorf("device %s 未授权,无法%s剪切板请先在设备管理页授权", id, verb)
}
if !m.Online {
return fmt.Errorf("device %s 不在线", id)
}
return nil
}
// clipboardsee 实现 clipboardsee读取设备剪切板当前内容。
// 协议GUI 配套homeagent-clipboardsee → 回执 output 字段为剪切板文字。
func (d *devicectlDevice) clipboardsee(args map[string]interface{}) (interface{}, error) {
id, _ := args["device_id"].(string)
if err := d.clipboardCheck(id, "读取"); err != nil {
return nil, err
}
reqID := newReqID()
if err := d.reg.PushCmd(id, reqID, "clipboardsee", "homeagent"); err != nil {
return nil, fmt.Errorf("下发读取命令失败: %w", err)
}
res, err := d.reg.AwaitResult(reqID, 15*time.Second)
if err != nil {
d.reg.SaveResult(reqID, map[string]interface{}{"accepted": true, "error": err.Error(), "pending": true})
return nil, fmt.Errorf("设备未在超时内回执: %w", err)
}
if res["status"] != "ok" {
errMsg, _ := res["error"].(string)
if errMsg == "" {
errMsg = fmt.Sprintf("status=%v", res["status"])
}
return nil, fmt.Errorf("读取剪切板失败: %s", errMsg)
}
content, _ := res["output"].(string)
out := map[string]interface{}{
"req_id": reqID,
"content": content,
"empty": content == "",
}
d.reg.SaveResult(reqID, out)
return out, nil
}
// clipboardsue 实现 clipboardsue把文字写入设备剪切板。
// 协议GUI 配套homeagent-clipboardsue <文字>,回执 ok 表示已写入。
func (d *devicectlDevice) clipboardsue(args map[string]interface{}) (interface{}, error) {
id, _ := args["device_id"].(string)
text, _ := args["text"].(string)
if text == "" {
return nil, fmt.Errorf("text required要写入剪切板的内容")
}
if err := d.clipboardCheck(id, "写入"); err != nil {
return nil, err
}
reqID := newReqID()
// 命令格式clipboardsue <文字>GUI 端取首个空格后的全部内容作为写入文本)
if err := d.reg.PushCmd(id, reqID, "clipboardsue "+text, "homeagent"); err != nil {
return nil, fmt.Errorf("下发写入命令失败: %w", err)
}
res, err := d.reg.AwaitResult(reqID, 15*time.Second)
if err != nil {
d.reg.SaveResult(reqID, map[string]interface{}{"accepted": true, "error": err.Error(), "pending": true})
return nil, fmt.Errorf("设备未在超时内回执: %w", err)
}
if res["status"] != "ok" {
errMsg, _ := res["error"].(string)
if errMsg == "" {
errMsg = fmt.Sprintf("status=%v", res["status"])
}
return nil, fmt.Errorf("写入剪切板失败: %s", errMsg)
}
out := map[string]interface{}{
"req_id": reqID,
"written": len(text),
"preview": truncateForPreview(text, 60),
}
d.reg.SaveResult(reqID, out)
return out, nil
}
// truncateForPreview 截断长文本用于回执预览。
func truncateForPreview(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "..."
}