fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)
This commit is contained in:
JianFeeeee
2026-10-17 16:34:29 +08:00
parent c2474f57a2
commit 0111ac3a7a
36 changed files with 5059 additions and 227 deletions

View File

@ -17,15 +17,15 @@ import (
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
apiKey string
name string
sdk *sdk.PluginSDK
client *http.Client
apiKey string
provider string
model string
size string
baseURL string
dataDir string // <data>/ai_images:生成本地图片存放目录
model string
size string
baseURL string
dataDir string // <data>/ai_images:生成本地图片存放目录
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -119,12 +119,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "base_url", Default: "", Type: "string",
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
Category: "ai_image",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "provider", Default: "openai", Type: "string",
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
Category: "ai_image",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "model", Default: "dall-e-3", Type: "string",
@ -143,17 +143,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.size = getSetting(s.Settings(), "size", "1024x1024")
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
}
}
}
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
tp := p.name + "_"
s.RegisterTool(tp+"generate", sdk.ToolDef{
@ -170,12 +170,12 @@ os.MkdirAll(p.dataDir, 0755)
},
}, p.handleGenerate)
fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model)
log.Printf("[%s] started (provider=%s, model=%s)", p.name, p.provider, p.model)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -285,7 +286,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err)
}
p.loadEvents()
@ -401,7 +402,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.wg.Add(1)
go p.remindLoop()
fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events))
log.Printf("[%s] started (%d events)", p.name, len(p.events))
return nil
}
@ -409,7 +410,7 @@ func (p *Plugin) Stop() error {
p.remindTicker.Stop()
close(p.stopCh)
p.wg.Wait()
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}
@ -611,9 +612,9 @@ func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
log.Printf("[calendar] onRemove cleanup: %v", err)
} else {
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
log.Printf("[calendar] onRemove removed %s", p.eventsFile())
}
}

View File

@ -1,49 +1,62 @@
# editdoc · Office 文档编辑
# editdoc · 全能办公
编辑 `.docx` / `.xlsx` / `.pptx` 内容:查找替换、改单元格、插行。
对 `.docx` / `.xlsx` / `.pptx` / `.xls` / `.csv` / `.md` / `.txt` 做**新建、读取、编辑、转换**。
> ⚠️ **版本说明**:本目录是 **v1.0.0**,只有 `edit_document` 一个工具。
> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt)
> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。
> **版本**:v2.0.0(全能办公)。较 v1.0.0 新增 `read_document`、`create_document`、
> `create_from_template`、`convert_document`、`office_check` 五个工具;
> `edit_document` 的名字与全部 v1 参数**保持不变**,老调用不会破。
## 工具
| 工具 | 说明 |
| 工具 | 作用 |
|---|---|
| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** |
参数:
| 参数 | 说明 |
|---|---|
| `file` | 文档路径(必填) |
| `operation` | `replace_text`(查找替换)/ `set_cell`(设置单元格)/ `insert_row`(插入行)(必填) |
| `target` | 要查找的文本(`replace_text` 用) |
| `replacement` | 替换为的文本(`replace_text` 用) |
| `sheet` | 工作表名(xlsx 可选) |
| `row` | 行号(`set_cell` / `insert_row` 用) |
| `col` | 列号(`set_cell` 用) |
| `value` | 单元格值(`set_cell` 用) |
编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。
| `read_document` | 读为结构化 JSON(docx 段落+表格+页眉页脚 / xlsx 逐表逐格含公式与计算值 / pptx 逐页 / xls / csv / md / txt),大文件可 `offset`/`limit` 分段 |
| `create_document` | 从零新建。docx 支持 Markdown 正文;xlsx 支持多表(表名+表头+行);pptx 用 `##` 分页 |
| `create_from_template` | 复制模板再套用替换 |
| `edit_document` | 编辑既有文件(v1 八参数 + 查找替换/正则大小写/第 N 处/表格与单元格/图片/样式/幻灯片/`dry_run`) |
| `convert_document` | 格式转换(docx↔md/pdf、xlsx↔csv 等,借助 soffice / pandoc) |
| `office_check` | 自检:依赖库可用性与版本、外部工具、格式×能力矩阵 |
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` |
| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** |
| `script_path` | 空 | `edit_doc.py` 的绝对路径。**留空即推荐值** —— 用内嵌脚本;仅当该文件含 v2 协议标记时才被采用,否则自动忽略并回退内嵌 |
| `venv_python` | 空 | 执行脚本的 Python 解释器(需装 python-docx / openpyxl / python-pptx)。**必须配置** |
| `timeout_sec` | 60 | 单次调用超时;大文件或格式转换(soffice 首次启动慢)建议调到 180 |
## 工作原理
本插件是 Go 写的薄壳:把参数序列化成 JSON,交给 Python 脚本 `edit_doc.py` 执行实际文档操作。
文档解析依赖 Python 侧的库(python-docx / openpyxl / python-pptx 之类),所以:
Go 侧只做参数编排与结构化返回,全部业务逻辑在 Python 脚本 `edit_doc.py` 里。
- **需要自备 `edit_doc.py`**:它不在本目录里。
- 用 `venv_python` 指向装了这些库的解释器,避免污染系统 Python。
**脚本是内嵌的**(`//go:embed embed/edit_doc.py`),原因:`hmapdev` 打包器只把
`plugin.json` + `plugin.bin` 放进 `.hmap`,**不打包任何资源文件**。若让脚本单独
存在于 `plugin_data/`,就会出现「Go 侧升级了、脚本忘了换」的版本漂移。
内嵌 + 内容指纹校验使两侧永远同版本:
1. 首次调用时把内嵌脚本释放到 `DataDir()/runtime/edit_doc.py`,并写 `.stamp` 指纹;
2. 插件升级后指纹变化 → 自动重新释放;
3. `script_path` 显式指向的文件**仅当含 `EDITDOC_PROTOCOL = "v2"` 标记时**才被采用
(便于运维临时覆盖调试);否则忽略并在返回体里带 `script_path_ignored`,让降级可见。
**业务脚本只有一份**:`embed/edit_doc.py`。不要再添加同名的顶层副本 ——
它不会被 embed,改了不生效,而两份同名文件是典型的漂移源(见
`TestOnlyOneBusinessScript`)。
## 依赖
Python 侧:`python-docx`、`openpyxl`、`python-pptx`(`.xls` 另需 `xlrd`)。
外部工具(可选,转换能力用):`soffice`(LibreOffice)、`pandoc`。
用 `office_check` 随时查看本机实际可用的能力矩阵。
## 构建
```bash
hmapdev build
```
## 测试
```bash
go test ./...
```

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,18 @@ module editdoc
go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
require github.com/JianFeeeee/homeagentsdk v1.4.0
replace github.com/JianFeeeee/homeagentsdk => ../../
replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

View File

@ -1,5 +1,3 @@
//go:build !windows || !cgo
package main
import (

View File

@ -1,15 +1,23 @@
{
"name": "editdoc",
"name_zh": "文档编辑",
"name_zh": "全能办公",
"name_en": "Document Editor",
"version": "1.0.0",
"description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。",
"version": "2.0.0",
"description": "全能办公插件:新建/读取/编辑/转换 docx·xlsx·pptx·md·csv·txt(基于 python-docx / openpyxl / python-pptx / soffice / pandoc)",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["editdoc", "office", "document"],
"entry": "plugin.bin",
"tags": [
"editdoc",
"office",
"document",
"excel",
"word",
"powerpoint"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}
"source_dirs": [],
"sdk": "1.4.0"
}

View File

@ -2,21 +2,60 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// editDocPy 内嵌 Python 后端脚本。
//
// 为何必须内嵌:hmapdev 的打包器只把 plugin.json + plugin.bin 放进 .hmap
// (加上可选的 README/LICENSE),**不打包任何资源文件**。
// 而本插件的全部业务逻辑就在 edit_doc.py 里,若让它单独存在于
// plugin_data/editdoc/,就会出现「Go 侧升级了、脚本忘了换」的版本漂移
// (mc 插件就踩过这个坑:工具注册了但子脚本还是旧的,报未知命令)。
// 内嵌 + 内容指纹校验可以让两侧永远同版本。
//
//go:embed embed/edit_doc.py
var embeddedScript []byte
// scriptStamp 返回内嵌脚本的内容指纹。
func scriptStamp() string {
h := sha256.Sum256(embeddedScript)
return hex.EncodeToString(h[:])[:16]
}
// scriptProtocolMarker 是 edit_doc.py 里的 v2 协议标记(见 embed/edit_doc.py 顶部)。
// resolveScript 靠它识别「script_path 指向的是不是 v2 脚本」,防止旧脚本被误用。
const scriptProtocolMarker = `EDITDOC_PROTOCOL = "v2"`
// Plugin 是 editdoc v2.0.0(全能办公)插件。
//
// 设计要点(与 v1.0.0 的关系):
// - edit_document 工具名与 file/operation/target/replacement/sheet/row/col/value
// 参数语义**完全保留**,老调用不会破。
// - 全部实现委托给同目录下的 edit_doc.py,Go 侧只做参数编排 + 结构化返回。
// - 所有子命令统一走 runScript,错误一律结构化(ok:false + error + hint),不 panic。
type Plugin struct {
name string
sdk *sdk.PluginSDK
scriptPath string
venvPython string
timeoutSec int
// ignoredScriptPath 记录「配置里的 script_path 因不是 v2 脚本而被忽略」的路径。
// 非空时 runScript 会在返回体里带上 script_path_ignored,让上层看得见这次降级。
ignoredScriptPath string
}
func (p *Plugin) Name() string { return p.name }
@ -25,138 +64,469 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
// ---- 配置项(v1 的 script_path / venv_python 原样保留,新增 timeout_sec)----
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "script_path", Default: "", Type: "string",
DisplayName: "编辑脚本路径",
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
Category: p.name,
DisplayName: "办公脚本路径",
Description: "edit_doc.py 的绝对路径;留空(推荐)时使用 DataDir()/runtime/edit_doc.py(升级时按指纹自动更新)。" +
"仅当该文件含 v2 协议标记时才会被采用,否则自动忽略并回退内嵌脚本",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "venv_python", Default: "", Type: "string",
DisplayName: "venv Python 解释器",
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错",
Description: "执行 edit_doc.py 使用的 Python 解释器(需装有 python-docx/openpyxl/python-pptx);必须配置",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout_sec", Default: 60, Type: "int",
DisplayName: "脚本超时(秒)",
Description: "单次调用 edit_doc.py 的超时上限;大文件或格式转换(soffice 首次启动慢)建议调到 180",
Category: p.name, Min: 5, Max: 900,
})
if v, err := s.Settings().Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = str
}
}
if v, err := s.Settings().Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = str
}
}
s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document",
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
NoMemory: true,
p.reloadSettings()
// ---- 工具注册 ----
// 工具名不带插件名前缀:v1 的 edit_document 就是无前缀的,保持一致性。
s.RegisterTool("read_document", sdk.ToolDef{
Name: "read_document",
Description: "读取办公文档内容并返回结构化数据。支持 .docx(段落+表格+页眉页脚)、" +
".xlsx/.xlsm(逐表逐格,公式与计算值都给)、.pptx(逐页文本框与表格)、.xls、.csv/.md/.txt。" +
"大文件用 offset/limit 分段读。改文件前先用它看内容。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "文档绝对路径"},
"sheet": map[string]interface{}{"type": "string", "description": "xlsx:只读某张表(可选)"},
"offset": map[string]interface{}{"type": "integer", "description": "分段读的起始偏移(docx=块序号, xlsx/pptx=页/表序号),默认 0"},
"limit": map[string]interface{}{"type": "integer", "description": "本次最多返回多少段,默认全部"},
"formulas": map[string]interface{}{"type": "boolean", "description": "xlsx 是否附带公式的计算值,默认 true"},
},
"required": []string{"file"},
},
}, p.handleRead)
s.RegisterTool("create_document", sdk.ToolDef{
Name: "create_document",
Description: "从零新建办公文档。支持 .docx(title+content,content 支持 Markdown:# 标题、**粗体**、*斜体*、- 列表)、" +
".xlsx(sheets 多表:表名+表头+行数据)、.pptx(title+content,## 分页)、.md、.csv、.txt。" +
"目录不存在会自动创建;文件已存在时需传 overwrite=true 才会覆盖(覆盖前自动 .bak 留档)。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "要创建的文件绝对路径(扩展名决定格式)"},
"title": map[string]interface{}{"type": "string", "description": "标题(docx/pptx/md)"},
"subtitle": map[string]interface{}{"type": "string", "description": "副标题(pptx 首页)"},
"content": map[string]interface{}{"type": "string", "description": "正文内容(docx/md/txt 支持 Markdown;pptx 用 ## 分页)"},
"sheets": map[string]interface{}{"type": "array", "description": "xlsx 多表:[{name, headers, rows}]", "items": map[string]interface{}{"type": "object"}},
"headers": map[string]interface{}{"type": "array", "description": "单表模式的表头(xlsx/csv)", "items": map[string]interface{}{"type": "string"}},
"rows": map[string]interface{}{"type": "array", "description": "单表模式的数据行(xlsx/csv)", "items": map[string]interface{}{"type": "array"}},
"tables": map[string]interface{}{"type": "array", "description": "docx 追加表格:[{headers, rows}]", "items": map[string]interface{}{"type": "object"}},
"overwrite": map[string]interface{}{"type": "boolean", "description": "文件已存在时是否覆盖,默认 false(报错)"},
},
"required": []string{"file"},
},
}, p.handleCreate)
s.RegisterTool("create_from_template", sdk.ToolDef{
Name: "create_from_template",
Description: "基于已有文件复制一份再套用替换(模板场景)。先 shutil.copy2 复制,再对 docx/xlsx/文本做文本替换。" +
"replacements 是 {\"旧文本\":\"新文本\"} 映射;也可用 target/replacement 只替换一处。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"template": map[string]interface{}{"type": "string", "description": "模板文件绝对路径"},
"file": map[string]interface{}{"type": "string", "description": "新文件绝对路径"},
"replacements": map[string]interface{}{"type": "object", "description": "替换映射 {\"旧\":\"新\"}"},
"target": map[string]interface{}{"type": "string", "description": "单处替换的原文本(可选)"},
"replacement": map[string]interface{}{"type": "string", "description": "单处替换的新文本(可选)"},
"overwrite": map[string]interface{}{"type": "boolean", "description": "目标已存在时是否覆盖"},
},
"required": []string{"template", "file"},
},
}, p.handleCreateFromTemplate)
// ---- 兼容性红线:工具名 + 参数与 v1.0.0 完全一致 ----
s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document",
Description: "编辑 Office 文档(会覆盖原文件,编辑前自动 .bak 留档)。支持 .docx / .xlsx / .xlsm / .pptx。" +
"operation 可选:replace_text(查找替换,支持 regex/case_sensitive/nth)、set_cell、insert_row、" +
"delete_row、insert_paragraph、append_paragraph、set_style、" +
"insert_image、insert_table、merge_cells、add_sheet、delete_sheet、rename_sheet。" +
"注意:pptx 无插入行 API,insert_row 在 pptx 上语义为「表格末尾追加一行」;merge_cells 不支持 pptx。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
// v1 原有 8 个参数,语义不变
"file": map[string]interface{}{"type": "string", "description": "文档文件路径"},
"operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"},
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"},
"operation": map[string]interface{}{"type": "string", "description": "操作(见工具描述)"},
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text);set_style 时为空表示全文"},
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"},
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"},
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx)"},
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row/delete_row)"},
"col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"},
"value": map[string]interface{}{"type": "string", "description": "单元格值(set_cell)"},
"value": map[string]interface{}{"type": "string", "description": "写入值(set_cell/add_sheet/rename_sheet/段落文本)"},
// v2 扩展参数
"regex": map[string]interface{}{"type": "boolean", "description": "target 按正则解释,默认 false"},
"case_sensitive": map[string]interface{}{"type": "boolean", "description": "是否区分大小写,默认 true"},
"nth": map[string]interface{}{"type": "integer", "description": "只替换第 N 处(1 基),默认全部替换"},
"count": map[string]interface{}{"type": "integer", "description": "delete_row 删除几行,默认 1"},
"index": map[string]interface{}{"type": "integer", "description": "insert_paragraph 的插入位置(段落序号)"},
"row2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束行"},
"col2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束列"},
"table": map[string]interface{}{"type": "integer", "description": "docx 表格序号(merge_cells/insert_table),从 0 开始"},
"headers": map[string]interface{}{"type": "array", "description": "insert_table 的表头", "items": map[string]interface{}{"type": "string"}},
"rows": map[string]interface{}{"type": "array", "description": "insert_table 的数据行", "items": map[string]interface{}{"type": "array"}},
"image": map[string]interface{}{"type": "string", "description": "insert_image 的图片路径"},
"width_inch": map[string]interface{}{"type": "number", "description": "insert_image 的宽度(英寸)"},
"slide": map[string]interface{}{"type": "integer", "description": "pptx 页号(从 1 开始)"},
"size": map[string]interface{}{"type": "number", "description": "set_style 字号(磅)"},
"bold": map[string]interface{}{"type": "boolean", "description": "set_style 加粗"},
"italic": map[string]interface{}{"type": "boolean", "description": "set_style 斜体"},
"color": map[string]interface{}{"type": "string", "description": "set_style 字体颜色(如 FF0000)"},
"bg_color": map[string]interface{}{"type": "string", "description": "xlsx set_style 背景色"},
"align": map[string]interface{}{"type": "string", "description": "set_style 对齐:left/center/right/justify"},
"dry_run": map[string]interface{}{"type": "boolean", "description": "只校验不改动"},
},
"required": []string{"file", "operation"},
},
}, p.handleEditDocument)
}, p.handleEdit)
s.RegisterTool("convert_document", sdk.ToolDef{
Name: "convert_document",
Description: "办公文档格式转换。md→docx/html/pdf(pandoc)、xlsx→csv(openpyxl,多表自动分文件)、" +
"pptx→txt/md(结构化抽取)、office→pdf(soffice)。缺外部依赖时会在返回里点名缺哪个,不静默失败。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "源文件绝对路径"},
"to": map[string]interface{}{"type": "string", "description": "目标格式:docx / pdf / html / csv / txt / md"},
"output": map[string]interface{}{"type": "string", "description": "输出路径(可选,默认同名换扩展名)"},
"sheet": map[string]interface{}{"type": "string", "description": "xlsx→csv 时指定工作表(可选,默认全部)"},
"overwrite": map[string]interface{}{"type": "boolean", "description": "输出已存在时覆盖"},
"timeout": map[string]interface{}{"type": "integer", "description": "转换超时(秒),默认 120"},
},
"required": []string{"file", "to"},
},
}, p.handleConvert)
s.RegisterTool("office_check", sdk.ToolDef{
Name: "office_check",
Description: "办公能力自检(独立可调用,不依赖真实文件)。返回:Python 解释器版本、每个依赖库" +
"(python-docx/openpyxl/python-pptx/xlrd/markdown)的可用性与版本、外部工具(soffice/pandoc)、" +
"以及格式×能力矩阵。换机器或升级插件后可用它一条命令验收。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{},
},
}, p.handleCheck)
// 首次启动就把脚本释放好,让 office_check 立刻可用(也便于运维确认落盘位置)
if sp, err := p.resolveScript(); err == nil {
fmt.Fprintf(os.Stderr, "[%s] 已就绪(脚本: %s, 指纹: %s)\n", p.name, sp, scriptStamp())
if p.ignoredScriptPath != "" {
fmt.Fprintf(os.Stderr, "[%s] 注意:script_path=%s 已被忽略(非 v2 脚本),当前使用内嵌脚本\n",
p.name, p.ignoredScriptPath)
}
} else {
fmt.Fprintf(os.Stderr, "[%s] 脚本释放失败: %v\n", p.name, err)
}
return nil
}
func (p *Plugin) Stop() error { return nil }
func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) {
file, _ := args["file"].(string)
if file == "" {
return nil, fmt.Errorf("file is required")
func (p *Plugin) reloadSettings() {
s := p.sdk.Settings()
if v, err := s.Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = strings.TrimSpace(str)
}
}
operation, _ := args["operation"].(string)
if operation == "" {
return nil, fmt.Errorf("operation is required")
if v, err := s.Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = strings.TrimSpace(str)
}
}
if _, err := os.Stat(file); os.IsNotExist(err) {
return map[string]interface{}{
"content": fmt.Sprintf("文件不存在: %s", file),
}, nil
p.timeoutSec = 60
if v, err := s.Get("timeout_sec"); err == nil {
switch n := v.(type) {
case float64:
if n > 0 {
p.timeoutSec = int(n)
}
case int64:
if n > 0 {
p.timeoutSec = int(n)
}
case int:
if n > 0 {
p.timeoutSec = n
}
}
}
pyArgs := map[string]interface{}{}
if v, ok := args["target"]; ok {
pyArgs["target"] = v
}
if v, ok := args["replacement"]; ok {
pyArgs["replacement"] = v
}
if v, ok := args["sheet"]; ok {
pyArgs["sheet"] = v
}
if v, ok := args["row"]; ok {
pyArgs["row"] = v
}
if v, ok := args["col"]; ok {
pyArgs["col"] = v
}
if v, ok := args["value"]; ok {
pyArgs["value"] = v
}
pyArgsJSON, _ := json.Marshal(pyArgs)
scriptPath := p.scriptPath
if scriptPath == "" {
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
}
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
}
if p.venvPython == "" {
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
}
if _, err := os.Stat(p.venvPython); err != nil {
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
}
var out bytes.Buffer
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("edit document: %w", err)
}
var result struct {
Ok bool `json:"ok"`
Error string `json:"error,omitempty"`
Count int `json:"count,omitempty"`
}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("编辑完成,输出: %s", out.String()),
"file": file,
}, nil
}
if !result.Ok {
return map[string]interface{}{
"content": fmt.Sprintf("编辑失败: %s", result.Error),
}, nil
}
msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file)
if result.Count > 0 {
msg += fmt.Sprintf("\n共处理 %d 处", result.Count)
}
return map[string]interface{}{
"content": msg,
"file": file,
}, nil
}
// resolveScript 返回 edit_doc.py 的可用路径。
//
// 优先级:
// 1. 配置 script_path 指向的文件,且**含 v2 协议标记**(便于运维手动覆盖调试)
// 2. DataDir()/runtime/edit_doc.py(内嵌脚本释放位置,指纹驱动的自动更新)
//
// 关键防呆:script_path 指向的文件若存在但**不含** v2 协议标记,说明它是旧版脚本
// (v1 的 argv 约定是 <file> <op> <json>,与 v2 的 <cmd> <json> 完全不同)。
// 若照样采用,6 个工具会全部报「文件不存在: create/read/edit/...」,等于把线上打挂。
// 因此这里主动忽略它、回退内嵌脚本,并置 p.ignoredScriptPath 让调用方能看到。
func (p *Plugin) resolveScript() (string, error) {
p.ignoredScriptPath = ""
if p.scriptPath != "" {
if data, err := os.ReadFile(p.scriptPath); err == nil {
if bytes.Contains(data, []byte(scriptProtocolMarker)) {
return p.scriptPath, nil
}
// 存在但不是 v2 脚本:忽略,回退内嵌(绝不能让它把工具打挂)
p.ignoredScriptPath = p.scriptPath
fmt.Fprintf(os.Stderr,
"[%s] 忽略 script_path=%s:缺少 v2 协议标记 %q,判定为旧版脚本;改用内嵌脚本\n",
p.name, p.scriptPath, scriptProtocolMarker)
}
}
// 释放内嵌脚本
dir := ""
if p.sdk != nil {
dir = p.sdk.Settings().DataDir()
}
if dir == "" {
return "", fmt.Errorf("拿不到插件数据目录(Settings().DataDir() 为空),且 script_path 未指向有效文件")
}
rt := filepath.Join(dir, "runtime")
if err := os.MkdirAll(rt, 0755); err != nil {
return "", fmt.Errorf("创建运行时目录失败: %w", err)
}
jsPath := filepath.Join(rt, "edit_doc.py")
stampPath := filepath.Join(rt, ".stamp")
want := scriptStamp()
if got, err := os.ReadFile(stampPath); err == nil && string(got) == want {
if _, e := os.Stat(jsPath); e == nil {
return jsPath, nil
}
}
if err := os.WriteFile(jsPath, embeddedScript, 0644); err != nil {
return "", fmt.Errorf("释放内嵌脚本失败: %w", err)
}
_ = os.WriteFile(stampPath, []byte(want), 0644)
return jsPath, nil
}
// resolvePython 返回可用的 python 解释器(配置为空时回退到 PATH 里的 python3)。
func (p *Plugin) resolvePython() (string, error) {
py := p.venvPython
if py == "" {
if cand, err := exec.LookPath("python3"); err == nil {
py = cand
}
}
if py == "" {
return "", fmt.Errorf("venv_python 未配置且 PATH 中找不到 python3")
}
if _, err := os.Stat(py); err != nil {
return "", fmt.Errorf("Python 解释器不存在: %s", py)
}
return py, nil
}
// scriptError 是结构化错误体,直接透传给上层 LLM。
type scriptError struct {
Error string `json:"error"`
Hint string `json:"hint"`
}
// runScript 执行 edit_doc.py <cmd> <argsJSON>,返回解析后的 map。
//
// 统一错误处理:脚本缺失/解释器缺失/超时/非 JSON 输出,都转成带 hint 的结构化返回,
// 而不是抛裸 error(v1 是直接 return nil, err,上层只能看到一句无上下文的失败)。
func (p *Plugin) runScript(cmd string, args map[string]interface{}) (map[string]interface{}, error) {
p.reloadSettings() // 支持改配置后无需重载插件
scriptPath, err := p.resolveScript()
if err != nil {
return nil, fmt.Errorf("%s;请在插件配置 script_path 指定 edit_doc.py 的绝对路径", err)
}
py, err := p.resolvePython()
if err != nil {
return nil, fmt.Errorf("%s;请在插件配置 venv_python 指定 python 解释器", err)
}
argsJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("参数序列化失败: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(p.timeoutSec)*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
c := exec.CommandContext(ctx, py, scriptPath, cmd, string(argsJSON))
c.Stdout = &stdout
c.Stderr = &stderr
runErr := c.Run()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("脚本执行超时(%d 秒);大文件或格式转换可调高 timeout_sec 配置",
p.timeoutSec)
}
out := strings.TrimSpace(stdout.String())
if out == "" {
msg := strings.TrimSpace(stderr.String())
if runErr != nil {
return nil, fmt.Errorf("脚本无输出且退出异常: %v; stderr: %s", runErr, truncate(msg, 300))
}
return nil, fmt.Errorf("脚本无输出(stderr: %s)", truncate(msg, 300))
}
// 脚本只保证最后一行是 JSON(soffice/pandoc 可能往 stdout 混东西)
line := out
if i := strings.LastIndex(out, "\n"); i >= 0 {
line = strings.TrimSpace(out[i+1:])
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
return nil, fmt.Errorf("脚本输出不是合法 JSON: %s; 原文: %s",
err, truncate(out, 300))
}
if se := strings.TrimSpace(stderr.String()); se != "" && runErr != nil {
m["stderr"] = truncate(se, 300)
}
// script_path 因非 v2 脚本被忽略时,把这次降级明确告知上层
if p.ignoredScriptPath != "" {
m["script_path_ignored"] = p.ignoredScriptPath
m["script_path_ignored_reason"] = "该文件缺少 v2 协议标记,判定为旧版脚本;本次已改用内嵌脚本"
}
return m, nil
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// respond 把脚本返回整形成工具返回值:ok:true 时给 content/summary,
// ok:false 时给 isError + error + hint。
func respondOffice(m map[string]interface{}) (interface{}, error) {
if ok, _ := m["ok"].(bool); !ok {
out := map[string]interface{}{
"isError": true,
"content": "操作失败: " + str(m["error"]),
"error": m["error"],
"hint": m["hint"],
}
for _, k := range []string{"path", "exists", "missing", "format", "cmd",
"script_path_ignored", "script_path_ignored_reason"} {
if v, has := m[k]; has {
out[k] = v
}
}
return out, nil
}
// 成功:把脚本的结构化字段原样上抛,便于 LLM 二次利用
out := map[string]interface{}{}
for k, v := range m {
out[k] = v
}
if s, has := m["summary"]; has {
out["content"] = s
}
return out, nil
}
func str(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
// ---------------------------------------------------------------- handlers
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("read", args)
if err != nil {
return toolErr("read_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("create", args)
if err != nil {
return toolErr("create_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCreateFromTemplate(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("create_from_template", args)
if err != nil {
return toolErr("create_from_template", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
// v1 兼容:file / operation 必填
if str(args["file"]) == "" {
return map[string]interface{}{"isError": true, "content": "edit_document 需要 file 参数"}, nil
}
if str(args["operation"]) == "" {
return map[string]interface{}{"isError": true, "content": "edit_document 需要 operation 参数"}, nil
}
m, err := p.runScript("edit", args)
if err != nil {
return toolErr("edit_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleConvert(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("convert", args)
if err != nil {
return toolErr("convert_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCheck(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("check", map[string]interface{}{})
if err != nil {
return toolErr("office_check", err), nil
}
return respondOffice(m)
}
func toolErr(tool string, err error) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": fmt.Sprintf("%s 失败:%s", tool, err.Error()),
"error": err.Error(),
}
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil

View File

@ -0,0 +1,178 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// fakeSettings 是 SettingsAPI 的最小实现,只为把 resolveScript 的 DataDir 分支跑通。
// 其余方法测试用不到,给个安全默认值即可。
type fakeSettings struct {
dataDir string
defs []*sdk.ConfigDef
}
func (f *fakeSettings) Get(string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) Set(string, interface{}) error { return nil }
func (f *fakeSettings) SetSecret(string, interface{}) error { return nil }
func (f *fakeSettings) GetSecret(string) (string, error) { return "", nil }
func (f *fakeSettings) List(string) ([]string, error) { return nil, nil }
func (f *fakeSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) SetCore(string, interface{}) error { return nil }
func (f *fakeSettings) ListCore(string) ([]string, error) { return nil, nil }
func (f *fakeSettings) DataDir() string { return f.dataDir }
func (f *fakeSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) SetPlugin(string, string, interface{}) error { return nil }
func (f *fakeSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (f *fakeSettings) RegisterDef(def sdk.ConfigDef) { f.defs = append(f.defs, &def) }
func (f *fakeSettings) Defs(string) []*sdk.ConfigDef { return f.defs }
func (f *fakeSettings) Dump() map[string]interface{} { return nil }
func (f *fakeSettings) Plugins() []string { return nil }
// newTestPlugin 造一个带可用 DataDir 的 Plugin(sdk 走 sdk.New,只注入 SettingsAPI)。
func newTestPlugin(t *testing.T, scriptPath string) *Plugin {
t.Helper()
sett := &fakeSettings{dataDir: t.TempDir()}
s := sdk.New("editdoc", sett, nil, nil, nil, nil)
return &Plugin{
name: "editdoc",
sdk: s,
scriptPath: scriptPath,
venvPython: "/usr/local/bin/python3",
timeoutSec: 60,
}
}
// 内嵌脚本必须自带 v2 协议标记,否则防呆会把自己的脚本也挡掉。
func TestEmbeddedScriptHasProtocolMarker(t *testing.T) {
if !containsMarker(embeddedScript) {
t.Fatalf("内嵌脚本缺少协议标记 %q —— 防呆会把内嵌脚本自己判为旧版", scriptProtocolMarker)
}
// 标记必须与 embed/edit_doc.py 顶部一致(防手改两处不同步)
t.Logf("协议标记=%q 内嵌脚本指纹=%s", scriptProtocolMarker, scriptStamp())
}
// 业务脚本只能有**一份**:embed/edit_doc.py。
//
// 为何钉这一条(2026-10-13 迁入仓库时实测):原工作目录里同时存在
// edit_doc.py 与 embed/edit_doc.py —— 两份逐字节相同的副本,而只有
// embed/ 那份被 //go:embed 引用。同名双副本是典型的漂移源:
// 改了顶层那份(看着像“主文件”)而真正生效的是 embed/ 里的,
// 或反过来;两种都不会报错,只会让行为与源码对不上。
func TestOnlyOneBusinessScript(t *testing.T) {
if _, err := os.Stat("edit_doc.py"); err == nil {
t.Error("存在顶层 edit_doc.py:业务脚本只应有 embed/edit_doc.py 一份(顶部副本未被 embed,改了不生效)")
}
if _, err := os.Stat(filepath.Join("embed", "edit_doc.py")); err != nil {
t.Fatalf("缺少 embed/edit_doc.py: %v", err)
}
}
// script_path 指向 v1 脚本(无标记)→ 必须忽略它、回退内嵌脚本,
// 并记录 ignoredScriptPath。这是防呆的核心契约。
func TestResolveScriptIgnoresV1Script(t *testing.T) {
dir := t.TempDir()
v1 := filepath.Join(dir, "edit_doc_v1.py")
// v1 脚本的真实特征:argv 约定 <file> <op> <json>,且没有 v2 标记
if err := os.WriteFile(v1, []byte(
"import sys\npath = sys.argv[1]\noperation = sys.argv[2]\n"), 0644); err != nil {
t.Fatal(err)
}
p := newTestPlugin(t, v1)
got, err := p.resolveScript()
if err != nil {
t.Fatalf("应回退到内嵌脚本,却报错: %v", err)
}
if got == v1 {
t.Fatal("v1 脚本被采用了 —— 防呆失效,6 个工具会被打挂")
}
if filepath.Base(got) != "edit_doc.py" {
t.Fatalf("应回退到 runtime/edit_doc.py,实际: %s", got)
}
if p.ignoredScriptPath != v1 {
t.Fatalf("ignoredScriptPath 未记录,实际: %q", p.ignoredScriptPath)
}
// 回退出来的脚本必须是内嵌那份(字节一致)
released, err := os.ReadFile(got)
if err != nil {
t.Fatalf("内嵌脚本未落盘: %v", err)
}
if string(released) != string(embeddedScript) {
t.Fatal("落盘的脚本与内嵌脚本字节不一致")
}
t.Logf("v1 被正确忽略,回退到 %s", got)
}
// script_path 指向含标记的 v2 脚本 → 必须采用它,且不记 ignoredScriptPath。
func TestResolveScriptAcceptsV2Script(t *testing.T) {
dir := t.TempDir()
v2 := filepath.Join(dir, "edit_doc_v2.py")
if err := os.WriteFile(v2, []byte(
"#!/usr/bin/env python3\n"+scriptProtocolMarker+"\n"), 0644); err != nil {
t.Fatal(err)
}
p := newTestPlugin(t, v2)
got, err := p.resolveScript()
if err != nil {
t.Fatalf("应直接采用 v2 脚本,却报错: %v", err)
}
if got != v2 {
t.Fatalf("v2 脚本未被采用,实际用了: %s", got)
}
if p.ignoredScriptPath != "" {
t.Fatalf("v2 脚本不应被标记忽略: %q", p.ignoredScriptPath)
}
}
// script_path 为空 → 走内嵌脚本释放,且指纹命中第二次不再重复写。
func TestResolveScriptFallsBackToEmbedded(t *testing.T) {
p := newTestPlugin(t, "")
got, err := p.resolveScript()
if err != nil {
t.Fatalf("空 script_path 应回退内嵌脚本: %v", err)
}
if p.ignoredScriptPath != "" {
t.Fatalf("空配置不该有 ignoredScriptPath: %q", p.ignoredScriptPath)
}
// 第二次调用应复用(指纹 + 文件都在),路径一致
got2, err := p.resolveScript()
if err != nil || got2 != got {
t.Fatalf("第二次解析不稳定: %s vs %s (%v)", got, got2, err)
}
// .stamp 指纹应有内容
stamp, err := os.ReadFile(filepath.Join(filepath.Dir(got), ".stamp"))
if err != nil || string(stamp) != scriptStamp() {
t.Fatalf("指纹文件异常: %q %v", stamp, err)
}
}
// 指向不存在的路径 → 不该报错,应静默回退内嵌脚本。
func TestResolveScriptMissingPathFallsBack(t *testing.T) {
p := newTestPlugin(t, filepath.Join(t.TempDir(), "not-there.py"))
got, err := p.resolveScript()
if err != nil {
t.Fatalf("路径不存在应回退内嵌脚本: %v", err)
}
if p.ignoredScriptPath != "" {
t.Fatalf("不存在的路径不该记为 ignored: %q", p.ignoredScriptPath)
}
if filepath.Base(got) != "edit_doc.py" {
t.Fatalf("应回退到 runtime/edit_doc.py,实际: %s", got)
}
}
// containsMarker 与 resolveScript 内部用的是同一判据,供测试复用。
func containsMarker(b []byte) bool {
m := []byte(scriptProtocolMarker)
for i := 0; i+len(m) <= len(b); i++ {
if string(b[i:i+len(m)]) == string(m) {
return true
}
}
return false
}

View File

@ -159,19 +159,19 @@ const (
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
listenAddr string
napcatURL string
remoteDir string
filesDir string
webhookToken string
ownerIDs []int64
botID int64
botNickname string
dmPolicy string
groupPolicy string
httpClient *http.Client
name string
sdk *sdk.PluginSDK
listenAddr string
napcatURL string
remoteDir string
filesDir string
webhookToken string
ownerIDs []int64
botID int64
botNickname string
dmPolicy string
groupPolicy string
httpClient *http.Client
// sendClient 专用于**发送类**端点(send_private_msg / send_group_msg)。
//
// ★ 为何与查询类分开(2026-10-13 实测):QQ 发送要经过 NapCat 的
@ -188,7 +188,7 @@ type Plugin struct {
//
// 发送超时给足 120s(与插件内 TTS/下载同量级);查询类保持 30s
// (它们是幂等的,超时就是真超时,快速失败更有利于排查)。
sendClient *http.Client
sendClient *http.Client
allowFrom map[int64]struct{}
groupAllowFrom map[int64]struct{}
srv *http.Server
@ -1302,6 +1302,36 @@ func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, q
return matchesToolAllowlist(name, patterns), auth
}
// extraKeyComeFrom 是内核写在帧上的「来源标识」键。
//
// 契约(见内核 internal/agent/core/comefrom.go):
//
// "user" 用户请求在边界上发起(默认语义,本插件的权限门只应管这类)
// "internal" agent 体系内部发起(驻留子/转投/自主续跑)⇒ 不走用户权限模型
// 缺失 旧内核 ⇒ 按 "user" 处理,**行为必须与改动前逐字一致**
//
// 为什么用 Extra 而不是具名字段:内核用 Extra 的通用袋跨进程透传插件自建键
// (proc 桥的 fExtraBag),与既有 `input_source` / `qq_auth` 同一读法。
const extraKeyComeFrom = "comefrom"
// isInternalToolCall 报告本帧的工具调用是否由 agent 内部委派发起。
//
// ★ 保守默认:**只有明确读到 "internal" 才算内部**。
// 缺失、空串、类型不对、未知取值 —— 全部当作 "user"(旧行为)。
// 这个方向不能反:把用户请求误判成 internal 就是**权限门整体失效**,
// 那比"多拦一次内部调用"严重得多。
func isInternalToolCall(ctx *sdk.StageContext) bool {
if ctx == nil || ctx.Extra == nil {
return false
}
v, ok := ctx.Extra[extraKeyComeFrom]
if !ok {
return false
}
s, _ := v.(string)
return s == "internal"
}
// isAtBot checks if the message contains an @-mention of the bot.
func (p *Plugin) isAtBot(msg interface{}) bool {
segments, ok := msg.([]interface{})
@ -1391,6 +1421,37 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
if len(ctx.ToolCalls) == 0 {
return nil
}
// ★ **内部委派的工具调用不走用户权限模型**(2026-10-16)。
//
// 内核在帧上写了 `comefrom`:
// "user" = 用户请求在边界上发起(**本插件要管的就是这类**)
// "internal" = agent 体系内部发起(驻留子/转投/自主续跑)
// 缺失 = 旧内核,按 "user" 处理(保持既有行为,勿改)
//
// ## 为何这是正确的(实测故障 + 语义)
//
// 现场(2026-10-16):驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running
// 但**零产出**,它那一轮 8 个工具(cmd_run/files_ls/notify_parent/…)全被
// 本门拦下。日志实证:每个工具调用前都有
// `[proc] qq stage before_toolcall 改写了 1 个字段`(= 写入了拒绝响应)。
//
// 根因:驻留子的输入**继承自父**(转投刻意保留原事件,否则同步调用方永久
// 挂起),于是 `input_source` 仍是 "qq" ⇒ 本门把驻留子当成"那位 QQ 用户
// 在提请求" ⇒ 按用户白名单判 ⇒ 开发工具一条都不在白名单里 ⇒ 全拒。
//
// 但用户请求**已经过了这道门**(父按真实身份判定过)。驻留子做的是
// **父自己的委派决定**;对已经准入的工作再查一次"用户白名单",既拦掉合法
// 工作、又零安全增益(同一次请求,父已判定)。
//
// 为何不"只跳白名单、仍查硬私有":硬私有(calendar_/email_/memory_…)
// 防的是用户偷看私人数据;驻留子是父的内部执行体,父对它本就有完整
// 权限 —— 再查一遍等于"机器人自己派出去的活被自己拦住",同样荒谬。
//
// 为何放在 `auth.Active` 判断**之前**:internal 与"非本插件来源"在语义上
// 是同一类(都不是"一条 QQ 用户消息"),走同一条放行路径最不容易出错。
if isInternalToolCall(ctx) {
return nil
}
tc := &ctx.ToolCalls[0]
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
if !auth.Active {

View File

@ -725,6 +725,7 @@ func TestNapcatConvertsOneBotFailureToError(t *testing.T) {
// 与我修过的 HTTP 客户端超时是**两条不同路径**:
// - HTTP 超时:client.Post 返回 err(已有告警)
// - NapCat 超时:HTTP 200 + 响应体里带 NTEvent 字样(本次才补)
//
// 只报警「失败」而不说「可能已送出」,模型必重发。
//
// 判据必须穿过 napcat() 真实调用链(单测 isNapcatSendUnconfirmed 测不出
@ -816,3 +817,151 @@ func TestNapcatSendActionTimeoutRegardlessOfServiceName(t *testing.T) {
t.Errorf("服务名不是 sendMsg 的 NTEvent 超时也要套用发送超时语义,实为 %v", err)
}
}
// ===========================================================================
// 「来源标识」(comefrom)与用户权限门的关系(2026-10-16)
//
// 现场故障:驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running 但
// **零产出** —— 它那一轮 8 个工具(cmd_run / files_ls / notify_parent /
// output_send__commondev_* / inputch_note …)**全部**被本插件的
// before_toolcall 拦下。它自己的处理表原文:
//
// 本轮所有需要的工具都被权限策略挡下了,实测被拒的有:
// - cmd_run —— 无法进入工作目录、无法看 git 状态
//
// 日志实证(09:21:25~09:21:40,每个工具调用前都紧跟):
//
// [agent] executing tool: cmd_run (plugin=cmd)
// [proc] qq stage before_toolcall 改写了 1 个字段 ← 写入了拒绝响应
//
// 而同一时段父 main 的工具调用**有 result**、驻留子的**零 result**。
//
// 根因:驻留子的输入继承自父(转投刻意保留原事件),`input_source` 仍是
// "qq",于是本门把“父的内部委派”误当成“那位 QQ 用户新提的请求”,
// 按用户白名单判定 ⇒ 开发工具一条都不在白名单里 ⇒ 全拒。
// ===========================================================================
// ★★★ **核心回归判据**:内部委派的工具调用**不得**被用户权限门拦。
//
// 这条直接覆盖现场故障:用同一份身份(非 owner)、同一个被拒工具(cmd_run),
// 只改 comefrom 一个键 —— internal 必须放行、user 必须拦。
func TestInternalToolCallBypassesUserPermissionGate(t *testing.T) {
// 基准:非 owner 私聊 ⇒ cmd_run 不在白名单 ⇒ 必须被拦
base := func() *sdk.StageContext {
ctx := toolCallContext("cmd_run", nil)
ctx.Extra = map[string]interface{}{}
return ctx
}
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{Active: true, UserID: 2198972886}
ctxUser := base()
if err := p.beforeToolcall(ctxUser); err != nil {
t.Fatal(err)
}
if ctxUser.Response == nil {
t.Fatal("前置条件错:非 owner 调 cmd_run 本应被拦(判据基线不成立)")
}
// ① comefrom=internal(驻留子)⇒ 必须放行
p2 := newPermissionTestPlugin(t)
p2.auth = qqAuthContext{Active: true, UserID: 2198972886}
ctxInternal := base()
ctxInternal.Extra[extraKeyComeFrom] = "internal"
if err := p2.beforeToolcall(ctxInternal); err != nil {
t.Fatal(err)
}
if ctxInternal.Response != nil {
t.Fatalf("★ 内部委派的 cmd_run 仍被用户权限门拦住(缺陷回归): %s", *ctxInternal.Response)
}
// ② 硬私有工具同样放行(驻留子是父的内部执行体,父本就有完整权限)
p3 := newPermissionTestPlugin(t)
p3.auth = qqAuthContext{Active: true, UserID: 2198972886}
ctxHard := toolCallContext("memory_recall", nil)
ctxHard.Extra = map[string]interface{}{extraKeyComeFrom: "internal"}
if err := p3.beforeToolcall(ctxHard); err != nil {
t.Fatal(err)
}
if ctxHard.Response != nil {
t.Fatalf("★ 内部委派的硬私有工具被拦: %s", *ctxHard.Response)
}
}
// ★★★ 判据:**只有**明确 "internal" 才放行 —— 其余一律按用户处理。
//
// 这个方向绝不能反:把用户请求误判成 internal = 权限门整体失效,
// 比"多拦一次内部调用"严重得多。
func TestComeFromOnlyInternalIsTrusted(t *testing.T) {
for _, tc := range []struct {
name string
setup func(m map[string]interface{})
trusted bool
}{
{"internal", func(m map[string]interface{}) { m[extraKeyComeFrom] = "internal" }, true},
{"user", func(m map[string]interface{}) { m[extraKeyComeFrom] = "user" }, false},
{"缺失(旧内核)", func(m map[string]interface{}) {}, false},
{"空串", func(m map[string]interface{}) { m[extraKeyComeFrom] = "" }, false},
{"未知取值", func(m map[string]interface{}) { m[extraKeyComeFrom] = "internal-ish" }, false},
{"大小写不同", func(m map[string]interface{}) { m[extraKeyComeFrom] = "INTERNAL" }, false},
{"类型错(bool)", func(m map[string]interface{}) { m[extraKeyComeFrom] = true }, false},
{"类型错(数字)", func(m map[string]interface{}) { m[extraKeyComeFrom] = 1.0 }, false},
} {
t.Run(tc.name, func(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{Active: true, UserID: 2198972886}
ctx := toolCallContext("cmd_run", nil)
ctx.Extra = map[string]interface{}{}
tc.setup(ctx.Extra)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
blocked := ctx.Response != nil
if tc.trusted && blocked {
t.Errorf("★ 应放行却拦了: %s", *ctx.Response)
}
if !tc.trusted && !blocked {
t.Error("★ 应拦却放行了(权限门失效)")
}
})
}
}
// ★★★ 判据:**owner 的请求行为不变**(comefrom=user 时不得因为改动而放宽)。
//
// 防的是"为了让驻留子通过,把门整体放松"这类错误修法。
func TestComeFromUserKeepsOwnerAndNonOwnerSemantics(t *testing.T) {
// owner + comefrom=user ⇒ 完整放行(含硬私有)
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{Active: true, Owner: true, UserID: 2198972886}
ctx := toolCallContext("calendar_list", nil)
ctx.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("★ owner 的请求被拦(权限门被收紧了): %s", *ctx.Response)
}
// 非 owner + comefrom=user + 硬私有 ⇒ 仍必须拒
p2 := newPermissionTestPlugin(t)
p2.auth = qqAuthContext{Active: true, UserID: 10001}
ctx2 := toolCallContext("calendar_list", nil)
ctx2.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
if err := p2.beforeToolcall(ctx2); err != nil {
t.Fatal(err)
}
if ctx2.Response == nil {
t.Fatal("★ 非 owner 的硬私有调用应仍被拒(改动把门放松了)")
}
// 无身份(auth.Active=false)+ 有 comefrom=user ⇒ 本门不介入(旧行为)
p3 := newPermissionTestPlugin(t)
ctx3 := toolCallContext("cmd_run", nil)
ctx3.Extra = map[string]interface{}{extraKeyComeFrom: "user"}
if err := p3.beforeToolcall(ctx3); err != nil {
t.Fatal(err)
}
if ctx3.Response != nil {
t.Fatalf("非本插件来源不该被本门拦: %s", *ctx3.Response)
}
}

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
@ -119,7 +120,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err)
}
p.loadData()
@ -184,7 +185,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.wg.Add(1)
go p.pollLoop()
fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin)
log.Printf("[%s] started (%d feeds, poll every %dm)", p.name, len(p.feeds), pollMin)
return nil
}
@ -193,7 +194,7 @@ func (p *Plugin) Stop() error {
p.pollTicker.Stop()
p.wg.Wait()
p.saveData()
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}
@ -486,7 +487,7 @@ func (p *Plugin) cleanupData() {
for _, f := range []string{"feeds.json"} {
path := filepath.Join(p.dataDir, f)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
log.Printf("[%s] onRemove cleanup %s: %v", p.name, path, err)
}
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
@ -95,7 +96,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
ctx.Lock()
defer ctx.Unlock()
if len(ctx.ToolResults) > 0 {
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
log.Printf("[%s] stage after_toolcall(own): %s", p.name, ctx.ToolResults[0].Name)
}
return nil
}, sdk.StageScopeOwnTools)
@ -115,25 +116,25 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
return err
}
fmt.Printf("[%s] started\n", p.name)
log.Printf("[%s] started", p.name)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}
type wttrResp struct {
CurrentCondition []struct {
TempC string `json:"temp_C"`
FeelsLikeC string `json:"FeelsLikeC"`
Humidity string `json:"humidity"`
WindspeedKmph string `json:"windspeedKmph"`
Winddir16Point string `json:"winddir16Point"`
Pressure string `json:"pressure"`
Visibility string `json:"visibility"`
WeatherDesc []struct {
TempC string `json:"temp_C"`
FeelsLikeC string `json:"FeelsLikeC"`
Humidity string `json:"humidity"`
WindspeedKmph string `json:"windspeedKmph"`
Winddir16Point string `json:"winddir16Point"`
Pressure string `json:"pressure"`
Visibility string `json:"visibility"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
LocalObsDateTime string `json:"localObsDateTime"`
@ -153,7 +154,7 @@ type wttrResp struct {
}
type wttrDay struct {
Date string `json:"date"`
Date string `json:"date"`
Astronomy []struct {
Sunrise string `json:"sunrise"`
Sunset string `json:"sunset"`
@ -161,8 +162,8 @@ type wttrDay struct {
MaxtempC string `json:"maxtempC"`
MintempC string `json:"mintempC"`
Hourly []struct {
TempC string `json:"tempC"`
WeatherDesc []struct {
TempC string `json:"tempC"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
WindspeedKmph string `json:"windspeedKmph"`