mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
Compare commits
60 Commits
v1.2.0
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 72011bc8bf | |||
| 0111ac3a7a | |||
| c2474f57a2 | |||
| 65d2bd9e61 | |||
| b73a95bb31 | |||
| b2872f3702 | |||
| bd26c2a402 | |||
| 777c3a0ad3 | |||
| 0b497f4876 | |||
| 25bd243662 | |||
| e0ee0c43bb | |||
| d685789988 | |||
| b6a6168e65 | |||
| c7e5d2a446 | |||
| c16411a77d | |||
| 5759e58bad | |||
| 275a054287 | |||
| 56c694cd7c | |||
| dc9e7d0495 | |||
| 29c61f0e07 | |||
| 82e8d9dbac | |||
| 1b8218afa9 | |||
| deeda22650 | |||
| 1d7c330cfc | |||
| bd73a9b241 | |||
| e417c69fc8 | |||
| a176cc3e20 | |||
| 255d6479ad | |||
| 9b6abe1b73 | |||
| 0a6e2b7dc4 | |||
| e97cafc8de | |||
| 5af2a86816 | |||
| 7717bf5ca5 | |||
| db207fd9b7 | |||
| 953fbb2f50 | |||
| 7ef9bc2ad3 | |||
| cfa72df3e9 | |||
| fe1c4cdb09 | |||
| a01fe21ab1 | |||
| f09891f054 | |||
| efb396d7b3 | |||
| 4852d70d77 | |||
| 63b6eafaf0 | |||
| 21221f20c5 | |||
| 11303e3ee4 | |||
| f4f6968987 | |||
| da046b2520 | |||
| 4cb3a0bda4 | |||
| e50bffa34f | |||
| 934eb4da7d | |||
| 4f4a03d368 | |||
| 4482235312 | |||
| 4cf2df5be6 | |||
| ebd700eaf9 | |||
| 7c0b7a1fb0 | |||
| 8c10b7ecc7 | |||
| fcb7490f63 | |||
| 9206353858 | |||
| 83a54f321e | |||
| b93fe6b878 |
251
.github/workflows/ci.yml
vendored
Normal file
251
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# HomeAgent SDK 仓 CI。
|
||||||
|
#
|
||||||
|
# 与 release.yml 分工:本文件管「推送到 main / PR 时的检查」,release.yml 管
|
||||||
|
# 「release/** 推送时的发版」。此前本仓**只有** release.yml —— main 的推送与
|
||||||
|
# PR 完全没有检查,而 SDK 是被外部插件开发者直接依赖的契约面(sdk/plugin.go
|
||||||
|
# 的接口一旦破,所有外部插件编译失败),必须有门。
|
||||||
|
#
|
||||||
|
# 设计原则与主仓一致:**CI 里每条命令都是本地已实测通过的**。
|
||||||
|
# 不写「应该有用来试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态。
|
||||||
|
#
|
||||||
|
# 明确不进 CI 的:
|
||||||
|
# - `hmapdev skill install`(会写 ~/.claude、~/.codex 等**开发者本机目录**)
|
||||||
|
# - 需要内核仓在场的检查(本仓独立可测;sync-lua-sdk.sh 会在缺内核时
|
||||||
|
# 明确 skip 而不是假装成功)
|
||||||
|
# - 任何网络/真机依赖
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, 'release/**']
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# SDK 本身不需要 cgo(hmapdev 与 sdk 包都是纯 Go)—— 与主仓相反。
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
GOFLAGS: -buildvcs=false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── 根模块:SDK 对外接口所在 ──
|
||||||
|
go:
|
||||||
|
name: Go build / vet / test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
|
||||||
|
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
|
||||||
|
# 读根 go.mod 会装 1.21,examples 构建必然失败
|
||||||
|
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
|
||||||
|
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
|
||||||
|
# 所以本地「能过」不代表 CI 能过。
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
- name: go build ./...
|
||||||
|
run: go build ./...
|
||||||
|
- name: go vet ./...
|
||||||
|
run: go vet ./...
|
||||||
|
- name: go test ./...
|
||||||
|
run: go test ./... -count=1 -timeout 10m
|
||||||
|
|
||||||
|
# ── hmapdev 工具链(嵌套 module,根模块的 ./... 不会进入它)──
|
||||||
|
hmapdev:
|
||||||
|
name: hmapdev (${{ matrix.goos }}/${{ matrix.goarch }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
ext: ''
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
ext: ''
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
ext: ''
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
ext: ''
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
ext: '.exe'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
|
||||||
|
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
|
||||||
|
# 读根 go.mod 会装 1.21,examples 构建必然失败
|
||||||
|
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
|
||||||
|
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
|
||||||
|
# 所以本地「能过」不代表 CI 能过。
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# tools/hmapdev 是**独立 module**(有自己的 go.mod)——
|
||||||
|
# 根模块的 `go test ./...` 不会进入它,必须单独跑,否则它的测试
|
||||||
|
# 永远不在 CI 里执行。这是 Go 的模块边界,不是配置问题。
|
||||||
|
- name: go test(嵌套 module)
|
||||||
|
working-directory: tools/hmapdev
|
||||||
|
run: go test ./... -count=1 -timeout 10m
|
||||||
|
|
||||||
|
# main 包在 tools/hmapdev —— 不指定工作目录会在仓库根跑 `go build .`,
|
||||||
|
# 而根目录没有 Go 文件(包在 sdk/ 子目录),报 "no Go files in ..."。
|
||||||
|
- name: 交叉编译
|
||||||
|
working-directory: tools/hmapdev
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
out="hmapdev_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.ext }}"
|
||||||
|
go build -trimpath \
|
||||||
|
-ldflags "-X github.com/JianFeeeee/homeagentsdk/meta.Version=0.0.0-ci" \
|
||||||
|
-o "/tmp/$out" .
|
||||||
|
test -s "/tmp/$out" || { echo "ERROR: 未产出 $out"; exit 1; }
|
||||||
|
printf " ✓ %-28s %s 字节\n" "$out" "$(stat -c %s "/tmp/$out")"
|
||||||
|
file "/tmp/$out"
|
||||||
|
|
||||||
|
# ── 示例插件:发版产物里最容易被使用者直接安装的东西 ──
|
||||||
|
#
|
||||||
|
# 为什么必须验:示例是使用者的模板(拷了就改),且**发版会连同 .hmap 一起发**
|
||||||
|
# —— 插件二进制与内核是协议绑定的(ProtocolVersion + 统一共享内存区魔数),
|
||||||
|
# 示例编不出来就意味着这次发版发不出配套产物。
|
||||||
|
#
|
||||||
|
# ★ 示例**不能**用 `go build` 验:它们是插件(只有 plugin.go、没有 func main),
|
||||||
|
# 必须由 hmapdev 注入 main 包装。直接 go build 会得到
|
||||||
|
# "function main is undeclared in the main package" —— 那不是缺陷,
|
||||||
|
# 是构建方式不对(本地实测确认)。
|
||||||
|
examples:
|
||||||
|
name: Examples (${{ matrix.target }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
# windows 不在列:build-examples.sh 会明确拒绝(协议 2 的统一共享
|
||||||
|
# 内存区未移植到 Windows),那是有意的设计而不是待修缺陷。
|
||||||
|
target: ['linux/amd64', 'darwin/arm64']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
|
||||||
|
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
|
||||||
|
# 读根 go.mod 会装 1.21,examples 构建必然失败
|
||||||
|
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
|
||||||
|
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
|
||||||
|
# 所以本地「能过」不代表 CI 能过。
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 构建 hmapdev(宿主)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd tools/hmapdev
|
||||||
|
go build -o "$GITHUB_WORKSPACE/build/hmapdev_host" .
|
||||||
|
|
||||||
|
# 用发版同一个脚本(package/build-examples.sh),避免 CI 与发版路径分叉。
|
||||||
|
- name: 构建全部示例
|
||||||
|
env:
|
||||||
|
HMAPDEV: ${{ github.workspace }}/build/hmapdev_host
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bash package/build-examples.sh "$TARGET" /tmp/examples
|
||||||
|
echo
|
||||||
|
n=$(find /tmp/examples -name '*.hmap' | wc -l)
|
||||||
|
echo " .hmap 产物数: $n"
|
||||||
|
# 示例目录数应与产物数一致(少一个都要红)
|
||||||
|
want=$(find example -mindepth 2 -maxdepth 2 -name plugin.go | wc -l)
|
||||||
|
echo " 示例源码数: $want"
|
||||||
|
[ "$n" -ge "$want" ] || { echo "ERROR: 产物少于示例数"; exit 1; }
|
||||||
|
for f in /tmp/examples/*.hmap; do
|
||||||
|
printf " %9.1fKB %s\n" \
|
||||||
|
"$(stat -c %s "$f" | awk '{print $1/1024}')" "$(basename "$f")"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 一致性与文档 ──
|
||||||
|
consistency:
|
||||||
|
name: Consistency & docs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# sdk.lua 有**四份**副本(本仓三份 + 内核仓一份),历史上漂移过,
|
||||||
|
# 出现「mock 有、内核没有」的静默失配。此处钉住本仓内的三份。
|
||||||
|
- name: Lua SDK 副本一致性
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
canon=sdk/lua/sdk.lua
|
||||||
|
[ -f "$canon" ] || { echo "ERROR: 缺 $canon"; exit 1; }
|
||||||
|
want=$(sha256sum "$canon" | cut -d' ' -f1)
|
||||||
|
fail=0
|
||||||
|
for f in tools/hmapdev/assets/sdk.lua example/luademo/sdk.lua; do
|
||||||
|
have=$(sha256sum "$f" | cut -d' ' -f1)
|
||||||
|
if [ "$have" = "$want" ]; then
|
||||||
|
echo " ✓ $f"
|
||||||
|
else
|
||||||
|
echo " ✗ $f 与 $canon 不一致" >&2
|
||||||
|
echo " canon=$want" >&2
|
||||||
|
echo " this =$have" >&2
|
||||||
|
echo " 解法:bash scripts/sync-lua-sdk.sh" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$fail" = "0" ] || exit 1
|
||||||
|
|
||||||
|
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
|
||||||
|
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
|
||||||
|
# 读根 go.mod 会装 1.21,examples 构建必然失败
|
||||||
|
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
|
||||||
|
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
|
||||||
|
# 所以本地「能过」不代表 CI 能过。
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# skills/ 会随 SDK store 分发给所有 agent(hmapdev skill install)。
|
||||||
|
# skill list 会读 skills/ 目录并报告可用项 —— 目录结构坏掉会在这里红。
|
||||||
|
- name: 技能目录可被 hmapdev 识别
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# ★ 必须把 hmapdev 建在**仓库内**:resolveSkillsSource() 先从
|
||||||
|
# 可执行文件位置向上找仓库(tools/hmapdev → ../../skills),
|
||||||
|
# 落空才回退到 SDK store 的活跃版本 —— 而 CI 里没有 store,
|
||||||
|
# 于是报 "no active SDK version set"。建到 /tmp 就会落空。
|
||||||
|
mkdir -p build
|
||||||
|
( cd tools/hmapdev && go build -o "$GITHUB_WORKSPACE/build/hmapdev_ci" . )
|
||||||
|
./build/hmapdev_ci skill list
|
||||||
|
./build/hmapdev_ci skill path
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
# 文档站会被部署(deploy-sdk-site.sh)——站点构建坏了此前无从发现。
|
||||||
|
# --strict 把 warning 升级为 error:断链、缺失引用都会红。
|
||||||
|
#
|
||||||
|
# ★ 不能用 yaml.safe_load 校验 mkdocs.yml:它含 mkdocs-material 的
|
||||||
|
# `!!python/name:` 标签(配置 emoji 的官方写法),safe_load 处理不了,
|
||||||
|
# 会报 ConstructorError —— 那是校验方式不对,不是配置有问题。
|
||||||
|
- name: 文档站构建(mkdocs --strict)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 -m pip install --quiet mkdocs-material
|
||||||
|
mkdocs build --strict --site-dir /tmp/site
|
||||||
314
.github/workflows/release.yml
vendored
Normal file
314
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
# HomeAgent SDK 仓发布流水线:release/** 推送即发版。
|
||||||
|
#
|
||||||
|
# 与主仓的 Release 流水线同构(见主仓 .github/workflows/release.yml),
|
||||||
|
# 差异只在产物与打包命令:
|
||||||
|
#
|
||||||
|
# 主仓 → 3 个 deb + 1 个 tar.gz(含 719MB 向量模型)
|
||||||
|
# SDK → hmapdev_{linux,darwin}_{amd64,arm64} + hmapdev_windows_amd64.exe
|
||||||
|
# + SHA256SUMS(约 140MB)
|
||||||
|
#
|
||||||
|
# 产物清单依据:gitcode 上 v1.2.0/v1.3.0 的实际附件(各 6 个),
|
||||||
|
# 以及 docs/git-branching.md §七 的「发版产物清单」。
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['release/**']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_tests:
|
||||||
|
description: '跳过发版前的 go test 门(仅用于已知红的历史维护线)'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
GOFLAGS: -buildvcs=false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
name: Prepare
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.ver.outputs.version }}
|
||||||
|
tag: ${{ steps.ver.outputs.tag }}
|
||||||
|
prerelease: ${{ steps.ver.outputs.prerelease }}
|
||||||
|
exists: ${{ steps.ver.outputs.exists }}
|
||||||
|
skip_tests: ${{ steps.ver.outputs.skip_tests }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# 版本事实源是 meta/meta.go 的 Version,故发版动作 =
|
||||||
|
# 在 release/vX.Y.x 上把它改成目标版本后推送。
|
||||||
|
#
|
||||||
|
# 幂等闸门:tag 已存在 ⇒ 整轮跳过(改文档不会重发版)。
|
||||||
|
#
|
||||||
|
# go test 门可跳过:新旧发布线的测试健康状况不同,硬门会让历史
|
||||||
|
# 维护线完全无法发版。发版人可在**改动 meta 的那个提交**里写
|
||||||
|
# [skip-release-tests] 显式跳过 —— 决定因此可被 git 历史审计。
|
||||||
|
# (标记查在改 meta 的提交上而不是 HEAD:发版提交之后常还会跟
|
||||||
|
# 几个提交,只看 HEAD 会让标记被顶掉、静默失效。)
|
||||||
|
- id: ver
|
||||||
|
name: 读取 meta.Version 并检查 tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
V=$(sed -n 's/^[[:space:]]*Version = "\(.*\)"/\1/p' \
|
||||||
|
meta/meta.go | head -1)
|
||||||
|
if [ -z "$V" ]; then
|
||||||
|
echo "ERROR: 无法从 meta/meta.go 读出 Version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v$V" >> "$GITHUB_OUTPUT"
|
||||||
|
case "$V" in
|
||||||
|
*-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;;
|
||||||
|
*) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;;
|
||||||
|
esac
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/v$V" \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " tag v$V 已存在 —— 跳过发版"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " 将为 v$V 发版"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SKIP="${{ inputs.skip_tests }}"
|
||||||
|
REL_COMMIT=$(git log -1 --format=%H -- meta/meta.go)
|
||||||
|
REL_MSG=$(git log -1 --pretty=%B "$REL_COMMIT")
|
||||||
|
case "$REL_MSG" in
|
||||||
|
*'[skip-release-tests]'*) MARKER=1 ;;
|
||||||
|
*) MARKER=0 ;;
|
||||||
|
esac
|
||||||
|
echo " 发版提交: ${REL_COMMIT:0:12}"
|
||||||
|
if [ "$SKIP" = "true" ] || [ "$MARKER" = "1" ]; then
|
||||||
|
echo "skip_tests=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " ⚠️ **已请求跳过发版前的 go test 门**"
|
||||||
|
else
|
||||||
|
echo "skip_tests=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " 发版前会跑 go test 门"
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build hmapdev
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.exists == 'false'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# 门:go build 是硬门;go test 可在发版 commit 里显式跳过。
|
||||||
|
#
|
||||||
|
# 测试分两处跑 —— tools/hmapdev 是**独立 module**,根模块的
|
||||||
|
# `go test ./...` 不会进入它(这是 Go 的模块边界,不是配置问题)。
|
||||||
|
- name: go build(硬门)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
- name: go test(根模块)
|
||||||
|
if: needs.prepare.outputs.skip_tests != 'true'
|
||||||
|
run: go test ./... -count=1 -timeout 15m
|
||||||
|
|
||||||
|
- name: go test(tools/hmapdev 独立 module)
|
||||||
|
if: needs.prepare.outputs.skip_tests != 'true'
|
||||||
|
working-directory: tools/hmapdev
|
||||||
|
run: go test ./... -count=1 -timeout 15m
|
||||||
|
|
||||||
|
- name: go test 被跳过(显式声明的后果)
|
||||||
|
if: needs.prepare.outputs.skip_tests == 'true'
|
||||||
|
run: |
|
||||||
|
echo "::warning title=go test 门已跳过::本次发版未跑 go test,产物可能建立在单元测试失败的代码上。"
|
||||||
|
|
||||||
|
- name: 打包 hmapdev(5 个平台)
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf build
|
||||||
|
bash package/build.sh all hmapdev
|
||||||
|
echo
|
||||||
|
echo " 产物:"
|
||||||
|
for f in build/*; do
|
||||||
|
printf " %8.1fMB %s\n" \
|
||||||
|
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$(basename "$f")"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 逐个确认 5 个平台都产出了 —— 只查目录非空会漏掉"少了一个平台"。
|
||||||
|
- name: 验证产物齐全
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
missing=0
|
||||||
|
for f in hmapdev_linux_amd64 hmapdev_linux_arm64 \
|
||||||
|
hmapdev_darwin_amd64 hmapdev_darwin_arm64 \
|
||||||
|
hmapdev_windows_amd64.exe; do
|
||||||
|
if [ -s "build/$f" ]; then
|
||||||
|
echo " ✓ $f"
|
||||||
|
else
|
||||||
|
echo " ✗ 缺 $f" >&2
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$missing" = "0" ] || exit 1
|
||||||
|
|
||||||
|
# 校验和必须**全部产物齐全之后**一次算完(边打边算会漏包);
|
||||||
|
# 且只覆盖本批产物 —— build/ 可能残留上次的,故先清干净再建。
|
||||||
|
- name: 生成 SHA256SUMS
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /tmp/out
|
||||||
|
cp build/hmapdev_linux_amd64 build/hmapdev_linux_arm64 \
|
||||||
|
build/hmapdev_darwin_amd64 build/hmapdev_darwin_arm64 \
|
||||||
|
build/hmapdev_windows_amd64.exe /tmp/out/
|
||||||
|
cd /tmp/out
|
||||||
|
sha256sum hmapdev_* > SHA256SUMS
|
||||||
|
echo " SHA256SUMS:"
|
||||||
|
sed 's/^/ /' SHA256SUMS
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: hmapdev
|
||||||
|
path: /tmp/out/*
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish
|
||||||
|
needs: [prepare, build]
|
||||||
|
if: needs.prepare.outputs.exists == 'false'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: hmapdev
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: 打 tag
|
||||||
|
env:
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$TAG" -m "$TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
- name: 建 release 并上传附件
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
|
PRE: ${{ needs.prepare.outputs.prerelease }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd dist
|
||||||
|
FLAGS=()
|
||||||
|
[ "$PRE" = "true" ] && FLAGS+=(--prerelease)
|
||||||
|
gh release create "$TAG" \
|
||||||
|
--title "HomeAgent SDK $VERSION" \
|
||||||
|
--notes "HomeAgent 插件 SDK $VERSION
|
||||||
|
|
||||||
|
\`hmapdev\` 工具链(Linux / macOS / Windows,amd64 + arm64)。
|
||||||
|
校验见 SHA256SUMS。
|
||||||
|
|
||||||
|
内核版本需与 SDK 的中版本对齐;协议不配套时插件握手会失败
|
||||||
|
(魔数不匹配),此时升级内核或改用对应版本的 SDK。" \
|
||||||
|
"${FLAGS[@]}" \
|
||||||
|
./hmapdev_* ./SHA256SUMS
|
||||||
|
echo "=== release 内容 ==="
|
||||||
|
gh release view "$TAG" --json assets \
|
||||||
|
--jq '.assets[] | " \(.name) \(.size) 字节"'
|
||||||
|
|
||||||
|
- name: 回读校验
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /tmp/back && cd /tmp/back
|
||||||
|
# 同主仓:/tmp/back 不是 git 仓库,gh 无法从上下文推断仓库,
|
||||||
|
# 必须显式 --repo。
|
||||||
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY"
|
||||||
|
for f in *; do
|
||||||
|
printf " %8.1fMB %s\n" \
|
||||||
|
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$f"
|
||||||
|
done
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
echo " ✓ 回读校验通过"
|
||||||
|
|
||||||
|
sync-gitcode:
|
||||||
|
name: Sync to gitcode
|
||||||
|
needs: [prepare, publish]
|
||||||
|
if: needs.prepare.outputs.exists == 'false'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- id: tok
|
||||||
|
name: 检查 gitcode 凭据
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.GITCODE_TOKEN }}" ]; then
|
||||||
|
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " 未配置 GITCODE_TOKEN —— 跳过 gitcode 同步"
|
||||||
|
fi
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
if: steps.tok.outputs.ok == 'true'
|
||||||
|
with:
|
||||||
|
name: hmapdev
|
||||||
|
path: dist
|
||||||
|
- name: 推 tag 与附件到 gitcode
|
||||||
|
if: steps.tok.outputs.ok == 'true'
|
||||||
|
env:
|
||||||
|
GC_TOKEN: ${{ secrets.GITCODE_TOKEN }}
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$TAG" -m "$TAG" 2>/dev/null || true
|
||||||
|
GC_URL="https://JianFeeeee:${GC_TOKEN}@gitcode.com"
|
||||||
|
git push "${GC_URL}/JianFeeeee/homeagent-sdk.git" "$TAG"
|
||||||
|
curl -sS --max-time 60 -X POST \
|
||||||
|
-H "private-token: ${GC_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://gitcode.com/api/v5/repos/JianFeeeee/homeagent-sdk/releases" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"body\":\"同步自 GitHub\"}" \
|
||||||
|
-o /tmp/.gcrel -w " 建 release → %{http_code}\n"
|
||||||
|
cd dist
|
||||||
|
# 脚本的路径语义是 os.path.join(ASSET_DIR, name) ⇒
|
||||||
|
# 必须 cd 进资产目录、ASSET_DIR=.、并传**裸文件名**。
|
||||||
|
# 传 "./x" 或 "dist/x" 都会拼成 dist/dist/x 而找不到文件。
|
||||||
|
#
|
||||||
|
# 为何显式列名而不是让它自动扫描:自动扫描只认 ARTIFACT_SUFFIXES
|
||||||
|
# 里的扩展名,而 hmapdev 的产物多数**没有扩展名**(只有 windows
|
||||||
|
# 那个是 .exe)⇒ 自动扫描会静默地一个都不传。
|
||||||
|
ASSET_DIR=. GITCODE_REPO=JianFeeeee/homeagent-sdk \
|
||||||
|
python3 ../scripts/upload-assets.py "$TAG" "$GC_TOKEN" \
|
||||||
|
hmapdev_linux_amd64 hmapdev_linux_arm64 \
|
||||||
|
hmapdev_darwin_amd64 hmapdev_darwin_arm64 \
|
||||||
|
hmapdev_windows_amd64.exe SHA256SUMS
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -35,3 +35,17 @@ z_entry.c
|
|||||||
|
|
||||||
# plugindev binary in tools/
|
# plugindev binary in tools/
|
||||||
tools/plugindev/plugindev
|
tools/plugindev/plugindev
|
||||||
|
|
||||||
|
# 文档站构建产物(由 tools/apidoc/build.sh 生成)
|
||||||
|
site_build/
|
||||||
|
# mkdocs 缓存
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# agent 入口的「全文汇总」是派生件:由 gensite 把 docs/ 下所有 Markdown 拼成一份,
|
||||||
|
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
|
||||||
|
# llms.txt(索引,小且稳定)仍提交。
|
||||||
|
docs/llms-full.txt
|
||||||
|
|
||||||
|
# ★ rawfile/ 的内容是随包资源(可能内嵌 node/pi 等上百兆二进制),
|
||||||
|
# 由构建方自行准备,不进版本库。忽略规则放在 rawfile/.gitignore 内
|
||||||
|
# (自包含,避免依赖上层规则的作用域与否定式语义)。
|
||||||
|
|||||||
682
LICENSE
682
LICENSE
@ -1,661 +1,21 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
MIT License
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
Copyright (c) 2026 JianFeeeee
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this license document, but changing it is not allowed.
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
Preamble
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
furnished to do so, subject to the following conditions:
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
share and change all versions of a program--to make sure it remains free
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
software for all its users.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
When we speak of free software, we are referring to freedom, not
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
SOFTWARE.
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
190
README.md
190
README.md
@ -2,9 +2,18 @@
|
|||||||
|
|
||||||
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
||||||
|
|
||||||
|
> 📖 **完整文档**:<https://sdk.homeagent.jianfgit.xyz/>
|
||||||
|
>
|
||||||
|
> 快速开始 / API 参考 / 指南 / 示例插件都在那里,**本 README 只是速览**,
|
||||||
|
> 接口细节以文档站为准。直接进入:[快速开始](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/)
|
||||||
|
> · [API 参考](https://sdk.homeagent.jianfgit.xyz/api/) · [能力边界](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/)
|
||||||
|
>
|
||||||
|
> 面向 agent 的纯文本入口:[`llms.txt`](https://sdk.homeagent.jianfgit.xyz/llms.txt)
|
||||||
|
> (含全部 API 的 [`llms-full.txt`](https://sdk.homeagent.jianfgit.xyz/llms-full.txt))。
|
||||||
|
|
||||||
## 版本与兼容性
|
## 版本与兼容性
|
||||||
|
|
||||||
当前:**SDK 1.2.0**(需内核 **1.2.0+**)。
|
当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
|
||||||
|
|
||||||
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
||||||
|
|
||||||
@ -12,21 +21,59 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||||
| 1.2.0 起 | 1.2.0 |
|
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||||
|
| 1.3.0 起 | **1.3.0** |
|
||||||
|
|
||||||
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
||||||
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
||||||
|
|
||||||
|
因此 **SDK 仓在一个中版本里只发一次**(`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
|
||||||
|
(2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
|
||||||
|
|
||||||
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
|
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
|
||||||
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
|
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
|
||||||
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
|
实测验证:在新内核上直接建链通过,因为握手校验的是主版本号、不是 SDK 全等版本)。
|
||||||
想用新字段时重编即可。
|
想用新字段时重编即可。
|
||||||
|
|
||||||
**1.1.x 插件升到 1.2.x:接口纯追加,但必须重编。** 公开接口没有签名变更(新增
|
**1.1.x 插件升到 1.2.x:接口纯追加;若内核共享区布局改了则需重编。** 公开接口没有签名变更
|
||||||
`InjectOptions` 与六个 `*Opts` 变体、`ChannelDef.ContextPolicy`),不调新能力就不受影响;
|
(新增 `InjectOptions` 与六个 `*Opts` 变体、`ChannelDef.ContextPolicy`),不调新能力就不受影响。
|
||||||
但内核的**插件运行协议升到了 2**(统一共享内存区的 fd3 布局改变,**不支持滚动升级**),
|
1.2.0 同时把 fd3 换成了统一共享区(SuperBlock + StageContext + EvtRing),
|
||||||
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
|
这类**布局**变化由握手时的结构判据拦下(统一区魔数、`field_count` 描述符个数),
|
||||||
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
|
而不是靠某个版本号——它们都是二进制里的派生事实,不会「忘同步」。
|
||||||
|
|
||||||
|
## 1.3.0 新增:注入优先级与动态输出通道
|
||||||
|
|
||||||
|
### 注入优先级(`InjectOptions.Priority`)
|
||||||
|
|
||||||
|
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
|
||||||
|
|
||||||
|
| 级别 | 常量 | 谁用 |
|
||||||
|
|---|---|---|
|
||||||
|
| L1–L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选(L1 最低) |
|
||||||
|
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
|
||||||
|
|
||||||
|
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
|
||||||
|
⇒ 存量插件不需要改一行、也不需要重编。
|
||||||
|
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
|
||||||
|
(现场保存/恢复对插件透明)。
|
||||||
|
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
|
||||||
|
|
||||||
|
### 动态输出通道(`UnregisterOutputChannel`)
|
||||||
|
|
||||||
|
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
|
||||||
|
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
|
||||||
|
|
||||||
|
1.3.0 起成对提供:
|
||||||
|
|
||||||
|
| API | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
|
||||||
|
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
|
||||||
|
|
||||||
|
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
|
||||||
|
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`,
|
||||||
|
违规会让**整条 LLM 请求**被上游拒绝(2026-09-13 生产事故:`device/<id>` 导致全量对话 403)。
|
||||||
|
派生规则与约束见下方「输出通道」一节。
|
||||||
|
|
||||||
## 注入行为与上下文裁剪(1.2.0)
|
## 注入行为与上下文裁剪(1.2.0)
|
||||||
|
|
||||||
@ -84,11 +131,15 @@ type Plugin interface {
|
|||||||
|
|
||||||
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
|
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
|
||||||
|
|
||||||
|
> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*`
|
||||||
|
> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**,
|
||||||
|
> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。
|
||||||
|
|
||||||
| 分类 | 方法 | 说明 |
|
| 分类 | 方法 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||||
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道,def 为 `ChannelDef`(NoMemory/Cleaner) |
|
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
|
||||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道,def 为 `ChannelDef`,caps 为能力位掩码 |
|
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
|
||||||
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
||||||
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||||
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||||
@ -135,6 +186,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
|
|||||||
|
|
||||||
### 输出通道
|
### 输出通道
|
||||||
|
|
||||||
|
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
|
||||||
|
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
|
||||||
|
> "这个工具不可用",而是**整条请求被 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||||
|
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
|
||||||
|
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`(13 字符)的余量。
|
||||||
|
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
|
||||||
|
> 内核**不会**替你净化。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
|
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
|
||||||
```
|
```
|
||||||
@ -287,16 +346,24 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
## hmapdev 工具链
|
## hmapdev 工具链
|
||||||
|
|
||||||
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
|
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
|
||||||
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
|
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),
|
||||||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
|
下载后加入 PATH 即可:
|
||||||
|
|
||||||
|
> ⚠️ **二进制的实际分发地址目前是 gitcode**(两个仓的 release 附件不同步):
|
||||||
|
> `hmapdev_linux_amd64` 等 5 个平台二进制 + `SHA256SUMS` 在
|
||||||
|
> <https://github.com/JianFeeeee/homeagentsdk/releases>。
|
||||||
|
> GitHub 侧(<https://github.com/JianFeeeee/homeagentsdk/releases>)从
|
||||||
|
> 下一个 SDK 版本(v1.4.0)起才会同步发布 —— 因为 SDK 仓**移仓后还没发过版**。
|
||||||
|
> 源码与文档一律以 GitHub 为准,**只有二进制暂时还得到 gitcode 取**。
|
||||||
|
|
||||||
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
|
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
|
||||||
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||||
> (旧目录会被自动沿用,不会丢已装版本)。
|
> (旧目录会被自动沿用,不会丢已装版本)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 从 release 附件下载(以最新 SDK 发布 / linux amd64 为例)
|
# 从 release 附件下载(以 linux amd64 为例,<版本> 如 v1.3.0)
|
||||||
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
|
# 当前实际分发地址是 gitcode(见下方说明):
|
||||||
|
curl -Lo hmapdev https://github.com/JianFeeeee/homeagentsdk/releases/download/<版本>/hmapdev_linux_amd64
|
||||||
chmod +x hmapdev
|
chmod +x hmapdev
|
||||||
|
|
||||||
# 或从源码自己编
|
# 或从源码自己编
|
||||||
@ -437,6 +504,18 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
插件崩溃时平台自动拉起,保障服务可用性。
|
插件崩溃时平台自动拉起,保障服务可用性。
|
||||||
|
|
||||||
|
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`):
|
||||||
|
|
||||||
|
| 参数 | 值 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
|
||||||
|
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
|
||||||
|
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
|
||||||
|
|
||||||
|
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
|
||||||
|
(`n > 3`)**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
|
||||||
|
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
|
||||||
|
|
||||||
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
|
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
|
||||||
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
|
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
|
||||||
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
|
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
|
||||||
@ -473,6 +552,57 @@ ctx.Unlock()
|
|||||||
|
|
||||||
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
||||||
|
|
||||||
|
## 项目声明 SDK 版本(plg.json 的 `sdk` 字段)
|
||||||
|
|
||||||
|
`hmapdev init` 生成的工程里,`plg.json` 会带一个 `sdk` 字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "MyPlugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"entry": "plugin.bin",
|
||||||
|
"sdk": "1.2.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
它的语义是**本插件针对的 SDK 版本**,工具链据此在本地 SDK 存储里选择版本:
|
||||||
|
命中就用它,并把 `go.mod` 的 `require`/`replace` 同步到该版本;未命中则**明确报错**
|
||||||
|
(列出已装版本 + `hmapdev sdk install vX.Y.Z`),**绝不静默退化成 `current`**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ hmapdev build
|
||||||
|
[hmapdev] SDK 1.2.0(项目声明 sdk=1.2.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么要这个字段:以前项目里没有任何「我要哪版 SDK」的声明,工具链只能用存储里的
|
||||||
|
`current`——谁改过 `current` 就拿谁的版本编,出错时表现为一堆看不懂的编译错误
|
||||||
|
(例如存储里只有陈旧的 `v0.8.0` 时,模板项目首次构建会报 `undefined: sdk.InjectOptions`)。
|
||||||
|
|
||||||
|
**写法必须是完整版本号(`1.2.0`),不接受区间写法(`1.2`)。** 原因见上文的版本纪律:
|
||||||
|
SDK 版本跟随内核中版本、patch 位恒为 `.0`,一条内核线只对应一个 SDK 版本;
|
||||||
|
写区间会让人误以为同一条线里还能挑不同 SDK(工具链会直接拒绝并说明这条规矩)。
|
||||||
|
|
||||||
|
- 显式 `--sdk-path` 或 `plg.json` 的 `sdk_path` 优先(本机改 SDK 联调时用);
|
||||||
|
- 存量工程(`plg.json` 没有 `sdk` 字段)行为不变,仍按 `current` 构建;
|
||||||
|
- 产物 `.hmap` 里的 `plugin.json` 会记录**实际选中的 SDK 版本**,便于事后追溯。
|
||||||
|
|
||||||
|
## IDE 支持:VSCode 扩展(`tools/vscode-hmapdev`)
|
||||||
|
|
||||||
|
调试插件的实操回路是「构建 → 运行 → 看内核日志」,这三步都在 IDE 之外很别扭,
|
||||||
|
所以仓库里带了一个 VSCode 扩展([tools/vscode-hmapdev](tools/vscode-hmapdev)):
|
||||||
|
|
||||||
|
- **plg.json 诊断**:必需字段、`sdk` 是否是完整版本号、声明的 SDK 是否已安装(直接给安装命令);
|
||||||
|
- **状态栏**:`插件 · SDK <声明> · hmapdev <版本>`,工具链缺失或工程有错时变色;
|
||||||
|
- **命令 / 任务**:build / build(全部目标)/ clean / debug(解释执行),编译错误进 Problems;
|
||||||
|
- **跟随内核日志**:读 `<dataDir>/log` 下最新的 `homed_*.log` 并按插件名过滤。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/vscode-hmapdev && npm install && npm run compile # 然后在 VSCode 里按 F5
|
||||||
|
```
|
||||||
|
|
||||||
|
它不是源码级调试器(没有断点/单步):插件要么编译成产物在内核里跑、要么用
|
||||||
|
`hmapdev debug` 解释执行,两条路都没有 DAP 会话;扩展做的是构建、运行、看日志与清单校验。
|
||||||
|
|
||||||
## 示例插件
|
## 示例插件
|
||||||
|
|
||||||
| 插件 | 类型 | 说明 |
|
| 插件 | 类型 | 说明 |
|
||||||
@ -494,9 +624,9 @@ ctx.Unlock()
|
|||||||
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
||||||
|
|
||||||
**发版时附带预编译示例产物**:SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
|
**发版时附带预编译示例产物**:SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
|
||||||
`.hmap` 与 `SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核**协议绑定**(`ProtocolVersion`
|
`.hmap` 与 `SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核在**布局上绑定**(主版本号
|
||||||
+ 共享内存区魔数),只发工具链不发示例产物,很容易拿旧产物去装而握手失败——那看起来像
|
+ 统一共享区魔数 + `field_count`),只发工具链不发示例产物,很容易拿旧产物去装而握手失败
|
||||||
「插件坏了」而不是「版本不配套」。
|
——那看起来像「插件坏了」而不是「版本不配套」。
|
||||||
|
|
||||||
## Remote Device SDK
|
## Remote Device SDK
|
||||||
|
|
||||||
@ -843,14 +973,30 @@ curl -X POST http://127.0.0.1:9876/plugins \
|
|||||||
|
|
||||||
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- 文档站:**<https://sdk.homeagent.jianfgit.xyz/>**
|
||||||
|
- 快速开始:[环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/) · [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin/) · [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin/)
|
||||||
|
- API 参考:[总览](https://sdk.homeagent.jianfgit.xyz/api/) · [工具](https://sdk.homeagent.jianfgit.xyz/api/tools/) · [阶段钩子](https://sdk.homeagent.jianfgit.xyz/api/stages/) · [记忆](https://sdk.homeagent.jianfgit.xyz/api/memory/) · [输入/输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels/)
|
||||||
|
- 指南:[能力边界(哪些 API 外部可用)](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/) · [工具并发声明](https://sdk.homeagent.jianfgit.xyz/guide/parallel-tool-declaration/) · [流式多 tool_call](https://sdk.homeagent.jianfgit.xyz/guide/stream-tool-call-index/) · [场景记忆](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory/) · [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging/)
|
||||||
|
- 版本与兼容:<https://sdk.homeagent.jianfgit.xyz/versions/>
|
||||||
|
- 核心仓(内核 / 记忆 / 调度):<https://github.com/JianFeeeee/HomeAgent>
|
||||||
|
- 介绍站:<https://introduce.homeagent.jianfgit.xyz/>
|
||||||
|
|
||||||
|
> 文档站为自托管(国内直连,不必挂代理)。构建与部署见核心仓的
|
||||||
|
> `deploy-sdk-site.sh`(`--check` 只比对、`--rollback` 回滚)。
|
||||||
|
|
||||||
## 许可
|
## 许可
|
||||||
|
|
||||||
SDK 以 **AGPL-3.0-only** 发布,全文见 [LICENSE](LICENSE)。
|
SDK 以 **MIT** 发布,全文见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
**这对插件开发者是实质性约束**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
|
**这是刻意的宽松**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
|
||||||
插件因此是本 SDK 的衍生作品,**必须以相同许可(AGPL-3.0-only)发布**;并且因为 AGPL §13
|
若用 AGPL 之类的传染许可,插件作者就会被强制以其对外开源。选 MIT 就是为了
|
||||||
覆盖网络交互,通过 HTTP/WebSocket 等向用户提供服务的插件同样要向使用者提供源码。
|
让插件作者**自由选择自己的许可**——闭源、商业、私有均可,无需向本项目回馈,
|
||||||
若你的插件需要闭源,唯一合规路径是另行取得本项目的例外/商业授权——目前不提供。
|
也无需取得任何例外或商业授权。第三方插件生态的安全与活跃正建立在这条之上。
|
||||||
|
|
||||||
|
前提是 SDK 本身**完全自包含**:`go.mod` 零外部依赖,`sdk/` 只依赖 Go 标准库
|
||||||
|
(`sync`),不引用核心仓的任何代码,因此 MIT 授权不与其他许可冲突。
|
||||||
|
|
||||||
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
|
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
|
||||||
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
|
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
|
||||||
|
|||||||
75
README_EN.md
75
README_EN.md
@ -2,9 +2,19 @@
|
|||||||
|
|
||||||
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||||||
|
|
||||||
|
> 📖 **Full documentation**: <https://sdk.homeagent.jianfgit.xyz/>
|
||||||
|
>
|
||||||
|
> Quick start / API reference / guides / example plugins all live there. **This README is a
|
||||||
|
> summary only** — the docs site is authoritative for interface details.
|
||||||
|
> Jump to: [Getting started](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/)
|
||||||
|
> · [API reference](https://sdk.homeagent.jianfgit.xyz/api/) · [Capability boundary](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/)
|
||||||
|
>
|
||||||
|
> Agent-friendly plain-text entry points: [`llms.txt`](https://sdk.homeagent.jianfgit.xyz/llms.txt)
|
||||||
|
> and [`llms-full.txt`](https://sdk.homeagent.jianfgit.xyz/llms-full.txt) (the whole docs in one file).
|
||||||
|
|
||||||
## Version and Compatibility
|
## Version and Compatibility
|
||||||
|
|
||||||
Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
|
||||||
|
|
||||||
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
||||||
|
|
||||||
@ -12,17 +22,37 @@ Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||||
| 1.2.0 onward | 1.2.0 |
|
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||||
|
| 1.3.0 onward | **1.3.0** |
|
||||||
|
|
||||||
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
||||||
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
||||||
forced to chase releases or suspect your version is stale, when not one character of the interface
|
forced to chase releases or suspect your version is stale, when not one character of the interface
|
||||||
has changed.
|
has changed.
|
||||||
|
|
||||||
|
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
|
||||||
|
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
|
||||||
|
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
|
||||||
|
|
||||||
|
## New in 1.3.0: Injection Priority and Dynamic Output Channels
|
||||||
|
|
||||||
|
- **`InjectOptions.Priority` / `PriorityL1`–`PriorityL4`** — a plugin declares the interrupt level of
|
||||||
|
its own injection; the kernel schedules L1–L4, where **L4 is reserved for the kernel and
|
||||||
|
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
|
||||||
|
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
|
||||||
|
Queued input has no level: anything can jump ahead of it.
|
||||||
|
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
|
||||||
|
channels that die with their resource (one channel per remote device) can now be unregistered;
|
||||||
|
previously they lingered and the model kept "successfully" sending into a dead channel.
|
||||||
|
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
|
||||||
|
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
|
||||||
|
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
|
||||||
|
external IDs — never use the raw ID.
|
||||||
|
|
||||||
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
||||||
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
||||||
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
||||||
because the handshake validates `ProtocolVersion`, not the SDK version). Rebuild only when you want
|
because the handshake validates the **major** version, not the exact SDK version). Rebuild only when you want
|
||||||
the new fields.
|
the new fields.
|
||||||
|
|
||||||
**Upgrading a 1.1.x plugin to 1.2.x: the interface is purely additive, but a rebuild is required.**
|
**Upgrading a 1.1.x plugin to 1.2.x: the interface is purely additive, but a rebuild is required.**
|
||||||
@ -308,7 +338,7 @@ Plugin developers only need to implement the `Plugin` interface and export a `Ne
|
|||||||
`hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is
|
`hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is
|
||||||
named after that package format). Prebuilt binaries ship as **release assets**
|
named after that package format). Prebuilt binaries ship as **release assets**
|
||||||
(linux/darwin/windows × amd64/arm64); download from
|
(linux/darwin/windows × amd64/arm64); download from
|
||||||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
|
[Releases](https://github.com/JianFeeeee/homeagentsdk/releases) and put it on your PATH:
|
||||||
|
|
||||||
> Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0.
|
> Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0.
|
||||||
> The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk`
|
> The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk`
|
||||||
@ -316,7 +346,7 @@ named after that package format). Prebuilt binaries ship as **release assets**
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From release assets (latest SDK release / linux amd64 shown)
|
# From release assets (latest SDK release / linux amd64 shown)
|
||||||
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<version>/hmapdev_linux_amd64
|
curl -Lo hmapdev https://github.com/JianFeeeee/homeagentsdk/releases/download/<version>/hmapdev_linux_amd64
|
||||||
chmod +x hmapdev
|
chmod +x hmapdev
|
||||||
|
|
||||||
# Or build from source
|
# Or build from source
|
||||||
@ -423,6 +453,19 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||||||
|
|
||||||
|
Restarts are **rate-limited**. Defaults (kernel `internal/plugin/registry.go`):
|
||||||
|
|
||||||
|
| Parameter | Value | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `procRestartBackoff` | `1s` | Before restart #n, wait `n × 1s` (linear backoff, not immediate) |
|
||||||
|
| `procMaxRestarts` | `3` | Max restarts within the window |
|
||||||
|
| `procCrashWindow` | `5min` | No new crash within the window resets the count |
|
||||||
|
|
||||||
|
So the actual sequence is **1s → 2s → 3s**; the **4th** crash in the same 5-minute
|
||||||
|
window (`n > 3`) is **not** restarted automatically and needs manual intervention.
|
||||||
|
This is not instant, invisible recovery — if your plugin must be back in seconds,
|
||||||
|
reconnect and rebuild your own state in `OnStart`.
|
||||||
|
|
||||||
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
||||||
> external connection has been established, and that connection setup usually happens in a
|
> external connection has been established, and that connection setup usually happens in a
|
||||||
> background goroutine while the kernel reads the flag from another one — which is inherently
|
> background goroutine while the kernel reads the flag from another one — which is inherently
|
||||||
@ -483,8 +526,8 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
|||||||
|
|
||||||
**Prebuilt example artifacts ship with every release**: besides the 5-platform `hmapdev`, an SDK
|
**Prebuilt example artifacts ship with every release**: besides the 5-platform `hmapdev`, an SDK
|
||||||
release contains the example plugins' `.hmap` files plus `SHA256SUMS`/`MANIFEST.txt`. The reason is
|
release contains the example plugins' `.hmap` files plus `SHA256SUMS`/`MANIFEST.txt`. The reason is
|
||||||
that plugin binaries are **protocol-bound** to the kernel (`ProtocolVersion` + the shared-memory
|
that plugin binaries are **layout-bound** to the kernel (major version + unified-region magic +
|
||||||
magic), so shipping the toolchain without matching artifacts invites installing an old artifact —
|
`field_count`), so shipping the toolchain without matching artifacts invites installing an old artifact —
|
||||||
which fails the handshake and looks like "the plugin is broken" rather than "the versions don't
|
which fails the handshake and looks like "the plugin is broken" rather than "the versions don't
|
||||||
match".
|
match".
|
||||||
|
|
||||||
@ -816,14 +859,18 @@ Or upload via the WebUI plugin management page, or manually place the `.hmap` in
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The SDK is released under **AGPL-3.0-only** — see [LICENSE](LICENSE).
|
The SDK is released under the **MIT license** — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
**This is a substantive constraint for plugin developers**: the SDK is **statically linked** into
|
**This permissiveness is deliberate**: the SDK is **statically linked** into your plugin
|
||||||
your plugin (its source ends up in the plugin binary), so the plugin is a derivative work of
|
(its source ends up in the plugin binary). Under a copyleft license such as AGPL that would
|
||||||
this SDK and **must be released under the same license**. Because AGPL §13 covers network
|
force plugin authors to open-source their work; MIT exists precisely so that plugin authors
|
||||||
interaction, a plugin that serves users over HTTP/WebSocket must also offer them the source.
|
can **pick their own license** — closed-source, commercial or private — with no obligation to
|
||||||
If you need a closed-source plugin, the only compliant route is a separate exception/commercial
|
contribute back and no need for any exception or commercial grant. The safety and vitality of
|
||||||
license from this project — none is offered today.
|
the third-party plugin ecosystem rest on this.
|
||||||
|
|
||||||
|
This is sound because the SDK is **fully self-contained**: `go.mod` has zero external
|
||||||
|
dependencies and `sdk/` imports only the Go standard library (`sync`), never any code from the
|
||||||
|
core repository — so the MIT grant conflicts with nothing.
|
||||||
|
|
||||||
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
|
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
|
||||||
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
|
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
|
||||||
|
|||||||
204
docs/api/bridge.md
Normal file
204
docs/api/bridge.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 桥接装配点(Bridge)
|
||||||
|
|
||||||
|
以下方法**不是给插件业务代码调的**——它们由 `hmapdev` 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例。列在这里是为了让「公开 API 面」完整,并说明每个注入点对应什么能力。
|
||||||
|
|
||||||
|
### `APIRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type APIRegistrar func(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
APIRegistrar registers a plugin API for external access.
|
||||||
|
|
||||||
|
<small>`plugin.go:410`</small>
|
||||||
|
|
||||||
|
### `InputChannelRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InputChannelRegistrar func(name string, def ChannelDef) error
|
||||||
|
```
|
||||||
|
|
||||||
|
InputChannelRegistrar registers an input channel with its memory behavior.
|
||||||
|
|
||||||
|
<small>`plugin.go:413`</small>
|
||||||
|
|
||||||
|
### `OutputChannelRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
OutputChannelRegistrar registers an output channel that the output_send tool can use.
|
||||||
|
|
||||||
|
<small>`plugin.go:416`</small>
|
||||||
|
|
||||||
|
### `OutputChannelUnregistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type OutputChannelUnregistrar func(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
OutputChannelUnregistrar 注销一个输出通道。
|
||||||
|
|
||||||
|
为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的
|
||||||
|
动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
|
||||||
|
必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
|
||||||
|
|
||||||
|
<small>`plugin.go:423`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetDocMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:718`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetEventSubscriber`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:742`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetIOInjector`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetIOInjector(io IOInjector)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetIOInjector sets the IO injector (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:699`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetInputChannelRegistrar`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:692`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetKnowledgeAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:724`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetLLMAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetLLMAPI(llm LLMAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:730`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetMemoryAPI sets the memory API (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:706`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelRegistrar`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:678`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelUnregistrar`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上,桥接模板不注入。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:685`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetPluginMgrAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:749`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetSocialAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetSocialAPI(social SocialAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:736`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetTextMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:712`</small>
|
||||||
|
|
||||||
|
### `ToolRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolRegistrar registers a tool dynamically.
|
||||||
|
|
||||||
|
<small>`plugin.go:404`</small>
|
||||||
|
|
||||||
79
docs/api/builtin-only.md
Normal file
79
docs/api/builtin-only.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 仅内置插件可用的 API
|
||||||
|
|
||||||
|
这些 API **存在于公开 SDK 包里**,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级。列在这里是为了让边界显式——而不是让你在运行时才发现拿不到。
|
||||||
|
|
||||||
|
判断依据全部来自源码与 `hmapdev` 桥接模板,逐条记在每条说明里。
|
||||||
|
|
||||||
|
### `PriorityL4`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL4
|
||||||
|
```
|
||||||
|
|
||||||
|
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:165`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Events`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Events() EventSubscriber
|
||||||
|
```
|
||||||
|
|
||||||
|
Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:550`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetEventSubscriber`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:742`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelUnregistrar`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上,桥接模板不注入。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:685`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.UnregisterOutputChannel`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
外部插件的桥接模板只注入 SetOutputChannelRegistrar(proc_main.go.tmpl:693-705),**未**注入 SetOutputChannelUnregistrar(grep Unregistrar 在 templates/ 下无命中)。因此外部插件的 regOutputUnreg 为 nil,UnregisterOutputChannel 会命中 `if reg != nil` 的 else 分支**直接返回 nil**(sdk/plugin.go:539-549)——即**静默无效**:不报错、通道也没注销。内置插件由 internal/sdk/plugin.go:330 注入 cfg.RegOutputUnreg,真正生效。外部插件要让通道下线,只能重载插件。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) UnregisterOutputChannel(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
|
||||||
|
|
||||||
|
<small>`plugin.go:643`</small>
|
||||||
|
|
||||||
|
### `EventSubscriber.Subscribe`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
|
||||||
|
```go
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
```
|
||||||
|
|
||||||
650
docs/api/channels.md
Normal file
650
docs/api/channels.md
Normal file
@ -0,0 +1,650 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 输入 / 输出通道
|
||||||
|
|
||||||
|
通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口。**输入通道**接收外部消息,**输出通道**把消息投递出去。
|
||||||
|
|
||||||
|
## `IOInjector`
|
||||||
|
|
||||||
|
IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||||
|
All methods accept (source, channel) where channel is the target output channel
|
||||||
|
for routing the agent's response.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`InjectInputMedia`](#ioinjectorinjectinputmedia) | |
|
||||||
|
| [`InjectInputMediaOpts`](#ioinjectorinjectinputmediaopts) | |
|
||||||
|
| [`InjectInputMediaSync`](#ioinjectorinjectinputmediasync) | |
|
||||||
|
| [`InjectInputMediaSyncOpts`](#ioinjectorinjectinputmediasyncopts) | |
|
||||||
|
| [`InjectInputSync`](#ioinjectorinjectinputsync) | InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 |
|
||||||
|
| [`InjectInputSyncOpts`](#ioinjectorinjectinputsyncopts) | |
|
||||||
|
| [`InjectInterruptMedia`](#ioinjectorinjectinterruptmedia) | |
|
||||||
|
| [`InjectInterruptMediaOpts`](#ioinjectorinjectinterruptmediaopts) | |
|
||||||
|
| [`InjectInterruptText`](#ioinjectorinjectinterrupttext) | |
|
||||||
|
| [`InjectInterruptTextOpts`](#ioinjectorinjectinterrupttextopts) | |
|
||||||
|
| [`InjectText`](#ioinjectorinjecttext) | |
|
||||||
|
| [`InjectTextNoMemory`](#ioinjectorinjecttextnomemory) | |
|
||||||
|
| [`InjectTextOpts`](#ioinjectorinjecttextopts) | 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 |
|
||||||
|
| [`SetToolBlocks`](#ioinjectorsettoolblocks) | SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 |
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:329`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:340`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:330`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:341`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputSync(source, channel, text string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
||||||
|
用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
|
||||||
|
<small>`plugin.go:325`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:339`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:331`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:342`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:320`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:338`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:321`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectTextNoMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectTextNoMemory(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
|
||||||
|
|
||||||
|
<small>`plugin.go:322`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
|
||||||
|
|
||||||
|
上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
|
||||||
|
保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
|
||||||
|
|
||||||
|
<small>`plugin.go:337`</small>
|
||||||
|
|
||||||
|
### `IOInjector.SetToolBlocks`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||||
|
tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||||
|
|
||||||
|
<small>`plugin.go:328`</small>
|
||||||
|
|
||||||
|
### `CapAudio`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapAudio
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:430`</small>
|
||||||
|
|
||||||
|
### `CapFile`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapFile
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:428`</small>
|
||||||
|
|
||||||
|
### `CapImage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:429`</small>
|
||||||
|
|
||||||
|
### `CapStructured`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapStructured
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:431`</small>
|
||||||
|
|
||||||
|
### `CapText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapText
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:427`</small>
|
||||||
|
|
||||||
|
### `ChannelDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ChannelDef struct { NoMemory bool `json:"no_memory,omitempty"` Cleaner func(string) string `json:"-"` ContextPolicy string `json: …
|
||||||
|
```
|
||||||
|
|
||||||
|
ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||||
|
NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||||
|
Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||||
|
ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||||
|
RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
|
||||||
|
ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
|
||||||
|
|
||||||
|
JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||||
|
没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||||
|
那样新增字段会被静默丢掉。
|
||||||
|
|
||||||
|
<small>`plugin.go:178`</small>
|
||||||
|
|
||||||
|
### `ContextPolicyNone`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const ContextPolicyNone
|
||||||
|
```
|
||||||
|
|
||||||
|
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||||
|
|
||||||
|
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||||
|
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||||
|
插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||||
|
|
||||||
|
<small>`plugin.go:44`</small>
|
||||||
|
|
||||||
|
### `ContextPolicyPrune`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const ContextPolicyPrune
|
||||||
|
```
|
||||||
|
|
||||||
|
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||||
|
|
||||||
|
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||||
|
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||||
|
插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||||
|
|
||||||
|
<small>`plugin.go:45`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
|
||||||
|
blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
|
||||||
|
发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
|
||||||
|
的「下一轮 tool message」语义。
|
||||||
|
等价于 InjectInputMediaOpts(..., InjectOptions{})。
|
||||||
|
|
||||||
|
<small>`plugin.go:805`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:844`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
|
||||||
|
等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
|
||||||
|
|
||||||
|
<small>`plugin.go:811`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:851`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputSync(source, channel, text string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSync injects a text message and synchronously waits for the agent reply,
|
||||||
|
returning the reply text (empty string if none). Replies must be dispatched back
|
||||||
|
to the source channel by the caller.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
|
||||||
|
<small>`plugin.go:796`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:835`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||||
|
blocks 随中断消息一起发给模型。
|
||||||
|
|
||||||
|
<small>`plugin.go:868`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:860`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||||
|
等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
|
|
||||||
|
<small>`plugin.go:777`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
|
||||||
|
|
||||||
|
中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文,
|
||||||
|
是否需要据此裁剪由调用方决定(默认不裁剪)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:828`</small>
|
||||||
|
|
||||||
|
### `InjectOptions`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InjectOptions struct { NoMemory bool ContextPolicy string // RecallPolicy 声明此次注入是否据其内容召回相关记忆。 // 空串 = 默认(输入/注<><E6B3A8> …
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||||
|
|
||||||
|
零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||||
|
因此调用方只有在确实需要改变行为时才需要填它。
|
||||||
|
|
||||||
|
为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
|
||||||
|
属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
|
||||||
|
也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
|
||||||
|
|
||||||
|
NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||||
|
ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
||||||
|
RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。
|
||||||
|
|
||||||
|
中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
||||||
|
|
||||||
|
CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
|
||||||
|
|
||||||
|
空串 = 按注入的 source 查通道定义(既有行为)。
|
||||||
|
为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
|
||||||
|
而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
|
||||||
|
不指定就只能退到「按 source 查不到就不清洗」。
|
||||||
|
|
||||||
|
<small>`plugin.go:132`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectText injects a text message into the agent pipeline.
|
||||||
|
等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
|
|
||||||
|
<small>`plugin.go:783`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectTextNoMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectTextNoMemory injects a text message without generating memory.
|
||||||
|
等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
|
||||||
|
|
||||||
|
<small>`plugin.go:789`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:818`</small>
|
||||||
|
|
||||||
|
### `PriorityL1`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL1
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:161`</small>
|
||||||
|
|
||||||
|
### `PriorityL2`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL2
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:162`</small>
|
||||||
|
|
||||||
|
### `PriorityL3`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL3
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:163`</small>
|
||||||
|
|
||||||
|
### `PriorityL4`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL4
|
||||||
|
```
|
||||||
|
|
||||||
|
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:165`</small>
|
||||||
|
|
||||||
|
### `RecallPolicyAuto`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const RecallPolicyAuto
|
||||||
|
```
|
||||||
|
|
||||||
|
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
|
||||||
|
|
||||||
|
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
|
||||||
|
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
|
||||||
|
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
|
||||||
|
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
|
||||||
|
|
||||||
|
<small>`plugin.go:65`</small>
|
||||||
|
|
||||||
|
### `RecallPolicyNone`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const RecallPolicyNone
|
||||||
|
```
|
||||||
|
|
||||||
|
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
|
||||||
|
|
||||||
|
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
|
||||||
|
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
|
||||||
|
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
|
||||||
|
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
|
||||||
|
|
||||||
|
<small>`plugin.go:64`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterInputChannel`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterInputChannel registers an input channel with its memory behavior.
|
||||||
|
|
||||||
|
契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)
|
||||||
|
注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**:
|
||||||
|
只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子";
|
||||||
|
没登记就划分会直接失败(`inputch 未注册`)。
|
||||||
|
|
||||||
|
只登记输出通道(RegisterOutputChannel)而没登记输入通道时,内核会兜底登记同名
|
||||||
|
inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。
|
||||||
|
|
||||||
|
def.NoMemory: 此通道输入不参与记忆计算
|
||||||
|
def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` |
|
||||||
|
|
||||||
|
<small>`plugin.go:665`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterOutputChannel`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterOutputChannel registers an output channel that the output_send tool can route to.
|
||||||
|
|
||||||
|
与 RegisterInputChannel 的分工:本函数声明**出站**(output_send__<name> 的回复发给谁);
|
||||||
|
入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
|
||||||
|
若该通道同时也是你的注入入口,两个都要登记。
|
||||||
|
|
||||||
|
name: channel name (e.g. "qq", "webui")。
|
||||||
|
|
||||||
|
❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
|
||||||
|
而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
|
||||||
|
而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||||
|
网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
|
||||||
|
所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
|
||||||
|
若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
|
||||||
|
而不是把原始值直接当通道名。
|
||||||
|
caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||||
|
desc: description of the channel, expected meta format, and type enum
|
||||||
|
def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||||
|
handler: receives args map with keys: payload (string), type (string), meta (string|optional)
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:57` | `if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args…` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:57` | `s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[strin…` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` |
|
||||||
|
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:632`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetToolBlocks`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
|
||||||
|
的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
|
||||||
|
|
||||||
|
<small>`plugin.go:876`</small>
|
||||||
|
|
||||||
|
### `ValidContextPolicy`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ValidContextPolicy(policy string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
|
||||||
|
|
||||||
|
<small>`plugin.go:49`</small>
|
||||||
|
|
||||||
|
### `ValidRecallPolicy`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ValidRecallPolicy(policy string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
|
||||||
|
|
||||||
|
<small>`plugin.go:69`</small>
|
||||||
|
|
||||||
86
docs/api/constants.md
Normal file
86
docs/api/constants.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 常量与枚举
|
||||||
|
|
||||||
|
SDK 里的取值枚举。其中带「仅内置」标注的取值在内核侧会被夹到较低级别。
|
||||||
|
|
||||||
|
## StageOnInput 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `StageOnInput` | |
|
||||||
|
| `StagePreAction` | |
|
||||||
|
| `StagePostAction` | |
|
||||||
|
| `StageBeforeToolcall` | |
|
||||||
|
| `StageAfterToolcall` | |
|
||||||
|
| `StageBeforeOutput` | |
|
||||||
|
| `StageAfterOutput` | |
|
||||||
|
|
||||||
|
## ContextPolicyNone 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ContextPolicyNone` | |
|
||||||
|
| `ContextPolicyPrune` | |
|
||||||
|
|
||||||
|
## RecallPolicyNone 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `RecallPolicyNone` | |
|
||||||
|
| `RecallPolicyAuto` | |
|
||||||
|
|
||||||
|
## ScenePolicyAuto 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ScenePolicyAuto` | |
|
||||||
|
| `ScenePolicyNone` | |
|
||||||
|
|
||||||
|
## PriorityL1 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `PriorityL1` | |
|
||||||
|
| `PriorityL2` | |
|
||||||
|
| `PriorityL3` | |
|
||||||
|
| `PriorityL4` | PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 |
|
||||||
|
|
||||||
|
## EventRawInput 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `EventRawInput` | |
|
||||||
|
| `EventAgentOutput` | |
|
||||||
|
| `EventAgentLLMChain` | |
|
||||||
|
| `EventToolCall` | |
|
||||||
|
| `EventReasoning` | |
|
||||||
|
| `EventStage` | |
|
||||||
|
| `EventSystem` | |
|
||||||
|
| `EventReasoningDelta` | 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。 |
|
||||||
|
| `EventContentDelta` | |
|
||||||
|
|
||||||
|
## StageScopeGlobal 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `StageScopeGlobal` | StageScopeGlobal receives all stage events (default). |
|
||||||
|
| `StageScopeOwnTools` | StageScopeOwnTools only receives events for this plugin's own tool calls |
|
||||||
|
|
||||||
|
## CapText 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `CapText` | |
|
||||||
|
| `CapFile` | |
|
||||||
|
| `CapImage` | |
|
||||||
|
| `CapAudio` | |
|
||||||
|
| `CapStructured` | |
|
||||||
|
|
||||||
|
## ProxyAuthHomeAgent 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ProxyAuthHomeAgent` | ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话 |
|
||||||
|
| `ProxyAuthNone` | ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。 |
|
||||||
|
|
||||||
69
docs/api/events.md
Normal file
69
docs/api/events.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 事件(Events)
|
||||||
|
|
||||||
|
订阅内核事件。**注意**:外部分布式插件的事件订阅不走 `Events()`(该接口在外部插件路径上未被注入,恒为 nil),而是由 `hmapdev` 生成的运行时通过 `events.subscribe` 完成。详见下方说明。
|
||||||
|
|
||||||
|
## `EventSubscriber`
|
||||||
|
|
||||||
|
EventSubscriber allows plugins to subscribe to kernel events.
|
||||||
|
This is a restricted interface: plugins can subscribe but the kernel
|
||||||
|
controls which events are delivered.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Subscribe`](#eventsubscribersubscribe) | |
|
||||||
|
|
||||||
|
### `EventSubscriber.Subscribe`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
|
||||||
|
```go
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:378`</small>
|
||||||
|
|
||||||
|
### `Event`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Event struct { Type EventType `json:"type"` Source string `json:"source"` Payload map[string]interface{} `json:"payload"` T …
|
||||||
|
```
|
||||||
|
|
||||||
|
Event represents a system event published by the kernel.
|
||||||
|
|
||||||
|
<small>`plugin.go:364`</small>
|
||||||
|
|
||||||
|
### `EventHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type EventHandler func(evt *Event)
|
||||||
|
```
|
||||||
|
|
||||||
|
EventHandler processes a system event.
|
||||||
|
|
||||||
|
<small>`plugin.go:372`</small>
|
||||||
|
|
||||||
|
### `EventType`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type EventType string
|
||||||
|
```
|
||||||
|
|
||||||
|
EventType identifies the kind of system event.
|
||||||
|
|
||||||
|
<small>`plugin.go:346`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Events`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Events() EventSubscriber
|
||||||
|
```
|
||||||
|
|
||||||
|
Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:550`</small>
|
||||||
|
|
||||||
39
docs/api/index.md
Normal file
39
docs/api/index.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# API 参考
|
||||||
|
|
||||||
|
本页所有内容**从源码生成**(`tools/apidoc`),签名与说明直接取自 `sdk/*.go` 的
|
||||||
|
文档注释。因此不存在「文档写了一套、代码是另一套」的情况——发现不一致时,
|
||||||
|
改的是源码注释,不是这里。
|
||||||
|
|
||||||
|
## 怎么找 API
|
||||||
|
|
||||||
|
<div id="api-search"></div>
|
||||||
|
|
||||||
|
用上面的搜索框可以:
|
||||||
|
|
||||||
|
- **按名称搜**:`InjectText`、`RegisterTool`、`memory.recall`
|
||||||
|
- **按描述搜**:`注册工具`、`注入`、`重载`、`崩溃`
|
||||||
|
- **按签名搜**:`(string) error`、`[]ContentBlock`
|
||||||
|
- 带 <span class="api-badge api-badge-builtin">仅内置</span>
|
||||||
|
标记的条目在**外部插件里拿不到**,多数情况下你不需要它
|
||||||
|
|
||||||
|
## 章节划分
|
||||||
|
|
||||||
|
按「你想做什么」组织,不是按 Go 的符号类别:
|
||||||
|
|
||||||
|
| 章节 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| [工具(Tools)](tools.md) | 注册 LLM 可调用的工具——插件最常用的能力形态 |
|
||||||
|
| [阶段钩子(Stages)](stages.md) | 在处理管道的固定点位插入逻辑 |
|
||||||
|
| [记忆(Memory)](memory.md) | 三层记忆的读写:图 / 文档 / 文本,以及知识库 |
|
||||||
|
| [输入/输出通道](channels.md) | 与外界交换消息,以及往流水线里注入内容 |
|
||||||
|
| [配置(Settings)](settings.md) | 声明插件配置项,内核渲染到 WebUI |
|
||||||
|
| [生命周期(Lifecycle)](lifecycle.md) | 启动、停止、卸载、自动重启 |
|
||||||
|
| [事件(Events)](events.md) | 订阅内核事件 |
|
||||||
|
| [LLM 调用](llm.md) | 插件主动调用模型 |
|
||||||
|
| [常量与枚举](constants.md) | 取值枚举 |
|
||||||
|
| [桥接装配点](bridge.md) | 由 `hmapdev` 生成的运行时调用,插件业务代码不碰 |
|
||||||
|
| [仅内置插件可用](builtin-only.md) | 边界汇总——外部插件拿不到的 API 全在这里 |
|
||||||
|
|
||||||
|
!!! tip "先看「能力边界」能省很多时间"
|
||||||
|
如果你正在设计插件,先读 [能力边界](../guide/capability-boundary.md):
|
||||||
|
它说明哪些能力外部插件有、哪些没有,以及**为什么**。
|
||||||
209
docs/api/lifecycle.md
Normal file
209
docs/api/lifecycle.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 生命周期(Lifecycle)
|
||||||
|
|
||||||
|
插件的启动、停止与卸载回调。停止与卸载是两件事:**停止**是进程/加载状态变化,**卸载**(onRemove)是插件被删除前的清理机会。
|
||||||
|
|
||||||
|
## `Plugin`
|
||||||
|
|
||||||
|
Plugin is the interface every plugin must implement.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Name`](#pluginname) | |
|
||||||
|
| [`Start`](#pluginstart) | |
|
||||||
|
| [`Stop`](#pluginstop) | |
|
||||||
|
|
||||||
|
### `Plugin.Name`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Name() string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:14`</small>
|
||||||
|
|
||||||
|
### `Plugin.Start`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Start(sdk *PluginSDK) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:15`</small>
|
||||||
|
|
||||||
|
### `Plugin.Stop`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Stop() error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:16`</small>
|
||||||
|
|
||||||
|
## `PluginMgrAPI`
|
||||||
|
|
||||||
|
PluginMgrAPI 提供插件管理能力(外部插件可调用)。
|
||||||
|
由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`IsPluginDisabled`](#pluginmgrapiisplugindisabled) | IsPluginDisabled 查询插件是否被禁用。 |
|
||||||
|
| [`ListLoadedPlugins`](#pluginmgrapilistloadedplugins) | ListLoadedPlugins 列出已加载插件。 |
|
||||||
|
| [`ReloadOne`](#pluginmgrapireloadone) | ReloadOne 重载单个插件(停止后重新加载)。 |
|
||||||
|
|
||||||
|
### `PluginMgrAPI.IsPluginDisabled`
|
||||||
|
|
||||||
|
```go
|
||||||
|
IsPluginDisabled(name string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
IsPluginDisabled 查询插件是否被禁用。
|
||||||
|
|
||||||
|
<small>`plugin.go:389`</small>
|
||||||
|
|
||||||
|
### `PluginMgrAPI.ListLoadedPlugins`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListLoadedPlugins() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
ListLoadedPlugins 列出已加载插件。
|
||||||
|
|
||||||
|
<small>`plugin.go:387`</small>
|
||||||
|
|
||||||
|
### `PluginMgrAPI.ReloadOne`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ReloadOne(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
ReloadOne 重载单个插件(停止后重新加载)。
|
||||||
|
|
||||||
|
<small>`plugin.go:385`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.AutoRestart`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) AutoRestart() bool
|
||||||
|
```
|
||||||
|
|
||||||
|
AutoRestart 返回插件是否允许自动重启。
|
||||||
|
|
||||||
|
<small>`plugin.go:895`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.PluginMgr`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) PluginMgr() PluginMgrAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
||||||
|
May be nil if the host did not wire it.
|
||||||
|
|
||||||
|
<small>`plugin.go:757`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.PluginName`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) PluginName() string
|
||||||
|
```
|
||||||
|
|
||||||
|
PluginName returns the name of the plugin.
|
||||||
|
|
||||||
|
<small>`plugin.go:501`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterOnRemoveHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterOnRemoveHandler(fn func())
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
|
||||||
|
注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
|
||||||
|
适用于清理外部资源、删除配置表、下线状态等删除后处理。
|
||||||
|
可注册多个;执行后清空(一次删除只执行一次)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:296` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
| [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
|
||||||
|
<small>`plugin.go:930`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterPluginAPI`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterPluginAPI(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterPluginAPI registers this plugin's API for access by other plugins.
|
||||||
|
|
||||||
|
<small>`plugin.go:606`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterStopHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterStopHandler(fn func())
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterStopHandler 注册插件停止阶段的清理回调。
|
||||||
|
注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
|
||||||
|
适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
|
||||||
|
可注册多个;执行后清空(进程停止前只执行一次)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` |
|
||||||
|
| [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` |
|
||||||
|
|
||||||
|
<small>`plugin.go:905`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RunOnRemoveHandlers`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RunOnRemoveHandlers()
|
||||||
|
```
|
||||||
|
|
||||||
|
RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
|
||||||
|
由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
|
||||||
|
|
||||||
|
<small>`plugin.go:941`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RunStopHandlers`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RunStopHandlers()
|
||||||
|
```
|
||||||
|
|
||||||
|
RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
|
||||||
|
由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
|
||||||
|
|
||||||
|
<small>`plugin.go:916`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetAutoRestart`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetAutoRestart(enabled bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetAutoRestart 设置插件崩溃后内核是否自动重启它。
|
||||||
|
默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||||
|
|
||||||
|
重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s),
|
||||||
|
且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。
|
||||||
|
注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:47` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:47` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:63` | `s.SetAutoRestart(true)` |
|
||||||
|
|
||||||
|
<small>`plugin.go:888`</small>
|
||||||
|
|
||||||
50
docs/api/llm.md
Normal file
50
docs/api/llm.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# LLM 调用
|
||||||
|
|
||||||
|
让插件自己调用模型(而不是只等模型来调你)。
|
||||||
|
|
||||||
|
## `LLMAPI`
|
||||||
|
|
||||||
|
LLMAPI provides access to the LLM provider manager.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`CurrentSource`](#llmapicurrentsource) | |
|
||||||
|
| [`ListSources`](#llmapilistsources) | |
|
||||||
|
| [`SetSource`](#llmapisetsource) | |
|
||||||
|
|
||||||
|
### `LLMAPI.CurrentSource`
|
||||||
|
|
||||||
|
```go
|
||||||
|
CurrentSource() string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:7`</small>
|
||||||
|
|
||||||
|
### `LLMAPI.ListSources`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListSources() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:5`</small>
|
||||||
|
|
||||||
|
### `LLMAPI.SetSource`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetSource(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:6`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.LLM`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) LLM() LLMAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM returns the LLM provider API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:536`</small>
|
||||||
|
|
||||||
383
docs/api/memory.md
Normal file
383
docs/api/memory.md
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 记忆(Memory)
|
||||||
|
|
||||||
|
三层记忆的读写接口:**图记忆**(三元组关系)、**文档记忆**(带元数据的文档)、**文本记忆**(事件流水)。以及知识库。
|
||||||
|
|
||||||
|
## `DocMemoryAPI`
|
||||||
|
|
||||||
|
DocMemoryAPI provides access to the document vector store.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Insert`](#docmemoryapiinsert) | |
|
||||||
|
| [`InsertWithMedia`](#docmemoryapiinsertwithmedia) | InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 |
|
||||||
|
| [`Query`](#docmemoryapiquery) | |
|
||||||
|
| [`Remove`](#docmemoryapiremove) | |
|
||||||
|
| [`Stats`](#docmemoryapistats) | |
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Insert`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Insert(doc *Doc) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:76`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.InsertWithMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||||
|
```
|
||||||
|
|
||||||
|
InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
|
||||||
|
内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
|
||||||
|
媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
|
||||||
|
因此图片按自己的向量被召回,不依赖任何生成的描述文本。
|
||||||
|
|
||||||
|
<small>`memory.go:81`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Query`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Query(text string, topK int) []*Doc
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:75`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Remove`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Remove(id string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:82`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Stats`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Stats() map[string]interface{}
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:83`</small>
|
||||||
|
|
||||||
|
## `KnowledgeAPI`
|
||||||
|
|
||||||
|
KnowledgeAPI provides access to the knowledge store.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Add`](#knowledgeapiadd) | |
|
||||||
|
| [`List`](#knowledgeapilist) | |
|
||||||
|
| [`Search`](#knowledgeapisearch) | |
|
||||||
|
|
||||||
|
### `KnowledgeAPI.Add`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Add(name, content string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:6`</small>
|
||||||
|
|
||||||
|
### `KnowledgeAPI.List`
|
||||||
|
|
||||||
|
```go
|
||||||
|
List() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:7`</small>
|
||||||
|
|
||||||
|
### `KnowledgeAPI.Search`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Search(query string, topK int) ([]*Knowledge, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:5`</small>
|
||||||
|
|
||||||
|
## `MemoryAPI`
|
||||||
|
|
||||||
|
MemoryAPI provides access to the graph memory (entity-relation store).
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Commit`](#memoryapicommit) | |
|
||||||
|
| [`Introspect`](#memoryapiintrospect) | |
|
||||||
|
| [`MergeEntities`](#memoryapimergeentities) | |
|
||||||
|
| [`Purge`](#memoryapipurge) | |
|
||||||
|
| [`Recall`](#memoryapirecall) | |
|
||||||
|
|
||||||
|
### `MemoryAPI.Commit`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Commit(triples []Triple) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:6`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Introspect`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Introspect() (map[string]interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:7`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.MergeEntities`
|
||||||
|
|
||||||
|
```go
|
||||||
|
MergeEntities(source, target string) (int, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:8`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Purge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Purge(criteria map[string]string, mode string) (int, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:9`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Recall`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Recall(query []string, depth int) ([]Entity, []Relation, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:5`</small>
|
||||||
|
|
||||||
|
## `SocialAPI`
|
||||||
|
|
||||||
|
SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
||||||
|
External plugins can query person traits and social networks but cannot modify them.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`GetNetwork`](#socialapigetnetwork) | |
|
||||||
|
| [`GetPerson`](#socialapigetperson) | |
|
||||||
|
| [`GetRelations`](#socialapigetrelations) | |
|
||||||
|
| [`GetTrait`](#socialapigettrait) | |
|
||||||
|
| [`ListPersons`](#socialapilistpersons) | |
|
||||||
|
|
||||||
|
### `SocialAPI.GetNetwork`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:104`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetPerson`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetPerson(name string) (*PersonProfile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:101`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetRelations`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetRelations(name string) ([]SocialRelation, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:103`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetTrait`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetTrait(name, trait string) (string, bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:102`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.ListPersons`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListPersons() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:105`</small>
|
||||||
|
|
||||||
|
## `TextMemoryAPI`
|
||||||
|
|
||||||
|
TextMemoryAPI provides access to chronological text event storage.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Append`](#textmemoryapiappend) | |
|
||||||
|
|
||||||
|
### `TextMemoryAPI.Append`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Append(evt TextEvent) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:44`</small>
|
||||||
|
|
||||||
|
### `Doc`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Doc struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` Score …
|
||||||
|
```
|
||||||
|
|
||||||
|
Doc represents a document in the document store.
|
||||||
|
|
||||||
|
MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
|
||||||
|
|
||||||
|
<small>`memory.go:89`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.DocMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) DocMemory() DocMemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
DocMemory returns the document memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:522`</small>
|
||||||
|
|
||||||
|
### `Entity`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Entity struct { Name string `json:"name"` Type string `json:"type"` MentionCount int `json:"mention_count"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
Entity represents a named entity in the knowledge graph.
|
||||||
|
|
||||||
|
<small>`memory.go:13`</small>
|
||||||
|
|
||||||
|
### `Knowledge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Knowledge struct { Name string `json:"name"` // Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。 // // 为何加这个字段:对<EFBC9A><E5AFB9> …
|
||||||
|
```
|
||||||
|
|
||||||
|
Knowledge represents a knowledge entry.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
|
||||||
|
|
||||||
|
<small>`knowledge.go:11`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Knowledge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Knowledge() KnowledgeAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Knowledge returns the knowledge store API (may be nil if not available).
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
|
||||||
|
|
||||||
|
<small>`plugin.go:529`</small>
|
||||||
|
|
||||||
|
### `MediaAttachment`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MediaAttachment struct { Digest string `json:"digest,omitempty"` MIME string `json:"mime,omitempty"` Data []byte `json:"data,omitempty"` Name string `json:"name,omite …
|
||||||
|
```
|
||||||
|
|
||||||
|
TextEvent represents a single text memory event.
|
||||||
|
MediaAttachment 描述一份与记忆关联的媒体。
|
||||||
|
|
||||||
|
两个方向共用一个类型:
|
||||||
|
- 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
|
||||||
|
- 读出(Query):内核只填 Digest/MIME,**不回 Data**——
|
||||||
|
一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
|
||||||
|
需要字节时拿 Digest 单独取。
|
||||||
|
|
||||||
|
刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
|
||||||
|
它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
|
||||||
|
|
||||||
|
<small>`memory.go:58`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Memory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Memory() MemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory returns the graph memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:508`</small>
|
||||||
|
|
||||||
|
### `PersonProfile`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PersonProfile struct { Name string `json:"name"` Traits map[string]string `json:"traits,omitempty"` Relations []SocialRelation `json:"relations,omitemp …
|
||||||
|
```
|
||||||
|
|
||||||
|
PersonProfile represents a person's complete profile (traits + social relations).
|
||||||
|
|
||||||
|
<small>`memory.go:109`</small>
|
||||||
|
|
||||||
|
### `Relation`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Relation struct { SourceName string `json:"source_name"` TargetName string `json:"target_name"` RelationType string `json:"relation_type"` Confidence float6 …
|
||||||
|
```
|
||||||
|
|
||||||
|
Relation represents a relationship between two entities.
|
||||||
|
|
||||||
|
<small>`memory.go:20`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Social`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Social() SocialAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Social returns the social graph API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:543`</small>
|
||||||
|
|
||||||
|
### `SocialRelation`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SocialRelation struct { Person string `json:"person"` Relation string `json:"relation"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
SocialRelation represents a social relationship between two persons.
|
||||||
|
|
||||||
|
<small>`memory.go:116`</small>
|
||||||
|
|
||||||
|
### `TextEvent`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TextEvent struct { Role string `json:"role"` Content string `json:"content"` Timestamp int64 `json:"timestamp"` Channel …
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:65`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.TextMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) TextMemory() TextMemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
TextMemory returns the text memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:515`</small>
|
||||||
|
|
||||||
|
### `Triple`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Triple struct { Subject string `json:"subject"` Relation string `json:"relation"` Object string `json:"object"` Confidence float64 `json:"c …
|
||||||
|
```
|
||||||
|
|
||||||
|
Triple represents a subject-relation-object triple for the knowledge graph.
|
||||||
|
|
||||||
|
SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
|
||||||
|
所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
|
||||||
|
|
||||||
|
<small>`memory.go:31`</small>
|
||||||
|
|
||||||
1044
docs/api/misc.md
Normal file
1044
docs/api/misc.md
Normal file
File diff suppressed because it is too large
Load Diff
197
docs/api/settings.md
Normal file
197
docs/api/settings.md
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 配置(Settings)
|
||||||
|
|
||||||
|
声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表。
|
||||||
|
|
||||||
|
## `SettingsAPI`
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`DataDir`](#settingsapidatadir) | DataDir returns the plugin-specific data directory (guaranteed to exist): |
|
||||||
|
| [`Defs`](#settingsapidefs) | Defs returns config definitions matching the prefix. |
|
||||||
|
| [`Dump`](#settingsapidump) | Dump returns all config values. |
|
||||||
|
| [`Get`](#settingsapiget) | Get reads the plugin's own config value (config_<name> table). |
|
||||||
|
| [`GetCore`](#settingsapigetcore) | GetCore reads the core config table. |
|
||||||
|
| [`GetPlugin`](#settingsapigetplugin) | GetPlugin reads another plugin's config table. |
|
||||||
|
| [`List`](#settingsapilist) | List returns all keys matching the given prefix. |
|
||||||
|
| [`ListCore`](#settingsapilistcore) | ListCore lists core config keys matching the prefix. |
|
||||||
|
| [`ListPlugin`](#settingsapilistplugin) | ListPlugin lists another plugin's config keys matching the prefix. |
|
||||||
|
| [`Plugins`](#settingsapiplugins) | Plugins returns a list of all plugin config namespaces. |
|
||||||
|
| [`RegisterDef`](#settingsapiregisterdef) | RegisterDef registers a config definition for UI display. |
|
||||||
|
| [`Set`](#settingsapiset) | Set writes a config value to the plugin's own config table. |
|
||||||
|
| [`SetCore`](#settingsapisetcore) | SetCore writes to the core config table. |
|
||||||
|
| [`SetPlugin`](#settingsapisetplugin) | SetPlugin writes to another plugin's config table. |
|
||||||
|
|
||||||
|
### `SettingsAPI.DataDir`
|
||||||
|
|
||||||
|
```go
|
||||||
|
DataDir() string
|
||||||
|
```
|
||||||
|
|
||||||
|
DataDir returns the plugin-specific data directory (guaranteed to exist):
|
||||||
|
<daemon data>/plugin_data/<plugin_name>. Plugins should persist any
|
||||||
|
runtime files (generated images, caches, downloads) here.
|
||||||
|
|
||||||
|
<small>`settings.go:25`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Defs`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Defs(prefix string) []*ConfigDef
|
||||||
|
```
|
||||||
|
|
||||||
|
Defs returns config definitions matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:40`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Dump`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Dump() map[string]interface{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dump returns all config values.
|
||||||
|
|
||||||
|
<small>`settings.go:43`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Get`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Get(key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Get reads the plugin's own config value (config_<name> table).
|
||||||
|
|
||||||
|
<small>`settings.go:5`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.GetCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetCore(key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GetCore reads the core config table.
|
||||||
|
|
||||||
|
<small>`settings.go:14`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.GetPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetPlugin(plugin, key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GetPlugin reads another plugin's config table.
|
||||||
|
|
||||||
|
<small>`settings.go:28`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.List`
|
||||||
|
|
||||||
|
```go
|
||||||
|
List(prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
List returns all keys matching the given prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:11`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.ListCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListCore(prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListCore lists core config keys matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:20`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.ListPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListPlugin(plugin, prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListPlugin lists another plugin's config keys matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:34`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Plugins`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Plugins() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins returns a list of all plugin config namespaces.
|
||||||
|
|
||||||
|
<small>`settings.go:46`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.RegisterDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
RegisterDef(def ConfigDef)
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterDef registers a config definition for UI display.
|
||||||
|
|
||||||
|
<small>`settings.go:37`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Set`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Set(key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Set writes a config value to the plugin's own config table.
|
||||||
|
|
||||||
|
<small>`settings.go:8`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.SetCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetCore(key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SetCore writes to the core config table.
|
||||||
|
|
||||||
|
<small>`settings.go:17`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.SetPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetPlugin(plugin, key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SetPlugin writes to another plugin's config table.
|
||||||
|
|
||||||
|
<small>`settings.go:31`</small>
|
||||||
|
|
||||||
|
### `ConfigDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ConfigDef struct { Key string `json:"key"` Default interface{} `json:"default,omitempty"` Type string `json:"type"` DisplayName string …
|
||||||
|
```
|
||||||
|
|
||||||
|
ConfigDef describes a configuration field for the WebUI.
|
||||||
|
|
||||||
|
<small>`settings.go:50`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Settings`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Settings() SettingsAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings returns the settings API for reading/writing plugin configuration.
|
||||||
|
sett 在 New 时一次性写入且无 setter,故不需要加锁。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:68` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:63` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:67` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:505`</small>
|
||||||
|
|
||||||
154
docs/api/stages.md
Normal file
154
docs/api/stages.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 阶段钩子(Stages)
|
||||||
|
|
||||||
|
在消息处理管道的固定点位插入自己的逻辑。阶段比工具更底层:工具是模型主动调用的,阶段是流程经过时必然触发的。
|
||||||
|
|
||||||
|
### `StageContext.IsResponded`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) IsResponded() bool
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:213`</small>
|
||||||
|
|
||||||
|
### `StageContext.Lock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) Lock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:158` | `p.sessMu.Lock()` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:128` | `p.srvMu.Lock()` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:37` | `p.runMu.Lock()` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:427` | `p.mu.Lock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:211`</small>
|
||||||
|
|
||||||
|
### `StageContext.RLock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) RLock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:267` | `p.mu.RLock()` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:649` | `p.mu.RLock()` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:224` | `p.mu.RLock()` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1152` | `ctx.RLock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:209`</small>
|
||||||
|
|
||||||
|
### `StageContext.RUnlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) RUnlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:273` | `p.mu.RUnlock()` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:650` | `defer p.mu.RUnlock()` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:229` | `p.mu.RUnlock()` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1155` | `ctx.RUnlock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:210`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterStage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope)
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterStage registers a handler for a pipeline stage.
|
||||||
|
|
||||||
|
scope: StageScopeGlobal (default) — receives all stage events.
|
||||||
|
StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:152` | `s.RegisterStage(sdk.StagePreAction, p.stagePreAction)` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:719` | `s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)` |
|
||||||
|
| [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` |
|
||||||
|
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` |
|
||||||
|
|
||||||
|
<small>`plugin.go:571`</small>
|
||||||
|
|
||||||
|
### `Stage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Stage string
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage represents a point in the message processing pipeline.
|
||||||
|
|
||||||
|
<small>`plugin.go:26`</small>
|
||||||
|
|
||||||
|
### `StageContext`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageContext struct { mu sync.RWMutex RawMessage string UserID string GroupID string ContextMsgs []map[string]interface{} L …
|
||||||
|
```
|
||||||
|
|
||||||
|
StageContext provides context for stage handlers.
|
||||||
|
|
||||||
|
<small>`plugin.go:189`</small>
|
||||||
|
|
||||||
|
### `StageHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageHandler func(ctx *StageContext) error
|
||||||
|
```
|
||||||
|
|
||||||
|
StageHandler is a function that handles a pipeline stage event.
|
||||||
|
|
||||||
|
<small>`plugin.go:23`</small>
|
||||||
|
|
||||||
|
### `StageRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageRegistrar func(stage Stage, handler StageHandler)
|
||||||
|
```
|
||||||
|
|
||||||
|
StageRegistrar registers a stage handler.
|
||||||
|
|
||||||
|
<small>`plugin.go:407`</small>
|
||||||
|
|
||||||
|
### `StageScope`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageScope int
|
||||||
|
```
|
||||||
|
|
||||||
|
StageScope controls which events a stage handler receives.
|
||||||
|
|
||||||
|
<small>`plugin.go:393`</small>
|
||||||
|
|
||||||
|
### `StageContext.Unlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:164` | `p.sessMu.Unlock()` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:129` | `defer p.srvMu.Unlock()` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:40` | `p.runMu.Unlock()` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:432` | `p.mu.Unlock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:212`</small>
|
||||||
|
|
||||||
77
docs/api/tools.md
Normal file
77
docs/api/tools.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 工具(Tools)
|
||||||
|
|
||||||
|
注册 LLM 可调用的工具。工具是插件最主要的能力形态:模型看到 `ToolDef` 的说明后决定是否调用,调用时执行你的 `ToolHandler`。
|
||||||
|
|
||||||
|
### `ContentBlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *ImageURL `json:"image_url,omitempty"` AudioURL *AudioURL `jso …
|
||||||
|
```
|
||||||
|
|
||||||
|
ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)。
|
||||||
|
插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
|
||||||
|
请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
|
||||||
|
|
||||||
|
<small>`plugin.go:954`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterTool`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterTool registers a tool that the LLM can call.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:76` | `s.RegisterTool(tp+"a2a_query", sdk.ToolDef{` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:70` | `s.RegisterTool(tp+"acp_query", sdk.ToolDef{` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:85` | `s.RegisterTool(tp+"video", sdk.ToolDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:557`</small>
|
||||||
|
|
||||||
|
### `ToolCall`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolCall represents a model's request to call a tool.
|
||||||
|
|
||||||
|
<small>`plugin.go:227`</small>
|
||||||
|
|
||||||
|
### `ToolDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolDef struct { Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Description string …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolDef describes a tool that the plugin exposes.
|
||||||
|
|
||||||
|
<small>`plugin.go:279`</small>
|
||||||
|
|
||||||
|
### `ToolHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolHandler is a function that handles a tool call.
|
||||||
|
|
||||||
|
<small>`plugin.go:20`</small>
|
||||||
|
|
||||||
|
### `ToolResult`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolResult struct { CallID string `json:"call_id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Success bool `json:"suc …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolResult represents the result of a tool call.
|
||||||
|
|
||||||
|
<small>`plugin.go:235`</small>
|
||||||
|
|
||||||
3080
docs/assets/api-index.json
Normal file
3080
docs/assets/api-index.json
Normal file
File diff suppressed because it is too large
Load Diff
27
docs/assets/logo-mark.svg
Normal file
27
docs/assets/logo-mark.svg
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
|
||||||
|
|
||||||
|
<g transform="translate(200,200)">
|
||||||
|
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
|
||||||
|
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
|
||||||
|
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
|
||||||
|
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
|
||||||
|
|
||||||
|
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
|
||||||
|
|
||||||
|
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
28
docs/assets/logo.svg
Normal file
28
docs/assets/logo.svg
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
|
||||||
|
<rect width="400" height="400" fill="#F8FAFC"/>
|
||||||
|
|
||||||
|
<g transform="translate(200,200)">
|
||||||
|
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
|
||||||
|
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
|
||||||
|
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
|
||||||
|
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
|
||||||
|
|
||||||
|
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
|
||||||
|
|
||||||
|
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
60
docs/examples/index.md
Normal file
60
docs/examples/index.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 示例插件
|
||||||
|
|
||||||
|
SDK 仓 `example/` 下有多个**真实可编译**的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态。
|
||||||
|
|
||||||
|
每个示例都能用 `hmapdev build` 打成 `.hmap` 装进内核直接跑。
|
||||||
|
|
||||||
|
## `a2a`
|
||||||
|
|
||||||
|
用到的 API:`Error` · `InjectInputSync` · `Lock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
|
||||||
|
|
||||||
|
## `acp`
|
||||||
|
|
||||||
|
用到的 API:`Error` · `InjectInputSync` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
|
||||||
|
|
||||||
|
## `ai_image`
|
||||||
|
|
||||||
|
用到的 API:`Error` · `RegisterTool` · `SetAutoRestart` · `Settings`
|
||||||
|
|
||||||
|
## `bili`
|
||||||
|
|
||||||
|
用到的 API:`Lock` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
|
||||||
|
|
||||||
|
## `browser`
|
||||||
|
|
||||||
|
用到的 API:`Error` · `InjectInterruptTextOpts` · `InjectTextNoMemory` · `Lock` · `RegisterInputChannel` · `Unlock`
|
||||||
|
|
||||||
|
## `calendar`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOnRemoveHandler` · `RegisterStopHandler`
|
||||||
|
|
||||||
|
## `deepsearch`
|
||||||
|
|
||||||
|
用到的 API:`RegisterStopHandler`
|
||||||
|
|
||||||
|
## `memo`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOnRemoveHandler` · `RegisterStage`
|
||||||
|
|
||||||
|
## `qq`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOutputChannel` · `RegisterStage`
|
||||||
|
|
||||||
|
## `recoverydiag`
|
||||||
|
|
||||||
|
用到的 API:`Knowledge`
|
||||||
|
|
||||||
|
## `rss`
|
||||||
|
|
||||||
|
用到的 API:`RegisterOnRemoveHandler`
|
||||||
|
|
||||||
|
## `sanitizer`
|
||||||
|
|
||||||
|
用到的 API:`RegisterStage`
|
||||||
|
|
||||||
|
## `weather`
|
||||||
|
|
||||||
|
用到的 API:`RegisterOutputChannel` · `RegisterStage`
|
||||||
|
|
||||||
75
docs/guide/capability-boundary.md
Normal file
75
docs/guide/capability-boundary.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# 能力边界:哪些 API 外部插件能用
|
||||||
|
|
||||||
|
HomeAgent 有两类插件:
|
||||||
|
|
||||||
|
| 类型 | 说明 | 分发 |
|
||||||
|
|---|---|---|
|
||||||
|
| **外部插件** | 第三方开发,编译成 `.hmap` 后安装 | 独立分发,**可闭源** |
|
||||||
|
| **内置插件** | 编译进内核,`init()` 自注册 | 随内核发行,需合入主仓 |
|
||||||
|
|
||||||
|
SDK 包是**同一个** `github.com/JianFeeeee/homeagentsdk/sdk`,但两类插件拿到的
|
||||||
|
**能力不同**:外部插件跑在独立进程里,由内核通过桥接注入能力(IPC,不是共享内存里的直接调用)。
|
||||||
|
|
||||||
|
本页说明边界在哪、为什么,以及**怎么在写代码前就知道某个 API 是否可用**。
|
||||||
|
|
||||||
|
## 一句话规则
|
||||||
|
|
||||||
|
> **公开 SDK 包里声明的符号,不等于外部插件拿得到。**
|
||||||
|
|
||||||
|
原因是:有些能力只有进程内的内置插件才可能拥有(比如直接读事件发布通道、
|
||||||
|
直接注入到内核 IO 层)。外部插件通过桥接运行时拿到的是一份**受注入的能力集合**。
|
||||||
|
|
||||||
|
## 外部插件**不可用**的 API
|
||||||
|
|
||||||
|
这些 API 在公开包里存在,但在外部插件路径上拿不到。文档里每条都带
|
||||||
|
<span class="api-badge api-badge-builtin">仅内置</span> 标记,
|
||||||
|
完整清单见 [仅内置插件可用](../api/builtin-only.md)。
|
||||||
|
|
||||||
|
| API | 外部插件的实际情况 | 该用什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sdk.PluginSDK.Events()` | **恒为 nil**。桥接运行时不注入 event subscriber(`SetEventSubscriber` 全仓无调用点) | 桥接运行时已按你的声明完成 `events.subscribe`;Lua 插件用 `sdk.events.subscribe` |
|
||||||
|
| `sdk.PluginSDK.SetEventSubscriber` | 无人调用 | 同上 |
|
||||||
|
| `UnregisterOutputChannel` | 桥接只注入 registrar、**不注入 unregistrar**,调用是**静默无效**(返回 nil,不报错也不注销) | `RegisterOutputChannel` 可用;注销需重载插件 |
|
||||||
|
| `SocialAPI` 的写操作 | 公开接口只有 6 个**只读**方法 | 读用 `s.GetPerson` 等;写需内置插件 |
|
||||||
|
| `EventSubscriber.Publish` | 公开接口**刻意只有 Subscribe**,没有 Publish | 只订阅 |
|
||||||
|
| `PriorityL4` | 声明会被内核**夹到 L3** | 用 L1–L3 |
|
||||||
|
| `RegisterChannel` / `ListChannels` / `OutputChan` / `InjectInput` / `InjectInterrupt` | 只存在于内核内部 SDK | `RegisterInputChannel` / `RegisterOutputChannel` / `InjectText` 等公开方法 |
|
||||||
|
| `PluginMgr()` 的完整能力 | 公开 `PluginMgrAPI` **只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` / `IsPluginDisabled`) | 就这 3 个;`ReloadPlugins`/`Disable`/`Remove` 属内部接口 |
|
||||||
|
|
||||||
|
!!! warning "两处常见的文档错误(本站已更正)"
|
||||||
|
1. **`PluginMgr()` 不是「仅内置可用」**。桥接模板显式注入了它
|
||||||
|
(`base.SetPluginMgrAPI(procPluginMgr{})`),公开 `PluginMgrAPI` 也注明
|
||||||
|
「外部插件可调用」。真正的区别是**方法数量**:公开面 3 个,内部面 9 个。
|
||||||
|
容易混淆是因为两个包里有**同名但不同**的接口:
|
||||||
|
`sdk.PluginMgrAPI`(3 方法)与 `internal/sdk.PluginManager`(9 方法)。
|
||||||
|
2. **`Events()` 恒为 nil 这件事以前没写清**。旧文档把 `Events()` 当作
|
||||||
|
可用的订阅入口,但桥接运行时不注入 subscriber。外部插件的事件订阅
|
||||||
|
实际由生成的运行时通过 `events.subscribe` 完成。
|
||||||
|
|
||||||
|
## 判定依据来自哪里
|
||||||
|
|
||||||
|
本站的「仅内置」标记不是猜的,逐条来自:
|
||||||
|
|
||||||
|
1. **`tools/hmapdev/templates/proc_main.go.tmpl`** —— 外部插件运行时**实际注入**
|
||||||
|
哪些能力,看 `buildPluginSDK()` 里的 `base.Set*` 调用。
|
||||||
|
2. **`internal/sdk`** —— 内置插件用的完整接口,与公开包对照。
|
||||||
|
3. **内核 RPC 协议表**(`internal/plugin/proc/protocol.go`)—— 外部插件**能发哪些请求**。
|
||||||
|
|
||||||
|
每条裁定的具体依据写在该 API 的告警框里,可以直接核对。
|
||||||
|
|
||||||
|
## 怎么快速确认
|
||||||
|
|
||||||
|
- 用 [API 搜索](../api/index.md) 搜 API 名或功能描述,带
|
||||||
|
<span class="api-badge api-badge-builtin">仅内置</span> 的就是外部不可用
|
||||||
|
- 直接看 [仅内置插件可用](../api/builtin-only.md) 汇总页
|
||||||
|
- 拿不准时,**读 `example/` 下的示例插件** —— 它们全是外部插件,
|
||||||
|
能被它们编译通过的写法,外部就一定可用
|
||||||
|
|
||||||
|
## 为什么这样设计
|
||||||
|
|
||||||
|
不是为了限制,而是**IPC 边界决定了能力边界**:外部插件跑在独立进程里,
|
||||||
|
内核只能通过显式的注入点把能力交过去。凡是需要「持有内核内部数据结构」
|
||||||
|
的能力(事件发布通道、IO 通道、插件注册表全量操作),进程外都无法安全暴露。
|
||||||
|
|
||||||
|
这套边界同时带来好处:插件崩溃不会带崩内核(进程隔离),
|
||||||
|
以及**插件可以闭源**(SDK 是 MIT,见[首页](../index.md#_3))。
|
||||||
111
docs/guide/first-lua-plugin.md
Normal file
111
docs/guide/first-lua-plugin.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# 第一个 Lua 插件
|
||||||
|
|
||||||
|
Lua 插件适合**轻量、快速原型**:不需要 Go 编译环境,改完重启内核即可生效。
|
||||||
|
但它有一个必须理解的限制 —— 执行模型是**被动回调**。
|
||||||
|
|
||||||
|
## 执行模型(先读这段)
|
||||||
|
|
||||||
|
Lua 插件跑在内核进程内的 gopher-lua 解释器里(单 Lua 状态 + 互斥锁):
|
||||||
|
|
||||||
|
- **被动回调**:`main.lua` 只在加载时执行一次。此后工具、阶段钩子、
|
||||||
|
输入输出通道全部由内核事件驱动回调你的 Lua 函数。**插件不能自己启动后台任务。**
|
||||||
|
- **没有并发**:Lua 侧没有 goroutine、协程调度,也没有 `os` / `io` 库和 socket 监听。
|
||||||
|
唯一主动出站通道是 `sdk.http.get/post`(同步请求)。
|
||||||
|
- **任何阻塞循环都会持锁卡死该插件的全部调用。**
|
||||||
|
|
||||||
|
!!! warning "要常驻服务就用 Go 插件"
|
||||||
|
需要监听端口、后台轮询、定时任务的,请用 [Go 插件](first-plugin.md)
|
||||||
|
(可自行启动 goroutine)。Lua 侧的等价做法是**事件驱动**:把逻辑挂在
|
||||||
|
工具、阶段钩子或通道回调上。
|
||||||
|
|
||||||
|
## 生成工程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev init myluaplugin --lua
|
||||||
|
cd myluaplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
myluaplugin/
|
||||||
|
├── plg.json — entry: "main.lua", targets: "lua"
|
||||||
|
├── main.lua — 插件实现
|
||||||
|
├── sdk.lua — SDK 模拟层(支持独立测试)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 一个完整的插件
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- main.lua
|
||||||
|
local plugin = {
|
||||||
|
name = "myluaplugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
function plugin.start(sdk)
|
||||||
|
sdk.log("info", "myluaplugin starting...")
|
||||||
|
|
||||||
|
sdk.register_tool("myluaplugin_hello", {
|
||||||
|
description = "向指定的人打招呼",
|
||||||
|
parameters = {
|
||||||
|
type = "object",
|
||||||
|
properties = {
|
||||||
|
who = { type = "string", description = "要打招呼的对象" }
|
||||||
|
},
|
||||||
|
required = { "who" }
|
||||||
|
}
|
||||||
|
}, function(args)
|
||||||
|
return { content = "hello, " .. (args.who or "world") .. "!" }
|
||||||
|
end)
|
||||||
|
|
||||||
|
sdk.log("info", "myluaplugin started")
|
||||||
|
end
|
||||||
|
|
||||||
|
function plugin.stop()
|
||||||
|
sdk.log("info", "myluaplugin stopped")
|
||||||
|
end
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地测试
|
||||||
|
|
||||||
|
`sdk.lua` 是纯 Lua 的 SDK 模拟实现,可以直接用解释器跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lua main.lua
|
||||||
|
# [lua-plugin] info: myluaplugin starting...
|
||||||
|
# [lua-plugin] register_tool: myluaplugin_hello
|
||||||
|
# [lua-plugin] info: myluaplugin started
|
||||||
|
```
|
||||||
|
|
||||||
|
在内核里运行时,`sdk.*` 由 Go 层注入,`sdk.lua` 里所有 `-- !impl` 标记的函数
|
||||||
|
会被替换成真实实现。
|
||||||
|
|
||||||
|
## API 约定的两点
|
||||||
|
|
||||||
|
- **注册类函数调用即时报错**(抛 Lua error)—— 注册失败不会静默。
|
||||||
|
- **数据类函数统一返回 `(result, err)`**,`err` 为 nil 表示成功。
|
||||||
|
核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||||
|
|
||||||
|
Lua 侧的 `sdk.*` 能力与外部 Go 插件对齐至 SDK 1.3.0(需内核 1.4.0+)。
|
||||||
|
|
||||||
|
!!! note "历史提醒"
|
||||||
|
1.1–1.3 期间,媒体 / 注入标志位 / 优先级能力只在 Go 侧有,Lua 侧静默缺失。
|
||||||
|
现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住
|
||||||
|
「`sdk.lua` 承诺的每个函数都有运行时绑定」。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # → dist/myluaplugin_lua.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
Lua 插件直接打包源码,不经过编译。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [能力边界](capability-boundary.md) —— Lua 与 Go 外部插件的能力面一致
|
||||||
|
- [打包与发布](packaging.md)
|
||||||
|
- [示例](../examples/index.md) —— `example/luademo` 是 Lua 版参考实现
|
||||||
162
docs/guide/first-plugin.md
Normal file
162
docs/guide/first-plugin.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# 第一个 Go 插件
|
||||||
|
|
||||||
|
以下是一个**能直接跑起来**的最小插件:注册一个工具、声明一项配置、处理停止与卸载。
|
||||||
|
|
||||||
|
## 1. 生成工程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev init myplugin
|
||||||
|
cd myplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
生成的结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
myplugin/
|
||||||
|
├── plg.json — 插件元信息(名称、版本、入口、目标平台)
|
||||||
|
├── plugin.go — 插件实现
|
||||||
|
├── go.mod — 模块定义
|
||||||
|
├── README.md
|
||||||
|
└── thirdpart/ — 外部源码存放目录(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
`hmapdev build` 时会在构建目录自动生成子进程运行时(`z_proc_gen.go` 等),
|
||||||
|
**不需要手工创建,也不要提交**。
|
||||||
|
|
||||||
|
## 2. 插件实现
|
||||||
|
|
||||||
|
插件的全部契约是一个 `Plugin` 接口([API 参考](../api/lifecycle.md#plugin)):
|
||||||
|
|
||||||
|
| 方法 | 何时调用 |
|
||||||
|
|---|---|
|
||||||
|
| `Name() string` | 内核需要标识这个插件时 |
|
||||||
|
| `Start(*sdk.PluginSDK) error` | 插件加载后。**在这里注册工具、通道、配置** |
|
||||||
|
| `Stop() error` | 插件停止时(重载、禁用、内核退出都会触发) |
|
||||||
|
|
||||||
|
再加一个工厂函数。**名字必须是 `NewPluginFactory`** —— 生成的运行时按这个名字调用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "不要写成 `NewPlugin`"
|
||||||
|
生成的子进程运行时调用的入口是 `NewPluginFactory`。仓库里有 3 个早期示例
|
||||||
|
同时保留了两个名字(`NewPlugin` 只是遗留别名),但新插件只写
|
||||||
|
`NewPluginFactory` 即可。写错名字的后果是**编译能过、加载时找不到入口**。
|
||||||
|
|
||||||
|
## 3. 一个完整的例子
|
||||||
|
|
||||||
|
这是一个「打招呼」工具,带一项配置:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
|
||||||
|
// ① 声明配置项:内核会把它渲染到 WebUI 设置页
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.myplugin.greeting",
|
||||||
|
Default: "hello",
|
||||||
|
Type: "string",
|
||||||
|
DisplayName: "问候语",
|
||||||
|
Description: "打招呼时使用的前缀",
|
||||||
|
Category: "myplugin",
|
||||||
|
})
|
||||||
|
|
||||||
|
// ② 注册工具:模型看到 Description 后决定是否调用
|
||||||
|
tp := p.name + "_"
|
||||||
|
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||||
|
Name: tp + "hello",
|
||||||
|
Description: "向指定的人打招呼",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"who": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要打招呼的对象",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"who"},
|
||||||
|
},
|
||||||
|
}, p.handleHello)
|
||||||
|
|
||||||
|
// ③ 卸载(插件被删除)前清理自己产生的数据。
|
||||||
|
// 注意与 Stop 的区别:Stop 在每次重载时也会触发。
|
||||||
|
s.RegisterOnRemoveHandler(func() {
|
||||||
|
fmt.Printf("[%s] 清理数据\n", p.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||||
|
who, _ := args["who"].(string)
|
||||||
|
|
||||||
|
greeting := "hello"
|
||||||
|
if v, err := p.sdk.Settings().Get("plugin.myplugin.greeting"); err == nil && v != "" {
|
||||||
|
greeting = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("%s, %s!", greeting, who),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 工具返回值的两条约定
|
||||||
|
|
||||||
|
`ToolHandler` 返回 `(interface{}, error)`,模型侧看到的是一条 tool message:
|
||||||
|
|
||||||
|
- **正常结果**:返回一个 map,把要展示给模型的文本放在 `content` 字段。
|
||||||
|
未识别的字段也会一并传给模型,可以放结构化数据。
|
||||||
|
- **业务失败**:返回 `map[string]interface{}{"isError": true, "content": "原因"}`
|
||||||
|
**并返回 nil error**。这样模型能看到失败原因并自行调整;
|
||||||
|
若返回 Go 的 `error`,那是**工具调用本身出错**,语义不同。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"isError": true, "content": msg}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 构建与安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # 默认产出多平台 bundle
|
||||||
|
# → dist/myplugin_bundle.hmap
|
||||||
|
|
||||||
|
hmapdev build --no-bundle # 只构建当前平台
|
||||||
|
# → dist/myplugin_linux_amd64.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
安装到内核:在 WebUI 的插件管理页上传 `.hmap`,或从 URL / 本地路径安装。
|
||||||
|
详见 [打包与发布](packaging.md)。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [能力边界](capability-boundary.md) —— 哪些 API 外部插件能用
|
||||||
|
- [工具(Tools)](../api/tools.md) —— `ToolDef` 的完整字段
|
||||||
|
- [记忆(Memory)](../api/memory.md) —— 让插件读写长期记忆
|
||||||
|
- [示例插件](../examples/index.md) —— `example/memo` 是个完整的可读实现
|
||||||
68
docs/guide/getting-started.md
Normal file
68
docs/guide/getting-started.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# 环境与工具链
|
||||||
|
|
||||||
|
`hmapdev` 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
|
||||||
|
最终产出 `.hmap` 插件包(工具名即取自这个包格式)。
|
||||||
|
|
||||||
|
!!! note "改名说明"
|
||||||
|
1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
||||||
|
`~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||||
|
(旧目录会自动继续沿用)。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
从源码构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/JianFeeeee/homeagentsdk
|
||||||
|
cd homeagentsdk/tools/hmapdev
|
||||||
|
go build -o hmapdev
|
||||||
|
# 把 hmapdev 放进 PATH,或直接用 ./hmapdev
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以从 SDK 的 release 附件下载预编译二进制(`hmapdev_linux_amd64` 等,
|
||||||
|
共 5 个平台:linux/darwin/windows × amd64/arm64)。
|
||||||
|
|
||||||
|
> 仓已迁到 GitHub;gitcode 仅作国内镜像(源码同步,**release 附件暂时仍在那里**):
|
||||||
|
> `https://github.com/JianFeeeee/homeagentsdk/releases`。
|
||||||
|
> Go 模块路径仍是 `github.com/JianFeeeee/homeagentsdk` —— 这是有意保留的,
|
||||||
|
> 改模块路径会让现有插件的 `go.mod` 全面失效。
|
||||||
|
|
||||||
|
## SDK 版本管理
|
||||||
|
|
||||||
|
`hmapdev` 会维护一份本地 SDK 存储,`init` 时按 `plg.json` 里的 `sdk` 字段
|
||||||
|
选择版本。两者**必须**一致,否则编译出的插件与内核协议可能错配。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev sdk list # 已安装的 SDK 版本
|
||||||
|
hmapdev sdk current # 当前使用的版本
|
||||||
|
hmapdev sdk latest # 最新可用版本
|
||||||
|
hmapdev sdk install v1.2.0 # 安装指定版本
|
||||||
|
hmapdev sdk use v1.2.0 # 切换版本
|
||||||
|
hmapdev sdk path # 当前 SDK 路径
|
||||||
|
```
|
||||||
|
|
||||||
|
存储在 `~/.homeagent/hmapdev/sdk/<version>/`。
|
||||||
|
|
||||||
|
!!! warning "版本未命中会**明确报错**"
|
||||||
|
`plg.json` 声明的 `sdk` 版本若不在本地存储里,`hmapdev` 不会退回某个默认版本,
|
||||||
|
而是报错并让你先 `hmapdev sdk install`。这是有意的:静默降级会产出与内核
|
||||||
|
协议不匹配的插件,那种失败要到运行时才暴露。
|
||||||
|
|
||||||
|
## 源码调试
|
||||||
|
|
||||||
|
不编译直接跑插件源码,输出调用轨迹:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev debug [dir] # dir 默认当前目录
|
||||||
|
```
|
||||||
|
|
||||||
|
写 Lua 插件时更简单——`sdk.lua` 是 SDK 模拟层,可以直接用解释器跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lua main.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [第一个 Go 插件](first-plugin.md)
|
||||||
|
- [第一个 Lua 插件](first-lua-plugin.md)
|
||||||
56
docs/guide/multi-platform.md
Normal file
56
docs/guide/multi-platform.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 多平台构建
|
||||||
|
|
||||||
|
## 默认就是多平台
|
||||||
|
|
||||||
|
`hmapdev build` 默认 bundle 模式,一次产出含三个平台的单个 `.hmap`:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/myplugin_bundle.hmap
|
||||||
|
└── plugin.bin.linux.amd64
|
||||||
|
└── plugin.bin.darwin.amd64
|
||||||
|
└── plugin.bin.windows.amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
安装时内核挑当前平台那份,重命名为 `plugin.bin`。
|
||||||
|
|
||||||
|
## 逐平台构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build --no-bundle # 按 plg.json 的 targets 构建
|
||||||
|
hmapdev build --target linux/arm64 # 追加一个目标
|
||||||
|
```
|
||||||
|
|
||||||
|
`plg.json` 里声明目标:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "myplugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"targets": "linux/amd64,windows/amd64"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
单平台输出文件名:`{name}_{os}_{arch}.hmap`。
|
||||||
|
|
||||||
|
## 交叉编译
|
||||||
|
|
||||||
|
子进程插件**不再需要 cgo**,所以交叉编译不需要目标平台的 C 工具链 ——
|
||||||
|
这是 v1.0.0 的收益之一。
|
||||||
|
|
||||||
|
!!! note "bundle 模式忽略 `targets`"
|
||||||
|
固定构建 linux/amd64、darwin/amd64、windows/amd64。如果你只需要其中一个,
|
||||||
|
用 `--no-bundle` 更快。
|
||||||
|
|
||||||
|
## 平台能力差异
|
||||||
|
|
||||||
|
历史上有过一处真实的平台断层,现已消除:
|
||||||
|
|
||||||
|
- **v1.0.0 之前**:Windows 上插件只看到 **3 个 stage 字段、且无法写回**。
|
||||||
|
- **v1.0.0 起**:Windows 与其他平台**共用同一套 RPC 实现**,16 字段全可见 + 写回。
|
||||||
|
|
||||||
|
因此**不必**为 Windows 写条件分支 —— 除非你的插件自己用了平台专有的外部命令。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [打包与发布](packaging.md)
|
||||||
|
- [环境与工具链](getting-started.md)
|
||||||
114
docs/guide/packaging.md
Normal file
114
docs/guide/packaging.md
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# 打包与发布
|
||||||
|
|
||||||
|
`hmapdev build` 一次完成编译与打包,产出 `.hmap` 分发包(zip 格式,内含
|
||||||
|
`plugin.json` 清单 + 二进制)。
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # 默认 bundle(多平台合集)
|
||||||
|
hmapdev build --no-bundle # 只构建 plg.json targets 里的平台
|
||||||
|
hmapdev build --target linux/arm64 # 在 targets 基础上追加目标
|
||||||
|
hmapdev build --outdir out # 指定输出目录(默认 dist)
|
||||||
|
hmapdev build --sdk-path <path> # 覆盖 go.mod 的 replace 指向的 SDK
|
||||||
|
hmapdev build --replace <mod@path> # 追加 go.mod replace(可多次)
|
||||||
|
```
|
||||||
|
|
||||||
|
执行流程:
|
||||||
|
|
||||||
|
1. 读 `plg.json` 的 `targets` / `bundle` 决定构建目标
|
||||||
|
2. 生成子进程运行时代码(`z_proc_gen.go`、`z_proc_shm_*.go`)
|
||||||
|
3. **Go 插件**:`go build`(普通可执行文件,`CGO_ENABLED=0`)
|
||||||
|
**Lua 插件**:直接打包源码,不编译
|
||||||
|
4. 生成 `plugin.json` 输出清单
|
||||||
|
5. 打成 `.hmap`
|
||||||
|
|
||||||
|
## 两个 JSON 的区别
|
||||||
|
|
||||||
|
这一点经常混淆:
|
||||||
|
|
||||||
|
| 文件 | 谁维护 | 作用 | 关键字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `plg.json` | **你** | 项目元信息,构建输入 | `targets`、`bundle` |
|
||||||
|
| `plugin.json` | `hmapdev` 自动生成 | 构建产物清单 | `entry`、`platforms` |
|
||||||
|
|
||||||
|
`plg.json` 里的 `sdk` 字段声明**本插件针对的 SDK 版本**;未命中本地 SDK 存储
|
||||||
|
会明确报错(见[环境与工具链](getting-started.md))。
|
||||||
|
|
||||||
|
## 多平台(bundle)
|
||||||
|
|
||||||
|
`build` 默认就是 bundle 模式:一次编译 linux/amd64、darwin/amd64、windows/amd64,
|
||||||
|
产出一个含全部平台二进制的 `.hmap`;安装时内核挑当前平台那份。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # → dist/myplugin_bundle.hmap
|
||||||
|
hmapdev build --no-bundle # → dist/myplugin_linux_amd64.hmap 等
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note "bundle 模式会忽略 `plg.json` 的 `targets`"
|
||||||
|
固定构建上述三个平台。交叉编译需要对应工具链(如 Linux 上构建 darwin 需要
|
||||||
|
clang / macOS SDK),缺工具链时会失败 —— 此时用 `--no-bundle` 只构建当前平台。
|
||||||
|
|
||||||
|
bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分,安装时重命名为 `plugin.bin`。
|
||||||
|
|
||||||
|
## 产物形态
|
||||||
|
|
||||||
|
子进程插件是**普通可执行文件**,不分平台后缀:
|
||||||
|
|
||||||
|
| 平台 | 二进制 |
|
||||||
|
|---|---|
|
||||||
|
| Linux / macOS / Windows | `plugin.bin` |
|
||||||
|
|
||||||
|
!!! warning "v1.0.0 破坏性变更:不再加载 `.so` / `.dll`"
|
||||||
|
外部插件从 C ABI 动态库改为**子进程 + 共享内存**。
|
||||||
|
|
||||||
|
- `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。
|
||||||
|
新内核遇到旧产物会跳过并报可操作错误,不崩溃。
|
||||||
|
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev`
|
||||||
|
(原 `plugindev`)重编即可。
|
||||||
|
- `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写 `plugin.so` 也无妨),
|
||||||
|
现在只用于区分 Lua 插件。
|
||||||
|
- 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
三种方式(`9876` 是 pluginmgr 的本地端口,默认只监听 `127.0.0.1`、无鉴权):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 URL 安装(仅 http/https,流式下载不落盘)
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "https://example.com/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 从本地路径安装(读取文件,不移动原文件)
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"path": "/path/to/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 直接上传二进制
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
--data-binary @dist/myplugin_bundle.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后调用 `/api/v1/plugins/reload` 或重启内核生效。
|
||||||
|
|
||||||
|
走 WebUI 的 HTTP API(默认 `8080`,需 `api_key` 鉴权,内部代理到 pluginmgr):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8080/api/v1/plugins \
|
||||||
|
-H "Authorization: Bearer <api_key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"path": "/path/to/myplugin.hmap"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以在 WebUI 的插件管理页面上传。
|
||||||
|
|
||||||
|
## 发布前自查
|
||||||
|
|
||||||
|
- [ ] `plg.json` 的 `sdk` 版本与目标内核匹配
|
||||||
|
- [ ] `version` 已递增(内核按版本判断是否需要重装)
|
||||||
|
- [ ] 若插件有外部状态,`SetAutoRestart(false)` 或在 `Start` 里重建连接
|
||||||
|
(崩溃重启是**线性退避** 1s→2s→3s,5 分钟内第 4 次崩溃即停止,
|
||||||
|
见[生命周期](../api/lifecycle.md#pluginsdksetautorestart))
|
||||||
|
- [ ] `RegisterOnRemoveHandler` 里清理自己写下的数据文件
|
||||||
|
- [ ] 在 `-race` 下跑一遍:插件的 `Start` 与工具的并发访问是最常见的竞态来源
|
||||||
112
docs/guide/parallel-tool-declaration.md
Normal file
112
docs/guide/parallel-tool-declaration.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# 工具并发声明:`ParallelSafe` / `Serial`
|
||||||
|
|
||||||
|
> 对应 `sdk.ToolDef` 的两个字段。内核在**同一轮**收到多个 `tool_call` 时,
|
||||||
|
> 依据它们决定并发还是整批串行。
|
||||||
|
>
|
||||||
|
> 状态:已随 2026-09 的并行内核落地并在生产启用。
|
||||||
|
|
||||||
|
## 1. 为什么是"保守 opt-in"
|
||||||
|
|
||||||
|
**默认整批串行。** 只有当**批内每一个**工具都显式声明 `ParallelSafe: true`
|
||||||
|
时,那一批才并发;**只要有一个不声明,整批退回串行**。
|
||||||
|
|
||||||
|
这不是"漏了声明导致退化"的将就,而是刻意的设计:
|
||||||
|
|
||||||
|
- 存量插件**不改一行**就得到保守行为(整批串行),不会被升级意外并发
|
||||||
|
- 声明是**责任**而非特权 —— 声明者必须自己确认线程安全
|
||||||
|
- 宁可慢,不可错:一次错误的并发可能让两个工具抢同一个 SQLite 写、
|
||||||
|
同一台设备、或同一个输出通道
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 同批全是安全工具 → 并发
|
||||||
|
tools: [a(ParallelSafe), b(ParallelSafe)] ⇒ 并发
|
||||||
|
|
||||||
|
// 只要有一个没声明 → 整批串行
|
||||||
|
tools: [a(ParallelSafe), b(默认)] ⇒ 串行
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 三个条件都满足才可以声明 `ParallelSafe`
|
||||||
|
|
||||||
|
1. **handler 自身线程安全** —— 不持有跨调用的可变状态
|
||||||
|
2. **不与同批其它工具争抢同一资源** —— SQLite 写、设备、同一输出通道
|
||||||
|
3. **执行顺序无关** —— 顺序敏感的工具应留 `false`,由内核保序
|
||||||
|
|
||||||
|
第 3 条常被忽略:内核能保证**用户可见的消息**按声明顺序落盘,但**工具
|
||||||
|
之间的实际执行先后**在并发模式下不确定。有顺序依赖就留 `false`。
|
||||||
|
|
||||||
|
## 3. `Serial`:显式的反向标记
|
||||||
|
|
||||||
|
```go
|
||||||
|
Serial bool `json:"serial,omitempty"`
|
||||||
|
```
|
||||||
|
|
||||||
|
`ParallelSafe` 的零值 `false` 已经表达"串行",插件**无法区分**:
|
||||||
|
|
||||||
|
- "我没想过"
|
||||||
|
- "我确认过**必须**串行,且有原因"
|
||||||
|
|
||||||
|
一旦工具作者需要把"这里**故意**串行,是有原因的"写进代码(而不只是没填),
|
||||||
|
这个区分就是必需的 —— 否则只能靠命名约定传递意图。
|
||||||
|
|
||||||
|
适用场景:读操作但有隐含顺序约束(终端 `read`/`resize` 这类共享会话
|
||||||
|
状态);写操作虽已加锁但需要串行以获得可预测的交错顺序。
|
||||||
|
|
||||||
|
**优先级:`Serial` 胜出。** 即使同时写了 `ParallelSafe: true`,`Serial`
|
||||||
|
仍然生效 —— 显式声明"必须串行"不允许被 `ParallelSafe` 或任何默认值覆盖。
|
||||||
|
|
||||||
|
## 4. 写法
|
||||||
|
|
||||||
|
声明字段放在 `ToolDef` 结构体的**末尾**,遵循既有 `NoMemory` 的风格:
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterTool(sdk.ToolDef{
|
||||||
|
Name: "my_readonly_query",
|
||||||
|
Description: "……",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
Handler: h.query,
|
||||||
|
ParallelSafe: true, // 声明在末尾
|
||||||
|
}, s)
|
||||||
|
```
|
||||||
|
|
||||||
|
需要"故意串行"时:
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterTool(sdk.ToolDef{
|
||||||
|
Name: "my_terminal_input",
|
||||||
|
Handler: h.input,
|
||||||
|
Serial: true, // 胜出,忽略 ParallelSafe
|
||||||
|
}, s)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 内置工具
|
||||||
|
|
||||||
|
核心仓的内置工具用 `toolDefOptions` / `parallelOpts()` 声明
|
||||||
|
(`internal/agent/core/tooldefs.go`),最终由 `buildToolDefs` 汇总成
|
||||||
|
`ToolDef.ParallelSafe`。
|
||||||
|
|
||||||
|
内核只提供并行调度基础设施,**不硬编码任何工具名的安全状态表** ——
|
||||||
|
状态由每个工具在自己的声明结构里给出,查询时走聚合表。
|
||||||
|
|
||||||
|
## 6. 效果(实测)
|
||||||
|
|
||||||
|
同批 N 个各约 250ms 的工具,新版内核"内部并发"对"强制串行":
|
||||||
|
|
||||||
|
| N | 加速比 |
|
||||||
|
| --- | --- |
|
||||||
|
| 2 | ~1.41× |
|
||||||
|
| 4 | ~2.22× |
|
||||||
|
| 8 | ~3.88× |
|
||||||
|
|
||||||
|
批耗时几乎不随 N 增长,串行批严格线性。
|
||||||
|
|
||||||
|
> 压测时**必须同时记录实际执行的工具数**,不能只看耗时。
|
||||||
|
> 某次对照中旧版本耗时更短、但因适配器缺 `stream_index` 导致
|
||||||
|
> **实际处理 0 个工具** —— 那不是性能提升,是全失败。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
设计背景与踩坑见**核心仓**文档(不在本仓):
|
||||||
|
|
||||||
|
- `docs/zh/toolcall-contract-and-sequence-design.md` —— 契约与序列设计
|
||||||
|
- `docs/zh/toolcall-parallel-execution-plan.md` —— 阶段、实测压测数据
|
||||||
|
- `docs/zh/deploy-runbook.md` —— 生产部署(含适配器 `stream_index` 相关)
|
||||||
113
docs/guide/scene-memory.md
Normal file
113
docs/guide/scene-memory.md
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# 场景记忆(Scene Memory)
|
||||||
|
|
||||||
|
> 场景式记忆是内核 v1.3 起的能力。它不新增 API 面,只影响**你的输入被怎样记住与取回**。
|
||||||
|
> 与 `NoMemory` / `ContextPolicy` / `RecallPolicy` 并列为第四项声明:`ScenePolicy`。
|
||||||
|
|
||||||
|
## 它解决什么问题
|
||||||
|
|
||||||
|
三层记忆按**字面相关性**召回:你得说出相近的词,记忆才会被取回来。
|
||||||
|
场景记忆补上另一半:按**场合**召回。
|
||||||
|
|
||||||
|
同一场合再次出现时,当时挂在这个场合上的约定、偏好、人物关系会自动回来——
|
||||||
|
与这次说了什么措辞无关。
|
||||||
|
|
||||||
|
```
|
||||||
|
你:以后在群里回消息简短点
|
||||||
|
└─ 这条记忆挂到场面「chan:qq + peer:group_xxx」上
|
||||||
|
|
||||||
|
一周后,同一个群里有人问「上次说的格式是什么」
|
||||||
|
└─ 场面重现(还没等你提到「格式」),那条约定已经被取回
|
||||||
|
```
|
||||||
|
|
||||||
|
## 场面是自己长出来的
|
||||||
|
|
||||||
|
场景**不需要声明**。每轮交互,内核采集一组可观察信号当这轮<E8BF99><E8BDAE><EFBFBD>「场面指纹」:
|
||||||
|
|
||||||
|
| 特征 | 来源 | 权重 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `chan` | 输入通道名 | 1.0 | 最强的同一性信号 |
|
||||||
|
| `peer` / `peer_group` | 注入点给的 `payload` 里的 `group_id`/`user_id`/`chat_id` 等 | 1.0 | 群与私聊分开,避免互相命中 |
|
||||||
|
| `tool` | 触发这一步的工具名 | 0.8 | 行为信号 |
|
||||||
|
| `topic` | 清洗后输入的内容词 | 0.4 | 软信号,同场面的不同话题不该被拆开 |
|
||||||
|
| `part` | 时段(夜间/上午/下午/晚间) | 0.2 | 最弱,只做辅助 |
|
||||||
|
|
||||||
|
指纹反复重合时,一场场面就成形了。相似度按**加权 Jaccard** 算
|
||||||
|
(共享特征的权重和 ÷ 并集的权重和)——不加权的话,一次偶然的话题重合
|
||||||
|
会把两个不同场面并成一个。
|
||||||
|
|
||||||
|
**同类场面出现第二次才被认定。** 一次性的交互不建场面:
|
||||||
|
那不是「场面」,建了只会让图库被一次性事件撑满。
|
||||||
|
|
||||||
|
## 声明你的参与姿态
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.ChannelDef{
|
||||||
|
ScenePolicy: sdk.ScenePolicyNone, // 这条通道不参与场面识别
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或单次注入覆盖:
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.InjectOptions{
|
||||||
|
ScenePolicy: sdk.ScenePolicyNone,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 取值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `""`(空)/ `ScenePolicyAuto` | **参与**(默认,保持既有行为) |
|
||||||
|
| `ScenePolicyNone` | **不参与**:不产任何场面指纹,也不派生场景键 |
|
||||||
|
|
||||||
|
**默认是参与而不是不参与**,与 `ContextPolicy` 刻意相反。原因是场景只
|
||||||
|
**附加**检索路径、不改记忆本体,默认关会让存量通道突然失去场景召回;
|
||||||
|
而「关」是少数意图(纯内部信号)。
|
||||||
|
|
||||||
|
声明 `none` 之后连时段特征都不产——一个不参与的门面不该在场面索引里
|
||||||
|
留下任何足迹。
|
||||||
|
|
||||||
|
### 谁该考虑关掉
|
||||||
|
|
||||||
|
内核自循环(`system`)、心跳(`timer`)、内部状态汇报(`kernel`)这类
|
||||||
|
纯内部信号。它们每次触发都在撑一个场面,会把不相干的交互聚到一起。
|
||||||
|
|
||||||
|
反过来说,**多标一个通道通常没有代价**:一个没人往上面写记忆的场面,
|
||||||
|
召回时返回空。关不关都不影响正确性——所以拿不准时,默认参与就好。
|
||||||
|
|
||||||
|
## 怎么给场面命名
|
||||||
|
|
||||||
|
场景键有两种来源:
|
||||||
|
|
||||||
|
**通道派生(默认)**——`evt.Source` 派生出 `chan:qq` 这类键。你不用管。
|
||||||
|
|
||||||
|
**显式声明(进阶)**——在注入时给出更有语义的键:
|
||||||
|
|
||||||
|
```go
|
||||||
|
p.sdk.InjectInterruptTextOpts("qq", "qq", text, sdk.InjectOptions{
|
||||||
|
ScenePolicy: sdk.ScenePolicyAuto,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以通过 `payload["scene"]` 传层级键(支持 `string` / `[]string` /
|
||||||
|
`[]interface{}` 三种形态):
|
||||||
|
|
||||||
|
```go
|
||||||
|
"chan:qq/peer:group_1027"
|
||||||
|
```
|
||||||
|
|
||||||
|
召回走**前缀匹配**(`chan:qq` 能覆盖 `chan:qq/peer:xxx`),用 `/` 兜底
|
||||||
|
以免 `chan:qq` 误吞 `chan:qq2` 这种同前缀但不同层的场景。
|
||||||
|
|
||||||
|
## 场面记忆不改变什么
|
||||||
|
|
||||||
|
- **不改记忆本体**:场景是记忆的**附加索引**,删掉场景不删记忆。
|
||||||
|
- **不让模型负责**:`memory_commit` 的 `scene` 留空即可,内核会挂到本轮
|
||||||
|
解析出的场面上。留空是安全的一侧——猜错的场面会把无关记忆钉死。
|
||||||
|
- **不影响同步通道**:`webui` / `cli` / 终端走 `ResponseCh`,不经
|
||||||
|
`output_send__*`,与场面无关。
|
||||||
|
|
||||||
|
## 相关 API
|
||||||
|
|
||||||
|
- `ChannelDef.ScenePolicy` —— 通道级声明(见 [输入/输出通道](../api/channels.md))
|
||||||
|
- `InjectOptions.ScenePolicy` —— 单次注入覆盖(见 [其他类型](../api/misc.md))
|
||||||
|
- `ScenePolicyAuto` / `ScenePolicyNone` / `ValidScenePolicy` —— 常量与校验
|
||||||
81
docs/guide/security.md
Normal file
81
docs/guide/security.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# 受限 SDK 与安全
|
||||||
|
|
||||||
|
外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层**安全边界**:
|
||||||
|
外部插件的进程不共享内核地址空间,能力通过显式注入点交过去。
|
||||||
|
|
||||||
|
## 三层隔离
|
||||||
|
|
||||||
|
| 层 | 机制 | 防住了什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| **进程** | 插件跑在独立子进程 | 插件 panic / 内存越界**不会带崩内核** |
|
||||||
|
| **能力** | 只注入显式声明的接口 | 插件拿不到未授权的内核内部结构 |
|
||||||
|
| **权限** | 公开接口是内部接口的**只读子集** | 插件无法改写他人数据 |
|
||||||
|
|
||||||
|
第一种是 v1.0.0 从 C ABI 动态库改为子进程 + 共享内存的直接收益:
|
||||||
|
在此之前,插件 panic 会带崩 `homed`。
|
||||||
|
|
||||||
|
## 受限接口是怎么实现的
|
||||||
|
|
||||||
|
**按接口裁剪,而不是按方法裁剪。** 同一个概念在公开包与内部包里是**两个不同的
|
||||||
|
接口声明**,公开的那个只保留安全子集:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 公开 SDK:6 个只读方法
|
||||||
|
type SocialAPI interface {
|
||||||
|
GetPerson(name string) (*PersonProfile, error)
|
||||||
|
GetTrait(name, trait string) (string, bool)
|
||||||
|
GetRelations(name string) ([]SocialRelation, error)
|
||||||
|
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||||
|
ListPersons() ([]string, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
写操作只在内核内部接口里。这样外部插件**在类型层面就调不到**,
|
||||||
|
不是靠运行时检查拦截。
|
||||||
|
|
||||||
|
同理,`EventSubscriber` 公开版**刻意只有 `Subscribe`,没有 `Publish`**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 插件可以订阅,但由内核决定投递哪些事件
|
||||||
|
type EventSubscriber interface {
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进程边界带来的约束
|
||||||
|
|
||||||
|
事件订阅是理解这层边界的典型例子。公开包里有一个 `Events() EventSubscriber`,
|
||||||
|
但**外部插件拿到的恒为 nil** —— 桥接运行时不注入它(`SetEventSubscriber`
|
||||||
|
在全仓没有调用点)。外部插件的事件订阅由生成的运行时通过 `events.subscribe`
|
||||||
|
RPC 完成,Lua 插件走内部 SDK 的 `Subscribe`。
|
||||||
|
|
||||||
|
这不是缺陷,而是进程边界的结果:跨进程无法共享内核的事件发布通道。
|
||||||
|
详见[能力边界](capability-boundary.md)。
|
||||||
|
|
||||||
|
## 共享内存中的数据面
|
||||||
|
|
||||||
|
工具调用帧、Cleaner、输入输出通道、媒体块、文档与知识正文**都走共享内存**,
|
||||||
|
RPC 只传偏移描述符。因此:
|
||||||
|
|
||||||
|
- 大对象不经 JSON 序列化,避免了大 payload 的性能与内存放大;
|
||||||
|
- StageContext 在同一份状态上读改写,消除了副本模型的 lost update
|
||||||
|
(实测由 35.8~36.8% 降到 0)。
|
||||||
|
|
||||||
|
`SharedRef`(共享内存描述符)是**内部实现细节**,插件开发者看不到它 ——
|
||||||
|
公开 SDK 只暴露普通字符串与 map。
|
||||||
|
|
||||||
|
## 插件作者的实践建议
|
||||||
|
|
||||||
|
- **不要在 `Start` 里长时间阻塞** —— 内核在等待它返回。
|
||||||
|
- **工具处理器要可并发**:模型可能并发发起多个调用;共享状态用锁保护
|
||||||
|
(`example/memo` 用 `sync.RWMutex`)。
|
||||||
|
- **写文件用原子替换**(临时文件 + rename),避免进程被强杀时截断数据。
|
||||||
|
- **声明 `NoMemory`**:定时提醒、连接状态这类不是对话内容的东西,
|
||||||
|
别让它们污染记忆(`InjectOptions{NoMemory: true}`)。
|
||||||
|
- **在 `-race` 下测**:插件重载瞬间的并发访问是历史高发缺陷。
|
||||||
|
|
||||||
|
## 许可与分发
|
||||||
|
|
||||||
|
SDK 是 **MIT**,插件可以**闭源分发**,可商用、可私有,无需回馈。
|
||||||
|
这是刻意的:SDK 随插件静态链接(源码进入插件二进制),用传染性许可会
|
||||||
|
强迫插件开源。内核本身是 AGPL-3.0-only,但那是内核的许可,与外部插件无关。
|
||||||
108
docs/guide/stream-tool-call-index.md
Normal file
108
docs/guide/stream-tool-call-index.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# 流式多 `tool_call`:适配器必须透传 `index`
|
||||||
|
|
||||||
|
> 面向在 Lua 里写适配器(`transform_stream_chunk`)的插件作者。
|
||||||
|
>
|
||||||
|
> 状态:已随 2026-09 的并行内核落地;生产 `openai.lua` 等适配器已修复。
|
||||||
|
|
||||||
|
## 1. 问题
|
||||||
|
|
||||||
|
OpenAI 兼容的流式响应里,同一轮的多个 `tool_call` 以**分片**形式到达,
|
||||||
|
靠 `index` 字段区分归属:
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[
|
||||||
|
{"index":0,"id":"call_a","function":{"name":"alpha","arguments":""}}]}}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[
|
||||||
|
{"index":1,"id":"call_b","function":{"name":"beta","arguments":""}}]}}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[
|
||||||
|
{"index":0,"function":{"arguments":"{\"x\":1}"}}]}}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[
|
||||||
|
{"index":1,"function":{"arguments":"{\"y\":2}"}}]}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**每个 SSE chunk 通常只含一个 `tool_call` 元素。** 内核按 `index` 分桶累积
|
||||||
|
`id` / `name` / `arguments`。
|
||||||
|
|
||||||
|
## 2. 适配器必须做的事
|
||||||
|
|
||||||
|
`transform_stream_chunk` 的输出 JSON 里,每个 tool call 分片都要带
|
||||||
|
**`stream_index`**(值取上游的 `index`):
|
||||||
|
|
||||||
|
```lua
|
||||||
|
table.insert(tcs, {
|
||||||
|
id = tc.id or "",
|
||||||
|
type = tc.type or "function",
|
||||||
|
name = name,
|
||||||
|
raw_arguments = raw_args,
|
||||||
|
-- ★ 必须透传上游 index(键名是 stream_index,不是 index)。
|
||||||
|
-- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片。
|
||||||
|
stream_index = tc.index or 0
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### ★ 键名是 `stream_index`,不是 `index`
|
||||||
|
|
||||||
|
内核的 `ToolCall.StreamIndex` 标签是 `json:"stream_index"`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
StreamIndex int `json:"stream_index,omitempty"`
|
||||||
|
```
|
||||||
|
|
||||||
|
写成 `index` 会被 Go 的解码器**静默丢弃**(无匹配字段),
|
||||||
|
`StreamIndex` 恒为 0 ⇒ 全部落进 `accs[0]`。
|
||||||
|
|
||||||
|
## 3. 不透传的实际后果
|
||||||
|
|
||||||
|
不是"少个字段",而是**多工具并行调用整体失效**:
|
||||||
|
|
||||||
|
| 现象 | 原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` 相互覆盖 | 全进 `accs[0]`,后写的赢 |
|
||||||
|
| `arguments` 碎片混拼 | 两个工具的 JSON 片段交错拼接 |
|
||||||
|
| 报"参数不是合法 JSON" | 上面拼接的产物解析失败 |
|
||||||
|
| 工具被当成**空参数**调用 | 同上 |
|
||||||
|
|
||||||
|
2026-09-27 的对照压测里,旧适配器耗时**更短**但**实际处理 0 个工具** ——
|
||||||
|
每个工具都因参数非法失败。⇒ 压测**必须同时统计实际执行数**,不能只看耗时。
|
||||||
|
|
||||||
|
## 4. 还有两个容易踩的点
|
||||||
|
|
||||||
|
**① 不能按 `name` 过滤分片**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- ✗ 错:后续块的 name 为空但携带 arguments
|
||||||
|
if tc.function and tc.function.name then ... end
|
||||||
|
|
||||||
|
-- ✓ 对:无 name 但有 arguments 的分片也要收,累积时再校验 name
|
||||||
|
```
|
||||||
|
|
||||||
|
**② 扁平结构 + `stream_index`**
|
||||||
|
|
||||||
|
部分协议族(`server` / `kimicode` / `anthropic` / `ollama`)的流式 tool call
|
||||||
|
是**扁平**结构(`name`/`arguments` 直接在 `tc` 上,不在 `tc.function` 里),
|
||||||
|
同样要带 `stream_index`。
|
||||||
|
|
||||||
|
## 5. 自检
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 适配器是否透传
|
||||||
|
grep -n "stream_index" /home/newqqagent/adapters/<你的>.lua
|
||||||
|
|
||||||
|
# 2) 实测:发一个同轮多工具的请求,看是否两个都真被执行
|
||||||
|
# 内核日志里 executing tool 应出现两次(可能并发)
|
||||||
|
journalctl -u homeagent.service --since "-2 min" | grep "executing tool"
|
||||||
|
|
||||||
|
# 3) 有没有参数解析失败
|
||||||
|
journalctl -u homeagent.service --since "-2 min" | grep -E "参数|合法 JSON"
|
||||||
|
```
|
||||||
|
|
||||||
|
> `gemini.lua` 目前**没有**流式 tool call 实现,因此不涉及本条。
|
||||||
|
> Gemini 协议是 `functionCall` 而非 `tool_calls`,不能照搬 OpenAI 的做法。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- `docs/guide/parallel-tool-declaration.md` —— 并发声明 `ParallelSafe`/`Serial`
|
||||||
|
- 核心仓 `docs/zh/toolcall-parallel-execution-plan.md` —— 压测数据与协议族清单
|
||||||
49
docs/index.md
Normal file
49
docs/index.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# HomeAgent 插件 SDK
|
||||||
|
|
||||||
|
用 **Go** 或 **Lua** 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
|
||||||
|
注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
|
||||||
|
|
||||||
|
<div id="api-search"></div>
|
||||||
|
|
||||||
|
## 从这里开始
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-rocket-launch: **第一次写插件**
|
||||||
|
|
||||||
|
装工具链、生成工程、写一个工具、打包成 `.hmap` 装进内核跑起来。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 快速开始](guide/getting-started.md)
|
||||||
|
|
||||||
|
- :material-book-open-variant: **API 参考**
|
||||||
|
|
||||||
|
逐个符号的签名与说明,直接取自源码注释。附示例插件里的真实调用点。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 工具(Tools)](api/tools.md)
|
||||||
|
|
||||||
|
- :material-shield-lock: **能力边界**
|
||||||
|
|
||||||
|
哪些 API 外部插件能用、哪些仅内置插件可用,以及为什么。**先看这个能省很多时间。**
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 能力边界](guide/capability-boundary.md)
|
||||||
|
|
||||||
|
- :material-code-braces: **示例插件**
|
||||||
|
|
||||||
|
`example/` 下有多个真实可编译的插件,覆盖常见形态。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 示例总览](examples/index.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
SDK 以 **MIT** 发布 —— 插件作者可**自由选择自己的许可**(闭源、商业、私有均可),
|
||||||
|
不必同许可、也不必回馈。原因:SDK 会随插件一起静态链接(源码进入插件二进制),
|
||||||
|
若用传染性许可,插件作者就被强制开源;MIT 让第三方插件生态不必承担这个代价。
|
||||||
|
|
||||||
|
内核本身是 **AGPL-3.0-only**,但那是内核的许可,与外部插件无关 ——
|
||||||
|
SDK 完全自包含(`go.mod` 零外部依赖,只依赖 Go 标准库),不引用内核任何代码。
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
本文档站的 API 参考从源码生成,对应 SDK 版本见 [版本与兼容](versions.md)。
|
||||||
264
docs/javascripts/api-search.js
Normal file
264
docs/javascripts/api-search.js
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* API 即时检索。
|
||||||
|
*
|
||||||
|
* 为什么要自建:Material 内置搜索按「整页文本」建索引,搜 `InjectText`
|
||||||
|
* 会把所有提到它的页面都列出来,但**分不清哪一条是它的定义**;而且内置
|
||||||
|
* 索引要等 mkdocs build 才生成,改一行 API 也得重建。
|
||||||
|
*
|
||||||
|
* 这里读的是 `assets/api-index.json`——由 tools/apidoc/gensite 直接产出,
|
||||||
|
* 每条记录带 名称/签名/描述/类别/所属页面/是否仅内置/源文件:行号。
|
||||||
|
* 因此可以做到:
|
||||||
|
* - 按名称搜(精确/前缀优先)
|
||||||
|
* - 按描述搜(中文按字、英文按词,都对 API 的文档注释做匹配)
|
||||||
|
* - 按签名搜(如 "(string) error")
|
||||||
|
* - 过滤「仅内置」——外部插件作者最容易被这个绊住
|
||||||
|
*
|
||||||
|
* 设计取舍:纯前端、零依赖、不阻塞页面。索引 ~130 条、约 40KB,一次拉取足够。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var INDEX_URL = (function () {
|
||||||
|
// 文档站可能部署在子路径下,按当前页面深度回推到站点根。
|
||||||
|
var path = window.location.pathname;
|
||||||
|
var marker = "/api/";
|
||||||
|
var i = path.indexOf(marker);
|
||||||
|
if (i >= 0) return path.slice(0, i) + "/assets/api-index.json";
|
||||||
|
// guide/ 等目录同样回退一层。
|
||||||
|
var lastSlash = path.lastIndexOf("/");
|
||||||
|
return path.slice(0, lastSlash) + "/assets/api-index.json";
|
||||||
|
})();
|
||||||
|
|
||||||
|
var state = { all: [], loaded: false, loading: false };
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (state.loaded || state.loading) return Promise.resolve(state.all);
|
||||||
|
state.loading = true;
|
||||||
|
return fetch(INDEX_URL)
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
state.all = data || [];
|
||||||
|
state.loaded = true;
|
||||||
|
return state.all;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
state.all = [];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 打分 ---------- */
|
||||||
|
//
|
||||||
|
// 三级优先级:名称命中 > 描述命中 > 签名命中。
|
||||||
|
// 名称命中里再分「完全相等 / 前缀 / 子串」,因为用户敲 `InjectText` 时
|
||||||
|
// 想要的是那个符号,不是所有名字里含它的。
|
||||||
|
|
||||||
|
function score(item, q) {
|
||||||
|
var name = (item.n || "").toLowerCase();
|
||||||
|
var ql = q.toLowerCase();
|
||||||
|
var s = 0;
|
||||||
|
|
||||||
|
if (name === ql) s += 1000;
|
||||||
|
else if (name.indexOf(ql) === 0) s += 600;
|
||||||
|
else if (name.indexOf(ql) > 0) s += 350;
|
||||||
|
|
||||||
|
// 中文检索关键词(keywords.json 产出,字段 g)。
|
||||||
|
// 为什么需要:SDK 里 66/100 个符号是英文注释(`RegisterTool registers a
|
||||||
|
// tool that the LLM can call.`),懂中文的人搜「注册工具」会一条都找不到。
|
||||||
|
// 关键词命中给较高权重(仅次于名称精确命中),因为它就是为「按功能找」准备的。
|
||||||
|
var kws = item.g || [];
|
||||||
|
for (var ki = 0; ki < kws.length; ki++) {
|
||||||
|
var kw = String(kws[ki]).toLowerCase();
|
||||||
|
if (kw === ql) { s += 480; break; }
|
||||||
|
if (kw.indexOf(ql) >= 0) { s += 300; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限定符:PluginSDK.RegisterTool / IOInjector.InjectText。
|
||||||
|
// 额外支持「去掉 API/SDK 后缀」与「去掉点号」两种写法,
|
||||||
|
// 因为读者习惯写 `memory.recall`(RPC 名),而 Go 名是 `MemoryAPI.Recall`。
|
||||||
|
var qual = ((item.r || "") + "." + name).toLowerCase();
|
||||||
|
if (item.r && qual.indexOf(ql) >= 0) s += 200;
|
||||||
|
if (item.r) {
|
||||||
|
var flat = qual.replace(/[._]/g, "").replace(/apis?dk|sdk|api/g, "");
|
||||||
|
var qflat = ql.replace(/[._\s]/g, "");
|
||||||
|
if (qflat && flat.indexOf(qflat) >= 0) s += 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
var desc = (item.d || "").toLowerCase();
|
||||||
|
if (desc.indexOf(ql) >= 0) s += 120;
|
||||||
|
|
||||||
|
// 签名按 token 匹配:把查询拆词(去掉括号/逗号等标点),全部命中才算。
|
||||||
|
// 这样 `(string) error`、`ContentBlock 媒体` 这类片段都能搜到。
|
||||||
|
// 注意必须先去标点:否则 token `(string)` 永远匹配不到签名里的 `string`。
|
||||||
|
var sig = (item.s || "").toLowerCase();
|
||||||
|
if (sig.indexOf(ql) >= 0) s += 60;
|
||||||
|
var toks = ql
|
||||||
|
.replace(/[()\[\]{},;:]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(function (t) { return t.length > 1; });
|
||||||
|
if (toks.length && sig.length) {
|
||||||
|
var allSig = toks.every(function (t) { return sig.indexOf(t) >= 0; });
|
||||||
|
if (allSig) s += 55;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文按字匹配:中文没有词边界,逐字命中比整串更实用。
|
||||||
|
// 注意只把它当作**弱信号**:光靠逐字会把「注册工具」匹到凡是含「工具」
|
||||||
|
// 字样的任何东西(实测 ContextPolicyNone 的说明里有「工具调用」也会命中)。
|
||||||
|
// 所以阈值卡在 60% 以上才算有效命中。
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(q)) {
|
||||||
|
var hit = 0;
|
||||||
|
var hay = desc + " " + kws.join(" ");
|
||||||
|
for (var i = 0; i < q.length; i++) {
|
||||||
|
if (hay.indexOf(q[i]) >= 0) hit++;
|
||||||
|
}
|
||||||
|
var ratio = hit / q.length;
|
||||||
|
if (ratio >= 0.6) s += Math.round(hit * 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开 API 略优先于「仅内置」——后者通常是噪声。
|
||||||
|
if (s > 0 && !item.b) s += 15;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(q) {
|
||||||
|
var qq = (q || "").trim();
|
||||||
|
if (!qq) return [];
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < state.all.length; i++) {
|
||||||
|
var sc = score(state.all[i], qq);
|
||||||
|
if (sc > 0) out.push({ item: state.all[i], score: sc });
|
||||||
|
}
|
||||||
|
out.sort(function (a, b) {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
return (a.item.n || "").length - (b.item.n || "").length;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 渲染 ---------- */
|
||||||
|
//
|
||||||
|
// 挂在 Material 首页/目录页的一个容器上:#api-search。
|
||||||
|
// 没找到容器就不做任何事——这样同一份 JS 可以安全地全站引入。
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text != null) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(mount, q) {
|
||||||
|
mount.innerHTML = "";
|
||||||
|
if (!q.trim()) {
|
||||||
|
mount.appendChild(el("p", "api-hint",
|
||||||
|
"输入 API 名称、描述或签名片段。例:InjectText、注册工具、崩溃、memory.recall、ContentBlock"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var results = search(q);
|
||||||
|
if (!results.length) {
|
||||||
|
mount.appendChild(el("p", "api-hint", "没有匹配的 API。试试更短的词,或按功能描述搜(如「注入」「重载」)。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var head = el("p", "api-count", "命中 " + results.length + " 个 API");
|
||||||
|
mount.appendChild(head);
|
||||||
|
|
||||||
|
var list = el("ul", "api-results");
|
||||||
|
results.slice(0, 40).forEach(function (r) {
|
||||||
|
var it = r.item;
|
||||||
|
var li = el("li", "api-result");
|
||||||
|
|
||||||
|
var title = el("a", "api-name", (it.r ? it.r + "." : "") + it.n);
|
||||||
|
// 锚点必须用**完整标题文本**(`PluginSDK.InjectText`,点号被 slug 丢掉),
|
||||||
|
// 不是裸方法名 —— 否则跳到页面顶部而到不了那一条。
|
||||||
|
title.href = pageURL(it.p) + "#" + anchorOf((it.r ? it.r + "." : "") + it.n);
|
||||||
|
li.appendChild(title);
|
||||||
|
|
||||||
|
if (it.b) {
|
||||||
|
var badge = el("span", "api-badge api-badge-builtin", "仅内置");
|
||||||
|
badge.title = "外部(第三方)插件运行时拿不到这个 API";
|
||||||
|
li.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.appendChild(el("code", "api-sig", it.s || ""));
|
||||||
|
|
||||||
|
if (it.d) {
|
||||||
|
var d = el("span", "api-desc", it.d);
|
||||||
|
li.appendChild(d);
|
||||||
|
}
|
||||||
|
// 关键词是给检索用的;显示出来能让读者明白“为什么这条被匹配到”。
|
||||||
|
if (it.g && it.g.length) {
|
||||||
|
li.appendChild(el("span", "api-kw", it.g.slice(0, 6).join(" · ")));
|
||||||
|
}
|
||||||
|
if (it.f) {
|
||||||
|
li.appendChild(el("span", "api-loc", it.f + (it.l ? ":" + it.l : "")));
|
||||||
|
}
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
mount.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageURL(page) {
|
||||||
|
if (!page) return "#";
|
||||||
|
// 所有 API 章节都在 /api/ 下(生成物),示例页在 /examples/。
|
||||||
|
// 从当前 URL 回推到站点根,保证部署在子路径下也能用。
|
||||||
|
var path = window.location.pathname;
|
||||||
|
var i = path.indexOf("/api/");
|
||||||
|
var root;
|
||||||
|
if (i >= 0) {
|
||||||
|
root = path.slice(0, i + 1);
|
||||||
|
} else {
|
||||||
|
var j = path.indexOf("/guide/");
|
||||||
|
if (j >= 0) root = path.slice(0, j + 1);
|
||||||
|
else if (path.indexOf("/examples/") >= 0) root = path.slice(0, path.indexOf("/examples/") + 1);
|
||||||
|
else root = path.slice(0, path.lastIndexOf("/") + 1);
|
||||||
|
}
|
||||||
|
var dir = page === "examples" ? "examples" : "api";
|
||||||
|
return root + dir + "/" + page + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// anchorOf 复现 MkDocs 的 slug:小写、去掉非 [a-z0-9_-] 的字符(点号被去掉)、
|
||||||
|
// 下划线保留、空格转连字符。
|
||||||
|
function anchorOf(name) {
|
||||||
|
return String(name)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_ -]/g, "")
|
||||||
|
.replace(/\s+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
var box = document.getElementById("api-search");
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
var input = el("input", "api-input");
|
||||||
|
input.type = "search";
|
||||||
|
input.placeholder = "搜索 API:名称、描述、签名…";
|
||||||
|
input.setAttribute("autocomplete", "off");
|
||||||
|
input.setAttribute("spellcheck", "false");
|
||||||
|
|
||||||
|
var out = el("div", "api-output");
|
||||||
|
box.appendChild(input);
|
||||||
|
box.appendChild(out);
|
||||||
|
|
||||||
|
load().then(function () {
|
||||||
|
render(out, "");
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
render(out, input.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 支持 ?q= 直达(可从别处链接到一次检索)。
|
||||||
|
var m = /[?&]q=([^&]+)/.exec(window.location.search);
|
||||||
|
if (m) {
|
||||||
|
input.value = decodeURIComponent(m[1].replace(/\+/g, " "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", mount);
|
||||||
|
} else {
|
||||||
|
mount();
|
||||||
|
}
|
||||||
|
})();
|
||||||
36
docs/llms.txt
Normal file
36
docs/llms.txt
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# HomeAgent 插件 SDK
|
||||||
|
|
||||||
|
> 用 Go 或 Lua 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
|
||||||
|
> 注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
|
||||||
|
>
|
||||||
|
> SDK 以 MIT 发布(插件可闭源、可商用,无需回馈)。内核本身是 AGPL-3.0-only。
|
||||||
|
>
|
||||||
|
> 本文件是给 agent 的入口。下列每个链接都是**纯 Markdown 正文**,可直接读,
|
||||||
|
> 不含 HTML 样板;也可以直接取 https://sdk.homeagent.jianfgit.xyz/llms-full.txt 一次读完全部文档。
|
||||||
|
|
||||||
|
- [HomeAgent 插件 SDK](https://sdk.homeagent.jianfgit.xyz/index.md): 用 Go 或 Lua 为 HomeAgent 编写插件
|
||||||
|
- [桥接装配点(Bridge)](https://sdk.homeagent.jianfgit.xyz/api/bridge.md): 以下方法不是给插件业务代码调的——它们由 hmapdev 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例
|
||||||
|
- [仅内置插件可用的 API](https://sdk.homeagent.jianfgit.xyz/api/builtin-only.md): 这些 API 存在于公开 SDK 包里,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级
|
||||||
|
- [输入 / 输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels.md): 通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口
|
||||||
|
- [常量与枚举](https://sdk.homeagent.jianfgit.xyz/api/constants.md): SDK 里的取值枚举
|
||||||
|
- [事件(Events)](https://sdk.homeagent.jianfgit.xyz/api/events.md): 订阅内核事件
|
||||||
|
- [API 参考](https://sdk.homeagent.jianfgit.xyz/api/index.md): 本页所有内容从源码生成(tools/apidoc),签名与说明直接取自 sdk/*
|
||||||
|
- [生命周期(Lifecycle)](https://sdk.homeagent.jianfgit.xyz/api/lifecycle.md): 插件的启动、停止与卸载回调
|
||||||
|
- [LLM 调用](https://sdk.homeagent.jianfgit.xyz/api/llm.md): 让插件自己调用模型(而不是只等模型来调你)
|
||||||
|
- [记忆(Memory)](https://sdk.homeagent.jianfgit.xyz/api/memory.md): 三层记忆的读写接口:图记忆(三元组关系)、文档记忆(带元数据的文档)、文本记忆(事件流水)
|
||||||
|
- [其他类型](https://sdk.homeagent.jianfgit.xyz/api/misc.md): 剩余的类型与方法:PluginSDK 本体的访问器、StageContext 的并发控制,以及多模态辅助类型
|
||||||
|
- [配置(Settings)](https://sdk.homeagent.jianfgit.xyz/api/settings.md): 声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表
|
||||||
|
- [阶段钩子(Stages)](https://sdk.homeagent.jianfgit.xyz/api/stages.md): 在消息处理管道的固定点位插入自己的逻辑
|
||||||
|
- [工具(Tools)](https://sdk.homeagent.jianfgit.xyz/api/tools.md): 注册 LLM 可调用的工具
|
||||||
|
- [示例插件](https://sdk.homeagent.jianfgit.xyz/examples/index.md): SDK 仓 example/ 下有多个真实可编译的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态
|
||||||
|
- [能力边界:哪些 API 外部插件能用](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary.md): HomeAgent 有两类插件:
|
||||||
|
- [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin.md): Lua 插件适合轻量、快速原型:不需要 Go 编译环境,改完重启内核即可生效
|
||||||
|
- [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin.md): 以下是一个能直接跑起来的最小插件:注册一个工具、声明一项配置、处理停止与卸载
|
||||||
|
- [环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started.md): hmapdev 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
|
||||||
|
- [多平台构建](https://sdk.homeagent.jianfgit.xyz/guide/multi-platform.md): hmapdev build 默认 bundle 模式,一次产出含三个平台的单个
|
||||||
|
- [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging.md): hmapdev build 一次完成编译与打包,产出
|
||||||
|
- [工具并发声明:`ParallelSafe` / `Serial`](https://sdk.homeagent.jianfgit.xyz/guide/parallel-tool-declaration.md): > 对应 sdk
|
||||||
|
- [场景记忆(Scene Memory)](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory.md): > 场景式记忆是内核 v1
|
||||||
|
- [受限 SDK 与安全](https://sdk.homeagent.jianfgit.xyz/guide/security.md): 外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层安全边界:
|
||||||
|
- [流式多 `tool_call`:适配器必须透传 `index`](https://sdk.homeagent.jianfgit.xyz/guide/stream-tool-call-index.md): > 面向在 Lua 里写适配器(transform_stream_chunk)的插件作者
|
||||||
|
- [版本与兼容](https://sdk.homeagent.jianfgit.xyz/versions.md): SDK 版本跟随内核的中版本,patch 位恒为
|
||||||
123
docs/stylesheets/extra.css
Normal file
123
docs/stylesheets/extra.css
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
/* API 即时检索与文档站的少量本地样式。
|
||||||
|
只补 Material 没覆盖的部分,不覆盖主题变量(保持深浅色自动适配)。 */
|
||||||
|
|
||||||
|
#api-search {
|
||||||
|
margin: 1.2rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid var(--md-default-fg-color--lightest);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: var(--md-default-bg-color);
|
||||||
|
color: var(--md-default-fg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-input:focus {
|
||||||
|
outline: 2px solid var(--md-accent-fg-color);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-hint {
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-count {
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin: 0.8rem 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-results {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-result {
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
border-left: 3px solid var(--md-primary-fg-color);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
background: var(--md-code-bg-color);
|
||||||
|
border-radius: 0 0.2rem 0.2rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-name {
|
||||||
|
font-family: var(--md-code-font-family, monospace);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sig {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-desc {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「为何命中」的中文关键词(keywords.json 产出)。 */
|
||||||
|
.api-kw {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--md-accent-fg-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-loc {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--md-default-fg-color--lighter, var(--md-default-fg-color--light));
|
||||||
|
font-family: var(--md-code-font-family, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-badge {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
padding: 0.08rem 0.34rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-badge-builtin {
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
color: #b45309;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-color-scheme="slate"] .api-badge-builtin {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「仅内置」告警块里的依据说明通常很长,窄屏下允许更小字号。 */
|
||||||
|
@media screen and (max-width: 44.98em) {
|
||||||
|
.api-sig { font-size: 0.68rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 签名与长类型定义可能超出正文宽度(如 ContentBlock 的字段列表)。
|
||||||
|
highlight 代码块默认不换行,靠横向滚动;把默认改为换行显示,
|
||||||
|
因为文档读者更希望一眼看全签名而不是拖滚动条。 */
|
||||||
|
.md-typeset pre > code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 接口方法表里的签名可能很长,允许在任意位置折行。 */
|
||||||
|
.md-typeset table code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
285
docs/versions.md
Normal file
285
docs/versions.md
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
# 版本与兼容
|
||||||
|
|
||||||
|
## SDK 版本语义
|
||||||
|
|
||||||
|
**SDK 版本跟随内核的中版本,patch 位恒为 `.0`。**
|
||||||
|
|
||||||
|
整条内核 `1.1.x` 线(1.1.0、1.1.1、1.1.7…)共用 **SDK 1.1.0**;
|
||||||
|
只有内核进入 `1.2.0` 这种中版本跃迁时,SDK 才升到 1.2.0。
|
||||||
|
|
||||||
|
这样插件作者只需关心「我在为哪个中版本写插件」,不必跟着内核的每个 bugfix 换依赖。
|
||||||
|
当前内核声明的兼容上限是 **SDK 1.3.0**。
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| SDK | 内核 | 变化 | 需要重编? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1.3.0** | 1.4.0+ | 驻留子 agent、`RecallPolicy` 等 | 想用新 API 才需要 |
|
||||||
|
| **1.2.0** | 1.2.0 / 1.3.x | `InjectOptions{NoMemory, ContextPolicy}`、六个 `*Opts` 变体、`ChannelDef.ContextPolicy` | 不需要 |
|
||||||
|
| **1.1.0** | 1.1.x | 多模态贯通:`Triple.SentenceText`、`Doc.Attachments`、`MediaAttachment`、`InsertWithMedia`、媒体注入方法 | 不需要 |
|
||||||
|
| **1.0.0** | 1.0.0+ | **运行模型变更**:C ABI 动态库 → 子进程 + 共享内存 | **需要** |
|
||||||
|
|
||||||
|
### 1.0.0 是唯一一次破坏性变更
|
||||||
|
|
||||||
|
- `.so` / `.dylib` / `.dll` **不再被加载**,遇到旧产物会跳过并报可操作错误(不崩溃)。
|
||||||
|
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev` 重编即可。
|
||||||
|
- 产物从 `plugin.so` 变为 `plugin.bin`;不再需要 cgo。
|
||||||
|
|
||||||
|
### 1.1.0 / 1.2.0 是纯追加
|
||||||
|
|
||||||
|
两次都是**新增方法由插件调用、内核实现**,不调就不受影响。
|
||||||
|
零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**;
|
||||||
|
想用新字段的重编即可。
|
||||||
|
|
||||||
|
### SDK 1.4.0 → 本轮新增(PluginMgr 运行态启停)⚠️ 有一个例外
|
||||||
|
|
||||||
|
本轮给 `PluginMgrAPI` 新增 3 个方法(`StopPlugin` / `StartPlugin` /
|
||||||
|
`IsPluginRuntimeStopped`,见 `docs/zh/kernel-modularization-design.md` §2.6.7)。
|
||||||
|
|
||||||
|
按上面的规则,这属于"**插件调用、内核实现**"的纯追加 ⇒ 无需重编。**但有一个例外**:
|
||||||
|
|
||||||
|
| 对象 | 是否需要动 | 原因 |
|
||||||
|
|---|---|---|
|
||||||
|
| **已编译的存量插件**(`plugin.bin`) | ❌ 不需要 | 不调新方法就不受影响(与 1.1/1.2 一致) |
|
||||||
|
| **存量插件的源码要重编** | ⚠️ **需要同步 hmapdev** | `proc_main.go.tmpl` 里的 `procPluginMgr` 必须实现新方法,否则**编译不过** |
|
||||||
|
| **新插件** | ✅ 自动 | 模板已同步(`hmapdev` 已更新) |
|
||||||
|
|
||||||
|
!!! warning "为什么这次和 1.1/1.2 不一样"
|
||||||
|
1.1/1.2 只改了 `sdk/`(插件**调用**的一侧)与内核实现。
|
||||||
|
本轮还改了 **`tools/hmapdev/templates/proc_main.go.tmpl`** ——
|
||||||
|
那是**生成插件代码**的模板,属于"插件**被编译**的一侧"。
|
||||||
|
|
||||||
|
`plugin.bin` 里的 `procPluginMgr` 是**编译期**由模板生成的具体类型,
|
||||||
|
它必须满足 `PluginMgrAPI` 的**全部**方法(Go 接口是精确匹配)。
|
||||||
|
模板不同步 ⇒ 生成物不满足接口 ⇒ **编译失败**(不是运行期降级)。
|
||||||
|
|
||||||
|
⇒ 判据 `internal/plugin/proc/bridge_sync_test.go` 已有 3 条自动守这个
|
||||||
|
(SDK 方法集 ⇒ 能力表 / Handle 分派 / hmapdev 模板,三处必须同步)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 脚手架模板的协议通道纪律(`hmapdev init` 生成物)
|
||||||
|
|
||||||
|
子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道**
|
||||||
|
(SDK 侧 `log.SetOutput(os.Stderr)`)。这条纪律**必须写进脚手架**,
|
||||||
|
否则每个新插件作者都会踩。
|
||||||
|
|
||||||
|
### 曾踩的坑(2026-10-16 实测)
|
||||||
|
|
||||||
|
`hmapdev init` 生成的 `plugin.go` 原先用 `fmt.Printf` 打日志 ⇒ 写进 stdout
|
||||||
|
⇒ 内核 readLoop 收到非 JSON 文本:
|
||||||
|
|
||||||
|
```
|
||||||
|
[proc] final_probe 收到非法 JSON 帧(21 字节): invalid character 'i' in literal false
|
||||||
|
```
|
||||||
|
|
||||||
|
两个后果叠加、都很难定位:
|
||||||
|
|
||||||
|
1. 那句日志**丢了**(内核把它当坏帧丢弃);
|
||||||
|
2. 多了一条看起来像**协议故障**的报错 —— 而真因是"插件自己 printf"。
|
||||||
|
|
||||||
|
**A/B 实证**(同一内核,两个插件):
|
||||||
|
|
||||||
|
| 插件 | 脚手架 | 非法 JSON 帧 | 日志可见 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `final_probe` | 旧(`fmt.Printf`) | **1 次** | ❌ 丢失 |
|
||||||
|
| `fixed_probe` | 新(`log.Printf`) | **0 次** | ✅ `[fixed_probe] started` |
|
||||||
|
|
||||||
|
### 修法
|
||||||
|
|
||||||
|
模板改成 `log.Printf`,并把这条纪律写进生成物的**头部注释**
|
||||||
|
(让每个插件作者第一眼就看到)。
|
||||||
|
|
||||||
|
### 判据(`tools/hmapdev/scaffold_stdout_test.go`,3 条)
|
||||||
|
|
||||||
|
1. `TestScaffoldTemplateDoesNotWriteStdout` —— **代码行**里不得有
|
||||||
|
`fmt.Printf/Println/Print/os.Stdout`
|
||||||
|
2. `TestScaffoldTemplateUsesStderrLogging` —— 反向钉住:光禁 stdout 不够,
|
||||||
|
把日志全删也能过第一条 ⇒ 必须**真的**用 `log.Printf`(并 import "log")
|
||||||
|
3. `TestScaffoldLuaTemplateDoesNotWriteStdout` —— Lua 脚手架同理
|
||||||
|
|
||||||
|
**变异 2/2 咬住**:改回 `fmt.Printf` ⇒ 红;删光日志 ⇒ 红。
|
||||||
|
|
||||||
|
!!! warning "判据自身踩的坑(值得记住)"
|
||||||
|
第一版判据直接扫描模板字符串 ⇒ **被模板里的注释判红**(注释**故意**
|
||||||
|
提到 `fmt.Printf` 来讲这个坑)。必须**先剥注释**再扫代码行。
|
||||||
|
|
||||||
|
这是"源码扫描判据"的固有脆弱性:本仓规范要求把坑写进注释,
|
||||||
|
而注释里必然出现那些符号名 ⇒ **任何时候用字符串扫描判据,
|
||||||
|
都要先想"注释里会不会也有这个串"**(同类事故见
|
||||||
|
`docs/zh/kernel-modularization-design.md` §8.6,那里连续骗过三次)。
|
||||||
|
|
||||||
|
|
||||||
|
## 示例插件的协议通道纪律(已修)
|
||||||
|
|
||||||
|
上面的脚手架纪律同样适用于**示例插件** —— 它们是**子进程插件**
|
||||||
|
(`package main` + `plugin.bin`),stdout 即协议通道。
|
||||||
|
|
||||||
|
### 生产环境实测的缺陷(不是理论问题)
|
||||||
|
|
||||||
|
`journalctl -u homeagent | grep -c "非法 JSON 帧"` ⇒ **1038 条**,来源:
|
||||||
|
|
||||||
|
| 插件 | 次数 | 本仓? |
|
||||||
|
|---|---|---|
|
||||||
|
| `huawei_smarthome` | 720 | 第三方(内核侧报错就是为它准备的) |
|
||||||
|
| **`weather`** | 113 | ✅ 示例 |
|
||||||
|
| **`rss`** | 103 | ✅ 示例 |
|
||||||
|
| **`calendar`** | 102 | ✅ 示例 |
|
||||||
|
|
||||||
|
**根因**:这三个示例插件(以及 `ai_image`)用了 `fmt.Printf` 打日志 ⇒
|
||||||
|
裸文本进入 stdout 协议流 ⇒ 内核报非法帧并**丢弃该日志**。
|
||||||
|
|
||||||
|
### 修法(已完成)
|
||||||
|
|
||||||
|
4 个插件共 **14 处** `fmt.Printf` → `log.Printf`,并补 `"log"` import
|
||||||
|
(`calendar`/`rss`/`weather` 原先没 import log;`ai_image` 已有)。
|
||||||
|
|
||||||
|
★ 注意:`internal/plugins/cli` 里也有一处 `fmt.Println`,但它是
|
||||||
|
**进程内插件**(`plugin.RegisterFactory`,编译进内核),
|
||||||
|
写的是内核的真实终端 ⇒ **不是缺陷**。判据只针对"子进程插件"
|
||||||
|
(`package main`)。
|
||||||
|
|
||||||
|
### 判据(`tools/hmapdev/example_stdout_test.go`,2 条)
|
||||||
|
|
||||||
|
1. `TestExamplePluginsDoNotWriteStdout` —— 扫描全部**子进程**示例插件
|
||||||
|
(实测 20 个),剥注释后不得含 `fmt.Printf/Println/Print/os.Stdout`;
|
||||||
|
报错时给出**文件:行号**
|
||||||
|
2. `TestExamplePluginsUseStderrLogging` —— 反向钉住:至少 weather/rss/calendar
|
||||||
|
真的在用 `log.Printf`(否则"删光日志"也能过第一条)
|
||||||
|
|
||||||
|
### 内核侧:让非法帧报错**可定位**(这是对第三方插件的通用修复)
|
||||||
|
|
||||||
|
原报错只有 `收到非法 JSON 帧(N 字节): invalid character 'x'`:
|
||||||
|
不显示内容(不知是哪句话)、不提原因(看起来像内核故障)。
|
||||||
|
1038 条报错的定位花了一整轮排查 —— 就因为缺这两样。
|
||||||
|
|
||||||
|
现在报错含**三要素**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[proc] bad_probe 收到非法 JSON 帧(19 字节): invalid character 'b' looking for beginning of value
|
||||||
|
↑ 内容摘要: [bad_probe] started
|
||||||
|
可能原因:插件往 **stdout** 打印了日志,而 stdout 是JSON-RPC **协议通道**(stderr 才是日志通道)。
|
||||||
|
修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf(脚手架模板 1.4.0 起已修正)。
|
||||||
|
```
|
||||||
|
|
||||||
|
(`summarizeFrame` 做控制字符替换 + 截断,保证单行且不刷屏。)
|
||||||
|
|
||||||
|
判据在 `internal/plugin/proc/process_test.go`:
|
||||||
|
`TestSummarizeFrameIsReadable`、`TestIllegalFrameErrorIsActionable`。
|
||||||
|
**变异 2/2 咬住**(去掉 summarizeFrame 调用 ⇒ 红;报错不提原因 ⇒ 红)。
|
||||||
|
|
||||||
|
## 升级已装的 SDK store 与 hmapdev 二进制(操作步骤)
|
||||||
|
|
||||||
|
!!! danger "顺序不能反 —— 否则会装出「二进制新、store 旧」的组合"
|
||||||
|
**模板是 `//go:embed` 进 hmapdev 二进制的**(`tools/hmapdev/proc_runtime.go`),
|
||||||
|
而 **SDK 接口(`sdk/`)来自 store**。二者必须**同代**:
|
||||||
|
|
||||||
|
| 组合 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 旧二进制 + 旧 store | 自洽(但拿不到新能力) |
|
||||||
|
| 新二进制 + 新 store | ✅ 自洽 |
|
||||||
|
| **新二进制 + 旧 store** | ⚠️ 模板的 `procPluginMgr` 有 6 个方法,旧接口只有 3 个 ⇒ 生成物**多出 3 个方法**(Go 允许,但新方法调用的 `plugin.mgrStop` 内核可能不认) |
|
||||||
|
| **旧二进制 + 新 store** | ❌ 模板 3 个方法 vs 接口 6 个 ⇒ 生成物**不满足接口** ⇒ **编译失败** |
|
||||||
|
|
||||||
|
⇒ **先升级 store,再升级二进制**(或两者同一批做完)。
|
||||||
|
|
||||||
|
### 步骤(本机,用源码)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 用**当前**hmapdev 把本机源码装成 store(--from 在旧版也支持)
|
||||||
|
hmapdev sdk install --from <SDK 源码目录> # 例:third_party/homeagent-sdk
|
||||||
|
|
||||||
|
# 2) 重编 hmapdev 二进制(模板 embed 进去了,必须重编)
|
||||||
|
cd <SDK 源码>/tools/hmapdev
|
||||||
|
CGO_ENABLED=1 go build -tags onnxruntime -o /tmp/hmapdev-new . # TAG_ARGS 视内核而定
|
||||||
|
|
||||||
|
# 3) 原子替换(先备份!)
|
||||||
|
cp /usr/local/bin/hmapdev /usr/local/bin/hmapdev.bak.$(date +%Y%m%d-%H%M%S)
|
||||||
|
install -m 0755 /tmp/hmapdev-new /usr/local/bin/hmapdev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证(三步都要过)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev --version # 版本号
|
||||||
|
strings /usr/local/bin/hmapdev | grep -c mgrStop # ≥1 ⇒ 新模板已 embed
|
||||||
|
diff <源码>/tools/hmapdev/templates/proc_main.go.tmpl \
|
||||||
|
~/.homeagent/hmapdev/sdk/<ver>/tools/hmapdev/templates/proc_main.go.tmpl # 必须无差异
|
||||||
|
```
|
||||||
|
|
||||||
|
★ **`diff` 陷阱**:本机 PATH 上的 `diff` 是 HarmonyOS 工具链里的一个
|
||||||
|
**坏二进制**(`/opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff`),
|
||||||
|
对**不同**的文件也返回 **exit 0 且无输出** ⇒ 会给出"已同步"的**假结论**。
|
||||||
|
用 `cmp` 或 Python 比对,或显式用 `/usr/bin/diff`。
|
||||||
|
|
||||||
|
### 判据(自动守"三处同步")
|
||||||
|
|
||||||
|
`internal/plugin/proc/bridge_sync_test.go` 有 3 条,任一不同步即红:
|
||||||
|
|
||||||
|
1. `TestBridgeCoversEveryPublishedPluginMgrMethod` —— SDK 方法集 ↔ 能力表
|
||||||
|
2. `TestBridgeDispatchesEveryPluginMethod` —— 内核 Handle 分派
|
||||||
|
3. `TestHmapdevTemplateImplementsAllPluginMgrMethods` —— **hmapdev 模板**
|
||||||
|
|
||||||
|
第 3 条直接读 `third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl`,
|
||||||
|
所以**源码模板**始终被守;store 里的副本靠上面的 `diff` 验证。
|
||||||
|
|
||||||
|
!!! tip "什么时候必须重编"
|
||||||
|
只有一种情况:**内核跨了主版本**(如 1.x → 2.x)。
|
||||||
|
主版本跃迁是破坏性变更的定义,此后的帧格式与共享段布局都不兼容,
|
||||||
|
必须用配套 `hmapdev` 重编。
|
||||||
|
|
||||||
|
**中版本跃迁(如 1.1 → 1.2)不需要重编**:1.x 内的新增全部是
|
||||||
|
「插件调用、内核实现」,不调新面就不受影响。
|
||||||
|
|
||||||
|
## 协议兼容性:只认主版本号
|
||||||
|
|
||||||
|
**没有独立的协议版本号。** 兼容性由双方版本号的**主版本段**表态:
|
||||||
|
内核的 `meta.Version` 与插件的 `sdk_version`(插件包里的 `sdk_version` 字段,
|
||||||
|
即编译时链接的 SDK 版本)。
|
||||||
|
|
||||||
|
| 内核 | 插件 SDK | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1.4.0 | 1.0.0 / 1.1.0 / 1.3.0 | ✅ 建链(同主版本) |
|
||||||
|
| 1.4.0 | 2.0.0 | ❌ 拒绝(跨主版本) |
|
||||||
|
|
||||||
|
**为什么不设独立的协议号**:语义版本变动频繁(修 bug、加字段),如果每个
|
||||||
|
这类改动都要人去升一个额外的号,那个号必然会被漏升而腐烂(历史实证:
|
||||||
|
曾有一个 `ProtocolVersion` 常量,多次布局变更里只升过一次,而对应的守卫
|
||||||
|
测试长期处于失败状态、无人察觉)。让兼容性只挂在**始终会被发的那个号**上,
|
||||||
|
就没有「忘同步」这个失效模式。
|
||||||
|
|
||||||
|
跨主版本错配**不会静默失效**——握手时会显式拦下,并提示用配套 `hmapdev` 重编。
|
||||||
|
此外握手还会比对 `field_count`(StageContext 描述符个数):它决定标志位基址,
|
||||||
|
不一致会让插件读到错位字段,因此也在握手上显式拒绝。
|
||||||
|
|
||||||
|
## 怎么确认自己在用什么
|
||||||
|
|
||||||
|
装的 SDK 版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev sdk current
|
||||||
|
hmapdev sdk list
|
||||||
|
```
|
||||||
|
|
||||||
|
插件声明的目标版本在 `plg.json` 的 `sdk` 字段。若该版本不在本地存储里,
|
||||||
|
`hmapdev` 会**明确报错**,不静默降级 —— 静默降级会产出与内核协议不匹配的包,
|
||||||
|
那种失败要到运行时才暴露。
|
||||||
|
|
||||||
|
## 文档站对应的版本
|
||||||
|
|
||||||
|
本页与 [API 参考](api/index.md) 由 `tools/apidoc` 从源码生成,
|
||||||
|
内容随源码一起演进。发现文档与代码不一致时,**改的是源码注释**,
|
||||||
|
`go run ./tools/apidoc` 重新生成即可(见下方「维护」)。
|
||||||
|
|
||||||
|
## 维护(给 SDK 维护者)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd homeagent-sdk
|
||||||
|
go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json
|
||||||
|
go run ./tools/apidoc/gensite -api /tmp/api.json -out ./docs -examples ./example
|
||||||
|
mkdocs serve # 本地预览
|
||||||
|
mkdocs build # 产出 site_build/
|
||||||
|
```
|
||||||
|
|
||||||
|
API 面的**能力分层**(哪些 API 仅内置可用)记在 `tools/apidoc/tiers.json`,
|
||||||
|
每条裁定都附源码依据 —— 改这里而不是改生成物。
|
||||||
47
example/a2a/README.md
Normal file
47
example/a2a/README.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# a2a · Agent-to-Agent 通信
|
||||||
|
|
||||||
|
让本 Agent 与其他 Agent **双向互调**:既能对外暴露自己的能力,也能去问别的 Agent。
|
||||||
|
|
||||||
|
## 两个方向
|
||||||
|
|
||||||
|
| 方向 | 怎么实现 |
|
||||||
|
|---|---|
|
||||||
|
| **入站**(别人问我) | 插件起一个 HTTP 服务端,暴露 `/agent-card`(能力描述)与 `/a2a`(JSON-RPC 入口) |
|
||||||
|
| **出站**(我问别人) | 提供 `a2a_query` / `a2a_discover` 工具,主动向远端 A2A Agent 发起请求 |
|
||||||
|
|
||||||
|
## HTTP 端点
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /agent-card` | 返回 Agent Card:本 Agent 的能力描述,供对方发现 |
|
||||||
|
| `POST /a2a` | JSON-RPC 2.0 入口,接收对方的任务请求 |
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `a2a_a2a_query` | 向另一个 A2A Agent 发查询并取回复 |
|
||||||
|
| `a2a_a2a_discover` | 取对方的 Agent Card(能力描述) |
|
||||||
|
| `a2a_a2a_status` | 看本插件运行状态(监听地址、当前配置) |
|
||||||
|
| `a2a_a2a_configure` | 改配置并自动重启服务(可动态改监听地址) |
|
||||||
|
| `a2a_a2a_restart` | 重启 HTTP 服务端(连接异常或改配置后用) |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`tp`),按默认 `a2a_` 列出。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `listen` | `127.0.0.1:12000` | 服务端监听地址。**设为空可禁用 HTTP 服务**(只出站、不入站) |
|
||||||
|
|
||||||
|
## 典型用法
|
||||||
|
|
||||||
|
1. **先发现再调用**:`a2a_discover` 拿对方能力 → 决定要不要发、发什么 → `a2a_query`。
|
||||||
|
跳过 discovery 直接问,容易问出对方不支持的东西。
|
||||||
|
2. **只出站**:把 `listen` 设为空,本 Agent 不外露端口,但仍能主动联系别人。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -2,6 +2,6 @@ module a2a
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
require github.com/JianFeeeee/homeagentsdk v0.0.0
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
replace github.com/JianFeeeee/homeagentsdk => ../../
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg=
|
github.com/JianFeeeee/homeagentsdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg=
|
||||||
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=
|
github.com/JianFeeeee/homeagentsdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "a2a",
|
"name": "a2a",
|
||||||
"name_zh": "A2A 代理通信",
|
"name_zh": "A2A 代理通信",
|
||||||
"name_en": "A2A Agent Communication",
|
"name_en": "A2A Agent Communication",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
p.sessions = make(map[string]*a2aSession)
|
p.sessions = make(map[string]*a2aSession)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
|
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
|
||||||
@ -347,7 +350,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
if sess := p.sessions[sessionID]; sess != nil {
|
if sess := p.sessions[sessionID]; sess != nil {
|
||||||
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
|
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
|
||||||
if len(sess.History) > maxSessionTurns*2 {
|
if len(sess.History) > maxSessionTurns*2 {
|
||||||
sess.History = sess.History[len(sess.History)-maxSessionTurns*2 :]
|
sess.History = sess.History[len(sess.History)-maxSessionTurns*2:]
|
||||||
}
|
}
|
||||||
sess.LastUsed = time.Now()
|
sess.LastUsed = time.Now()
|
||||||
}
|
}
|
||||||
|
|||||||
51
example/acp/README.md
Normal file
51
example/acp/README.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# acp · Agent Client Protocol 通信
|
||||||
|
|
||||||
|
[ACP](https://agentclientprotocol.com/) 桥接:本 Agent 既能**当服务端**接别人的任务,也能**当客户端**去调别的 ACP Agent。
|
||||||
|
|
||||||
|
## 两个方向
|
||||||
|
|
||||||
|
| 角色 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| **服务端** | 在本机起 HTTP 服务,处理 `session/new` / `session/update`,接受其他 Agent 的任务请求 |
|
||||||
|
| **客户端** | 通过 `acp_query` 向远程 ACP Agent 发 `session/new` 并读回复 |
|
||||||
|
|
||||||
|
## 协议端点
|
||||||
|
|
||||||
|
- `POST /api/session` —— JSON-RPC,支持 `session/new` 与 `session/update`
|
||||||
|
- 客户端侧同时兼容**两种服务端**:SSE 型(流式 `session/reply`)与同步 JSON 型
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `acp_acp_query` | 向远程 ACP Agent 发起会话并等待回复,返回最终回答文本 |
|
||||||
|
| `acp_acp_status` | 查看运行状态与**当前活跃会话数** |
|
||||||
|
| `acp_acp_configure` | 改监听配置并重启 HTTP 服务 |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`tp`),按默认 `acp_` 列出。
|
||||||
|
|
||||||
|
`acp_query` 可指向的远端举例(源码注释给的):
|
||||||
|
|
||||||
|
- opencode:`http://127.0.0.1:13000`
|
||||||
|
- pi bridge:`http://127.0.0.1:12011`
|
||||||
|
- 回环到自身:`http://127.0.0.1:12001`
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `listen` | `127.0.0.1:12001` | 服务端监听地址。**设为空可禁用 HTTP 服务**(只出站) |
|
||||||
|
|
||||||
|
## 与 a2a 的区别
|
||||||
|
|
||||||
|
| | a2a | acp |
|
||||||
|
|---|---|---|
|
||||||
|
| 面向 | Agent ↔ Agent 对等通信 | 客户端 → Agent 会话(每次一个 session) |
|
||||||
|
| 会话 | 一问一答 | 有 session 生命周期,可续 |
|
||||||
|
| 发现 | `/agent-card` | 无(需已知地址) |
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -2,6 +2,6 @@ module acp
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
require github.com/JianFeeeee/homeagentsdk v0.0.0
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
replace github.com/JianFeeeee/homeagentsdk => ../../
|
||||||
@ -3,7 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "acp",
|
"name": "acp",
|
||||||
"name_zh": "ACP 代理通信",
|
"name_zh": "ACP 代理通信",
|
||||||
"name_en": "ACP Agent Client Protocol",
|
"name_en": "ACP Agent Client Protocol",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
|
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
|
||||||
@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
p.sessions = make(map[string]*sessionState)
|
p.sessions = make(map[string]*sessionState)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。
|
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。
|
||||||
|
|||||||
@ -1,13 +1,34 @@
|
|||||||
# ai_image
|
# ai_image · 文生图
|
||||||
|
|
||||||
ai_image plugin
|
按文字提示生成图片,下载到本地并返回**文件路径**。
|
||||||
|
|
||||||
## Build
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ai_image_generate` | 按 prompt 生成图片 |
|
||||||
|
|
||||||
|
返回值是**本地文件路径**(永久,不过期)。要把图给用户看,再用导出的通道
|
||||||
|
以 `type=image`、`payload=<该路径>` 发送。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `api_key` | 空 | OpenAI / Stable Diffusion 的 API Key |
|
||||||
|
| `base_url` | 空 | 自定义 OpenAI 兼容网关(**不带 `/v1` 尾缀**,如 `http://127.0.0.1:8081`)。留空走官方 `https://api.openai.com` |
|
||||||
|
| `provider` | `openai` | 服务方:`openai` / `stability` |
|
||||||
|
| `model` | `dall-e-3` | 模型名(如 `dall-e-3`、`sd-xl`) |
|
||||||
|
| `size` | `1024x1024` | 默认尺寸,也可 `1024x1792` / `1792x1024` |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **返回本地路径而不是远端 URL**:远端图床链接会过期,写进记忆就成了悬空指针。
|
||||||
|
下载到本地后路径稳定,可交给媒体存储做内容寻址。
|
||||||
|
- 配了 `base_url` 就能指向自建/兼容网关,不必依赖官方接口。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Upload the .hmap file through the Plugin Manager API.
|
|
||||||
|
|||||||
@ -2,6 +2,6 @@ module ai_image
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
require github.com/JianFeeeee/homeagentsdk v0.0.0
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
replace github.com/JianFeeeee/homeagentsdk => ../../
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
@ -143,17 +143,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
||||||
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
||||||
|
|
||||||
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
||||||
if p.sdk != nil {
|
if p.sdk != nil {
|
||||||
if dd := s.Settings().DataDir(); dd != "" {
|
if dd := s.Settings().DataDir(); dd != "" {
|
||||||
p.dataDir = dd
|
p.dataDir = dd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p.dataDir == "" {
|
if p.dataDir == "" {
|
||||||
// 旧版内核无 DataDir API 时退到 /tmp
|
// 旧版内核无 DataDir API 时退到 /tmp
|
||||||
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
||||||
}
|
}
|
||||||
os.MkdirAll(p.dataDir, 0755)
|
os.MkdirAll(p.dataDir, 0755)
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
||||||
@ -170,12 +170,12 @@ os.MkdirAll(p.dataDir, 0755)
|
|||||||
},
|
},
|
||||||
}, p.handleGenerate)
|
}, 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
func (p *Plugin) Stop() error {
|
||||||
fmt.Printf("[%s] stopped\n", p.name)
|
log.Printf("[%s] stopped", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
example/bili/README.md
Normal file
43
example/bili/README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# bili · B站视频下载
|
||||||
|
|
||||||
|
用 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 把 B 站视频下载到本地。
|
||||||
|
|
||||||
|
## 前置依赖
|
||||||
|
|
||||||
|
需要系统里装有 `yt-dlp`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -U yt-dlp # 或 apt install yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `bili_video` | 下载 B 站视频;不指定 `format` 时先返回可用清晰度列表,指定后真正下载并返回文件路径 |
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `url` | 视频地址 |
|
||||||
|
| `format` | 格式 ID。常用:`30112`/`30080`=1080P、`30064`=720P、`30032`=480P、`30016`=360P。不指定则自动选最优 |
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `output_dir` | `/tmp/bili_videos` | 下载目录 |
|
||||||
|
| `proxy` | 空 | yt-dlp 使用的 HTTP 代理(如 `http://127.0.0.1:7890`)。留空则不设代理 |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **`output_dir` 有安全校验**:它是配置项,但会拒绝被配成系统目录,避免 yt-dlp 往任意位置写文件。
|
||||||
|
- 两阶段用法:先不传 `format` 拿到清晰度清单(`format_id` + `format_note`),再带上选定的 ID 下载。这样模型不会盲选一个不存在的格式。
|
||||||
|
- B 站在部分网络环境下需要代理,见上面的 `proxy`。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -2,6 +2,6 @@ module bili
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
require github.com/JianFeeeee/homeagentsdk v0.0.0
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
replace github.com/JianFeeeee/homeagentsdk => ../../
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
|||||||
@ -2,21 +2,59 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
proxy string
|
proxy string
|
||||||
|
|
||||||
|
// runCancel 取消**正在跑**的 yt-dlp;runWG 等它真正退出。
|
||||||
|
//
|
||||||
|
// 为何需要:下载是分钟级操作,而 Stop() 必须能把它掐掉。
|
||||||
|
// 只 cancel 不 wait 的话内核会在插件死后立刻释放共享段,
|
||||||
|
// 而 yt-dlp 还在往插件的 stdout 写 —— 那正是内核 readLoop 挂死的成因。
|
||||||
|
runMu sync.Mutex
|
||||||
|
runCancel context.CancelFunc
|
||||||
|
runWG sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRun 登记一次外部命令运行,返回完成时调用 untrack。
|
||||||
|
func (p *Plugin) trackRun() (context.Context, func()) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
p.runMu.Lock()
|
||||||
|
p.runCancel = cancel
|
||||||
|
p.runWG.Add(1)
|
||||||
|
p.runMu.Unlock()
|
||||||
|
return ctx, func() {
|
||||||
|
p.runWG.Done()
|
||||||
|
p.runMu.Lock()
|
||||||
|
p.runCancel = nil
|
||||||
|
p.runMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// killRunGroup 掐掉正在跑的 yt-dlp 及其子进程(ffmpeg 等)。
|
||||||
|
func (p *Plugin) killRunGroup(pid int) {
|
||||||
|
if pid <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 负 pid = 整个进程组(yt-dlp 拉起的 ffmpeg 也在内)
|
||||||
|
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
func (p *Plugin) Name() string { return p.name }
|
||||||
@ -67,7 +105,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error { return nil }
|
// Stop 取消并等待正在跑的下载。
|
||||||
|
//
|
||||||
|
// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑,
|
||||||
|
// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿
|
||||||
|
// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死
|
||||||
|
// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报
|
||||||
|
// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
p.runMu.Lock()
|
||||||
|
cancel := p.runCancel
|
||||||
|
p.runMu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
// 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。
|
||||||
|
p.runWG.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type ytdlpFormat struct {
|
type ytdlpFormat struct {
|
||||||
FormatID string `json:"format_id"`
|
FormatID string `json:"format_id"`
|
||||||
@ -119,11 +174,20 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
|
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
|
||||||
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
ctx, done := p.trackRun()
|
||||||
|
defer done()
|
||||||
|
// CommandContext:Stop 里的 cancel 能直接掐掉它。
|
||||||
|
// Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内,
|
||||||
|
// killRunGroup 能一次带走整棵子进程树。
|
||||||
|
cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...)
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &out
|
cmd.Stderr = &out
|
||||||
cmd.Env = proxyEnv(p.proxy)
|
cmd.Env = proxyEnv(p.proxy)
|
||||||
|
setPgid(cmd)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("yt-dlp info 已取消(插件停止)")
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,12 +273,16 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
dlArgs = append(dlArgs, "-f", format)
|
dlArgs = append(dlArgs, "-f", format)
|
||||||
}
|
}
|
||||||
dlArgs = append(dlArgs, url)
|
dlArgs = append(dlArgs, url)
|
||||||
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...)
|
||||||
cmd2.Env = proxyEnv(p.proxy)
|
cmd2.Env = proxyEnv(p.proxy)
|
||||||
var dlOut bytes.Buffer
|
var dlOut bytes.Buffer
|
||||||
cmd2.Stdout = &dlOut
|
cmd2.Stdout = &dlOut
|
||||||
cmd2.Stderr = &dlOut
|
cmd2.Stderr = &dlOut
|
||||||
|
setPgid(cmd2)
|
||||||
if err := cmd2.Run(); err != nil {
|
if err := cmd2.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("下载已取消(插件停止)")
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,6 +324,15 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setPgid 让命令自成进程组:它自己拉的子进程(yt-dlp → ffmpeg)
|
||||||
|
// 都在同一组里,kill(-pgid) 能一次带走,避免孤儿持有 stdout 管道。
|
||||||
|
func setPgid(cmd *exec.Cmd) {
|
||||||
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr.Setpgid = true
|
||||||
|
}
|
||||||
|
|
||||||
func proxyEnv(proxy string) []string {
|
func proxyEnv(proxy string) []string {
|
||||||
env := os.Environ()
|
env := os.Environ()
|
||||||
if proxy != "" {
|
if proxy != "" {
|
||||||
|
|||||||
66
example/browser/README.md
Normal file
66
example/browser/README.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# browser · 统一浏览器
|
||||||
|
|
||||||
|
一个插件覆盖三种"访问网页"的能力,从最轻到最重。**按需选层**是这个插件的重点 ——
|
||||||
|
绝大多数抓取用 HTTP 就够,不该为了一句话启动 Chromium。
|
||||||
|
|
||||||
|
## 三种能力层
|
||||||
|
|
||||||
|
| 层 | 工具 | 何时用 |
|
||||||
|
|---|---|---|
|
||||||
|
| **搜索** | `browser_search` | 要的是"找到哪些页面",不是页面本身 |
|
||||||
|
| **quick(纯 HTTP)** | `browser_fetch`(`mode=quick`) | 静态页、API、能直接拿到 HTML |
|
||||||
|
| **normal(无头渲染)** | `browser_render` / `browser_fetch`(`mode=render`) | JS 渲染的页面,HTTP 拿不到内容 |
|
||||||
|
| **interactive(CDP)** | `browser_start` + `navigate`/`click`/`type`/`scroll`/`html`/`screenshot` | 需要交互:登录、点按、翻页 |
|
||||||
|
|
||||||
|
`browser_fetch` 的 `mode`:
|
||||||
|
|
||||||
|
- `auto`(默认):先试 HTTP,**遇 403/429 才降级**用 Chromium 渲染
|
||||||
|
- `render`:强制 Chromium
|
||||||
|
- `quick`:纯 HTTP,不降级
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `browser_search` | 网页搜索 |
|
||||||
|
| `browser_fetch` | 抓取 URL 内容,三种 mode 见上 |
|
||||||
|
| `browser_render` | 无头 Chromium 渲染并提取文本(normal) |
|
||||||
|
| `browser_start` | 启动交互式浏览器会话(CDP) |
|
||||||
|
| `browser_navigate` | 导航到指定 URL |
|
||||||
|
| `browser_click` | 点击元素 |
|
||||||
|
| `browser_type` | 输入文本 |
|
||||||
|
| `browser_scroll` | 滚动页面 |
|
||||||
|
| `browser_html` | 取当前页 HTML |
|
||||||
|
| `browser_screenshot` | 截图,**保存为 PNG 并返回路径**(拿 path 调 `describe_image` / `ocr_image`) |
|
||||||
|
| `browser_install` | 安装 systemd 托管的共享浏览器后端 |
|
||||||
|
| `browser_close` | 关闭会话 |
|
||||||
|
|
||||||
|
## 共享浏览器后端
|
||||||
|
|
||||||
|
`browser_install` 安装 `homeagent-browser.service`(systemd 托管)。
|
||||||
|
装上之后**所有 agent 共享同一个 Chromium 实例与登录态**,各自占独立标签页互不干扰
|
||||||
|
(同 source 复用自己的标签页)。
|
||||||
|
|
||||||
|
前提:本机已有 chromium 二进制,没有会提示先装(`apt install chromium` 或等价)。
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **搜索用 `cn.bing.com` 而不是 `www.bing.com`**:后者对程序化请求常回 302(同意/重定向页),
|
||||||
|
根本拿不到结果块。
|
||||||
|
- **标题取 `<h2>` 里的 `<a>`**:直接抓结果块里第一个 `<a>` 会拿到来源行而非标题。
|
||||||
|
- **摘要认 `b_lineclamp`**:旧版 Bing 用 `b_caption`,新版已迁走,两套都匹配。
|
||||||
|
- **有 SSRF 防护**:见源码 `SSRF` 段,抓取前校验目标地址,避免被诱导访问内网。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`testdata/bing_cn.html` 是搜索解析的固定样本,用它做离线断言,避免测试依赖真实网络。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
71
example/browser/chromedp_compat.go
Normal file
71
example/browser/chromedp_compat.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/chromedp/chromedp/remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chromedp v0.20 是**泛型重写**(Action[T] 返回值而不填指针),
|
||||||
|
// 本文件把旧写法收敛成几个小助手,让业务代码的改动面最小。
|
||||||
|
//
|
||||||
|
// 为何不直接在每个调用点改:插件里有 20+ 处 `chromedp.Run(ctx, 步骤...)`,
|
||||||
|
// 逐处改成「Run 取一个值 / Do 跑多步」会让 diff 淹没在机械改写里,
|
||||||
|
// 真正要审的「行为变了没有」反而看不清。收敛到助手后,业务代码保持原样。
|
||||||
|
|
||||||
|
// chromedpDo 依次跑多个不返回值的步骤(旧 Run(ctx, steps...) 的等价物)。
|
||||||
|
//
|
||||||
|
// 对应 v0.20 的 chromedp.Do。放在这里是为了让调用点看起来与旧代码一致。
|
||||||
|
func chromedpDo(ctx context.Context, steps ...chromedp.Action[chromedp.Void]) error {
|
||||||
|
return chromedp.Do(ctx, steps...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRemoteAllocator 连接一个已在运行的 CDP 端点(如共享浏览器后端)。
|
||||||
|
//
|
||||||
|
// v0.20 把它移到了独立子模块:chromedp.NewRemoteAllocator → remote.NewAllocator。
|
||||||
|
// 子模块存在的理由:核心模块现在只用标准库 + cdproto,需要 websocket 的东西
|
||||||
|
// 全部搬出去(见 chromedp docs/decisions/2026-10-04-...)。
|
||||||
|
func newRemoteAllocator(parent context.Context, url string) (context.Context, context.CancelFunc) {
|
||||||
|
return remote.NewAllocator(parent, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromedpTitle 取当前页标题。
|
||||||
|
func chromedpTitle(sel ...chromedp.QueryOption) chromedp.Action[string] {
|
||||||
|
return chromedp.Title()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromedpLocation 取当前页 URL。
|
||||||
|
func chromedpLocation() chromedp.Action[string] {
|
||||||
|
return chromedp.Location()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromedpOuterHTML 取元素的外部 HTML(旧写法传 &html 接收,现在直接返回)。
|
||||||
|
func chromedpOuterHTML(sel string, opts ...chromedp.QueryOption) chromedp.Action[string] {
|
||||||
|
return chromedp.OuterHTML(sel, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromedpScreenshot 截图(旧写法传 &buf 接收,现在直接返回)。
|
||||||
|
func chromedpScreenshot(sel string, opts ...chromedp.QueryOption) chromedp.Action[[]byte] {
|
||||||
|
return chromedp.Screenshot(sel, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromedpFullScreenshot 整页截图。
|
||||||
|
func chromedpFullScreenshot(quality int) chromedp.Action[[]byte] {
|
||||||
|
return chromedp.FullScreenshot(quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromedpCaptureScreenshot 截当前**视口**。
|
||||||
|
//
|
||||||
|
// ★ 为何不用 chromedp.Screenshot("body")(2026-10-11):后者内部是
|
||||||
|
// QueryAfter + NodeVisible,而 NodeVisible 在本机的 headless Chromium 上
|
||||||
|
// 永远不会满足 —— 实测会一直挂到调用方超时(生产表现为 60s 工具超时)。
|
||||||
|
// 详见 plugin.go 里 handleScreenshot 的注释(含完整对照表)。
|
||||||
|
func chromedpCaptureScreenshot() chromedp.Action[[]byte] {
|
||||||
|
return chromedp.CaptureScreenshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromedpEvaluate 执行 JS(返回 Void 表示不关心返回值)。
|
||||||
|
func chromedpEvaluateVoid(expr string) chromedp.Action[chromedp.Void] {
|
||||||
|
return chromedp.Evaluate[chromedp.Void](expr, nil)
|
||||||
|
}
|
||||||
107
example/browser/chromedp_compat_test.go
Normal file
107
example/browser/chromedp_compat_test.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 视口截图**必须**走 CaptureScreenshot,不能用 Screenshot(sel)。
|
||||||
|
//
|
||||||
|
// # 为什么这条判据存在(2026-10-11 实测)
|
||||||
|
//
|
||||||
|
// chromedp 的 Screenshot(sel) 内部是 QueryAfter + **NodeVisible**,
|
||||||
|
// 而 NodeVisible 在本机的 headless Chromium 上**永远不会满足** —— 它先
|
||||||
|
// 用 dom.GetBoxModel 拿盒子(实测 300µs 就成功),再用 visibleJS 判定
|
||||||
|
// (实测返回 true,offsetWidth=480),两步都对,但它仍一直等下去。
|
||||||
|
//
|
||||||
|
// 同一后端上的完整对照(实测):
|
||||||
|
//
|
||||||
|
// Do(Query body) ✓ Do(WaitReady body) ✓
|
||||||
|
// Do(WaitVisible body) ✗挂住 Do(WaitVisible html) ✗挂住
|
||||||
|
// Run(Screenshot html) ✗挂住 Run(FullScreenshot) ✓
|
||||||
|
// Run(CaptureScreenshot) ✓
|
||||||
|
//
|
||||||
|
// 且**旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是既有缺陷。
|
||||||
|
// 生产日志佐证:成功的都是 agent 传了 full:true(走 FullScreenshot),
|
||||||
|
// 超时的都是默认分支(走 Screenshot("body"))。
|
||||||
|
//
|
||||||
|
// 所以这条判据守的是:不许把 CaptureScreenshot 改回 Screenshot(sel)。
|
||||||
|
func TestViewportScreenshotUsesCaptureScreenshot(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("plugin.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(src)
|
||||||
|
|
||||||
|
i := strings.Index(s, "func (p *Plugin) handleScreenshot(")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("找不到 handleScreenshot")
|
||||||
|
}
|
||||||
|
seg := s[i:]
|
||||||
|
if j := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); j > 0 {
|
||||||
|
seg = seg[:j]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 视口分支必须用 CaptureScreenshot
|
||||||
|
if !strings.Contains(seg, "chromedpCaptureScreenshot()") {
|
||||||
|
t.Error("视口截图必须走 chromedpCaptureScreenshot()(=CaptureScreenshot):\n" +
|
||||||
|
" 用 Screenshot(\"body\") 会因 NodeVisible 在 headless 下永不满足而挂到超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 不得再出现挂住的那种写法
|
||||||
|
if strings.Contains(seg, `chromedpScreenshot(`) {
|
||||||
|
t.Error("handleScreenshot 里出现了 chromedpScreenshot(sel) —— " +
|
||||||
|
"该动作在 headless 下必挂(实测),改用 chromedpCaptureScreenshot()")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ full 分支仍走 FullScreenshot(那条一直是好的)
|
||||||
|
if !strings.Contains(seg, "chromedpFullScreenshot(") {
|
||||||
|
t.Error("full=true 应走 chromedpFullScreenshot(FullScreenshot 实测正常)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompatLayerExists 确认兼容层还在(迁移收敛点)。
|
||||||
|
//
|
||||||
|
// 这一层是为了让 20+ 处调用点的迁移不淹没 diff。若有人删掉它而没把
|
||||||
|
// 调用点改对,编译会红;但若有人把它改成直接透传错误 API,就只会
|
||||||
|
// 在运行时挂住 —— 所以这里钉住关键几个。
|
||||||
|
func TestCompatLayerExists(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("chromedp_compat.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(src)
|
||||||
|
for _, need := range []string{
|
||||||
|
"func chromedpDo(",
|
||||||
|
"func newRemoteAllocator(",
|
||||||
|
"func chromedpCaptureScreenshot(",
|
||||||
|
"func chromedpFullScreenshot(",
|
||||||
|
"func chromedpTitle(",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, need) {
|
||||||
|
t.Errorf("chromedp 兼容层缺少 %s", need)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 必须用 remote 子模块的分配器(v0.20 把它移出去了)
|
||||||
|
if !strings.Contains(s, "remote.NewAllocator") {
|
||||||
|
t.Error("newRemoteAllocator 必须用 remote.NewAllocator(v0.20 移出核心模块)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoLegacyPointerScreenshot 确认没有残留的旧式「传指针接收」写法。
|
||||||
|
//
|
||||||
|
// v0.20 的截图动作是**返回值**,传 &buf 会直接编译失败 —— 但这条判据
|
||||||
|
// 仍值得留着:它锁的是「值语义」这个契约,而不是某次编译结果。
|
||||||
|
func TestNoLegacyPointerScreenshot(t *testing.T) {
|
||||||
|
for _, f := range []string{"plugin.go", "chromedp_compat.go"} {
|
||||||
|
b, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
if strings.Contains(s, "Screenshot(&") || strings.Contains(s, "FullScreenshot(&") {
|
||||||
|
t.Errorf("%s 仍用旧式传指针接收截图(v0.20 已改为返回值)", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,24 +3,21 @@ module browser
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
github.com/JianFeeeee/homeagentsdk v0.0.0
|
||||||
github.com/chromedp/chromedp v0.9.5
|
github.com/chromedp/cdproto v0.157.8
|
||||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732
|
github.com/chromedp/chromedp v0.20.1
|
||||||
|
github.com/chromedp/chromedp/remote v0.2.0
|
||||||
github.com/chromedp/sysutil v1.0.0
|
github.com/chromedp/sysutil v1.0.0
|
||||||
github.com/gobwas/httphead v0.1.0
|
github.com/gobwas/httphead v0.1.0
|
||||||
github.com/gobwas/pool v0.2.1
|
github.com/gobwas/pool v0.2.1
|
||||||
github.com/gobwas/ws v1.3.2
|
github.com/gobwas/ws v1.4.0
|
||||||
github.com/josharian/intern v1.0.0
|
github.com/josharian/intern v1.0.0
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80
|
||||||
github.com/mailru/easyjson v0.7.7
|
github.com/mailru/easyjson v0.7.7
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde
|
||||||
golang.org/x/sys v0.16.0
|
golang.org/x/sys v0.47.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
require github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 // indirect
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
replace github.com/JianFeeeee/homeagentsdk => ../../
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
|
||||||
|
|||||||
@ -1,15 +1,25 @@
|
|||||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
|
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
|
||||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||||
|
github.com/chromedp/cdproto v0.157.8 h1:Tkq0AjsUlC3Z4sJfHBzV/uAAxnTF/AZyuSTjfV/1gvk=
|
||||||
|
github.com/chromedp/cdproto v0.157.8/go.mod h1:h68JaIuFr9o3yiayxEYhBdvaxgYO3shDrPSYhNlyfu0=
|
||||||
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
|
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
|
||||||
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
|
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
|
||||||
|
github.com/chromedp/chromedp v0.20.1 h1:983T9wmyM09JtROWlxhLQwljKOQovqt8CVE89W3U0NA=
|
||||||
|
github.com/chromedp/chromedp v0.20.1/go.mod h1:6z1Y5FdKUxRzxx2qcC5h1xjaBJrSWrZp1mjgITfNZig=
|
||||||
|
github.com/chromedp/chromedp/remote v0.2.0 h1:Jo5OuOEXMuH3IAOiY5RhXO5JvOpfKMFCZ+cPv6qIksM=
|
||||||
|
github.com/chromedp/chromedp/remote v0.2.0/go.mod h1:XW8uWIvN8Z4EUxHzYCb6p0lmeTYheYRrEEQpv5FHOH8=
|
||||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 h1:XGPtjxqp0FS8jj1Z7QGcSOSgyq1fUPjNyJXvi3KfI4A=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
||||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||||
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
@ -21,3 +31,5 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzb
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "browser",
|
"name": "browser",
|
||||||
"name_zh": "浏览器",
|
"name_zh": "浏览器",
|
||||||
"name_en": "Browser",
|
"name_en": "Browser",
|
||||||
"version": "2.3.0",
|
"version": "2.4.1",
|
||||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@ -21,7 +22,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
|
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
)
|
)
|
||||||
@ -41,6 +42,24 @@ type Plugin struct {
|
|||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
|
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
|
||||||
|
|
||||||
|
// screenshotsDir 是截图落盘目录(<data>/browser_screenshots)。
|
||||||
|
//
|
||||||
|
// ★ 为何要落盘而不是直接回 base64(2026-10-10 修):
|
||||||
|
//
|
||||||
|
// 工具结果是 JSON 文本,base64 PNG 会整段进模型上下文。
|
||||||
|
// 实测一次视口截图 100-500KB ⇒ base64 后 137-685KB
|
||||||
|
// (约 3-17 万 token),而模型**看不见**这些字符里的图。
|
||||||
|
//
|
||||||
|
// 更糟的是它为每个消费方制造了同一道重复劳动:生产日志里 2026-10-10
|
||||||
|
// 那轮 agent 调 browser_screenshot 后紧跟着 cmd_run(自己 base64 解码
|
||||||
|
// 写文件),最后才 describe_image —— 它在**绕开**这个设计,
|
||||||
|
// 而那一轮跑了 920 秒。
|
||||||
|
//
|
||||||
|
// 内核早已有约定:describe_image 的 path 参数描述写着
|
||||||
|
// 「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传
|
||||||
|
// file 路径)」。本插件返回路径才是合上这个约定。
|
||||||
|
screenshotsDir string
|
||||||
|
|
||||||
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
|
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
|
||||||
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
||||||
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
||||||
@ -159,6 +178,18 @@ func errResult(msg string) map[string]interface{} {
|
|||||||
return map[string]interface{}{"isError": true, "content": msg}
|
return map[string]interface{}{"isError": true, "content": msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) {
|
||||||
|
raw := strings.TrimSpace(readArg(args, "timeout", ""))
|
||||||
|
if raw == "" {
|
||||||
|
return 0, fmt.Errorf("timeout is required;创建浏览器会话时必须明确指定关闭时长,如 15m 或 2h")
|
||||||
|
}
|
||||||
|
timeout, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || timeout <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid timeout %q;请使用大于 0 的时长,如 15m 或 2h", raw)
|
||||||
|
}
|
||||||
|
return timeout, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
@ -191,6 +222,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
|||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "timeout", Default: "30", Type: "int",
|
Key: "timeout", Default: "30", Type: "int",
|
||||||
@ -215,9 +249,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||||
|
|
||||||
// 持久化 profile 根目录:<data>/browser_profiles
|
// 持久化 profile 根目录:<data>/browser_profiles
|
||||||
|
// 截图落盘目录:<data>/browser_screenshots
|
||||||
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
|
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
|
||||||
if s2, ok := dd.(string); ok && s2 != "" {
|
if s2, ok := dd.(string); ok && s2 != "" {
|
||||||
p.profilesDir = filepath.Join(s2, "browser_profiles")
|
p.profilesDir = filepath.Join(s2, "browser_profiles")
|
||||||
|
p.screenshotsDir = filepath.Join(s2, "browser_screenshots")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,14 +312,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||||
Name: tp + "start",
|
Name: tp + "start",
|
||||||
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
|
Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
"timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"},
|
||||||
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||||
},
|
},
|
||||||
|
"required": []string{"timeout"},
|
||||||
},
|
},
|
||||||
}, p.handleBrowserStart)
|
}, p.handleBrowserStart)
|
||||||
|
|
||||||
@ -303,13 +340,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
s.RegisterTool(tp+"screenshot", sdk.ToolDef{
|
s.RegisterTool(tp+"screenshot", sdk.ToolDef{
|
||||||
Name: tp + "screenshot",
|
Name: tp + "screenshot",
|
||||||
Description: "对交互式浏览器当前页面截图。返回 base64 编码的 PNG 图片。",
|
Description: "对交互式浏览器当前页面截图,**保存为 PNG 文件并返回绝对路径**。" +
|
||||||
|
"要看图请拿返回的 path 调 describe_image(path=...);要提取文字调 ocr_image(path=...)。" +
|
||||||
|
"(不再直接返回 base64——那样会把十几万 token 灌进上下文而你看不见图。)",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
|
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
|
||||||
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
|
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
|
||||||
"format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
|
"format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
|
||||||
|
"inline": map[string]interface{}{"type": "boolean", "description": "可选:额外返回 base64/data_uri(默认 false)。仅当调用方拿不到文件系统时才需要;带上会把大段 base64 灌进上下文"},
|
||||||
},
|
},
|
||||||
"required": []string{"id"},
|
"required": []string{"id"},
|
||||||
},
|
},
|
||||||
@ -471,7 +511,9 @@ type searchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
||||||
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count)
|
// 用 cn.bing.com:www.bing.com 对程序化请求常回 302(同意/重定向页),拿不到结果块。
|
||||||
|
// 另:Bing 忽略 count 参数,翻页靠 first=,这里保留 count 只为兼容旧调用语义。
|
||||||
|
u := fmt.Sprintf("https://cn.bing.com/search?q=%s&first=1&count=%d&setlang=zh-CN", url.QueryEscape(query), count)
|
||||||
req, _ := http.NewRequest("GET", u, nil)
|
req, _ := http.NewRequest("GET", u, nil)
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||||
@ -481,38 +523,112 @@ func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return parseBingResults(string(body), count), nil
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Bing 返回 HTTP %d(%d 字节)", resp.StatusCode, len(body))
|
||||||
|
}
|
||||||
|
results := parseBingResults(string(body), count)
|
||||||
|
if len(results) == 0 {
|
||||||
|
// 关键:把「解析不出来」与「真的没结果」区分开。
|
||||||
|
// 以前两者都变成 "No results found.",版式一变就静默退化成「搜不到」。
|
||||||
|
return nil, fmt.Errorf("Bing 返回 %d 字节但未解析出结果(可能被反爬或版式变更,可改用 deepsearch 插件)", len(body))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBingResults(html string, count int) []searchResult {
|
var (
|
||||||
|
bingBlockRe = regexp.MustCompile(`<li class="b_algo"`)
|
||||||
|
bingTitleRe = regexp.MustCompile(`(?s)<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||||
|
bingAnyLinkRe = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||||
|
bingSnipRe = regexp.MustCompile(`(?s)<p class="b_lineclamp[^"]*"[^>]*>(.*?)</p>`)
|
||||||
|
bingCaptionRe = regexp.MustCompile(`(?s)<div class="b_caption"[^>]*>(.*?)</div>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// splitBingBlocks 按块标记切分,每块内容延伸到下一个块标记为止。
|
||||||
|
//
|
||||||
|
// 不用 `<li class="b_algo"(?s)(.*?)</li>`:结果块内部可能嵌套 <li>(deep links),
|
||||||
|
// 非贪婪匹配会在错误位置截断;而且块内第一个 <a> 往往是 Bing 的「来源行」,
|
||||||
|
// 取到的是 `deepin.orghttps://www.deepin.org` 这种垃圾标题。
|
||||||
|
func splitBingBlocks(pageHTML string) []string {
|
||||||
|
locs := bingBlockRe.FindAllStringIndex(pageHTML, -1)
|
||||||
|
if len(locs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blocks := make([]string, 0, len(locs))
|
||||||
|
for i, loc := range locs {
|
||||||
|
end := len(pageHTML)
|
||||||
|
if i+1 < len(locs) {
|
||||||
|
end = locs[i+1][0]
|
||||||
|
}
|
||||||
|
blocks = append(blocks, pageHTML[loc[1]:end])
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBingResults(pageHTML string, count int) []searchResult {
|
||||||
|
if count <= 0 {
|
||||||
|
count = 5
|
||||||
|
}
|
||||||
var results []searchResult
|
var results []searchResult
|
||||||
re := regexp.MustCompile(`<li class="b_algo"(?s)(.*?)</li>`)
|
for _, block := range splitBingBlocks(pageHTML) {
|
||||||
matches := re.FindAllStringSubmatch(html, -1)
|
|
||||||
for _, m := range matches {
|
|
||||||
if len(results) >= count {
|
if len(results) >= count {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
block := m[1]
|
// 标题:现代 Bing 是 <h2><a href=...>标题</a></h2>;没有 h2 时才退回到块内第一个链接。
|
||||||
var r searchResult
|
var href, title string
|
||||||
hrefRe := regexp.MustCompile(`<a[^>]+href="([^"]+)"[^>]*>`)
|
if m := bingTitleRe.FindStringSubmatch(block); m != nil {
|
||||||
if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 {
|
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||||
r.URL = hm[1]
|
} else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil {
|
||||||
|
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||||
}
|
}
|
||||||
titleRe := regexp.MustCompile(`<a[^>]+href="[^"]+"[^>]*>(.*?)</a>`)
|
href = bingRealURL(html.UnescapeString(href))
|
||||||
if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 {
|
|
||||||
r.Title = stripTags(tm[1])
|
// 摘要:新版在 p.b_lineclamp*,旧版在 div.b_caption > p
|
||||||
|
var snippet string
|
||||||
|
if m := bingSnipRe.FindStringSubmatch(block); m != nil {
|
||||||
|
snippet = html.UnescapeString(stripTags(m[1]))
|
||||||
|
} else if m := bingCaptionRe.FindStringSubmatch(block); m != nil {
|
||||||
|
snippet = html.UnescapeString(stripTags(m[1]))
|
||||||
}
|
}
|
||||||
snipRe := regexp.MustCompile(`<div class="b_caption">.*?<p>(.*?)</p>`)
|
|
||||||
if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 {
|
title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet)
|
||||||
r.Snippet = stripTags(sm[1])
|
if href == "" || title == "" || !strings.HasPrefix(href, "http") {
|
||||||
}
|
continue
|
||||||
if r.URL != "" && r.Title != "" {
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
}
|
||||||
|
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bingRealURL 解开 Bing 的跳转包装:/ck/a?...&u=a1<base64url>&... → 真实 URL。
|
||||||
|
// 不解的话模型拿到的是 `https://cn.bing.com/ck/a?...` 这种不可读地址。
|
||||||
|
func bingRealURL(href string) string {
|
||||||
|
href = strings.TrimSpace(href)
|
||||||
|
if href == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.Contains(href, "/ck/a") && !strings.Contains(href, "u=a1") {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
u, err := url.Parse(href)
|
||||||
|
if err != nil {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
raw := u.Query().Get("u")
|
||||||
|
if !strings.HasPrefix(raw, "a1") {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
b64 := raw[2:]
|
||||||
|
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding} {
|
||||||
|
if dec, err := enc.DecodeString(b64); err == nil {
|
||||||
|
s := string(dec)
|
||||||
|
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||||
query := readArg(args, "query", "")
|
query := readArg(args, "query", "")
|
||||||
if query == "" {
|
if query == "" {
|
||||||
@ -725,25 +841,30 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
|
|
||||||
ok, needInstall, _ := p.ensureBackend()
|
ok, needInstall, _ := p.ensureBackend()
|
||||||
if ok {
|
if ok {
|
||||||
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
remoteCtx, remoteCancel := newRemoteAllocator(context.Background(), cdpEndpoint)
|
||||||
defer remoteCancel()
|
defer remoteCancel()
|
||||||
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||||
defer tabCancel()
|
defer tabCancel()
|
||||||
actions := []chromedp.Action{
|
steps := []chromedp.Action[chromedp.Void]{
|
||||||
chromedp.Navigate(rawURL),
|
chromedp.Navigate(rawURL),
|
||||||
chromedp.WaitReady("body"),
|
chromedp.WaitReady("body"),
|
||||||
}
|
}
|
||||||
if waitSec > 0 {
|
if waitSec > 0 {
|
||||||
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
|
steps = append(steps, chromedp.Sleep(time.Duration(waitSec)*time.Second))
|
||||||
}
|
}
|
||||||
actions = append(actions,
|
|
||||||
chromedp.Title(&title),
|
|
||||||
chromedp.OuterHTML("html", &html),
|
|
||||||
)
|
|
||||||
// 整体限时 30s,防慢页拖死工具
|
// 整体限时 30s,防慢页拖死工具
|
||||||
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
|
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
|
||||||
defer rcancel()
|
defer rcancel()
|
||||||
if err := chromedp.Run(rctx, actions...); err == nil {
|
// 标题与 HTML 现在是**返回值**,不再传指针接收。
|
||||||
|
// 顺序仍是「先跑完交互步骤,再取值」——取值动作本身也会被 Do 依次执行。
|
||||||
|
steps = append(steps, chromedp.WaitReady("html"))
|
||||||
|
if err := chromedpDo(rctx, steps...); err == nil {
|
||||||
|
if t, terr := chromedp.Run(rctx, chromedpTitle()); terr == nil {
|
||||||
|
title = t
|
||||||
|
}
|
||||||
|
if h, herr := chromedp.Run(rctx, chromedpOuterHTML("html")); herr == nil {
|
||||||
|
html = h
|
||||||
|
}
|
||||||
rendered = true
|
rendered = true
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
|
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
|
||||||
@ -852,7 +973,7 @@ func (p *Plugin) ensureBackend() (bool, bool, error) {
|
|||||||
// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。
|
// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。
|
||||||
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
|
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
|
||||||
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
||||||
if err := chromedp.Run(tabCtx); err != nil {
|
if err := chromedpDo(tabCtx); err != nil {
|
||||||
tabCancel()
|
tabCancel()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@ -873,7 +994,7 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
|
|||||||
}
|
}
|
||||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
ctx, _ := chromedp.NewContext(allocCtx)
|
ctx, _ := chromedp.NewContext(allocCtx)
|
||||||
if err := chromedp.Run(ctx); err != nil {
|
if err := chromedpDo(ctx); err != nil {
|
||||||
cancelAlloc()
|
cancelAlloc()
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
@ -881,10 +1002,9 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||||
timeoutStr := readArg(args, "timeout", "10m")
|
timeout, err := parseBrowserSessionTimeout(args)
|
||||||
timeout, err := time.ParseDuration(timeoutStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
timeout = 10 * time.Minute
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
source := readArg(args, "source", "")
|
source := readArg(args, "source", "")
|
||||||
@ -899,13 +1019,19 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
id := s.id
|
id := s.id
|
||||||
cur := s.currentURL
|
cur := s.currentURL
|
||||||
|
s.createdAt = time.Now()
|
||||||
|
s.timeout = timeout
|
||||||
|
closesAt := s.createdAt.Add(timeout)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": "reused",
|
"status": "reused",
|
||||||
"url": cur,
|
"url": cur,
|
||||||
"note": "已复用本来源的现有标签页(登录态全机共享)",
|
"timeout": timeout.String(),
|
||||||
|
"closes_at": closesAt.Format(time.RFC3339),
|
||||||
|
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -916,14 +1042,14 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
// 路径一:systemd 托管的共享后端(主路径)
|
// 路径一:systemd 托管的共享后端(主路径)
|
||||||
ok, needInstall, berr := p.ensureBackend()
|
ok, needInstall, berr := p.ensureBackend()
|
||||||
if ok {
|
if ok {
|
||||||
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
remoteCtx, remoteCancel := newRemoteAllocator(context.Background(), cdpEndpoint)
|
||||||
probe, _ := chromedp.NewContext(remoteCtx)
|
probe, _ := chromedp.NewContext(remoteCtx)
|
||||||
if err := chromedp.Run(probe); err != nil {
|
if err := chromedpDo(probe); err != nil {
|
||||||
remoteCancel()
|
remoteCancel()
|
||||||
return errResult("connect to browser backend failed: " + err.Error()), nil
|
return errResult("connect to browser backend failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||||
if err := chromedp.Run(tabCtx); err != nil {
|
if err := chromedpDo(tabCtx); err != nil {
|
||||||
remoteCancel()
|
remoteCancel()
|
||||||
return errResult("open tab failed: " + err.Error()), nil
|
return errResult("open tab failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
@ -959,7 +1085,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
|
|
||||||
initURL := readArg(args, "url", "")
|
initURL := readArg(args, "url", "")
|
||||||
if initURL != "" {
|
if initURL != "" {
|
||||||
if err := chromedp.Run(session.ctx,
|
if err := chromedpDo(session.ctx,
|
||||||
chromedp.Navigate(initURL),
|
chromedp.Navigate(initURL),
|
||||||
chromedp.WaitReady("body"),
|
chromedp.WaitReady("body"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -972,13 +1098,15 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
session.currentURL = initURL
|
session.currentURL = initURL
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
|
closesAt := session.createdAt.Add(timeout)
|
||||||
|
log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": "created",
|
"status": "created",
|
||||||
"mode": "shared-backend",
|
"mode": "shared-backend",
|
||||||
"url": initURL,
|
"url": initURL,
|
||||||
"timeout": timeout.String(),
|
"timeout": timeout.String(),
|
||||||
|
"closes_at": closesAt.Format(time.RFC3339),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1003,7 +1131,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error
|
|||||||
return errResult(err.Error()), nil
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
waitSec := int64(readArg(args, "wait", float64(2)))
|
waitSec := int64(readArg(args, "wait", float64(2)))
|
||||||
if err := chromedp.Run(s.ctx,
|
if err := chromedpDo(s.ctx,
|
||||||
chromedp.Navigate(rawURL),
|
chromedp.Navigate(rawURL),
|
||||||
chromedp.WaitReady("body"),
|
chromedp.WaitReady("body"),
|
||||||
chromedp.Sleep(time.Duration(waitSec)*time.Second),
|
chromedp.Sleep(time.Duration(waitSec)*time.Second),
|
||||||
@ -1035,21 +1163,98 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
|||||||
var buf []byte
|
var buf []byte
|
||||||
var err error
|
var err error
|
||||||
if full {
|
if full {
|
||||||
err = chromedp.Run(s.ctx, chromedp.FullScreenshot(&buf, 90))
|
buf, err = chromedp.Run(s.ctx, chromedpFullScreenshot(90))
|
||||||
} else {
|
} else {
|
||||||
err = chromedp.Run(s.ctx, chromedp.Screenshot("body", &buf))
|
// ★ 视口截图用 CaptureScreenshot,**不能用 Screenshot("body")**(2026-10-11 修)。
|
||||||
|
//
|
||||||
|
// Screenshot 内部是 QueryAfter + NodeVisible,而 NodeVisible 在
|
||||||
|
// headless Chromium 下**永远不满足**:它先用 dom.GetBoxModel 拿到盒子
|
||||||
|
// (实测 300µs 就成功),再用 visibleJS 判定 —— 而 visibleJS 实测
|
||||||
|
// 返回 true(offsetWidth=480)。两者都对,但它仍会一直等下去。
|
||||||
|
//
|
||||||
|
// 实测(同一 headless 后端,完整对照):
|
||||||
|
//
|
||||||
|
// Do(Query body) ✓ Do(WaitReady body) ✓
|
||||||
|
// Do(WaitVisible body) ✗挂 Do(WaitVisible html) ✗挂
|
||||||
|
// Run(Screenshot html) ✗挂 Run(FullScreenshot) ✓
|
||||||
|
//
|
||||||
|
// 且 **旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是早就存在的
|
||||||
|
// 缺陷。生产日志佐证:成功的 8 次都是 agent 传了 full:true(走
|
||||||
|
// FullScreenshot),超时的 5 次都是默认分支。
|
||||||
|
//
|
||||||
|
// CaptureScreenshot 截整个视口,不经过 NodeVisible 那条链,实测 70KB / 正常返回。
|
||||||
|
// 语义也更对:「不传 full 就只截当前视口」本来就是 API 文档的含义。
|
||||||
|
buf, err = chromedp.Run(s.ctx, chromedpCaptureScreenshot())
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errResult("screenshot failed: " + err.Error()), nil
|
return errResult("screenshot failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
b64 := base64.StdEncoding.EncodeToString(buf)
|
|
||||||
return map[string]interface{}{
|
// ★ 默认落盘并返回**路径**(2026-10-10 改)。
|
||||||
|
//
|
||||||
|
// 为何不改回直接回 base64:那是把「解码 + 写文件」这件重复劳动
|
||||||
|
// 推给每一个消费方,而模型自己做不到(它看不见 base64 里的图)。
|
||||||
|
// 生产日志实证过 agent 的绕行链路:browser_screenshot → cmd_run 自己
|
||||||
|
// 解码写盘 → describe_image。
|
||||||
|
path, saveErr := p.saveScreenshot(buf, format, id)
|
||||||
|
if saveErr != nil {
|
||||||
|
// 落盘失败时不静默退回 base64:那会让「上下文被灌爆」这件事
|
||||||
|
// 在磁盘满/权限错时重新出现,而且没有迹象。
|
||||||
|
return errResult("screenshot saved failed: " + saveErr.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res := map[string]interface{}{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"format": format,
|
"format": format,
|
||||||
"size": len(buf),
|
"size": len(buf),
|
||||||
"base64": b64,
|
// path 是**主要返回值**:模型应拿它去调 describe_image / ocr_image,
|
||||||
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
|
// 或交给 files_* / cmd_run 做后续处理。
|
||||||
}, nil
|
"path": path,
|
||||||
|
// hint 直接写在结果里:工具描述模型可能漏看,结果里的提示更近。
|
||||||
|
"hint": "截图已保存。要看图请调 describe_image(path=...);" +
|
||||||
|
"要 OCR 请调 ocr_image(path=...)。不要再自己解码 base64。",
|
||||||
|
}
|
||||||
|
|
||||||
|
// inline=true 时才额外带 base64:只有确实拿不到文件系统
|
||||||
|
// (如前端要把图直接嵌进消息)的少数场景才该用。
|
||||||
|
if v, ok := args["inline"].(bool); ok && v {
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(buf)
|
||||||
|
res["base64"] = b64
|
||||||
|
res["data_uri"] = fmt.Sprintf("data:image/png;base64,%s", b64)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveScreenshot 把截图写入 <data>/browser_screenshots/,返回**绝对路径**。
|
||||||
|
//
|
||||||
|
// 为何文件名带时间戳与会话 id:同一会话连续截图是常态(生产日志里一轮
|
||||||
|
// 内截了 3 次),固定文件名会互相覆盖,而 agent 很可能还想回看前一张。
|
||||||
|
// 时间戳用 `20060102-150405.000`(毫秒):同秒内多次截图也不撞名。
|
||||||
|
func (p *Plugin) saveScreenshot(buf []byte, format, sessionID string) (string, error) {
|
||||||
|
dir := p.screenshotsDir
|
||||||
|
if dir == "" {
|
||||||
|
// 未注入 data_dir(如测试环境)时退到临时目录,而不是报错:
|
||||||
|
// 截图本身是成功的,只该降级存储位置。
|
||||||
|
dir = filepath.Join(os.TempDir(), "homeagent-browser-screenshots")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("创建截图目录 %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
// 会话 id 由调用方传入,可能含路径分隔符 —— 过滤掉,
|
||||||
|
// 否则 `../` 能把截图写出目录外。
|
||||||
|
safeID := strings.Map(func(r rune) rune {
|
||||||
|
if r == '/' || r == '\\' || r == 0 {
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, sessionID)
|
||||||
|
name := fmt.Sprintf("shot-%s-%s.%s",
|
||||||
|
time.Now().Format("20060102-150405.000"), safeID, format)
|
||||||
|
full := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(full, buf, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("写入 %s: %w", full, err)
|
||||||
|
}
|
||||||
|
return full, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
|
||||||
@ -1065,15 +1270,17 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
|
|||||||
if v, ok := args["max_chars"].(float64); ok && v > 0 {
|
if v, ok := args["max_chars"].(float64); ok && v > 0 {
|
||||||
maxChars = int(v)
|
maxChars = int(v)
|
||||||
}
|
}
|
||||||
var html string
|
html, err := chromedp.Run(s.ctx, chromedpOuterHTML("html"))
|
||||||
if err := chromedp.Run(s.ctx, chromedp.OuterHTML("html", &html)); err != nil {
|
if err != nil {
|
||||||
return errResult("get html failed: " + err.Error()), nil
|
return errResult("get html failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
var title, currentURL string
|
var title, currentURL string
|
||||||
chromedp.Run(s.ctx,
|
if t, terr := chromedp.Run(s.ctx, chromedpTitle()); terr == nil {
|
||||||
chromedp.Title(&title),
|
title = t
|
||||||
chromedp.Location(¤tURL),
|
}
|
||||||
)
|
if u, uerr := chromedp.Run(s.ctx, chromedpLocation()); uerr == nil {
|
||||||
|
currentURL = u
|
||||||
|
}
|
||||||
truncated := len(html) > maxChars
|
truncated := len(html) > maxChars
|
||||||
if truncated {
|
if truncated {
|
||||||
html = html[:maxChars] + "\n\n[HTML truncated]"
|
html = html[:maxChars] + "\n\n[HTML truncated]"
|
||||||
@ -1097,7 +1304,7 @@ func (p *Plugin) handleClick(args map[string]interface{}) (interface{}, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errResult(err.Error()), nil
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
if err := chromedp.Run(s.ctx,
|
if err := chromedpDo(s.ctx,
|
||||||
chromedp.WaitVisible(selector),
|
chromedp.WaitVisible(selector),
|
||||||
chromedp.Click(selector),
|
chromedp.Click(selector),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -1117,7 +1324,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errResult(err.Error()), nil
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
actions := []chromedp.Action{
|
actions := []chromedp.Action[chromedp.Void]{
|
||||||
chromedp.WaitVisible(selector),
|
chromedp.WaitVisible(selector),
|
||||||
chromedp.Click(selector, chromedp.NodeVisible),
|
chromedp.Click(selector, chromedp.NodeVisible),
|
||||||
chromedp.Clear(selector),
|
chromedp.Clear(selector),
|
||||||
@ -1130,7 +1337,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) {
|
|||||||
if submit {
|
if submit {
|
||||||
actions = append(actions, chromedp.SendKeys(selector, "\r"))
|
actions = append(actions, chromedp.SendKeys(selector, "\r"))
|
||||||
}
|
}
|
||||||
if err := chromedp.Run(s.ctx, actions...); err != nil {
|
if err := chromedpDo(s.ctx, actions...); err != nil {
|
||||||
return errResult("type failed (element may not exist or page blocking): " + err.Error()), nil
|
return errResult("type failed (element may not exist or page blocking): " + err.Error()), nil
|
||||||
}
|
}
|
||||||
return map[string]interface{}{"status": "ok", "selector": selector}, nil
|
return map[string]interface{}{"status": "ok", "selector": selector}, nil
|
||||||
@ -1160,7 +1367,7 @@ func (p *Plugin) handleScroll(args map[string]interface{}) (interface{}, error)
|
|||||||
default:
|
default:
|
||||||
return errResult("dir 必须是 up/down/left/right"), nil
|
return errResult("dir 必须是 up/down/left/right"), nil
|
||||||
}
|
}
|
||||||
if err := chromedp.Run(s.ctx, chromedp.Evaluate(scrollJS, nil)); err != nil {
|
if err := chromedpDo(s.ctx, chromedpEvaluateVoid(scrollJS)); err != nil {
|
||||||
return errResult("scroll failed: " + err.Error()), nil
|
return errResult("scroll failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
return map[string]interface{}{"status": "ok", "dir": dir, "amount": amount}, nil
|
return map[string]interface{}{"status": "ok", "dir": dir, "amount": amount}, nil
|
||||||
@ -1204,13 +1411,20 @@ func (p *Plugin) cleanupLoop() {
|
|||||||
case <-p.stopCh:
|
case <-p.stopCh:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
now := time.Now()
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
for id, s := range p.sessions {
|
for id, s := range p.sessions {
|
||||||
if time.Since(s.createdAt) >= s.timeout {
|
s.mu.Lock()
|
||||||
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
|
closesAt := s.createdAt.Add(s.timeout)
|
||||||
|
expired := !now.Before(closesAt)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if expired {
|
||||||
|
log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339))
|
||||||
delete(p.sessions, id)
|
delete(p.sessions, id)
|
||||||
s.Close()
|
s.Close()
|
||||||
p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
|
// NoMemory:会话生命周期通知,不是记忆内容。
|
||||||
|
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||||
|
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|||||||
213
example/browser/plugin_test.go
Normal file
213
example/browser/plugin_test.go
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseBrowserSessionTimeoutRequiresExplicitValue(t *testing.T) {
|
||||||
|
_, err := parseBrowserSessionTimeout(map[string]interface{}{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "timeout is required") {
|
||||||
|
t.Fatalf("expected required timeout error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBrowserSessionTimeoutAcceptsPositiveDuration(t *testing.T) {
|
||||||
|
got, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": "2h30m"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != 2*time.Hour+30*time.Minute {
|
||||||
|
t.Fatalf("timeout=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBrowserSessionTimeoutRejectsInvalidOrNonPositive(t *testing.T) {
|
||||||
|
for _, value := range []string{"invalid", "0s", "-1m"} {
|
||||||
|
if _, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": value}); err == nil {
|
||||||
|
t.Errorf("timeout %q should be rejected", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowserStartReuseResetsExplicitCloseTime(t *testing.T) {
|
||||||
|
p := &Plugin{
|
||||||
|
name: "browser",
|
||||||
|
sessions: map[string]*BrowserSession{
|
||||||
|
"browser_1": {
|
||||||
|
id: "browser_1",
|
||||||
|
shared: true,
|
||||||
|
sessionKey: "qq",
|
||||||
|
createdAt: time.Now().Add(-time.Hour),
|
||||||
|
timeout: time.Minute,
|
||||||
|
currentURL: "https://example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
result, err := p.handleBrowserStart(map[string]interface{}{"source": "qq", "timeout": "3h"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := result.(map[string]interface{})
|
||||||
|
if out["status"] != "reused" || out["timeout"] != "3h0m0s" {
|
||||||
|
t.Fatalf("unexpected result: %#v", out)
|
||||||
|
}
|
||||||
|
s := p.sessions["browser_1"]
|
||||||
|
if s.timeout != 3*time.Hour || s.createdAt.Before(before) {
|
||||||
|
t.Fatalf("deadline not reset: createdAt=%v timeout=%v", s.createdAt, s.timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bing 解析器(2026-09 版式)─────────────────────────────
|
||||||
|
//
|
||||||
|
// 背景:旧实现把块内**第一个 <a>** 当标题 —— 拿到的是 Bing 的「来源行」
|
||||||
|
// `deepin.orghttps://www.deepin.org`;摘要正则 `<div class="b_caption">.*?<p>`
|
||||||
|
// 对现代 Bing 命中 0/N(摘要已迁到 p.b_lineclamp*),于是结果「有标题没摘要」,
|
||||||
|
// 模型只好反复换词重搜。夹具 testdata/bing_cn.html 是真实 cn.bing.com 响应裁剪。
|
||||||
|
|
||||||
|
func TestParseBingResultsRealBingHTML(t *testing.T) {
|
||||||
|
page, err := os.ReadFile("testdata/bing_cn.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取夹具失败: %v", err)
|
||||||
|
}
|
||||||
|
results := parseBingResults(string(page), 3)
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Fatalf("应解析出 3 条,实际 %d 条: %+v", len(results), results)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if !strings.HasPrefix(r.URL, "http") {
|
||||||
|
t.Errorf("第 %d 条 URL 不是真实地址: %q", i+1, r.URL)
|
||||||
|
}
|
||||||
|
if strings.Contains(r.Title, "http") || strings.Contains(r.Title, "://") {
|
||||||
|
t.Errorf("第 %d 条标题混入了 URL(旧 bug 的典型症状): %q", i+1, r.Title)
|
||||||
|
}
|
||||||
|
if r.Snippet == "" {
|
||||||
|
t.Errorf("第 %d 条没有摘要(旧 bug 的典型症状): %+v", i+1, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 第一条必须与样本里的真实结果一致
|
||||||
|
if results[0].URL != "https://www.deepin.org/" {
|
||||||
|
t.Errorf("第一条 URL 应为 https://www.deepin.org/,实际 %q", results[0].URL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(results[0].Title, "deepin") {
|
||||||
|
t.Errorf("第一条标题不对: %q", results[0].Title)
|
||||||
|
}
|
||||||
|
if len(results[0].Snippet) < 10 || strings.Contains(results[0].Snippet, "://") {
|
||||||
|
t.Errorf("第一条摘要不对(应是有内容的文本): %q", results[0].Snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 块内嵌套 <li>(deep links)时不能截断 —— 旧的 `<li class="b_algo"(?s)(.*?)</li>` 会在此翻车
|
||||||
|
func TestParseBingResultsNestedLiKeepsResult(t *testing.T) {
|
||||||
|
page := `<ol id="b_results"><li class="b_algo" data-id iid=SERP.1>` +
|
||||||
|
`<h2><a href="https://a.example/x" h="ID=SERP,1">真标题</a></h2>` +
|
||||||
|
`<div class="b_caption"><p class="b_lineclamp2">真摘要</p></div>` +
|
||||||
|
`<div><ul><li><a href="https://sub.example/deeplink">子链接</a></li></ul></div>` +
|
||||||
|
`</li><li class="b_algo"><h2><a href="https://b.example/y">第二条</a></h2>` +
|
||||||
|
`<p class="b_lineclamp3">摘要二</p></li></ol>`
|
||||||
|
rs := parseBingResults(page, 5)
|
||||||
|
if len(rs) != 2 {
|
||||||
|
t.Fatalf("应解析 2 条,实际 %d 条: %+v", len(rs), rs)
|
||||||
|
}
|
||||||
|
if rs[0].URL != "https://a.example/x" || rs[0].Title != "真标题" || rs[0].Snippet != "真摘要" {
|
||||||
|
t.Errorf("第一条解析错误: %+v", rs[0])
|
||||||
|
}
|
||||||
|
if rs[1].Title != "第二条" || rs[1].Snippet != "摘要二" {
|
||||||
|
t.Errorf("第二条(无 b_caption,摘要走 b_lineclamp3)解析错误: %+v", rs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBingRealURLDecodesRedirectWrapper(t *testing.T) {
|
||||||
|
// Bing 跳转包装:/ck/a?...&u=a1<base64url>
|
||||||
|
wrapped := "/ck/a?!&&p=abc&u=a1aHR0cHM6Ly93d3cuZGVlcGluLm9yZy96aC9EZWVwaW4v&ntb=1"
|
||||||
|
if got := bingRealURL(wrapped); got != "https://www.deepin.org/zh/Deepin/" {
|
||||||
|
t.Errorf("未解开跳转包装: %q", got)
|
||||||
|
}
|
||||||
|
if got := bingRealURL("https://direct.example/p"); got != "https://direct.example/p" {
|
||||||
|
t.Errorf("直链不应被改动: %q", got)
|
||||||
|
}
|
||||||
|
// 解不开时保守返回原值,不能返回空
|
||||||
|
bad := "/ck/a?u=a1!!!!"
|
||||||
|
if got := bingRealURL(bad); got == "" {
|
||||||
|
t.Errorf("解不开时应保留原值,实际返回空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTripFunc 把任意请求转给本地测试服务器,从而离线测 bingSearch 的完整路径
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
|
||||||
|
func TestBingSearchReportsParseFailureInsteadOfEmptyResult(t *testing.T) {
|
||||||
|
var seenURL string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("<html><body>no result blocks here</body></html>"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
seenURL = r.URL.String()
|
||||||
|
return srv.Client().Transport.RoundTrip(&http.Request{
|
||||||
|
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
|
||||||
|
})
|
||||||
|
})}}
|
||||||
|
|
||||||
|
if _, err := p.bingSearch("任意查询", 5); err == nil {
|
||||||
|
t.Fatal("解析不出结果时必须报错,而不是伪装成「没有结果」")
|
||||||
|
} else if !strings.Contains(err.Error(), "未解析出结果") {
|
||||||
|
t.Errorf("错误信息应说明是解析失败: %v", err)
|
||||||
|
}
|
||||||
|
// 数据源必须是 cn.bing.com(www.bing.com 对程序化请求回 302,拿不到结果块)
|
||||||
|
if !strings.Contains(seenURL, "cn.bing.com") {
|
||||||
|
t.Errorf("应请求 cn.bing.com,实际 %q", seenURL)
|
||||||
|
}
|
||||||
|
if strings.Contains(seenURL, "www.bing.com") {
|
||||||
|
t.Errorf("不应再请求 www.bing.com: %q", seenURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常路径:能解析出结果时返回结果且不报错
|
||||||
|
func TestBingSearchParsesFixtureThroughClient(t *testing.T) {
|
||||||
|
page, err := os.ReadFile("testdata/bing_cn.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取夹具失败: %v", err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(page)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
return srv.Client().Transport.RoundTrip(&http.Request{
|
||||||
|
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
|
||||||
|
})
|
||||||
|
})}}
|
||||||
|
|
||||||
|
results, err := p.bingSearch("deepin", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("应成功,实际 %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("应返回 2 条(count 生效),实际 %d", len(results))
|
||||||
|
}
|
||||||
|
if results[0].Snippet == "" {
|
||||||
|
t.Errorf("摘要不应为空: %+v", results[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析测试 URL 失败: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
235
example/browser/screenshot_test.go
Normal file
235
example/browser/screenshot_test.go
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 截图必须**落盘并返回路径**,而不是把 base64 塞进工具结果。
|
||||||
|
//
|
||||||
|
// # 为何这条判据重要(2026-10-10)
|
||||||
|
//
|
||||||
|
// 工具结果是 JSON 文本,base64 PNG 会整段进模型上下文,而模型**看不见**
|
||||||
|
// 这些字符里的图。实测一次视口截图 100-500KB ⇒ base64 后 137-685KB
|
||||||
|
// (约 3-17 万 token),纯粹是上下文浪费。
|
||||||
|
//
|
||||||
|
// 更关键的是它把「解码 + 写文件」这件重复劳动推给了每个消费方,
|
||||||
|
// 而模型做不到。生产日志实证过 agent 的绕行链路:
|
||||||
|
//
|
||||||
|
// browser_screenshot → cmd_run(自己 base64 解码写盘)→ describe_image
|
||||||
|
//
|
||||||
|
// 那一轮跑了 920 秒。内核早有对应约定:describe_image 的 path 参数描述写着
|
||||||
|
// 「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传 file 路径)」。
|
||||||
|
func TestSaveScreenshotWritesFileAndReturnsPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := &Plugin{name: "browser", screenshotsDir: dir}
|
||||||
|
|
||||||
|
// 造一个最小 PNG 头(内容不重要,测的是落盘行为)
|
||||||
|
data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3}
|
||||||
|
path, err := p.saveScreenshot(data, "png", "sess-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("saveScreenshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 返回的必须是**绝对路径**且指向真实存在的文件
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
t.Errorf("返回的应是绝对路径(模型要拿它调 describe_image),实为 %q", path)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("截图的路径不可读(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
if string(got) != string(data) {
|
||||||
|
t.Errorf("落盘内容与截图字节不一致:%d vs %d", len(got), len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 路径必须在 screenshotsDir 内
|
||||||
|
if !strings.HasPrefix(path, dir+string(filepath.Separator)) {
|
||||||
|
t.Errorf("截图应落在 %s 内,实为 %q", dir, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 文件名要能看出是截图且带 png 后缀
|
||||||
|
if !strings.HasSuffix(path, ".png") {
|
||||||
|
t.Errorf("文件名应带 .png 后缀,实为 %q", path)
|
||||||
|
}
|
||||||
|
if !strings.Contains(filepath.Base(path), "shot-") {
|
||||||
|
t.Errorf("文件名应可识别为截图,实为 %q", filepath.Base(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一会话连续截图不能互相覆盖。
|
||||||
|
//
|
||||||
|
// 为何专门测:生产日志里**一轮之内**就调了 3 次 browser_screenshot,
|
||||||
|
// 而 agent 很可能还想回看前一张。固定文件名会静默覆盖。
|
||||||
|
func TestSaveScreenshotDoesNotOverwrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := &Plugin{name: "browser", screenshotsDir: dir}
|
||||||
|
|
||||||
|
first, err := p.saveScreenshot([]byte("AAA"), "png", "sess-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 确保时间戳至少差 1ms(文件名精度是毫秒)
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
second, err := p.saveScreenshot([]byte("BBB"), "png", "sess-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first == second {
|
||||||
|
t.Fatalf("两次截图写到了同一个文件(%s)—— 前一张被覆盖", first)
|
||||||
|
}
|
||||||
|
b1, _ := os.ReadFile(first)
|
||||||
|
if string(b1) != "AAA" {
|
||||||
|
t.Errorf("第一次截图被覆盖了:%q", b1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话 id 不能把截图写出目录外(它来自模型,且被拼进路径)。
|
||||||
|
func TestSaveScreenshotRejectsPathTraversalInSessionID(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := &Plugin{name: "browser", screenshotsDir: dir}
|
||||||
|
|
||||||
|
for _, evil := range []string{"../../etc/evil", "a/../../b", `win\..\..\x`} {
|
||||||
|
path, err := p.saveScreenshot([]byte("X"), "png", evil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("saveScreenshot(%q): %v", evil, err)
|
||||||
|
}
|
||||||
|
// 关键:解析后仍必须在 dir 内
|
||||||
|
abs, _ := filepath.Abs(path)
|
||||||
|
base, _ := filepath.Abs(dir)
|
||||||
|
if !strings.HasPrefix(abs, base+string(filepath.Separator)) {
|
||||||
|
t.Errorf("会话 id %q 让截图写到了目录外:%s(应在 %s 内)", evil, abs, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未注入 data_dir 时应降级到临时目录,而不是让截图整条失败。
|
||||||
|
//
|
||||||
|
// 理由:截图本身是成功的,只有存储位置该降级。
|
||||||
|
// 直接报错会把「环境没配」变成「功能不可用」。
|
||||||
|
func TestSaveScreenshotFallsBackWhenDirUnset(t *testing.T) {
|
||||||
|
p := &Plugin{name: "browser"} // screenshotsDir 为空
|
||||||
|
path, err := p.saveScreenshot([]byte("X"), "png", "s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("未注入目录时应降级到临时目录,实为报错: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
t.Errorf("降级路径也应是绝对路径,实为 %q", path)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Errorf("降级路径的文件不存在: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具描述必须告诉模型「返回路径 + 拿 path 去 describe_image」。
|
||||||
|
//
|
||||||
|
// 为何钉描述而不只钉实现:模型是靠描述决定怎么用工具的。
|
||||||
|
// 描述若退回「返回 base64」,模型就会又去自己解码。
|
||||||
|
func TestScreenshotToolDescriptionMentionsPath(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("plugin.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(src)
|
||||||
|
i := strings.Index(s, `tp+"screenshot"`)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("找不到 screenshot 工具注册")
|
||||||
|
}
|
||||||
|
seg := s[i:]
|
||||||
|
if end := strings.Index(seg, "}, p.handleScreenshot)"); end > 0 {
|
||||||
|
seg = seg[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(seg, "path") {
|
||||||
|
t.Error("工具描述必须提到返回 path(模型靠描述决定怎么用工具)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(seg, "describe_image") {
|
||||||
|
t.Error("工具描述应指明拿 path 去调 describe_image —— " +
|
||||||
|
"不写的话模型不知道下一步该做什么")
|
||||||
|
}
|
||||||
|
if strings.Contains(seg, "返回 base64 编码的 PNG 图片") {
|
||||||
|
t.Error("工具描述退回了「返回 base64」,与实现不符(实现已改为落盘 + 返回路径)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleScreenshot 的**返回结构**必须是 path 而不是 base64。
|
||||||
|
//
|
||||||
|
// # 为何用源码断言而不是调用它
|
||||||
|
//
|
||||||
|
// handleScreenshot 需要真实的 chromedp 会话(真 Chromium + CDP),
|
||||||
|
// 单测跑不动。而「结果里返回什么字段」是**静态可判定**的结构事实,
|
||||||
|
// 源码断言能可靠守住,回归时立刻红。
|
||||||
|
//
|
||||||
|
// 为何需要这条额外判据:saveScreenshot 的单测只覆盖落盘函数本身,
|
||||||
|
// 把 handleScreenshot 改回「返回 base64」**不会**让它变红
|
||||||
|
// (变异测试实测过 —— 这是一个真实的判据漏洞)。
|
||||||
|
func TestHandleScreenshotReturnsPathNotBase64(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("plugin.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(src)
|
||||||
|
i := strings.Index(s, "func (p *Plugin) handleScreenshot(")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("找不到 handleScreenshot")
|
||||||
|
}
|
||||||
|
seg := s[i:]
|
||||||
|
if end := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); end > 0 {
|
||||||
|
seg = seg[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 必须调用 saveScreenshot 并把它返回的路径带进结果
|
||||||
|
if !strings.Contains(seg, "p.saveScreenshot(") {
|
||||||
|
t.Error("handleScreenshot 必须调用 saveScreenshot 落盘(而不是直接编 base64)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(seg, `"path":`) {
|
||||||
|
t.Error("handleScreenshot 的结果必须含 \"path\" 字段 —— " +
|
||||||
|
"模型拿它去调 describe_image / ocr_image")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② base64 只能是**可选**(受 inline 开关控制),不能无条件出现。
|
||||||
|
//
|
||||||
|
// 注意:这条要挡住的是「无条件把 base64 塞进返回值」,因为那会让
|
||||||
|
// 上下文被灌爆——正是本次要修的问题。变异测试发现单看
|
||||||
|
// `res["base64"] = b64` 是否存在不够:把 base64 写成 map 字面量的
|
||||||
|
// 一个键(`"base64": ...`)同样绕过它,所以两种写法都要查。
|
||||||
|
// ⚠️ 这条必须查**真实代码行**而不是整段文本:注释里也出现过 "inline"
|
||||||
|
// 和 "base64",按整段 Contains 判会把注释算成保护开关
|
||||||
|
// (变异测试实测:把 `if ...inline...` 改成 `if false` 仍能通过)。
|
||||||
|
code := stripLineComments(seg)
|
||||||
|
if strings.Contains(code, `"base64"`) || strings.Contains(code, `data_uri`) {
|
||||||
|
if !strings.Contains(code, "args[\"inline\"]") {
|
||||||
|
t.Error("返回结构里出现了 base64/data_uri,但没有真实的 inline 开关保护:" +
|
||||||
|
"无条件带上就等于把十几万 token 又灌回上下文(本次修复要消掉的问题)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 落盘失败时不得静默退回 base64
|
||||||
|
if strings.Contains(code, "saveErr") && !strings.Contains(code, `return errResult("screenshot saved failed`) {
|
||||||
|
t.Error("screenshot saved 失败时必须明确报错(return errResult)," +
|
||||||
|
"不得静默退回 base64 —— 那会让上下文被灌爆这件事没有迹象")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripLineComments 去掉整行注释与行尾注释,只留可执行代码。
|
||||||
|
//
|
||||||
|
// 为何需要:源码断言若把注释也算进去,就会出现「注释里的词冒充保护逻辑」
|
||||||
|
// 这种假通过。变异测试实测过这个坑。
|
||||||
|
func stripLineComments(src string) string {
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(src, "\n") {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(t, "//") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i := strings.Index(line, "//"); i >= 0 {
|
||||||
|
line = line[:i]
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
1
example/browser/testdata/bing_cn.html
vendored
Normal file
1
example/browser/testdata/bing_cn.html
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,13 +1,57 @@
|
|||||||
# calendar
|
# calendar · 日历事件
|
||||||
|
|
||||||
calendar plugin
|
事件管理:支持**重复事件**与**多档提醒**。
|
||||||
|
|
||||||
## Build
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `calendar_event_add` | 添加事件 |
|
||||||
|
| `calendar_event_list` | 列出即将到来的事件(含日期、时间、重复规则) |
|
||||||
|
| `calendar_event_update` | 更新事件(**只改传入的字段**;会重置提醒状态) |
|
||||||
|
| `calendar_event_delete` | 删除事件(连带**该事件及之后的所有重复实例**) |
|
||||||
|
| `calendar_today` | 今日事件 + 倒计时 |
|
||||||
|
| `calendar_week` | 本周事件,按天分组 |
|
||||||
|
| `calendar_month` | 月历网格,带事件标记点 |
|
||||||
|
| `calendar_search` | 按关键词搜标题 / 地点 / 备注 |
|
||||||
|
|
||||||
|
`calendar_event_add` 的时间格式:`YYYY-MM-DD HH:MM`;只给 `YYYY-MM-DD` 表示全天事件。
|
||||||
|
|
||||||
|
## 重复规则
|
||||||
|
|
||||||
|
`repeat` 取值:
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `none` | 不重复 |
|
||||||
|
| `daily` | 每天 |
|
||||||
|
| `weekday` | 每个工作日 |
|
||||||
|
| `weekly` | 每周 |
|
||||||
|
| `biweekly` | 每两周 |
|
||||||
|
| `monthly` | 每月 |
|
||||||
|
| `yearly` | 每年 |
|
||||||
|
| `lunar_yearly` | **按农历年**(生日、传统节日用) |
|
||||||
|
|
||||||
|
`lunar_yearly` 是刻意加的:农历节日按公历写死会逐年偏移。
|
||||||
|
|
||||||
|
## 提醒
|
||||||
|
|
||||||
|
`remind_before` 单位是**分钟**,可给多个、逗号分隔:
|
||||||
|
|
||||||
|
```
|
||||||
|
15,60,1440 # 提前 15 分钟 + 1 小时 + 1 天
|
||||||
|
0 或留空 # 不提醒
|
||||||
|
```
|
||||||
|
|
||||||
|
到点通过 `InjectInterruptText` 注入提醒,带 `NoMemory: true` ——
|
||||||
|
提醒是瞬时信号,不是记忆内容。通道 `calendar` 同样声明为 NoMemory。
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
事件存为 JSON,插件重启后保留。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Upload the .hmap file through the Plugin Manager API.
|
|
||||||
|
|||||||
@ -2,6 +2,6 @@ module calendar
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
require github.com/JianFeeeee/homeagentsdk v0.0.0
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
replace github.com/JianFeeeee/homeagentsdk => ../../
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@ -11,7 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -276,13 +277,16 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
|
|||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
|
|
||||||
|
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
|
||||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||||
if err != nil || dataDirVal == "" {
|
if err != nil || dataDirVal == "" {
|
||||||
dataDirVal = "."
|
dataDirVal = "."
|
||||||
}
|
}
|
||||||
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
|
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
|
||||||
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
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()
|
p.loadEvents()
|
||||||
|
|
||||||
@ -398,7 +402,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.wg.Add(1)
|
p.wg.Add(1)
|
||||||
go p.remindLoop()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -406,7 +410,7 @@ func (p *Plugin) Stop() error {
|
|||||||
p.remindTicker.Stop()
|
p.remindTicker.Stop()
|
||||||
close(p.stopCh)
|
close(p.stopCh)
|
||||||
p.wg.Wait()
|
p.wg.Wait()
|
||||||
fmt.Printf("[%s] stopped\n", p.name)
|
log.Printf("[%s] stopped", p.name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -608,9 +612,9 @@ func (p *Plugin) cleanupData() {
|
|||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
|
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 {
|
} else {
|
||||||
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
|
log.Printf("[calendar] onRemove removed %s", p.eventsFile())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
example/commondev/README.md
Normal file
28
example/commondev/README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# commondev
|
||||||
|
|
||||||
|
commondev plugin
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Channels
|
||||||
|
|
||||||
|
入站与出站是分开登记的两件事:
|
||||||
|
|
||||||
|
| 方向 | API | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名都要登记** |
|
||||||
|
| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__<name> 的回复发给谁;handler 收到 {payload,type,meta} |
|
||||||
|
|
||||||
|
def(ChannelDef)描述该通道在记忆计算层的行为:NoMemory: true = 该通道输入不进记忆;
|
||||||
|
Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。
|
||||||
|
|
||||||
|
> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。
|
||||||
|
> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Upload the .hmap file through the Plugin Manager API.
|
||||||
222
example/commondev/addressing_test.go
Normal file
222
example/commondev/addressing_test.go
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 「投到指定目录的指定 session」的判据。
|
||||||
|
//
|
||||||
|
// 用户问的是这句话能不能成立。成立的前提是**地址唯一确定一条线**:
|
||||||
|
// branch_id → 工作目录(program/<hash>-<name>)
|
||||||
|
// → pi session(SessionID)
|
||||||
|
// → 一对通道(InputCh / OutputCh)
|
||||||
|
//
|
||||||
|
// 这四者必须一一对应,且**不能被两条线共享**。共享的任何一环都会让
|
||||||
|
// "投到指定 session"变成"投到某一条(不确定)"。
|
||||||
|
//
|
||||||
|
// 最危险的一环是唯一性:独立 clone 没有 worktree 的
|
||||||
|
// 「同分支不可双挂」保护(git 会拒绝),所以插件必须自己补闸。
|
||||||
|
// 设计 §4.3 明确写了这条,但**此前没有判据钉住它**。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestPluginForAddr 造一个只做台账的 Plugin(不碰网络/进程)。
|
||||||
|
//
|
||||||
|
// ★ ledger.json 放在 t.TempDir() **之外**的独立临时目录:
|
||||||
|
// branch_open 会把 clone 转后台 goroutine(runPrepareWorkdir),
|
||||||
|
// 而它会在 programRoot() 下建目录 —— 与 t.TempDir() 的清理构成竞态
|
||||||
|
// (实测报 "TempDir RemoveAll cleanup: directory not empty")。
|
||||||
|
// 用 os.MkdirTemp + 手动清理可以让清理发生在 goroutine 之后。
|
||||||
|
func newTestPluginForAddr(t *testing.T) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.MkdirTemp("", "cdaddr")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
p.path = filepath.Join(dir, "ledger.json")
|
||||||
|
p.ledger = &Ledger{
|
||||||
|
Projects: map[string]*Project{},
|
||||||
|
Branches: map[string]*Branch{},
|
||||||
|
Jobs: map[string]*Job{},
|
||||||
|
Criteria: map[string]*Criteria{},
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:同名分支不能开两条未结的工程对话(唯一性闸)。
|
||||||
|
//
|
||||||
|
// 这是"投到指定 session"的**前提**:若同一个 branch_id 能对应两条线,
|
||||||
|
// 或者两条线共用同一个地址,寻址就不再唯一。
|
||||||
|
//
|
||||||
|
// ★ 独立 clone 丢掉了 git 的天然保护:worktree 模式下同一分支被两棵
|
||||||
|
// worktree 挂载时 git 直接 `fatal: 'feat/a' is already used`,
|
||||||
|
// 而独立 clone 完全没有这层拦截 —— 必须由插件补,否则两条线会
|
||||||
|
// 各自改各自的目录然后互相覆盖。
|
||||||
|
func TestDuplicateBranchIsRejected(t *testing.T) {
|
||||||
|
p := newTestPluginForAddr(t)
|
||||||
|
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
|
||||||
|
|
||||||
|
// 第一次开:应成功
|
||||||
|
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/a"})
|
||||||
|
m1, _ := r1.(map[string]interface{})
|
||||||
|
if m1["status"] != "ok" {
|
||||||
|
t.Fatalf("首次开分支应成功: %v", m1)
|
||||||
|
}
|
||||||
|
id1, _ := m1["branch_id"].(string)
|
||||||
|
if id1 == "" {
|
||||||
|
t.Fatal("应返回 branch_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次开同一个分支:应被唯一性闸拒绝
|
||||||
|
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/a"})
|
||||||
|
m2, _ := r2.(map[string]interface{})
|
||||||
|
if _, isErr := m2["error"]; !isErr {
|
||||||
|
t.Fatalf("★ 同名分支被允许开第二条(唯一性闸失效): %v", m2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m2["error"].(string), "唯一性闸") {
|
||||||
|
t.Errorf("拒绝理由应指明唯一性闸: %v", m2["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 台账里必须只有一条
|
||||||
|
if n := len(p.ledger.Branches); n != 1 {
|
||||||
|
t.Errorf("★ 台账里应只有 1 条分支,实际 %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**已结**的分支可以重开(闸只挡"未结"的)。
|
||||||
|
//
|
||||||
|
// 闸太严会让"合入后想再改同一个分支名"变成不可能 ——
|
||||||
|
// 而那是正常做法(分支已 merged,重开一条新的同名线是合理的)。
|
||||||
|
func TestClosedBranchCanBeReopened(t *testing.T) {
|
||||||
|
p := newTestPluginForAddr(t)
|
||||||
|
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
|
||||||
|
|
||||||
|
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/b"})
|
||||||
|
id1 := r1.(map[string]interface{})["branch_id"].(string)
|
||||||
|
|
||||||
|
// 标记为已合入
|
||||||
|
p.ledger.Branches[id1].Status = "merged"
|
||||||
|
|
||||||
|
// 重开应成功
|
||||||
|
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/b"})
|
||||||
|
m2, _ := r2.(map[string]interface{})
|
||||||
|
if m2["status"] != "ok" {
|
||||||
|
t.Fatalf("已结分支应可重开: %v", m2)
|
||||||
|
}
|
||||||
|
// 但 id 相同 ⇒ 会覆盖旧台账项;这本身是设计取舍,只断言不报错
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:review 状态同样算"未结"(不能趁复核期再开一条同类线)。
|
||||||
|
func TestReviewStatusStillBlocksDuplicate(t *testing.T) {
|
||||||
|
p := newTestPluginForAddr(t)
|
||||||
|
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
|
||||||
|
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c",
|
||||||
|
"work_desc": "x"})
|
||||||
|
id1 := r1.(map[string]interface{})["branch_id"].(string)
|
||||||
|
p.ledger.Branches[id1].Status = "review"
|
||||||
|
|
||||||
|
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c"})
|
||||||
|
if _, isErr := r2.(map[string]interface{})["error"]; !isErr {
|
||||||
|
t.Errorf("★ review 状态应算未结,但被允许重开")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:地址四元组**一一对应**且互不共享。
|
||||||
|
//
|
||||||
|
// branch_id 必须唯一映射到 一个目录 + 一个 session + 一对通道。
|
||||||
|
// 任意两条线共用其中任何一项,寻址就不唯一("投到指定 session"退化为
|
||||||
|
// "投到某一条")。
|
||||||
|
func TestAddressTupleIsUniquePerBranch(t *testing.T) {
|
||||||
|
p := newTestPluginForAddr(t)
|
||||||
|
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
|
||||||
|
// 造两条不同的线
|
||||||
|
for _, br := range []string{"feat/x", "feat/y"} {
|
||||||
|
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": br})
|
||||||
|
if _, isErr := r.(map[string]interface{})["error"]; isErr {
|
||||||
|
t.Fatalf("开 %s 失败: %v", br, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.ledger.Branches) != 2 {
|
||||||
|
t.Fatalf("应有 2 条线,实际 %d", len(p.ledger.Branches))
|
||||||
|
}
|
||||||
|
|
||||||
|
seenDir := map[string]string{}
|
||||||
|
seenID := map[string]bool{}
|
||||||
|
for id, b := range p.ledger.Branches {
|
||||||
|
if seenID[id] {
|
||||||
|
t.Errorf("★ branch_id 重复: %s", id)
|
||||||
|
}
|
||||||
|
seenID[id] = true
|
||||||
|
if b.Workdir == "" {
|
||||||
|
t.Errorf("★ %s 没有工作目录", id)
|
||||||
|
}
|
||||||
|
// 目录不能共享
|
||||||
|
if prev, dup := seenDir[b.Workdir]; dup {
|
||||||
|
t.Errorf("★ 两条线共用同一目录: %s(%s 与 %s)—— 投到目录无法区分线", b.Workdir, prev, id)
|
||||||
|
}
|
||||||
|
seenDir[b.Workdir] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 关键:不同分支名必须得到不同目录(设计 §4.3 的目录映射不变式)
|
||||||
|
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat.z"})
|
||||||
|
_ = r1
|
||||||
|
// feat.z 与 feat/z 派生后 slug 相同,但目录 hash 含原始分支名 ⇒ 目录不同
|
||||||
|
d1 := branchDir(p.programRoot(), "proj", "feat/z")
|
||||||
|
d2 := branchDir(p.programRoot(), "proj", "feat.z")
|
||||||
|
if d1 == d2 {
|
||||||
|
t.Errorf("★ feat/z 与 feat.z 得到了同一目录 %s —— 两条线会互相覆盖", d1)
|
||||||
|
}
|
||||||
|
if filepath.Base(d1) == filepath.Base(d2) {
|
||||||
|
t.Errorf("★ 目录基名相同 %s,说明 hash 没把分支名纳入", filepath.Base(d1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:投递必须打到**该 id 自己的**通道。
|
||||||
|
//
|
||||||
|
// deliverToPi 从台账取 SessionID;通道名由 id 派生。
|
||||||
|
// 若两条线的通道名撞了,内核路由就会把任务投到错的那条 pi。
|
||||||
|
func TestChannelNamesAreUniquePerBranch(t *testing.T) {
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, id := range []string{
|
||||||
|
"br-proj-feat_a", "br-proj-feat_b", "br-other-feat_a",
|
||||||
|
} {
|
||||||
|
in := inputChannelName(id)
|
||||||
|
out := outputChannelName(id)
|
||||||
|
for _, n := range []string{in, out} {
|
||||||
|
if prev, dup := names[n]; dup {
|
||||||
|
t.Errorf("★ 通道名冲突 %q(%s 与 %s)—— 投递会打到错的后端", n, prev, id)
|
||||||
|
}
|
||||||
|
names[n] = id
|
||||||
|
}
|
||||||
|
// outputch 不能含斜杠(会拼成 output_send__<name>)
|
||||||
|
if strings.Contains(out, "/") {
|
||||||
|
t.Errorf("★ outputch 含斜杠: %q(output_send__ 名不允许)", out)
|
||||||
|
}
|
||||||
|
// 上游要求 ^[a-zA-Z0-9_-]{1,64}$
|
||||||
|
if !validNamePattern.MatchString(out) || len(out) > 64 {
|
||||||
|
t.Errorf("outputch %q 不合规(须匹配 ^[a-zA-Z0-9_-]{1,64}$)", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**会话漂移必须响亮失败**(不静默改投新会话)。
|
||||||
|
//
|
||||||
|
// 这是"投到指定 session"的守门人:如果 pi 返回了不同的 session_id
|
||||||
|
// 而插件默默接受,任务就投到了另一条会话 —— 而调用方以为投到了指定的那条。
|
||||||
|
func TestSessionDriftIsRejectedNotSilentlyAccepted(t *testing.T) {
|
||||||
|
b, err := os.ReadFile("plugin.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("读不到 plugin.go")
|
||||||
|
}
|
||||||
|
src := string(b)
|
||||||
|
if !strings.Contains(src, "会话漂移") {
|
||||||
|
t.Fatal("★ deliverToPi 缺少会话漂移检查")
|
||||||
|
}
|
||||||
|
// 必须**拒绝**而不是记录后继续
|
||||||
|
if !strings.Contains(src, "拒绝继续") {
|
||||||
|
t.Error("★ 会话漂移应明确拒绝,而非仅记录")
|
||||||
|
}
|
||||||
|
}
|
||||||
338
example/commondev/alignment_test.go
Normal file
338
example/commondev/alignment_test.go
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 对齐审计:**逐个调用**已注册工具,检查它们真的能工作。
|
||||||
|
//
|
||||||
|
// 为何要这个:注册了名字 ≠ 能用。上一轮补了工具,但「能注册」只证明
|
||||||
|
// 编译通过。本文件对每个工具做一次最小真实调用,把「写了但没接上」
|
||||||
|
// (空返回、nil handler、路径写死错、参数名不匹配)暴露出来。
|
||||||
|
//
|
||||||
|
// 判定原则:
|
||||||
|
// - 返回 error ≠ 失败(很多工具对缺参数就是返回 error,那是正确的响亮行为)
|
||||||
|
// - 真正的失败是:panic、handler 为 nil、或**参数齐全却依然拒绝**
|
||||||
|
// - 这里的目标是发现"连正确调用都走不通"的工具
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listRegisteredTools 从 plugin.go 里抽出注册的工具名。
|
||||||
|
// 用源码而非运行期 sdk,因为 SDK 的 mock 不暴露注册表。
|
||||||
|
func listRegisteredTools(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile("plugin.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
const p = `p.sdk.RegisterTool("`
|
||||||
|
if i := strings.Index(line, p); i >= 0 {
|
||||||
|
rest := line[i+len(p):]
|
||||||
|
if j := strings.Index(rest, `"`); j > 0 {
|
||||||
|
out = append(out, rest[:j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerFor 通过名字映射到实际 handler 函数。
|
||||||
|
//
|
||||||
|
// ★ 这里刻意**手写映射**而不是反射:如果新增了工具却忘了在这里登记,
|
||||||
|
// 下面的覆盖率判据会报出来 —— 这正好是"新工具没接 handler"的探测。
|
||||||
|
func handlerFor(p *Plugin, name string) func(map[string]interface{}) (interface{}, error) {
|
||||||
|
switch name {
|
||||||
|
case "commondev_project_add", "commondev_project_open":
|
||||||
|
return p.handleProjectAdd
|
||||||
|
case "commondev_project_list":
|
||||||
|
return p.handleProjectList
|
||||||
|
case "commondev_branch_open":
|
||||||
|
return p.handleBranchOpen
|
||||||
|
case "commondev_branch_status":
|
||||||
|
return p.handleBranchStatus
|
||||||
|
case "commondev_branch_close":
|
||||||
|
return p.handleBranchClose
|
||||||
|
case "commondev_job_status":
|
||||||
|
return p.handleJobStatus
|
||||||
|
case "commondev_agent_up":
|
||||||
|
return p.handleAgentUp
|
||||||
|
case "commondev_agent_state":
|
||||||
|
return p.handleAgentState
|
||||||
|
case "commondev_agent_down":
|
||||||
|
return p.handleAgentDown
|
||||||
|
case "commondev_workdir_prepare":
|
||||||
|
return p.handleWorkdirPrepare
|
||||||
|
case "commondev_workdir_clean":
|
||||||
|
return p.handleWorkdirClean
|
||||||
|
case "commondev_test_define":
|
||||||
|
return p.handleTestDefine
|
||||||
|
case "commondev_test_list":
|
||||||
|
return p.handleTestList
|
||||||
|
case "commondev_test_run":
|
||||||
|
return p.handleTestRun
|
||||||
|
case "commondev_test_mutate":
|
||||||
|
return p.handleTestMutate
|
||||||
|
case "commondev_review_submit":
|
||||||
|
return p.handleReviewSubmit
|
||||||
|
case "commondev_review_result":
|
||||||
|
return p.handleReviewResult
|
||||||
|
case "commondev_conflict_check":
|
||||||
|
return p.handleConflictCheck
|
||||||
|
case "commondev_parallel_status":
|
||||||
|
return p.handleParallelStatus
|
||||||
|
case "commondev_pi_config_get":
|
||||||
|
return p.handlePiConfigGet
|
||||||
|
case "commondev_pi_config_set":
|
||||||
|
return p.handlePiConfigSet
|
||||||
|
case "commondev_pi_source_add":
|
||||||
|
return p.handlePiSourceAdd
|
||||||
|
case "commondev_pi_source_list":
|
||||||
|
return p.handlePiSourceList
|
||||||
|
case "commondev_pi_source_remove":
|
||||||
|
return p.handlePiSourceRemove
|
||||||
|
case "commondev_pi_source_default":
|
||||||
|
return p.handlePiSourceDefault
|
||||||
|
case "commondev_pi_source_import":
|
||||||
|
return p.handlePiSourceImport
|
||||||
|
case "commondev_pi_package_install", "commondev_pi_plugin_install":
|
||||||
|
return p.handlePiPackageInstall
|
||||||
|
case "commondev_pi_plugin_list":
|
||||||
|
return p.handlePiPluginList
|
||||||
|
case "commondev_pi_plugin_remove":
|
||||||
|
return p.handlePiPluginRemove
|
||||||
|
case "commondev_pi_plugin_update":
|
||||||
|
return p.handlePiPluginUpdate
|
||||||
|
case "commondev_pi_mcp_list":
|
||||||
|
return p.handlePiMCPList
|
||||||
|
case "commondev_pi_mcp_add":
|
||||||
|
return p.handlePiMCPAdd
|
||||||
|
case "commondev_pi_mcp_remove":
|
||||||
|
return p.handlePiMCPRemove
|
||||||
|
case "enc_store":
|
||||||
|
return p.handleEncStore
|
||||||
|
case "enc_list":
|
||||||
|
return p.handleEncList
|
||||||
|
case "enc_show":
|
||||||
|
return p.handleEncShow
|
||||||
|
case "enc_rm":
|
||||||
|
return p.handleEncRm
|
||||||
|
case "enc_shell":
|
||||||
|
return p.handleEncryptedShell
|
||||||
|
case "commondev_endpoint_set":
|
||||||
|
return p.handleEndpointSet
|
||||||
|
case "commondev_endpoint_list":
|
||||||
|
return p.handleEndpointList
|
||||||
|
case "commondev_endpoint_remove":
|
||||||
|
return p.handleEndpointRemove
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**每个注册的工具都必须有对应 handler**。
|
||||||
|
//
|
||||||
|
// 这条能抓出「加了 RegisterTool 却指向不存在的 handler」或
|
||||||
|
// 「加了工具但这里没同步登记」(后者是需要人补映射,属于审计信号)。
|
||||||
|
func TestEveryRegisteredToolHasHandler(t *testing.T) {
|
||||||
|
p := newAuditPlugin(t)
|
||||||
|
tools := listRegisteredTools(t)
|
||||||
|
if len(tools) < 40 {
|
||||||
|
t.Fatalf("注册工具数异常偏少(%d),插件文件可能没被正确解析", len(tools))
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for _, name := range tools {
|
||||||
|
if handlerFor(p, name) == nil {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
t.Fatalf("★ 这些工具没有可用 handler(注册了但没接上): %v", missing)
|
||||||
|
}
|
||||||
|
t.Logf("已注册 %d 个工具,全部有 handler", len(tools))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**无参数调用不得 panic**。
|
||||||
|
//
|
||||||
|
// 工具会遇到模型乱传参数(缺字段、类型错)。正确行为是返回 error,
|
||||||
|
// 不是崩掉整个插件进程。
|
||||||
|
func TestNoToolPanicsOnEmptyArgs(t *testing.T) {
|
||||||
|
p := newAuditPlugin(t)
|
||||||
|
for _, name := range listRegisteredTools(t) {
|
||||||
|
h := handlerFor(p, name)
|
||||||
|
if h == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Errorf("★ %s 空参数 panic: %v", name, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// 空 map + nil map 都要试:模型可能传 null
|
||||||
|
h(map[string]interface{}{})
|
||||||
|
h(nil)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:**本轮补的工具必须真的能完成一次正常调用**。
|
||||||
|
//
|
||||||
|
// 与前两条不同:这里给足参数,检查业务是否真走通。
|
||||||
|
// 「注册 + 不 panic」还不够 —— 一条写死的错误路径也能满足那两条。
|
||||||
|
func TestNewlyAddedToolsActuallyWork(t *testing.T) {
|
||||||
|
p := newAuditPlugin(t)
|
||||||
|
p.ledger.Projects["proj"] = &Project{
|
||||||
|
Name: "proj", RemoteURL: "https://example.invalid/o/r.git", DefaultBranch: "main"}
|
||||||
|
dir := t.TempDir()
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{
|
||||||
|
ID: "br-1", ProjectID: "proj", Name: "feat/x", Workdir: dir, Status: "open"}
|
||||||
|
|
||||||
|
// ① agent_state 应报出真实状态(此前完全不存在)
|
||||||
|
res, err := p.handleAgentState(map[string]interface{}{"branch_id": "br-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := res.(map[string]interface{})
|
||||||
|
if m["branch_id"] != "br-1" || m["agent_alive"] != false {
|
||||||
|
t.Errorf("agent_state 返回不对: %v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② agent_down 应成功停掉(无进程时也幂等成功)
|
||||||
|
res2, err := p.handleAgentDown(map[string]interface{}{"branch_id": "br-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res2.(map[string]interface{})["status"] != "ok" {
|
||||||
|
t.Errorf("agent_down 未成功: %v", res2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ workdir_clean 默认 dry_run,且能识别孤儿
|
||||||
|
root := p.programRoot()
|
||||||
|
os.MkdirAll(filepath.Join(root, "deadbeef-feat_x"), 0o755)
|
||||||
|
res3, err := p.handleWorkdirClean(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m3 := res3.(map[string]interface{})
|
||||||
|
if m3["status"] != "dry_run" {
|
||||||
|
t.Errorf("workdir_clean 默认应为 dry_run: %v", m3)
|
||||||
|
}
|
||||||
|
if n, _ := m3["count"].(int); n != 1 {
|
||||||
|
t.Errorf("应识别 1 个孤儿目录,实际 %v", m3["orphans"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ pi_config_get 必须能读到结构(即使没有任何 provider)
|
||||||
|
res4, err := p.handlePiConfigGet(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := res4.(map[string]interface{})["providers"]; !ok {
|
||||||
|
t.Errorf("pi_config_get 缺少 providers 字段: %v", res4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**设计文档点名的工具必须真的存在**。
|
||||||
|
//
|
||||||
|
// 这条把「文档说做了」与「代码里有」绑起来 —— 上一轮就是靠它
|
||||||
|
// 发现 pr_open / agent_state 等工具根本不存在。
|
||||||
|
//
|
||||||
|
// ⚠ 2026-10-15 起:**git 托管平台专有的工具已不在清单里**
|
||||||
|
// (account_* / repo_search / pr_* / protect_branch)—— 那些能力进 skill,
|
||||||
|
// 由 agent 用 safeshell + curl 完成。
|
||||||
|
func TestDesignNamedToolsExist(t *testing.T) {
|
||||||
|
tools := listRegisteredTools(t)
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, n := range tools {
|
||||||
|
have[n] = true
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"commondev_project_add", "commondev_project_list",
|
||||||
|
"commondev_branch_open", "commondev_branch_status", "commondev_branch_close",
|
||||||
|
"commondev_workdir_prepare", "commondev_workdir_clean",
|
||||||
|
"commondev_agent_up", "commondev_agent_state", "commondev_agent_down",
|
||||||
|
"commondev_pi_plugin_install", "commondev_pi_plugin_remove", "commondev_pi_plugin_list",
|
||||||
|
"commondev_pi_source_import", "commondev_pi_source_add", "commondev_pi_source_list",
|
||||||
|
"commondev_pi_source_default", "commondev_pi_config_get",
|
||||||
|
"commondev_test_define", "commondev_test_run", "commondev_test_mutate",
|
||||||
|
"commondev_review_submit",
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for _, w := range want {
|
||||||
|
if !have[w] {
|
||||||
|
missing = append(missing, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
t.Fatalf("设计点名的工具缺失: %v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:**git 平台专有的工具已全部移出插件**。
|
||||||
|
//
|
||||||
|
// 这条钉住 2026-10-15 的架构决定:平台知识进 skill。若有人又把
|
||||||
|
// account_* / pr_* / protect_branch 加回插件,这里会报红。
|
||||||
|
func TestPlatformToolsAreNotInPlugin(t *testing.T) {
|
||||||
|
tools := listRegisteredTools(t)
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, n := range tools {
|
||||||
|
have[n] = true
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"commondev_account_add", "commondev_account_list", "commondev_account_test",
|
||||||
|
"commondev_account_remove", "commondev_repo_search",
|
||||||
|
"commondev_pr_open", "commondev_pr_status", "commondev_pr_approve",
|
||||||
|
"commondev_pr_merge", "commondev_protect_branch",
|
||||||
|
} {
|
||||||
|
if have[forbidden] {
|
||||||
|
t.Errorf("★ %s 属 git 平台专有能力,不应再注册在插件里(应进 skill + safeshell)", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**平台专有代码不得回到插件里**。
|
||||||
|
//
|
||||||
|
// 钉住 2026-10-15 的架构决定(用户拍板):git 托管平台的知识进 skill,
|
||||||
|
// 插件只保留与平台无关的机制。若有人把 platformSpec / 按平台构造认证头
|
||||||
|
// 又加回来,这里会报红。
|
||||||
|
//
|
||||||
|
// 为什么要用判据而不只是文档:文档说了还会有人(包括将来的我)图省事加回去,
|
||||||
|
// 而那种回退在**功能上看起来完全正常** —— 只是把可移植性换成了硬编码。
|
||||||
|
func TestNoPlatformSpecificCodeInPlugin(t *testing.T) {
|
||||||
|
// 逐文件扫源码(排除测试与注释性提及由人工判断)
|
||||||
|
files := []string{"plugin.go", "credentials.go", "pi_tools.go", "criteria.go",
|
||||||
|
"encstore.go", "encshell.go", "conflict.go", "workdir.go", "workdir_security.go",
|
||||||
|
"util.go", "llmsource.go", "sidecar.go"}
|
||||||
|
forbidden := map[string]string{
|
||||||
|
"platformSpec(": "按平台构造请求(认证头/端点差异)应进 skill",
|
||||||
|
"plaftformSpec": "平台适配结构体",
|
||||||
|
"DefaultAPI": "硬编码平台 API 前缀",
|
||||||
|
"AuthHeader:": "硬编码平台认证头",
|
||||||
|
"RoleExecutor": "平台角色语义",
|
||||||
|
"handleAccountAdd": "平台凭据工具",
|
||||||
|
"handleRepoSearch": "平台搜索工具",
|
||||||
|
"handlePROpen": "平台 PR 工具",
|
||||||
|
"handleProtectBranch": "平台分支保护工具",
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
b, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src := string(b)
|
||||||
|
for sym, why := range forbidden {
|
||||||
|
// 只认代码出现,不认注释里的解释性提及
|
||||||
|
for i, line := range strings.Split(src, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "//") {
|
||||||
|
continue // 注释里说明"已移出"是允许的
|
||||||
|
}
|
||||||
|
if strings.Contains(line, sym) {
|
||||||
|
t.Errorf("★ %s:%d 含平台专有代码 %q(%s)", f, i+1, sym, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
example/commondev/async_test.go
Normal file
103
example/commondev/async_test.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ★ 判据:慢操作(clone/fetch/测试)**绝不能**出现在工具 handler 的同步路径里。
|
||||||
|
//
|
||||||
|
// 依据(实测踩到):内核把每次工具调用硬限制在 **60 秒**
|
||||||
|
// (internal/agent/core/toolcall.go:62,硬编码无配置项)。同步 clone 的后果是
|
||||||
|
// 工具报超时、但 mirror 其实已建好 —— 调用方只看到失败,状态却是"做了一半"。
|
||||||
|
//
|
||||||
|
// 这条判据用**源码文本**做检查(同本仓既有做法:判据类检查必须先剔除注释)。
|
||||||
|
func TestNoSyncCloneInToolHandlers(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("plugin.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 剔除注释,避免注释里提到 runGit 也会命中
|
||||||
|
body := stripComments(string(src))
|
||||||
|
|
||||||
|
// 工具 handler 的区间:从 handleProjectAdd 到 handleJobStatus 之前
|
||||||
|
start := strings.Index(body, "func (p *Plugin) handleProjectAdd")
|
||||||
|
end := strings.Index(body, "func (p *Plugin) handleJobStatus")
|
||||||
|
if start < 0 || end < 0 || end <= start {
|
||||||
|
t.Fatal("找不到 handler 区间(函数名可能已改)")
|
||||||
|
}
|
||||||
|
handlers := body[start:end]
|
||||||
|
|
||||||
|
// handleBranchOpen 的**后台函数**是允许慢操作的,单独排除
|
||||||
|
if i := strings.Index(handlers, "func (p *Plugin) runPrepareWorkdir"); i >= 0 {
|
||||||
|
handlers = handlers[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"runGit(", "prepareWorkdir("} {
|
||||||
|
if strings.Contains(handlers, bad) {
|
||||||
|
t.Errorf("工具 handler 里出现慢操作 %s —— 会撞 60s 工具上限;应转后台(见 runPrepareWorkdir)", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 但后台函数自己必须用到它(否则重构把功能删了)
|
||||||
|
if !strings.Contains(body, "func (p *Plugin) runPrepareWorkdir") ||
|
||||||
|
!strings.Contains(body, "prepareWorkdir(pr, branch, workdir)") {
|
||||||
|
t.Error("runPrepareWorkdir 未实现或未调用 prepareWorkdir —— 目录准备功能丢了")
|
||||||
|
}
|
||||||
|
// ★ 且必须切到目标分支(“每个目录下是独立仓库的独立分支”——用户原话)
|
||||||
|
//
|
||||||
|
// 判据必须检查**两条路径都切**:新建路径(clone 后)与复用路径(目录已存在)。
|
||||||
|
// 为何要查两处:初版只查字符串存在,而 ensureOnBranch 的**定义**本身
|
||||||
|
// (func … ensureOnBranch(branch, workdir string))就包含该子串 —— 于是
|
||||||
|
// 只删调用点、定义还在,判据仍然全绿(变异实验当场发现了这个漏洞)。
|
||||||
|
// ⇒ 改为数**调用点**个数(排除定义行)。
|
||||||
|
calls := 0
|
||||||
|
for _, ln := range strings.Split(body, "\n") {
|
||||||
|
t := strings.TrimSpace(ln)
|
||||||
|
if strings.HasPrefix(t, "func ") {
|
||||||
|
continue // 定义行不算
|
||||||
|
}
|
||||||
|
if strings.Contains(t, "p.ensureOnBranch(branch, workdir)") {
|
||||||
|
calls++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls < 2 {
|
||||||
|
t.Errorf("prepareWorkdir 切分支的调用点只有 %d 处(=new建路径+复用路径);"+
|
||||||
|
"缺一处就会让目录停在默认分支(main)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripComments 去掉 // 行注释与 /* */ 块注释。
|
||||||
|
//
|
||||||
|
// 为何必须先剔除:本仓既有教训 —— 注释里模仿真实签名会污染一切按文本匹配的判据。
|
||||||
|
func stripComments(s string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
inBlock := false
|
||||||
|
for _, ln := range lines {
|
||||||
|
trimmed := strings.TrimSpace(ln)
|
||||||
|
if inBlock {
|
||||||
|
if idx := strings.Index(ln, "*/"); idx >= 0 {
|
||||||
|
inBlock = false
|
||||||
|
ln = ln[idx+2:]
|
||||||
|
} else {
|
||||||
|
out.WriteString("\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "/*") {
|
||||||
|
if idx := strings.Index(ln, "*/"); idx >= 0 {
|
||||||
|
ln = ln[:strings.Index(ln, "/*")] + ln[idx+2:]
|
||||||
|
} else {
|
||||||
|
inBlock = true
|
||||||
|
out.WriteString("\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i := strings.Index(ln, "//"); i >= 0 {
|
||||||
|
ln = ln[:i]
|
||||||
|
}
|
||||||
|
out.WriteString(ln + "\n")
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
216
example/commondev/audit_test.go
Normal file
216
example/commondev/audit_test.go
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 审计补全项的判据。
|
||||||
|
//
|
||||||
|
// 这些判据对应审计发现的**真问题**(不是补全功能的装饰):
|
||||||
|
// · push 的凭据纪律(设计 §7.2)—— 曾经 pushURLWithToken 是死代码,
|
||||||
|
// 意味着「凭据不进 remote URL」这条纪律**从未被真正执行过**
|
||||||
|
// · 孤儿清理(listOrphanDirs 曾是死代码)
|
||||||
|
// · pi 配置写入的备份/回滚(设计 §3.6.4 纪律 2)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAuditPlugin(t *testing.T) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:凭据残留检查能识别 `scheme://user:pass@host` 形态。
|
||||||
|
func TestGitConfigCredentialResidueDetection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(dir, ".git"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(dir, ".git", "config"),
|
||||||
|
[]byte("[remote \"origin\"]\n\turl = https://user:tok3n@example.com/o/r.git\n"), 0o644)
|
||||||
|
if got := gitConfigCredentialResidue(dir); got == "" {
|
||||||
|
t.Fatal("★ 未检出内嵌凭据(设计 §7.2 要求 workdir_clean 内置此检查)")
|
||||||
|
}
|
||||||
|
// 干净的不应误报
|
||||||
|
dir2 := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(dir2, ".git"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(dir2, ".git", "config"),
|
||||||
|
[]byte("[remote \"origin\"]\n\turl = https://example.com/o/r.git\n"), 0o644)
|
||||||
|
if got := gitConfigCredentialResidue(dir2); got != "" {
|
||||||
|
t.Errorf("干净 remote 被误报: %s", got)
|
||||||
|
}
|
||||||
|
// scp 形态的 git@host 不是凭据(用户提示有误伤风险)
|
||||||
|
dir3 := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(dir3, ".git"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(dir3, ".git", "config"),
|
||||||
|
[]byte("[remote \"origin\"]\n\turl = git@github.com:o/r.git\n"), 0o644)
|
||||||
|
if got := gitConfigCredentialResidue(dir3); got != "" {
|
||||||
|
t.Errorf("scp 形态 git@host 被误报成凭据: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据(设计 §7.2 + 审计发现):workdir_clean
|
||||||
|
// - **默认 dry_run**(删除不可逆)
|
||||||
|
// - 只删**本插件生成的孤儿目录**,不碰台账里的目录、不碰陌生目录
|
||||||
|
func TestWorkdirCleanOnlyRemovesOrphans(t *testing.T) {
|
||||||
|
p := newAuditPlugin(t)
|
||||||
|
root := p.programRoot()
|
||||||
|
os.MkdirAll(root, 0o700)
|
||||||
|
|
||||||
|
known := "aabbccdd-feat_known"
|
||||||
|
orphan := "11223344-feat_orphan"
|
||||||
|
stranger := "not-ours-just-a-dir"
|
||||||
|
for _, d := range []string{known, orphan, stranger} {
|
||||||
|
os.MkdirAll(filepath.Join(root, d), 0o755)
|
||||||
|
}
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{
|
||||||
|
ID: "br-1", ProjectID: "proj", Name: "feat/known",
|
||||||
|
Workdir: filepath.Join(root, known), Status: "open"}
|
||||||
|
|
||||||
|
// ① dry_run 默认:什么都不删
|
||||||
|
res, err := p.handleWorkdirClean(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.(map[string]interface{})["status"] != "dry_run" {
|
||||||
|
t.Fatalf("默认应为 dry_run,实际 %v", res.(map[string]interface{})["status"])
|
||||||
|
}
|
||||||
|
for _, d := range []string{known, orphan, stranger} {
|
||||||
|
if _, err := os.Stat(filepath.Join(root, d)); err != nil {
|
||||||
|
t.Fatalf("★ dry_run 却删掉了 %s", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 实际清理:只删孤儿
|
||||||
|
res2, err := p.handleWorkdirClean(map[string]interface{}{"dry_run": false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
removed, _ := res2.(map[string]interface{})["removed"].([]string)
|
||||||
|
if len(removed) != 1 || removed[0] != orphan {
|
||||||
|
t.Fatalf("应只删 %s,实际 %v", orphan, removed)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, known)); err != nil {
|
||||||
|
t.Fatal("★ 删掉了台账里在用的目录")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, stranger)); err != nil {
|
||||||
|
t.Fatal("★ 删掉了不是本插件生成的目录(越界)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:agent_down 清掉 session/通道绑定,但**保留工作目录与状态**。
|
||||||
|
//
|
||||||
|
// 它与 branch_close 的区别正是"暂停"vs"收尾";若 agent_down 也删目录,
|
||||||
|
// 二者就没有区别了(而我实现时差点把两者写成一个)。
|
||||||
|
func TestAgentDownKeepsWorkdir(t *testing.T) {
|
||||||
|
p := newAuditPlugin(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{
|
||||||
|
ID: "br-1", ProjectID: "proj", Name: "feat/x", Workdir: dir,
|
||||||
|
Status: "open", SessionID: "s1", InputCh: "commondev/br-1", OutputCh: "commondev_br-1"}
|
||||||
|
|
||||||
|
res, err := p.handleAgentDown(map[string]interface{}{"branch_id": "br-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.(map[string]interface{})["status"] != "ok" {
|
||||||
|
t.Fatal("应成功")
|
||||||
|
}
|
||||||
|
b := p.ledger.Branches["br-1"]
|
||||||
|
if b.SessionID != "" || b.InputCh != "" || b.OutputCh != "" {
|
||||||
|
t.Errorf("★ session/通道绑定应被清掉(留着会让人以为进程还在): %+v", b)
|
||||||
|
}
|
||||||
|
if b.Status != "open" {
|
||||||
|
t.Errorf("★ 状态不该变(这是暂停不是收尾),实际 %s", b.Status)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
t.Fatal("★ agent_down 不该删工作目录(那是 branch_close 的事)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- pi 配置写入纪律(设计 §3.6.4) ----------------
|
||||||
|
|
||||||
|
// ★★★ 判据:**损坏的 models.json 必须拒绝覆盖**(纪律 2)。
|
||||||
|
//
|
||||||
|
// 静默当空会**抹掉用户已有的全部源** —— 那是最坏的一种"静默降级"。
|
||||||
|
func TestCorruptPiModelsIsNotSilentlyOverwritten(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
bad := "{ this is not json"
|
||||||
|
os.WriteFile(filepath.Join(dir, "models.json"), []byte(bad), 0o600)
|
||||||
|
|
||||||
|
if _, err := loadPiModels(dir); err == nil {
|
||||||
|
t.Fatal("★ 损坏的 models.json 未被拒绝 —— 静默当空会抹掉用户已有的源")
|
||||||
|
}
|
||||||
|
after, _ := os.ReadFile(filepath.Join(dir, "models.json"))
|
||||||
|
if string(after) != bad {
|
||||||
|
t.Fatal("损坏的文件被改动了")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:写坏时**回滚**(纪律 2:失败则回滚并响亮报错)。
|
||||||
|
func TestPiConfigRollbackRestoresPrevious(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
orig := `{"providers":{"keep":{"baseUrl":"http://keep","api":"openai-completions","models":[{"id":"m"}]}}}`
|
||||||
|
os.WriteFile(filepath.Join(dir, "models.json"), []byte(orig), 0o600)
|
||||||
|
|
||||||
|
backup, err := backupPiConfig(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 模拟"写坏了"
|
||||||
|
os.WriteFile(filepath.Join(dir, "models.json"), []byte("{broken"), 0o600)
|
||||||
|
rollbackPiConfig(dir, backup)
|
||||||
|
|
||||||
|
back, _ := os.ReadFile(filepath.Join(dir, "models.json"))
|
||||||
|
if string(back) != orig {
|
||||||
|
t.Fatalf("★ 回滚未恢复原内容\n前: %s\n后: %s", orig, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**api_key 绝不进任何返回结构**(纪律 3)。
|
||||||
|
func TestPiConfigGetNeverReturnsKey(t *testing.T) {
|
||||||
|
p := newAuditPlugin(t)
|
||||||
|
dir := p.piAgentDir()
|
||||||
|
os.MkdirAll(dir, 0o700)
|
||||||
|
os.WriteFile(filepath.Join(dir, "models.json"), []byte(
|
||||||
|
`{"providers":{"p1":{"baseUrl":"http://x","api":"openai-completions","apiKey":"SUPER_SECRET","models":[{"id":"m"}]}}}`), 0o600)
|
||||||
|
|
||||||
|
res, err := p.handlePiConfigGet(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := jsonDump(t, res)
|
||||||
|
if strings.Contains(s, "SUPER_SECRET") {
|
||||||
|
t.Fatalf("★★ api_key 泄漏到返回值里:%s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "key_set") {
|
||||||
|
t.Errorf("应报 key_set 而不是值: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonDump 把任意返回值转成 JSON 串(用于断言"某串不得出现")。
|
||||||
|
func jsonDump(t *testing.T, v interface{}) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:pi 插件工具的**两套命名都能到达同一实现**(早期稿用 plugin,细化稿用 package)。
|
||||||
|
func TestPiPluginToolAliasesRegistered(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("plugin.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(src)
|
||||||
|
for _, name := range []string{
|
||||||
|
`"commondev_pi_plugin_install"`, `"commondev_pi_package_install"`,
|
||||||
|
`"commondev_pi_plugin_list"`, `"commondev_pi_plugin_remove"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, name) {
|
||||||
|
t.Errorf("工具 %s 未注册(两套命名应都可用)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
example/commondev/channelname_test.go
Normal file
84
example/commondev/channelname_test.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 钉住文档 §2.2 的命名约束:
|
||||||
|
// inputch 可含斜杠;outputch **必须**只剩 [A-Za-z0-9_-],
|
||||||
|
// 否则会产出非法的 output_send__<name>(上游 400、agent 整体失语)。
|
||||||
|
func TestOutputChannelNameIsToolSafe(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"br-TrueAgent-feat_x",
|
||||||
|
"br-p1-feat/x", // 分支名带斜杠
|
||||||
|
"br-p1-feat.x", // 点
|
||||||
|
"br-p1-中文", // 非 ASCII
|
||||||
|
"br-p1-a b", // 空格
|
||||||
|
}
|
||||||
|
for _, id := range cases {
|
||||||
|
got := outputChannelName(id)
|
||||||
|
if !validToolName("output_send__" + got) {
|
||||||
|
t.Errorf("id=%q → outputch=%q ⇒ 工具名 %q 非法(会触发上游 400)",
|
||||||
|
id, got, "output_send__"+got)
|
||||||
|
}
|
||||||
|
// inputch 则允许斜杠(内核自己就用 child/<id>)
|
||||||
|
if in := inputChannelName(id); in != "commondev/"+id {
|
||||||
|
t.Errorf("inputch 应原样保留: %q", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validToolName 复刻上游对函数名的约束。
|
||||||
|
func validToolName(name string) bool {
|
||||||
|
if len(name) == 0 || len(name) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range name {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z',
|
||||||
|
r >= '0' && r <= '9', r == '_', r == '-':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 台账往返:保存后能读回,且损坏时拒绝静默重建。
|
||||||
|
func TestLedgerRoundTrip(t *testing.T) {
|
||||||
|
p := &Plugin{name: "commondev"}
|
||||||
|
p.path = t.TempDir() + "/ledger.json"
|
||||||
|
if err := p.loadLedger(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.ledger.Projects["demo"] = &Project{Name: "demo", RemoteURL: "https://x/y.git"}
|
||||||
|
if err := p.saveLocked(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := &Plugin{name: "commondev", path: p.path}
|
||||||
|
if err := q.loadLedger(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if q.ledger.Projects["demo"] == nil {
|
||||||
|
t.Fatal("台账未往返")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 钉住名字校验方向(这条是**真 bug 的回归测试**)。
|
||||||
|
//
|
||||||
|
// 初版用排除式正则 `[^a-zA-Z0-9_-]` 配 MatchString 判"合法",恰好反了:
|
||||||
|
// 合法名不含违法字符 ⇒ 返回 false ⇒ 把 `cdtest` 判非法。
|
||||||
|
// 是端到端跑时由 agent 调工具发现的 —— 所以它必须有回归判据。
|
||||||
|
func TestValidName(t *testing.T) {
|
||||||
|
legal := []string{"cdtest", "TrueAgent", "feat_x", "a-b", "A1"}
|
||||||
|
for _, s := range legal {
|
||||||
|
if !validName(s) {
|
||||||
|
t.Errorf("合法名被拒: %q(校验方向反了?)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
illegal := []string{"feat/x", "a b", "中文", "a.b", "", "a/b/c"}
|
||||||
|
for _, s := range illegal {
|
||||||
|
if validName(s) {
|
||||||
|
t.Errorf("非法名被放行: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
400
example/commondev/conflict.go
Normal file
400
example/commondev/conflict.go
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 刀 3:多项目/并行下的 **conflict 预检** 与 **base_sha 记录**。
|
||||||
|
//
|
||||||
|
// ## 为什么需要这一刀
|
||||||
|
//
|
||||||
|
// 设计 §4.2:`base_sha` 是用户原描述里**缺失**的一环,但必须补:
|
||||||
|
//
|
||||||
|
// 没有它,插件无法判断「两个 PR 改了同一文件时第二个该不该 rebase、
|
||||||
|
// 能不能自动合」,出问题也无法重放。
|
||||||
|
//
|
||||||
|
// 并行开发(§4.4)让这个问题从"可能"变成"必然":每分支一份独立 clone,
|
||||||
|
// 两棵树**互不可见**,冲突不会在开发时暴露,而是全部堆到 merge 时爆发。
|
||||||
|
// 那时才发现"两个分支改了同一个文件的同一段"就太晚了 ——
|
||||||
|
// 复审已经通过、判据已经变绿,但合不进去。
|
||||||
|
//
|
||||||
|
// ## 做法:在**提 PR 之前**算一次可合并性
|
||||||
|
//
|
||||||
|
// 用 `git merge-tree --write-tree` —— ★ 它**完全不碰工作区**(不写索引、
|
||||||
|
// 不动 HEAD、不留 MERGE_HEAD),所以可以在任何时候安全地跑,
|
||||||
|
// 不需要先 stash/checkout,也不会破坏正在进行的开发。
|
||||||
|
//
|
||||||
|
// 退出码语义(实测 git 2.53):
|
||||||
|
//
|
||||||
|
// 0 → 可干净合并
|
||||||
|
// 1 → 有冲突
|
||||||
|
// 其它 → 命令本身失败(仓库损坏等),**不能当成"无冲突"**
|
||||||
|
//
|
||||||
|
// ## 与旧写法(`git merge-tree <base> <b1> <b2>`)的对比
|
||||||
|
//
|
||||||
|
// 旧语法输出是给人看的文本,要靠 grep `CONFLICT` 判断 —— 脆弱且没有
|
||||||
|
// 明确的退出码。新语法(git 2.38+)直接给树 OID 与退出码。
|
||||||
|
// 本仓的环境是 git 2.53,用新语法;同时保留旧语法的兜底判断。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConflictReport 是一次冲突预检的结果。
|
||||||
|
type ConflictReport struct {
|
||||||
|
// Mergeable 是否可干净合并。
|
||||||
|
Mergeable bool `json:"mergeable"`
|
||||||
|
// Conflicts 是冲突文件列表(可为空)。
|
||||||
|
Conflicts []string `json:"conflicts,omitempty"`
|
||||||
|
// BaseSHA / HeadSHA 是本次比较的两端(用于审计与重放)。
|
||||||
|
BaseSHA string `json:"base_sha,omitempty"`
|
||||||
|
HeadSHA string `json:"head_sha,omitempty"`
|
||||||
|
// Target 是被合入的目标(通常是 main)。
|
||||||
|
Target string `json:"target"`
|
||||||
|
// Detail 是给人看的一行说明。
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
// Skipped 为真表示预检被跳过(而非"检查通过了")。
|
||||||
|
//
|
||||||
|
// ★ 这个字段很关键:把"没检查"和"检查通过"混为一谈,
|
||||||
|
// 会让调用方以为安全 —— 而实际什么都没验。
|
||||||
|
Skipped bool `json:"skipped,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────── base_sha 记录 ──────────────────────────
|
||||||
|
|
||||||
|
// captureBaseSHA 记录工作目录当前 HEAD(开线时的基点)。
|
||||||
|
//
|
||||||
|
// 必须在**第一次投递任务之前**调用:agent 一旦开始改,HEAD 就不再是基点了。
|
||||||
|
// 拿不到时如实返回错误(不静默留空 —— 空 base_sha 会让后续冲突判定失据)。
|
||||||
|
func (p *Plugin) captureBaseSHA(branchID, workdir string) (string, error) {
|
||||||
|
sha, err := runGitOut(workdir, "rev-parse", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("取 base_sha 失败: %w", err)
|
||||||
|
}
|
||||||
|
sha = strings.TrimSpace(sha)
|
||||||
|
// 空值守卫:实测 `git rev-parse HEAD` 要么成功给非空 sha、要么报错,
|
||||||
|
// 所以这条**实际不可达** —— 保留它是防御性的,不必为它写判据
|
||||||
|
//(写了会是"永远绿"的假判据,反而掩盖覆盖率的真实情况)。
|
||||||
|
if sha == "" {
|
||||||
|
return "", fmt.Errorf("取 base_sha 得到空值")
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if b, ok := p.ledger.Branches[branchID]; ok {
|
||||||
|
// 只记第一次:重开同一分支时基点不该被后续改动覆盖
|
||||||
|
// (覆盖会让"这条分支从哪出发"永久丢失,冲突判定与重放都失去依据)
|
||||||
|
if b.BaseSHA == "" {
|
||||||
|
b.BaseSHA = sha
|
||||||
|
} else {
|
||||||
|
sha = b.BaseSHA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return sha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────── 冲突预检 ──────────────────────────
|
||||||
|
|
||||||
|
// checkConflict 在**不动工作区**的前提下判断 branch 能否干净地合进 target。
|
||||||
|
//
|
||||||
|
// 两种实现路径,自动选择:
|
||||||
|
//
|
||||||
|
// ① 本地有该分支的 workdir ⇒ 直接在工作目录里跑 merge-tree(最快,无需网络)
|
||||||
|
// ② 否则 ⇒ 在 mirror 里跑(mirror 是 --mirror clone,含全部分支)
|
||||||
|
//
|
||||||
|
// ★ 为什么不在 PR 之后靠平台判定:那时已经推了分支、开了 PR,
|
||||||
|
//
|
||||||
|
// 冲突才暴露 → 白跑一轮。预检的意义就是**在浪费工作之前发现**。
|
||||||
|
func (p *Plugin) checkConflict(workdir, target string) ConflictReport {
|
||||||
|
rep := ConflictReport{Target: target}
|
||||||
|
if target == "" {
|
||||||
|
target = "main"
|
||||||
|
rep.Target = target
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地要有 target 的最新视角:mirror 的 fetch 由调用方保证
|
||||||
|
// (这里只做只读判定,不引入网络依赖 —— 预检失败不该阻断流程)
|
||||||
|
|
||||||
|
// 取 HEAD(本次要合入的提交)
|
||||||
|
head, err := runGitOut(workdir, "rev-parse", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
rep.Skipped = true
|
||||||
|
rep.Detail = "取 HEAD 失败,预检跳过(不等于无冲突): " + err.Error()
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
rep.HeadSHA = strings.TrimSpace(head)
|
||||||
|
|
||||||
|
// 目标分支在本地存在吗?不存在就没法预检(如实标记 Skipped)
|
||||||
|
if _, err := runGitOut(workdir, "rev-parse", "--verify", target); err != nil {
|
||||||
|
rep.Skipped = true
|
||||||
|
rep.Detail = fmt.Sprintf(
|
||||||
|
"本地没有 %s 引用,预检跳过(不等于无冲突)。"+
|
||||||
|
"先 fetch 目标分支再预检,或直接在平台上开 PR 让平台判定", target)
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
base, _ := runGitOut(workdir, "merge-base", target, "HEAD")
|
||||||
|
rep.BaseSHA = strings.TrimSpace(base)
|
||||||
|
|
||||||
|
// ★ 核心:merge-tree --write-tree(不碰工作区)
|
||||||
|
out, err := runGitOut(workdir, "merge-tree", "--write-tree", target, "HEAD")
|
||||||
|
if err == nil {
|
||||||
|
rep.Mergeable = true
|
||||||
|
rep.Detail = fmt.Sprintf("可干净合并进 %s(基点 %s)", target, shortSHA(rep.BaseSHA))
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非零退出:区分「冲突」与「命令失败」。
|
||||||
|
//
|
||||||
|
// ★ 这个区分极其重要:把命令失败当成"有冲突"会误报(让人去做无谓 rebase),
|
||||||
|
// 把它当成"无冲突"则更危险(带着冲突去合)。两者都不行 ⇒ 分别处理。
|
||||||
|
if isMergeTreeConflict(out) {
|
||||||
|
rep.Mergeable = false
|
||||||
|
rep.Conflicts = parseConflictFiles(out)
|
||||||
|
rep.Detail = fmt.Sprintf("与 %s 有冲突(%d 个文件):%s",
|
||||||
|
target, len(rep.Conflicts), strings.Join(rep.Conflicts, ", "))
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令失败(如 git 版本不支持 --write-tree)⇒ 退回旧语法再判一次
|
||||||
|
if rep2, ok := p.checkConflictLegacy(workdir, target); ok {
|
||||||
|
rep2.BaseSHA, rep2.HeadSHA = rep.BaseSHA, rep.HeadSHA
|
||||||
|
return rep2
|
||||||
|
}
|
||||||
|
|
||||||
|
rep.Skipped = true
|
||||||
|
rep.Detail = "merge-tree 不可用(git 过旧?),预检跳过(不等于无冲突): " +
|
||||||
|
truncate(strings.TrimSpace(out), 200)
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkConflictLegacy 用旧版 merge-tree 语法兜底(git < 2.38)。
|
||||||
|
//
|
||||||
|
// 旧语法输出是文本,靠 `CONFLICT` / 冲突标记判断 —— 脆弱,故只作兜底。
|
||||||
|
func (p *Plugin) checkConflictLegacy(workdir, target string) (ConflictReport, bool) {
|
||||||
|
rep := ConflictReport{Target: target}
|
||||||
|
base, err := runGitOut(workdir, "merge-base", target, "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return rep, false
|
||||||
|
}
|
||||||
|
out, _ := runGitOut(workdir, "merge-tree", strings.TrimSpace(base), target, "HEAD")
|
||||||
|
if !strings.Contains(out, "CONFLICT") && !strings.Contains(out, "<<<<<<<") {
|
||||||
|
rep.Mergeable = true
|
||||||
|
rep.Detail = fmt.Sprintf("可干净合并进 %s(旧语法判定)", target)
|
||||||
|
return rep, true
|
||||||
|
}
|
||||||
|
rep.Mergeable = false
|
||||||
|
rep.Conflicts = parseConflictFilesLegacy(out)
|
||||||
|
rep.Detail = fmt.Sprintf("与 %s 有冲突(旧语法判定,%d 个文件)",
|
||||||
|
target, len(rep.Conflicts))
|
||||||
|
return rep, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMergeTreeConflict 判断 merge-tree 的失败是"有冲突"还是"命令本身失败"。
|
||||||
|
//
|
||||||
|
// 依据:git 在冲突时退出码为 1;命令错误(如未知参数)通常是 129 等。
|
||||||
|
// `runGitOut` 把 stderr 一起并进 out,所以同时看退出码语义与输出特征。
|
||||||
|
func isMergeTreeConflict(out string) bool {
|
||||||
|
if strings.Contains(out, "CONFLICT") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 冲突时 merge-tree 会打印 "Auto-merging ... CONFLICT (content)",
|
||||||
|
// 或至少列出冲突阶段号(1/2/3 的 ls-tree 形态)
|
||||||
|
return strings.Contains(out, "Auto-merging") ||
|
||||||
|
strings.Contains(out, "Merge conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConflictFiles 从 `merge-tree --write-tree` 输出里提文件名。
|
||||||
|
//
|
||||||
|
// 输出形态(实测 git 2.53):
|
||||||
|
//
|
||||||
|
// <tree-oid>
|
||||||
|
// 100644 <oid> 1\tpath/to/file ← 阶段 1/2/3 即为冲突条目
|
||||||
|
// 100644 <oid> 2\tpath/to/file
|
||||||
|
// 100644 <oid> 3\tpath/to/file
|
||||||
|
// ...
|
||||||
|
// Auto-merging path
|
||||||
|
// CONFLICT (content): Merge conflict in path
|
||||||
|
func parseConflictFiles(out string) []string {
|
||||||
|
set := map[string]bool{}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
// 形态 ①:明确的 "CONFLICT ... in <path>"
|
||||||
|
if i := strings.Index(line, "CONFLICT"); i >= 0 {
|
||||||
|
if j := strings.Index(line, " in "); j > 0 {
|
||||||
|
set[strings.TrimSpace(line[j+4:])] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 形态 ②:ls-tree 阶段行 "100644 <oid> <stage>\t<path>"
|
||||||
|
if rest, ok := strings.CutPrefix(line, "100"); ok {
|
||||||
|
_ = rest
|
||||||
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
fields := strings.Fields(parts[0])
|
||||||
|
// mode, oid, stage
|
||||||
|
if len(fields) == 3 && (fields[2] == "1" || fields[2] == "2" || fields[2] == "3") {
|
||||||
|
set[strings.TrimSpace(parts[1])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out2 := make([]string, 0, len(set))
|
||||||
|
for f := range set {
|
||||||
|
out2 = append(out2, f)
|
||||||
|
}
|
||||||
|
sort.Strings(out2) // 稳定输出(map 顺序随机,回执要可对照)
|
||||||
|
return out2
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConflictFilesLegacy 从旧语法输出里提文件名(形态 "CONFLICT ... in <path>")。
|
||||||
|
func parseConflictFilesLegacy(out string) []string {
|
||||||
|
set := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if i := strings.Index(line, "CONFLICT"); i >= 0 {
|
||||||
|
if j := strings.Index(line, " in "); j > 0 {
|
||||||
|
set[strings.TrimSpace(line[j+4:])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := make([]string, 0, len(set))
|
||||||
|
for f := range set {
|
||||||
|
r = append(r, f)
|
||||||
|
}
|
||||||
|
sort.Strings(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortSHA 截短 sha 供人阅读(空值原样返回)。
|
||||||
|
func shortSHA(s string) string {
|
||||||
|
if len(s) > 8 {
|
||||||
|
return s[:8]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────── 多项目/并行盘点 ──────────────────────────
|
||||||
|
|
||||||
|
// parallelSummary 汇总「当前有哪些分支线在并行推进」。
|
||||||
|
//
|
||||||
|
// 为何要它:并行是有前提的(设计 §8.4)—— 并行的必须是**互不相干的模块**。
|
||||||
|
// 若两条线改了同一文件,冲突会堆到 merge 时才爆发。这个汇总让主管
|
||||||
|
// **在派活之前**就能看到"眼下有哪几条线、各自在哪",从而判断该不该并行。
|
||||||
|
func (p *Plugin) parallelSummary() map[string]interface{} {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
byProject := map[string][]map[string]interface{}{}
|
||||||
|
for _, b := range p.ledger.Branches {
|
||||||
|
if b.Status != "open" && b.Status != "review" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byProject[b.ProjectID] = append(byProject[b.ProjectID], map[string]interface{}{
|
||||||
|
"branch_id": b.ID,
|
||||||
|
"branch": b.Name,
|
||||||
|
"status": b.Status,
|
||||||
|
"agent": b.AgentName,
|
||||||
|
"workdir": b.Workdir,
|
||||||
|
"base_sha": shortSHA(b.BaseSHA),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
projects := make([]string, 0, len(byProject))
|
||||||
|
for k := range byProject {
|
||||||
|
projects = append(projects, k)
|
||||||
|
}
|
||||||
|
sort.Strings(projects)
|
||||||
|
|
||||||
|
out := make([]map[string]interface{}, 0, len(projects))
|
||||||
|
total := 0
|
||||||
|
for _, proj := range projects {
|
||||||
|
lines := byProject[proj]
|
||||||
|
total += len(lines)
|
||||||
|
out = append(out, map[string]interface{}{
|
||||||
|
"project": proj,
|
||||||
|
"count": len(lines),
|
||||||
|
"branches": lines,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"projects": out,
|
||||||
|
"total_branches": total,
|
||||||
|
"warning": "并行只适合**互不相干的模块**。若多条线改了同一批文件," +
|
||||||
|
"冲突会堆到 merge 时才爆发 —— 派活前先确认边界(设计 §8.4)。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// distinctWorkdirs 返回所有活跃分支的工作目录(供批量操作/清理)。
|
||||||
|
func (p *Plugin) distinctWorkdirs() []string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, b := range p.ledger.Branches {
|
||||||
|
if b.Workdir == "" || seen[b.Workdir] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(b.Workdir, ".git")); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[b.Workdir] = true
|
||||||
|
out = append(out, b.Workdir)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConflictCheck 是 commondev_conflict_check 的实现。
|
||||||
|
func (p *Plugin) handleConflictCheck(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id := strings.TrimSpace(str(args, "branch_id"))
|
||||||
|
p.mu.Lock()
|
||||||
|
b, ok := p.ledger.Branches[id]
|
||||||
|
var workdir, base string
|
||||||
|
if ok {
|
||||||
|
workdir, base = b.Workdir, b.BaseSHA
|
||||||
|
}
|
||||||
|
proj := ""
|
||||||
|
if ok {
|
||||||
|
if pr, ok2 := p.ledger.Projects[b.ProjectID]; ok2 {
|
||||||
|
proj = pr.DefaultBranch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(str(args, "target"))
|
||||||
|
if target == "" {
|
||||||
|
target = proj
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
target = "main"
|
||||||
|
}
|
||||||
|
if workdir == "" {
|
||||||
|
return map[string]interface{}{"error": "该分支没有工作目录(目录准备未完成?)"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rep := p.checkConflict(workdir, target)
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"mergeable": rep.Mergeable,
|
||||||
|
"target": rep.Target,
|
||||||
|
"detail": rep.Detail,
|
||||||
|
"base_sha": shortSHA(base), // 开线时记下的基点(审计用)
|
||||||
|
}
|
||||||
|
if len(rep.Conflicts) > 0 {
|
||||||
|
out["conflicts"] = rep.Conflicts
|
||||||
|
out["advice"] = "先在本分支 rebase/合并目标分支并解决冲突,再提 PR。" +
|
||||||
|
"★ 不要带冲突去开 PR —— 平台会拒绝,且白跑一轮判据。"
|
||||||
|
}
|
||||||
|
if rep.Skipped {
|
||||||
|
out["skipped"] = true
|
||||||
|
out["warning"] = "预检被跳过 —— 这**不等于**没有冲突。"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleParallelStatus 是 commondev_parallel_status 的实现。
|
||||||
|
func (p *Plugin) handleParallelStatus(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.parallelSummary(), nil
|
||||||
|
}
|
||||||
335
example/commondev/conflict_parse_test.go
Normal file
335
example/commondev/conflict_parse_test.go
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 用**真实 git 输出**验证冲突解析(从 git 2.53 实测抓取)。
|
||||||
|
func TestParseConflictFilesRealOutput(t *testing.T) {
|
||||||
|
// 这是 git merge-tree --write-tree 冲突时的**原样输出**
|
||||||
|
const real = `82d5993c292e11020e08a83074413e7cc6e2d5d1
|
||||||
|
100644 df967b96a579e45a18b8251732d16804b2e56a55 1 f.txt
|
||||||
|
100644 f70f10e4db19068f79bc43844b49f3eece45c4e8 2 f.txt
|
||||||
|
100644 223b7836fb19fdf64ba2d3cd6173c6a283141f78 3 f.txt
|
||||||
|
|
||||||
|
Auto-merging f.txt
|
||||||
|
CONFLICT (content): Merge conflict in f.txt
|
||||||
|
`
|
||||||
|
got := parseConflictFiles(real)
|
||||||
|
if len(got) != 1 || got[0] != "f.txt" {
|
||||||
|
t.Fatalf("应从真实输出解出 [f.txt],实际 %v", got)
|
||||||
|
}
|
||||||
|
if !isMergeTreeConflict(real) {
|
||||||
|
t.Fatal("真实冲突输出应被识别为冲突")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无冲突时不该误判。
|
||||||
|
func TestNoConflictOutputNotMisjudged(t *testing.T) {
|
||||||
|
const clean = `3d501af9404b81d5e9d7fe6254d082ff59108312
|
||||||
|
`
|
||||||
|
if isMergeTreeConflict(clean) {
|
||||||
|
t.Fatal("干净合并的输出不该被判为冲突")
|
||||||
|
}
|
||||||
|
if got := parseConflictFiles(clean); len(got) != 0 {
|
||||||
|
t.Fatalf("无冲突时不该解出文件: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多文件冲突要全部解出且稳定排序。
|
||||||
|
func TestParseMultipleConflictsStable(t *testing.T) {
|
||||||
|
const multi = `abc123
|
||||||
|
100644 aaa 1 z.txt
|
||||||
|
100644 bbb 2 z.txt
|
||||||
|
100644 ccc 3 z.txt
|
||||||
|
100644 ddd 1 a.txt
|
||||||
|
100644 eee 2 a.txt
|
||||||
|
100644 fff 3 a.txt
|
||||||
|
CONFLICT (content): Merge conflict in z.txt
|
||||||
|
CONFLICT (content): Merge conflict in a.txt
|
||||||
|
`
|
||||||
|
got := parseConflictFiles(multi)
|
||||||
|
if len(got) != 2 || got[0] != "a.txt" || got[1] != "z.txt" {
|
||||||
|
t.Fatalf("应解出 [a.txt z.txt] 且已排序,实际 %v", got)
|
||||||
|
}
|
||||||
|
// 重复出现(阶段行 + CONFLICT 行)不该重复计数
|
||||||
|
if len(got) != len(uniqueStrings(got)) {
|
||||||
|
t.Error("同一文件出现多次时应去重")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(s []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, v := range s {
|
||||||
|
if !seen[v] {
|
||||||
|
seen[v] = true
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 决定性判据:`checkConflict` 在**真实 git 仓库**上正确判定。
|
||||||
|
//
|
||||||
|
// 上面几条测的是 parser(纯函数)。这条跑真实 git —— 因为 parser 对
|
||||||
|
// 不代表「预检对」:命令拼错、退出码语义搞反、目标分支取错,
|
||||||
|
// 都只有真跑才暴露。
|
||||||
|
//
|
||||||
|
// 场景(与并行开发的真实情形一致):
|
||||||
|
//
|
||||||
|
// main ← base
|
||||||
|
// ├─ feat/a 改 f.txt → 合进 main(main 前进)
|
||||||
|
// └─ feat/b 改 f.txt → 此时预检必冲突
|
||||||
|
//
|
||||||
|
// 以及对照组:feat/c 改的是**另一个文件** → 预检应通过。
|
||||||
|
func TestCheckConflictOnRealGitRepo(t *testing.T) {
|
||||||
|
if _, err := os.Stat("/usr/bin/git"); err != nil {
|
||||||
|
if _, err2 := exec.LookPath("git"); err2 != nil {
|
||||||
|
t.Skip("环境无 git")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
repo := filepath.Join(base, "w")
|
||||||
|
|
||||||
|
run := func(dir string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %v 失败: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建仓 + 基线
|
||||||
|
// ★ 显式指定初始分支为 main:git 默认分支名随版本/配置而变
|
||||||
|
//(本机是 master),不指定的话下面的 "main" 会找不到。
|
||||||
|
run(base, "init", "-q", "-b", "main", repo)
|
||||||
|
run(repo, "config", "user.email", "t@t")
|
||||||
|
run(repo, "config", "user.name", "t")
|
||||||
|
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("base\n"), 0o600)
|
||||||
|
run(repo, "add", "-A")
|
||||||
|
run(repo, "commit", "-qm", "base")
|
||||||
|
|
||||||
|
// feat/a 改 f.txt
|
||||||
|
run(repo, "checkout", "-q", "-b", "feat/a")
|
||||||
|
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("A\n"), 0o600)
|
||||||
|
run(repo, "commit", "-qam", "A")
|
||||||
|
|
||||||
|
// feat/b 也改 f.txt(与 a 同一个文件 → 冲突)
|
||||||
|
run(repo, "checkout", "-q", "main")
|
||||||
|
run(repo, "checkout", "-q", "-b", "feat/b")
|
||||||
|
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("B\n"), 0o600)
|
||||||
|
run(repo, "commit", "-qam", "B")
|
||||||
|
|
||||||
|
// feat/c 改另一个文件(不冲突)
|
||||||
|
run(repo, "checkout", "-q", "main")
|
||||||
|
run(repo, "checkout", "-q", "-b", "feat/c")
|
||||||
|
os.WriteFile(filepath.Join(repo, "g.txt"), []byte("C\n"), 0o600)
|
||||||
|
run(repo, "add", "-A")
|
||||||
|
run(repo, "commit", "-qm", "C")
|
||||||
|
|
||||||
|
p := &Plugin{ledger: &Ledger{}}
|
||||||
|
|
||||||
|
// ① main 还没前进 ⇒ feat/b 可干净合并
|
||||||
|
run(repo, "checkout", "-q", "feat/b")
|
||||||
|
rep := p.checkConflict(repo, "main")
|
||||||
|
if rep.Skipped {
|
||||||
|
t.Fatalf("不该跳过: %s", rep.Detail)
|
||||||
|
}
|
||||||
|
if !rep.Mergeable {
|
||||||
|
t.Fatalf("main 未前进时 feat/b 应可合并,实际: %s", rep.Detail)
|
||||||
|
}
|
||||||
|
if rep.HeadSHA == "" || rep.BaseSHA == "" {
|
||||||
|
t.Error("应记录 head_sha 与 base_sha(审计与重放要用)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 把 feat/a 合进 main(main 前进)⇒ feat/b 必冲突
|
||||||
|
run(repo, "checkout", "-q", "main")
|
||||||
|
run(repo, "merge", "-q", "--no-ff", "feat/a", "-m", "merge a")
|
||||||
|
run(repo, "checkout", "-q", "feat/b")
|
||||||
|
rep = p.checkConflict(repo, "main")
|
||||||
|
if rep.Skipped {
|
||||||
|
t.Fatalf("不该跳过: %s", rep.Detail)
|
||||||
|
}
|
||||||
|
if rep.Mergeable {
|
||||||
|
t.Fatal("★ main 已含 feat/a 的改动、feat/b 改了同一文件 ⇒ 必须判为冲突")
|
||||||
|
}
|
||||||
|
if len(rep.Conflicts) == 0 || rep.Conflicts[0] != "f.txt" {
|
||||||
|
t.Fatalf("应指出冲突文件 f.txt,实际 %v(detail=%s)", rep.Conflicts, rep.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 对照:feat/c 改的是另一文件 ⇒ 仍可合并
|
||||||
|
run(repo, "checkout", "-q", "feat/c")
|
||||||
|
rep = p.checkConflict(repo, "main")
|
||||||
|
if rep.Skipped {
|
||||||
|
t.Fatalf("不该跳过: %s", rep.Detail)
|
||||||
|
}
|
||||||
|
if !rep.Mergeable {
|
||||||
|
t.Fatalf("feat/c 改了无关文件,应可合并,实际: %s", rep.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ ★ 关键:预检**不能改动工作区**
|
||||||
|
//(否则它就成了"会破坏开发状态"的副作用操作)
|
||||||
|
cur := strings.TrimSpace(run(repo, "rev-parse", "--abbrev-ref", "HEAD"))
|
||||||
|
if cur != "feat/c" {
|
||||||
|
t.Fatalf("预检把 HEAD 改到了 %s —— 它绝不该动工作区", cur)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(repo, "MERGE_HEAD")); err == nil {
|
||||||
|
t.Fatal("预检留下了 MERGE_HEAD —— 会污染后续提交")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**每一条 Skipped 路径都不得声称可合并**。
|
||||||
|
//
|
||||||
|
// 把"没检查"当成"检查通过"是最危险的静默降级 —— 调用方会以为安全。
|
||||||
|
//
|
||||||
|
// 为何要**逐路径**覆盖:checkConflict 有 3 条 Skipped 出口
|
||||||
|
// (取 HEAD 失败 / 目标分支不存在 / merge-tree 不可用),
|
||||||
|
// 初版只测了第 2 条,于是把第 1 条的 `rep.Mergeable` 改成 true 的变异
|
||||||
|
// **不报红** —— 判据覆盖不全,等于给其余两条留了洞。
|
||||||
|
func TestEverySkippedPathRefusesMergeable(t *testing.T) {
|
||||||
|
mkRepo := func(t *testing.T, withBranch bool) string {
|
||||||
|
repo := t.TempDir()
|
||||||
|
git := func(args ...string) {
|
||||||
|
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||||
|
cmd.Run()
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("git", "init", "-q", "-b", "main", repo).CombinedOutput(); err != nil {
|
||||||
|
t.Skipf("git init 失败: %v %s", err, out)
|
||||||
|
}
|
||||||
|
git("config", "user.email", "t@t")
|
||||||
|
git("config", "user.name", "t")
|
||||||
|
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("x\n"), 0o600)
|
||||||
|
git("add", "-A")
|
||||||
|
git("commit", "-qm", "init")
|
||||||
|
if withBranch {
|
||||||
|
git("checkout", "-q", "-b", "other")
|
||||||
|
}
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Plugin{ledger: &Ledger{}}
|
||||||
|
|
||||||
|
t.Run("目标分支不存在", func(t *testing.T) {
|
||||||
|
rep := p.checkConflict(mkRepo(t, false), "no-such-branch")
|
||||||
|
assertSkippedNotMergeable(t, rep)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("不是 git 仓库", func(t *testing.T) {
|
||||||
|
rep := p.checkConflict(t.TempDir(), "main")
|
||||||
|
assertSkippedNotMergeable(t, rep)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("工作目录不存在", func(t *testing.T) {
|
||||||
|
rep := p.checkConflict(filepath.Join(t.TempDir(), "nope"), "main")
|
||||||
|
assertSkippedNotMergeable(t, rep)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSkippedNotMergeable 是上面三条路径共用的断言。
|
||||||
|
func assertSkippedNotMergeable(t *testing.T, rep ConflictReport) {
|
||||||
|
t.Helper()
|
||||||
|
if !rep.Skipped {
|
||||||
|
t.Fatalf("应标记 Skipped(detail=%s)", rep.Detail)
|
||||||
|
}
|
||||||
|
if rep.Mergeable {
|
||||||
|
t.Fatal("★ Skipped 时绝不能声称 Mergeable —— 那是把「没检查」当「检查通过」")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rep.Detail, "不等于") {
|
||||||
|
t.Errorf("说明应明确「跳过≠无冲突」: %s", rep.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:`captureBaseSHA` 真的把 sha 写进台账,且**只写第一次**。
|
||||||
|
//
|
||||||
|
// "只写第一次"很重要:重开同一分支时若覆盖基点,"这条线从哪出发"
|
||||||
|
// 就永久丢失 —— 冲突判定与事后重放都失去依据。
|
||||||
|
func TestCaptureBaseSHARecordsOnceOnly(t *testing.T) {
|
||||||
|
repo := t.TempDir()
|
||||||
|
gitIn := func(args ...string) {
|
||||||
|
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||||
|
cmd.Run()
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("git", "init", "-q", "-b", "main", repo).CombinedOutput(); err != nil {
|
||||||
|
t.Skipf("git init 失败: %v %s", err, out)
|
||||||
|
}
|
||||||
|
gitIn("config", "user.email", "t@t")
|
||||||
|
gitIn("config", "user.name", "t")
|
||||||
|
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("1\n"), 0o600)
|
||||||
|
gitIn("add", "-A")
|
||||||
|
gitIn("commit", "-qm", "c1")
|
||||||
|
|
||||||
|
p := &Plugin{ledger: &Ledger{Branches: map[string]*Branch{
|
||||||
|
"br-x": {ID: "br-x", Name: "feat/x", Status: "open"},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
sha1, err := p.captureBaseSHA("br-x", repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sha1 == "" || len(sha1) < 7 {
|
||||||
|
t.Fatalf("应拿到 sha,实际 %q", sha1)
|
||||||
|
}
|
||||||
|
if p.ledger.Branches["br-x"].BaseSHA != sha1 {
|
||||||
|
t.Fatal("base_sha 未写进台账")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再提交一次(HEAD 前进),再捕获 → 仍应返回**第一次**的 sha
|
||||||
|
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("2\n"), 0o600)
|
||||||
|
gitIn("add", "-A")
|
||||||
|
gitIn("commit", "-qm", "c2")
|
||||||
|
|
||||||
|
sha2, err := p.captureBaseSHA("br-x", repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sha2 != sha1 {
|
||||||
|
t.Fatalf("基点不该被后续提交覆盖:第一次 %s,第二次 %s", shortSHA(sha1), shortSHA(sha2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:拿不到 sha 时**如实报错**,不静默返回空串。
|
||||||
|
//
|
||||||
|
// 空 base_sha 会让后续冲突判定失去依据,而"没记上"与"记了个空的"
|
||||||
|
// 在台账里长得一样 —— 所以必须报错。
|
||||||
|
func TestCaptureBaseSHAFailsLoudlyOnBadRepo(t *testing.T) {
|
||||||
|
p := &Plugin{ledger: &Ledger{Branches: map[string]*Branch{"br-y": {ID: "br-y"}}}}
|
||||||
|
if _, err := p.captureBaseSHA("br-y", filepath.Join(t.TempDir(), "not-a-repo")); err == nil {
|
||||||
|
t.Fatal("非 git 目录应报错,而不是返回空 sha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:**仅 ls-tree 阶段行**(无 "CONFLICT" 行)也要能解出文件名。
|
||||||
|
//
|
||||||
|
// 某些冲突类型(如 rename/rename)不打印 "CONFLICT ... in <path>",
|
||||||
|
// 只有 ls-tree 的三个阶段行。少了这条解析路径就会漏报冲突文件 ——
|
||||||
|
// 而"漏报"比"多报"危险:调用方以为没有冲突。
|
||||||
|
func TestParseConflictFromLsTreeOnly(t *testing.T) {
|
||||||
|
// 只有阶段行、没有 "CONFLICT ... in" 行(某些冲突类型如此)
|
||||||
|
const only = `abc123
|
||||||
|
100644 aaa 1 dir/x.go
|
||||||
|
100644 bbb 2 dir/x.go
|
||||||
|
100644 ccc 3 dir/x.go
|
||||||
|
`
|
||||||
|
got := parseConflictFiles(only)
|
||||||
|
if len(got) != 1 || got[0] != "dir/x.go" {
|
||||||
|
t.Fatalf("仅 ls-tree 形态也应解出 [dir/x.go],实际 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
184
example/commondev/credentials.go
Normal file
184
example/commondev/credentials.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 凭据存储:**保留的通用部分**。
|
||||||
|
//
|
||||||
|
// ## 2026-10-15 架构调整(用户拍板)
|
||||||
|
//
|
||||||
|
// **git 托管平台专有的部分已移出插件**(`platformSpec` / `account_*` 工具 /
|
||||||
|
// 角色语义 / 按平台构造认证头)—— 那些是**平台知识**,进 skill 由 agent
|
||||||
|
// 用 `safeshell` + `curl` 操作。理由:每接一个托管平台都要改 Go 代码、
|
||||||
|
// 重编译、重启服务,而平台差异只是「API 前缀 + 认证头」两个维度的不同。
|
||||||
|
//
|
||||||
|
// **本文件保留的是与平台无关的部分**:
|
||||||
|
// · 凭据的加密落盘(AES-256-GCM,自持密钥)
|
||||||
|
// · `${ENV_VAR}` 变量引用解析
|
||||||
|
// · 「不泄漏值」的展示形态
|
||||||
|
//
|
||||||
|
// 平台的用法见 skill `commondev-discipline`(含 Gitea/GitHub/GitLab/Gitee
|
||||||
|
// 的认证头对照表、PR 与分支保护的 curl 命令、以及三个实测踩到的 API 坑)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account 是一条凭据。
|
||||||
|
//
|
||||||
|
// ★ 已移除 `Platform` / `Role` / `BaseURL` 等**平台语义**字段:
|
||||||
|
// 本插件不再按平台构造请求,也不再有「执行侧/主管侧」的角色区分 ——
|
||||||
|
// 那是平台侧的(分支保护)与 skill 侧的知识。
|
||||||
|
type Account struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Token 是**旧路径**:明文或 `${ENV_VAR}` 引用。
|
||||||
|
// ★ 新登记会立即加密进 Sealed,本字段随即清空(见 sealAllLocked)。
|
||||||
|
// 本字段**绝不出现在任何工具返回值里**(见 redacted)。
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
// Sealed 是 AES-256-GCM 加密后的凭据(base64,nonce 前置)。
|
||||||
|
//
|
||||||
|
// 为何要加密:文件虽 0600,但会被备份、打包、随日志外带;
|
||||||
|
// 加密后即使文件外泄,没有密钥也不可用。
|
||||||
|
Sealed string `json:"sealed,omitempty"`
|
||||||
|
// Username 仅用于 git 提交身份(可选)。
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsFile 是磁盘上的凭据集合(独立文件,0600)。
|
||||||
|
type credentialsFile struct {
|
||||||
|
Accounts map[string]*Account `json:"accounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// varRefPattern 匹配 ${VAR} 或 $VAR 形式的变量引用。
|
||||||
|
var varRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
|
||||||
|
|
||||||
|
// credStore 是凭据管理器(与 Plugin 同生命周期)。
|
||||||
|
type credStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
data credentialsFile
|
||||||
|
|
||||||
|
// keyProvider 返回加密密钥(由 Plugin 注入)。
|
||||||
|
keyProvider func() ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCredStore(path string) (*credStore, error) {
|
||||||
|
cs := &credStore{path: path, data: credentialsFile{Accounts: map[string]*Account{}}}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cs, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// ★ 启动时校验权限:凭据文件必须 0600(本仓纪律:响亮失败,不静默降级)
|
||||||
|
if fi, err := os.Stat(path); err == nil {
|
||||||
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"凭据文件权限不安全:%s 是 %04o,必须是 0600(它是能 push 用户仓库的凭据)",
|
||||||
|
path, perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &cs.data); err != nil {
|
||||||
|
return nil, fmt.Errorf("凭据文件损坏(拒绝静默重建): %w", err)
|
||||||
|
}
|
||||||
|
if cs.data.Accounts == nil {
|
||||||
|
cs.data.Accounts = map[string]*Account{}
|
||||||
|
}
|
||||||
|
return cs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveLocked 写入并确权(调用方持锁)。
|
||||||
|
func (cs *credStore) saveLocked() error {
|
||||||
|
b, err := json.MarshalIndent(cs.data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := cs.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, cs.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSecret 把凭据里的变量引用解析成实际值。
|
||||||
|
//
|
||||||
|
// 返回错误的情形(都不静默降级):
|
||||||
|
// - 引用了未设置的环境变量
|
||||||
|
// - 解析结果为空
|
||||||
|
func resolveSecret(raw string) (string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("凭据为空")
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
out := varRefPattern.ReplaceAllStringFunc(raw, func(m string) string {
|
||||||
|
name := m
|
||||||
|
if strings.HasPrefix(m, "${") {
|
||||||
|
name = m[2 : len(m)-1]
|
||||||
|
} else {
|
||||||
|
name = m[1:]
|
||||||
|
}
|
||||||
|
v, ok := os.LookupEnv(name)
|
||||||
|
if !ok || v == "" {
|
||||||
|
missing = append(missing, name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"凭据引用的环境变量未设置: %s —— 请在插件进程的环境里提供它(不静默当空串,"+
|
||||||
|
"否则会退化成匿名访问、表现成 401 而非“凭据没配”)",
|
||||||
|
strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) == "" {
|
||||||
|
return "", fmt.Errorf("凭据解析后为空")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// redacted 返回凭据的可安全展示形态:**绝不暴露 secret**。
|
||||||
|
func (a *Account) redacted() map[string]interface{} {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"name": a.Name,
|
||||||
|
"username": a.Username,
|
||||||
|
"token_ref": tokenRefLabel(a.Token),
|
||||||
|
"token_set": a.Sealed != "" || strings.TrimSpace(a.Token) != "",
|
||||||
|
}
|
||||||
|
if a.Note != "" {
|
||||||
|
out["note"] = a.Note
|
||||||
|
}
|
||||||
|
if a.Sealed != "" {
|
||||||
|
out["storage"] = "encrypted(AES-256-GCM)"
|
||||||
|
} else {
|
||||||
|
out["storage"] = "plaintext-or-envref"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRefLabel 描述凭据来源但**不泄露值**。
|
||||||
|
func tokenRefLabel(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if varRefPattern.MatchString(raw) {
|
||||||
|
return raw // ${VAR} 是引用不是秘密,展示它对排查有用
|
||||||
|
}
|
||||||
|
return "(明文已设置)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsPath 是凭据文件路径(与台账同目录)。
|
||||||
|
func (p *Plugin) credentialsPath() string {
|
||||||
|
return filepath.Join(filepath.Dir(p.path), "credentials.json")
|
||||||
|
}
|
||||||
767
example/commondev/criteria.go
Normal file
767
example/commondev/criteria.go
Normal file
@ -0,0 +1,767 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 刀 4:**判据与复核**(`criteria` / `review`)—— 设计文档 §4.5 的落地。
|
||||||
|
//
|
||||||
|
// ## 核心主张:复核必须是**两步**(正向 + 变异)
|
||||||
|
//
|
||||||
|
// 用户原话:「我定义过单元测试后,对应 agent 完成修改后你直接让插件进行复核即可」
|
||||||
|
//
|
||||||
|
// 这里的隐含假设是「插件 GREEN = 改对了」。**该假设不成立** ——
|
||||||
|
// 插件只能跑主管**预先写下**的判据;判据没覆盖那条 bug,插件照样打勾。
|
||||||
|
//
|
||||||
|
// 本仓当日实证(我在 /tmp 复刻过一遍,结论一致):
|
||||||
|
//
|
||||||
|
// func Max(a,b int) int { if a>b { return a }; return b }
|
||||||
|
// 松判据只测 Max(5,3) == 5
|
||||||
|
// 变异 return b → return a(真 bug)⇒ **判据仍全绿**
|
||||||
|
// 补一条 Max(3,5) == 5 ⇒ 同一变异立刻报红
|
||||||
|
//
|
||||||
|
// ⇒ **没有反向阶段,就是在给"测试通过"这个信号不断充值信用,
|
||||||
|
// 直到它骗一次大的。**
|
||||||
|
//
|
||||||
|
// ## 两个阶段
|
||||||
|
//
|
||||||
|
// 正向 forward:跑判据,期望**通过**
|
||||||
|
// 反向 mutate :改坏被测代码,期望判据**报红**(不报红 ⇒ 判据太松)
|
||||||
|
//
|
||||||
|
// ## ★ 实现要点(每条都是踩过的坑)
|
||||||
|
//
|
||||||
|
// 1. **必须禁用测试缓存**(`-count=1`):变异后若命中缓存,测试根本没跑,
|
||||||
|
// "报红"就无从谈起 —— 而 Go 默认会缓存。实测复刻时就看到 `(cached)`。
|
||||||
|
// 2. **变异必须真的改到文件**:改坏→跑→**恢复**,恢复用 defer 保证
|
||||||
|
// 即使 panic 也不留脏文件(否则工作目录被污染,后续全乱)。
|
||||||
|
// 3. **恢复失败要响亮**:恢复不回去比变异本身危险得多(agent 会基于
|
||||||
|
// 坏代码继续改)。
|
||||||
|
// 4. **判据脚本的"通过"由退出码决定**,不是由输出文本里有没有 "PASS"。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------- 台账结构 ----------------
|
||||||
|
|
||||||
|
// Criteria 是**主管定义的一条判据**。
|
||||||
|
type Criteria struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
BranchID string `json:"branch_id,omitempty"` // 留空 = 项目级(所有分支共用)
|
||||||
|
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Kind 是判据类型:unit|interface|stress|build。
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
|
||||||
|
// Script 是判据本体(shell 命令)。退出码 0 = 通过。
|
||||||
|
Script string `json:"script"`
|
||||||
|
// Cwd 是执行目录(相对工作目录)。留空 = 工作目录根。
|
||||||
|
Cwd string `json:"cwd,omitempty"`
|
||||||
|
// TimeoutSec 单次执行上限。默认 300。
|
||||||
|
TimeoutSec int `json:"timeout_sec,omitempty"`
|
||||||
|
|
||||||
|
// Expect 描述"通过"的含义(**给人看**,不参与判定)。
|
||||||
|
Expect string `json:"expect,omitempty"`
|
||||||
|
|
||||||
|
// Mutations 是**变异规则**(反向阶段用)。
|
||||||
|
//
|
||||||
|
// 空 = 只做正向。但设计 §4.5 明确:**没有反向就是在给信号充值信用** ——
|
||||||
|
// 所以复核结果里会把"无变异规则"标成一个**警告**,而不是静默略过。
|
||||||
|
Mutations []Mutation `json:"mutations,omitempty"`
|
||||||
|
|
||||||
|
// DefHash 是定义指纹:判据内容变了它就该变。
|
||||||
|
//
|
||||||
|
// 用途:① 幂等(重复 define 同一条不产生新记录)
|
||||||
|
// ② 审计("这次复核跑的是哪一版判据")
|
||||||
|
DefHash string `json:"def_hash"`
|
||||||
|
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutation 是一条变异规则:把文件里的 old 换成 new。
|
||||||
|
type Mutation struct {
|
||||||
|
// ID 用于报告(如 "M1")。
|
||||||
|
ID string `json:"id"`
|
||||||
|
// File 是相对工作目录的路径。
|
||||||
|
File string `json:"file"`
|
||||||
|
// Old / New 是字面量替换(**不是正则** —— 正则容易改错地方)。
|
||||||
|
Old string `json:"old"`
|
||||||
|
New string `json:"new"`
|
||||||
|
// Note 说明这条变异想验证什么("判据是否覆盖了 b>a 的分支")。
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewRun 是一次复核执行的结果。
|
||||||
|
type ReviewRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BranchID string `json:"branch_id"`
|
||||||
|
Phase string `json:"phase"` // forward | mutate
|
||||||
|
Criteria string `json:"criteria_id"`
|
||||||
|
Result string `json:"result"` // pass | fail | error | skipped
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Evidence string `json:"evidence,omitempty"` // 输出摘要(截断)
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
EndedAt string `json:"ended_at,omitempty"`
|
||||||
|
// HeadSHA 是复核时的提交 —— 判据通过的是**那个提交**,不是一个会移动的目标。
|
||||||
|
HeadSHA string `json:"head_sha,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 判据定义 ----------------
|
||||||
|
|
||||||
|
// criteriaDefHash 计算判据定义的指纹。
|
||||||
|
//
|
||||||
|
// 为何要它:① 幂等(同定义重复登记不产生新记录);
|
||||||
|
// ② 审计("这次跑的是哪一版判据")。变异规则也必须进指纹 ——
|
||||||
|
// 只改变异规则而判据本体不变时,复核的正确性已经变了。
|
||||||
|
func criteriaDefHash(c *Criteria) string {
|
||||||
|
h := sha256.New()
|
||||||
|
for _, part := range []string{c.Name, c.Kind, c.Script, c.Cwd, c.Expect} {
|
||||||
|
h.Write([]byte(part))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
for _, m := range c.Mutations {
|
||||||
|
h.Write([]byte(m.ID + "\x01" + m.File + "\x01" + m.Old + "\x01" + m.New))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
fmt.Fprintf(h, "%d", c.TimeoutSec)
|
||||||
|
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 执行 ----------------
|
||||||
|
|
||||||
|
// runShell 在工作目录里跑一段命令,返回 (退出码, 合并输出)。
|
||||||
|
//
|
||||||
|
// ★ 超时是必须的:判据脚本可能挂住(死循环、等输入)。内核工具调用
|
||||||
|
// 有 60s 硬限,但这里是**后台作业**(不受它限),所以自己定上限。
|
||||||
|
func runShell(dir, script string, timeoutSec int) (int, string) {
|
||||||
|
if timeoutSec <= 0 {
|
||||||
|
timeoutSec = 300
|
||||||
|
}
|
||||||
|
ctx, cancel := newTimeoutCtx(time.Duration(timeoutSec) * time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", script)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
code := 0
|
||||||
|
if err != nil {
|
||||||
|
if ee, ok := err.(*exec.ExitError); ok {
|
||||||
|
code = ee.ExitCode()
|
||||||
|
} else {
|
||||||
|
code = -1 // 起不来(命令不存在等)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return code, string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCriteriaForward 执行一条判据的**正向**阶段。
|
||||||
|
//
|
||||||
|
// 返回 (是否通过, 证据摘要)。
|
||||||
|
func (p *Plugin) runCriteriaForward(c *Criteria, workdir string) (bool, string) {
|
||||||
|
dir := workdir
|
||||||
|
if c.Cwd != "" {
|
||||||
|
dir = filepath.Join(workdir, c.Cwd)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
return false, fmt.Sprintf("执行目录不存在: %s", dir)
|
||||||
|
}
|
||||||
|
code, out := runShell(dir, c.Script, c.TimeoutSec)
|
||||||
|
if code == 0 {
|
||||||
|
return true, summarize(out, "通过(退出码 0)")
|
||||||
|
}
|
||||||
|
return false, summarize(out, fmt.Sprintf("未通过(退出码 %d)", code))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCriteriaMutate 执行**反向**阶段:逐条变异,期望判据报红。
|
||||||
|
//
|
||||||
|
// ★ 这是本刀的核心。设计 §4.5:变异**不报红 ⇒ 判据太松**。
|
||||||
|
//
|
||||||
|
// 返回 (全部变异都被咬住, 逐条说明, 是否有严重错误)。
|
||||||
|
func (p *Plugin) runCriteriaMutate(c *Criteria, workdir string) (bool, string, bool) {
|
||||||
|
if len(c.Mutations) == 0 {
|
||||||
|
return false, "未定义变异规则 —— **无法判断判据是否太松**。" +
|
||||||
|
"设计 §4.5:没有反向阶段,就是在给「测试通过」这个信号充值信用。" +
|
||||||
|
"请在 commondev_test_define 里补 mutations", false
|
||||||
|
}
|
||||||
|
|
||||||
|
allCaught := true
|
||||||
|
var lines []string
|
||||||
|
for _, m := range c.Mutations {
|
||||||
|
caught, note, hardErr := p.runOneMutation(c, m, workdir)
|
||||||
|
if hardErr {
|
||||||
|
// 硬错误(改不到文件、恢复失败)—— 必须响亮,不能算"咬住"
|
||||||
|
allCaught = false
|
||||||
|
lines = append(lines, fmt.Sprintf(" ✗ %s 执行错误: %s", m.ID, note))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if caught {
|
||||||
|
lines = append(lines, fmt.Sprintf(" ✓ %s 被咬住(判据报红)%s", m.ID, suffixNote(m.Note)))
|
||||||
|
} else {
|
||||||
|
allCaught = false
|
||||||
|
lines = append(lines, fmt.Sprintf(" ✗ %s **未被咬住** —— 判据太松%s", m.ID, suffixNote(m.Note)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allCaught, strings.Join(lines, "\n"), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOneMutation 改坏 → 跑判据 → 恢复。返回 (是否被咬住, 说明, 硬错误)。
|
||||||
|
func (p *Plugin) runOneMutation(c *Criteria, m Mutation, workdir string) (bool, string, bool) {
|
||||||
|
if m.File == "" || m.Old == "" {
|
||||||
|
return false, "变异规则缺 file 或 old", true
|
||||||
|
}
|
||||||
|
path := filepath.Join(workdir, m.File)
|
||||||
|
orig, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Sprintf("读不到 %s: %v", m.File, err), true
|
||||||
|
}
|
||||||
|
text := string(orig)
|
||||||
|
|
||||||
|
// ★ Old 必须**恰好出现一次**:出现 0 次 = 变异无效(改不到);
|
||||||
|
// 出现多次 = 可能改错地方(第一个不一定是我们想改的那个)。
|
||||||
|
// 两者都必须报错 —— 否则"变异没生效"会被误判成"判据太松"(假警报),
|
||||||
|
// 或"改了别处"被误判成"咬住了"(假通过)。
|
||||||
|
n := strings.Count(text, m.Old)
|
||||||
|
if n == 0 {
|
||||||
|
return false, fmt.Sprintf("%s 里找不到要替换的片段(变异无效): %q", m.File, truncate(m.Old, 60)), true
|
||||||
|
}
|
||||||
|
if n > 1 {
|
||||||
|
return false, fmt.Sprintf("%s 里该片段出现 %d 次(可能改错地方,请给出更长的上下文): %q",
|
||||||
|
m.File, n, truncate(m.Old, 60)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutated := strings.Replace(text, m.Old, m.New, 1)
|
||||||
|
if mutated == text {
|
||||||
|
return false, "替换后内容未变化(Old 与 New 相同?)", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 写坏 → 跑 → **无论如何都恢复**(defer 保证 panic 也恢复)
|
||||||
|
restore := func() error { return os.WriteFile(path, orig, 0o644) }
|
||||||
|
if err := os.WriteFile(path, []byte(mutated), 0o644); err != nil {
|
||||||
|
return false, fmt.Sprintf("写变异失败: %v", err), true
|
||||||
|
}
|
||||||
|
defer func() { _ = restore() }()
|
||||||
|
|
||||||
|
passed, evidence := p.runCriteriaForward(c, workdir)
|
||||||
|
|
||||||
|
// 恢复并**校验**恢复成功
|
||||||
|
if err := restore(); err != nil {
|
||||||
|
return false, fmt.Sprintf("★ 恢复 %s 失败(工作目录已被污染): %v", m.File, err), true
|
||||||
|
}
|
||||||
|
back, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(back) != text {
|
||||||
|
return false, fmt.Sprintf("★ 恢复 %s 后内容不一致(工作目录已被污染)", m.File), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !passed {
|
||||||
|
return true, summarize(evidence, "判据报红(期望行为)"), false
|
||||||
|
}
|
||||||
|
// 变异了但判据仍通过 ⇒ 这条变异没被咬住 ⇒ 判据在该维度太松
|
||||||
|
return false, summarize(evidence, "变异后判据**仍通过**"), false
|
||||||
|
}
|
||||||
|
|
||||||
|
func suffixNote(note string) string {
|
||||||
|
if note == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "(" + note + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarize 把输出压成一条可读证据(**截断**:判据输出可能很长,
|
||||||
|
// 全塞进台账会让文件爆炸,而证据的价值在"能看出怎么失败的")。
|
||||||
|
func summarize(out, headline string) string {
|
||||||
|
out = strings.TrimSpace(out)
|
||||||
|
if out == "" {
|
||||||
|
return headline
|
||||||
|
}
|
||||||
|
return headline + "\n" + truncate(out, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 工具实现 ----------------
|
||||||
|
|
||||||
|
func (p *Plugin) handleTestDefine(args map[string]interface{}) (interface{}, error) {
|
||||||
|
proj := strings.TrimSpace(str(args, "project"))
|
||||||
|
if proj == "" {
|
||||||
|
return map[string]interface{}{"error": "project 必填"}, nil
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(str(args, "name"))
|
||||||
|
script := str(args, "script")
|
||||||
|
if name == "" || strings.TrimSpace(script) == "" {
|
||||||
|
return map[string]interface{}{"error": "name 与 script 必填"}, nil
|
||||||
|
}
|
||||||
|
kind := strings.TrimSpace(str(args, "kind"))
|
||||||
|
if kind == "" {
|
||||||
|
kind = "unit"
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "unit", "interface", "stress", "build":
|
||||||
|
default:
|
||||||
|
return map[string]interface{}{"error": "kind 只能是 unit|interface|stress|build"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Criteria{
|
||||||
|
ID: fmt.Sprintf("cr-%s-%s", safeNamePattern.ReplaceAllString(proj, "_"), shortHash(proj+name+script)),
|
||||||
|
ProjectID: proj,
|
||||||
|
BranchID: strings.TrimSpace(str(args, "branch_id")),
|
||||||
|
Name: name,
|
||||||
|
Kind: kind,
|
||||||
|
Script: script,
|
||||||
|
Cwd: strings.TrimSpace(str(args, "cwd")),
|
||||||
|
Expect: strings.TrimSpace(str(args, "expect")),
|
||||||
|
TimeoutSec: int(numArg(args, "timeout_sec")),
|
||||||
|
CreatedAt: nowRFC3339(),
|
||||||
|
}
|
||||||
|
// 变异规则
|
||||||
|
if raw, ok := args["mutations"].([]interface{}); ok {
|
||||||
|
for i, item := range raw {
|
||||||
|
mm, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := Mutation{
|
||||||
|
ID: str(mm, "id"),
|
||||||
|
File: str(mm, "file"),
|
||||||
|
Old: str(mm, "old"),
|
||||||
|
New: str(mm, "new"),
|
||||||
|
Note: str(mm, "note"),
|
||||||
|
}
|
||||||
|
if m.ID == "" {
|
||||||
|
m.ID = fmt.Sprintf("M%d", i+1)
|
||||||
|
}
|
||||||
|
if m.File == "" || m.Old == "" || m.New == "" {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("变异 %s 缺 file/old/new", m.ID)}, nil
|
||||||
|
}
|
||||||
|
c.Mutations = append(c.Mutations, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.ledger.Criteria == nil {
|
||||||
|
p.ledger.Criteria = map[string]*Criteria{}
|
||||||
|
}
|
||||||
|
// 幂等:同 ID 且定义指纹相同 ⇒ 不动("可迭代幂等")
|
||||||
|
if prev, exists := p.ledger.Criteria[c.ID]; exists {
|
||||||
|
if prev.DefHash == criteriaDefHash(c) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "unchanged", "criteria_id": c.ID,
|
||||||
|
"note": "同定义已存在,未改动(幂等)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
c.CreatedAt = prev.CreatedAt
|
||||||
|
c.UpdatedAt = nowRFC3339()
|
||||||
|
}
|
||||||
|
c.DefHash = criteriaDefHash(c)
|
||||||
|
p.ledger.Criteria[c.ID] = c
|
||||||
|
if err := p.saveLocked(); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"status": "ok", "criteria_id": c.ID, "kind": c.Kind,
|
||||||
|
"def_hash": c.DefHash, "mutations": len(c.Mutations),
|
||||||
|
}
|
||||||
|
if len(c.Mutations) == 0 {
|
||||||
|
out["warning"] = "未定义变异规则:复核时**无法判断判据是否太松**(设计 §4.5)"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleTestList(args map[string]interface{}) (interface{}, error) {
|
||||||
|
proj := strings.TrimSpace(str(args, "project"))
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
out := []map[string]interface{}{}
|
||||||
|
for _, c := range p.ledger.Criteria {
|
||||||
|
if proj != "" && c.ProjectID != proj {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, map[string]interface{}{
|
||||||
|
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
|
||||||
|
"project": c.ProjectID, "branch_id": c.BranchID,
|
||||||
|
"mutations": len(c.Mutations), "def_hash": c.DefHash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return out[i]["name"].(string) < out[j]["name"].(string)
|
||||||
|
})
|
||||||
|
return map[string]interface{}{"criteria": out, "count": len(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTimeoutCtx 是 context.WithTimeout 的薄包装。
|
||||||
|
func newTimeoutCtx(d time.Duration) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 复核执行(后台作业) ----------------
|
||||||
|
|
||||||
|
// reviewAll 对一个分支跑**全部**判据的复核,返回汇总。
|
||||||
|
//
|
||||||
|
// ★ 这是「主管按一下、插件跑完正反两阶段」的入口。
|
||||||
|
func (p *Plugin) reviewAll(branchID string) (map[string]interface{}, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
b, ok := p.ledger.Branches[branchID]
|
||||||
|
var workdir, proj string
|
||||||
|
if ok {
|
||||||
|
workdir, proj = b.Workdir, b.ProjectID
|
||||||
|
}
|
||||||
|
// 收集适用判据:分支级优先,项目级兜底
|
||||||
|
var list []*Criteria
|
||||||
|
for _, c := range p.ledger.Criteria {
|
||||||
|
if c.ProjectID != proj {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.BranchID != "" && c.BranchID != branchID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, c)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("工程对话 %s 不存在", branchID)
|
||||||
|
}
|
||||||
|
if workdir == "" {
|
||||||
|
return nil, fmt.Errorf("该分支没有工作目录")
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("项目 %s 没有定义任何判据 —— 先 commondev_test_define", proj),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
// ★ 固定顺序(map 遍历随机)—— 复核报告要可对照
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
if list[i].Kind != list[j].Kind {
|
||||||
|
return list[i].Kind < list[j].Kind
|
||||||
|
}
|
||||||
|
return list[i].Name < list[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
head, _ := runGitOut(workdir, "rev-parse", "HEAD")
|
||||||
|
head = strings.TrimSpace(head)
|
||||||
|
|
||||||
|
forwardOK := true
|
||||||
|
mutateOK := true
|
||||||
|
anyWithoutMutations := false
|
||||||
|
var report []map[string]interface{}
|
||||||
|
|
||||||
|
for _, c := range list {
|
||||||
|
fwdPass, fwdEv := p.runCriteriaForward(c, workdir)
|
||||||
|
if !fwdPass {
|
||||||
|
forwardOK = false
|
||||||
|
}
|
||||||
|
mutCaught, mutEv, _ := p.runCriteriaMutate(c, workdir)
|
||||||
|
if len(c.Mutations) == 0 {
|
||||||
|
anyWithoutMutations = true
|
||||||
|
} else if !mutCaught {
|
||||||
|
mutateOK = false
|
||||||
|
}
|
||||||
|
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
|
||||||
|
"forward_pass": fwdPass,
|
||||||
|
"forward_evidence": truncate(fwdEv, 800),
|
||||||
|
"mutations_total": len(c.Mutations),
|
||||||
|
"mutations_caught": mutCaught,
|
||||||
|
}
|
||||||
|
if len(c.Mutations) > 0 {
|
||||||
|
item["mutate_evidence"] = truncate(mutEv, 1200)
|
||||||
|
} else {
|
||||||
|
item["mutate_evidence"] = mutEv
|
||||||
|
}
|
||||||
|
report = append(report, item)
|
||||||
|
|
||||||
|
// 写复核历史(可审计:哪次复核、跑的是哪一版判据、在哪个提交上)
|
||||||
|
p.appendReviewRun(branchID, c.ID, "forward", fwdPass, fwdEv, head)
|
||||||
|
if len(c.Mutations) > 0 {
|
||||||
|
p.appendReviewRun(branchID, c.ID, "mutate", mutCaught, mutEv, head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 结论三条并列,**不合并成一个绿色勾**:
|
||||||
|
// 正向绿 + 反向没跑 ≠ 复核通过(那是"信号充值")
|
||||||
|
verdict := "pass"
|
||||||
|
notes := []string{}
|
||||||
|
if !forwardOK {
|
||||||
|
verdict = "fail"
|
||||||
|
notes = append(notes, "有判据未通过(实现有 bug)")
|
||||||
|
}
|
||||||
|
if anyWithoutMutations {
|
||||||
|
notes = append(notes, "★ 有判据未定义变异规则 ⇒ 无法判断它是否太松(设计 §4.5)")
|
||||||
|
if verdict == "pass" {
|
||||||
|
verdict = "pass_with_gaps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !mutateOK {
|
||||||
|
notes = append(notes, "★ 有变异**未被咬住** ⇒ 对应判据太松,实现可能有问题而判据看不见")
|
||||||
|
if verdict == "pass" {
|
||||||
|
verdict = "pass_with_gaps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"verdict": verdict, // pass | pass_with_gaps | fail
|
||||||
|
"forward_ok": forwardOK,
|
||||||
|
"mutate_ok": mutateOK,
|
||||||
|
"head_sha": shortSHA(head),
|
||||||
|
"criteria_run": len(report),
|
||||||
|
"report": report,
|
||||||
|
}
|
||||||
|
if len(notes) > 0 {
|
||||||
|
out["notes"] = notes
|
||||||
|
}
|
||||||
|
if verdict == "pass_with_gaps" {
|
||||||
|
out["warning"] = "正向全绿,但**反向阶段有缺口** —— " +
|
||||||
|
"这不是「通过」,是「判据没覆盖到」。插件 GREEN ≠ 改对了。"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendReviewRun 记一条复核历史。
|
||||||
|
func (p *Plugin) appendReviewRun(branchID, criteriaID, phase string, pass bool, evidence, head string) {
|
||||||
|
res := "fail"
|
||||||
|
if pass {
|
||||||
|
res = "pass"
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.ledger.ReviewRuns = append(p.ledger.ReviewRuns, ReviewRun{
|
||||||
|
ID: fmt.Sprintf("rv-%d", time.Now().UnixNano()%1000000000),
|
||||||
|
BranchID: branchID, Criteria: criteriaID, Phase: phase,
|
||||||
|
Result: res, Evidence: truncate(evidence, 500),
|
||||||
|
StartedAt: nowRFC3339(), EndedAt: nowRFC3339(),
|
||||||
|
HeadSHA: head,
|
||||||
|
})
|
||||||
|
// 只保留最近 500 条(防台账无限膨胀;复核历史是审计用,不需要全量)
|
||||||
|
if n := len(p.ledger.ReviewRuns); n > 500 {
|
||||||
|
p.ledger.ReviewRuns = p.ledger.ReviewRuns[n-500:]
|
||||||
|
}
|
||||||
|
_ = p.saveLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReviewSubmit 是 commondev_review_submit 的实现。
|
||||||
|
//
|
||||||
|
// ★ 复核可能跑很久(判据里可能有编译、压测)⇒ 走**后台作业**,
|
||||||
|
// 与 branch_open 的 clone 同理(内核工具调用有 60s 硬限)。
|
||||||
|
func (p *Plugin) handleReviewSubmit(args map[string]interface{}) (interface{}, error) {
|
||||||
|
branchID := strings.TrimSpace(str(args, "branch_id"))
|
||||||
|
if branchID == "" {
|
||||||
|
return map[string]interface{}{"error": "branch_id 必填"}, nil
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if _, ok := p.ledger.Branches[branchID]; !ok {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", branchID)}, nil
|
||||||
|
}
|
||||||
|
jobID := fmt.Sprintf("review-%s-%d", branchID, time.Now().UnixNano()%100000)
|
||||||
|
p.ledger.Jobs[jobID] = &Job{
|
||||||
|
ID: jobID, BranchID: branchID, Kind: "review",
|
||||||
|
Status: "pending", StartedAt: nowRFC3339(),
|
||||||
|
}
|
||||||
|
p.saveLocked()
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
p.setJob(jobID, "running", "正在复核(正向 + 变异)", "")
|
||||||
|
res, err := p.reviewAll(branchID)
|
||||||
|
if err != nil {
|
||||||
|
p.setJob(jobID, "failed", "", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e, isErr := res["error"]; isErr {
|
||||||
|
p.setJob(jobID, "failed", "", fmt.Sprint(e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verdict := fmt.Sprint(res["verdict"])
|
||||||
|
detail := fmt.Sprintf("复核结论: %s", verdict)
|
||||||
|
if notes, ok := res["notes"].([]string); ok && len(notes) > 0 {
|
||||||
|
detail += " | " + strings.Join(notes, ";")
|
||||||
|
}
|
||||||
|
p.setJob(jobID, "done", detail, "")
|
||||||
|
p.lastReview = res
|
||||||
|
}()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok", "job_id": jobID, "branch_id": branchID,
|
||||||
|
"next": "复核在后台进行(判据可能含编译/压测,超过 60s 工具上限)——" +
|
||||||
|
"用 commondev_job_status 查进度;用 commondev_review_result 取结论",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReviewResult 取最近一次复核结论。
|
||||||
|
func (p *Plugin) handleReviewResult(args map[string]interface{}) (interface{}, error) {
|
||||||
|
branchID := strings.TrimSpace(str(args, "branch_id"))
|
||||||
|
p.mu.Lock()
|
||||||
|
res := p.lastReview
|
||||||
|
// 也把历史摘要带上(审计)
|
||||||
|
var hist []map[string]interface{}
|
||||||
|
for _, r := range p.ledger.ReviewRuns {
|
||||||
|
if branchID != "" && r.BranchID != branchID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hist = append(hist, map[string]interface{}{
|
||||||
|
"id": r.ID, "phase": r.Phase, "criteria": r.Criteria,
|
||||||
|
"result": r.Result, "head_sha": shortSHA(r.HeadSHA), "at": r.StartedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if res == nil && len(hist) == 0 {
|
||||||
|
return map[string]interface{}{"error": "还没有复核记录(先 commondev_review_submit)"}, nil
|
||||||
|
}
|
||||||
|
// 只给最近 40 条(防刷屏)
|
||||||
|
if len(hist) > 40 {
|
||||||
|
hist = hist[len(hist)-40:]
|
||||||
|
}
|
||||||
|
out := map[string]interface{}{"history": hist, "history_count": len(hist)}
|
||||||
|
if res != nil {
|
||||||
|
out["last"] = res
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 单阶段复核工具(设计 §4.1) ----------------
|
||||||
|
//
|
||||||
|
// 设计 §4.1 把复核拆成两个工具:
|
||||||
|
//
|
||||||
|
// commondev_test_run 执行判据集合(正向)
|
||||||
|
// commondev_test_mutate 执行变异验证(反向)★ 必须有
|
||||||
|
//
|
||||||
|
// 而 commondev_review_submit 是**两者一起跑**(主管按一下就跑完)。
|
||||||
|
// 三种粒度都保留,因为排查时需要单跑某一阶段:
|
||||||
|
// "正向都过不了" 与 "正过但变异咬不住" 是**完全不同**的问题。
|
||||||
|
|
||||||
|
// criteriaFor 收集适用于某分支的判据(分支级优先,项目级兜底)。
|
||||||
|
func (p *Plugin) criteriaFor(branchID, proj string) []*Criteria {
|
||||||
|
p.mu.Lock()
|
||||||
|
var list []*Criteria
|
||||||
|
for _, c := range p.ledger.Criteria {
|
||||||
|
if c.ProjectID != proj {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.BranchID != "" && c.BranchID != branchID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, c)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
if list[i].Kind != list[j].Kind {
|
||||||
|
return list[i].Kind < list[j].Kind
|
||||||
|
}
|
||||||
|
return list[i].Name < list[j].Name
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupBranchWorkdir 取分支与其项目(找不到时给明确错误)。
|
||||||
|
func (p *Plugin) lookupBranchWorkdir(branchID string) (*Branch, *Project, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
b, ok := p.ledger.Branches[branchID]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("工程对话 %s 不存在", branchID)
|
||||||
|
}
|
||||||
|
proj := p.ledger.Projects[b.ProjectID]
|
||||||
|
if proj == nil {
|
||||||
|
return nil, nil, fmt.Errorf("分支 %s 的项目 %s 不存在", branchID, b.ProjectID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.Workdir) == "" {
|
||||||
|
return nil, nil, fmt.Errorf("该分支没有工作目录(先 commondev_branch_open)")
|
||||||
|
}
|
||||||
|
return b, proj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestRun 只跑**正向**阶段。
|
||||||
|
func (p *Plugin) handleTestRun(args map[string]interface{}) (interface{}, error) {
|
||||||
|
branchID := strings.TrimSpace(str(args, "branch_id"))
|
||||||
|
if branchID == "" {
|
||||||
|
return map[string]interface{}{"error": "branch_id 必填"}, nil
|
||||||
|
}
|
||||||
|
b, proj, err := p.lookupBranchWorkdir(branchID)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
list := p.criteriaFor(branchID, proj.Name)
|
||||||
|
if len(list) == 0 {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据(先 commondev_test_define)", proj.Name)}, nil
|
||||||
|
}
|
||||||
|
allPass, report := true, []map[string]interface{}{}
|
||||||
|
for _, c := range list {
|
||||||
|
pass, ev := p.runCriteriaForward(c, b.Workdir)
|
||||||
|
if !pass {
|
||||||
|
allPass = false
|
||||||
|
}
|
||||||
|
report = append(report, map[string]interface{}{
|
||||||
|
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
|
||||||
|
"pass": pass, "evidence": truncate(ev, 800),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"phase": "forward", "all_pass": allPass,
|
||||||
|
"criteria_run": len(report), "report": report,
|
||||||
|
}
|
||||||
|
if !allPass {
|
||||||
|
out["error"] = "有判据未通过(实现有 bug)"
|
||||||
|
}
|
||||||
|
// ★ 提醒:正向过了也不代表改对了
|
||||||
|
out["warning"] = "正向通过 ≠ 改对了 —— 还要跑 commondev_test_mutate" +
|
||||||
|
"(变异不报红 ⇒ 判据太松,见设计 §4.5)"
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestMutate 只跑**反向**阶段。
|
||||||
|
func (p *Plugin) handleTestMutate(args map[string]interface{}) (interface{}, error) {
|
||||||
|
branchID := strings.TrimSpace(str(args, "branch_id"))
|
||||||
|
if branchID == "" {
|
||||||
|
return map[string]interface{}{"error": "branch_id 必填"}, nil
|
||||||
|
}
|
||||||
|
b, proj, err := p.lookupBranchWorkdir(branchID)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
list := p.criteriaFor(branchID, proj.Name)
|
||||||
|
if len(list) == 0 {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据", proj.Name)}, nil
|
||||||
|
}
|
||||||
|
allCaught, withGaps, report := true, []string{}, []map[string]interface{}{}
|
||||||
|
for _, c := range list {
|
||||||
|
if len(c.Mutations) == 0 {
|
||||||
|
allCaught = false
|
||||||
|
withGaps = append(withGaps, c.Name)
|
||||||
|
report = append(report, map[string]interface{}{
|
||||||
|
"criteria_id": c.ID, "name": c.Name,
|
||||||
|
"mutations_total": 0, "all_caught": false,
|
||||||
|
"evidence": "★ 未定义变异规则 ⇒ 无法判断判据是否太松",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
caught, ev, _ := p.runCriteriaMutate(c, b.Workdir)
|
||||||
|
if !caught {
|
||||||
|
allCaught = false
|
||||||
|
}
|
||||||
|
report = append(report, map[string]interface{}{
|
||||||
|
"criteria_id": c.ID, "name": c.Name,
|
||||||
|
"mutations_total": len(c.Mutations), "all_caught": caught,
|
||||||
|
"evidence": truncate(ev, 1200),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"phase": "mutate", "all_caught": allCaught,
|
||||||
|
"criteria_run": len(report), "report": report,
|
||||||
|
}
|
||||||
|
if len(withGaps) > 0 {
|
||||||
|
out["criteria_without_mutations"] = withGaps
|
||||||
|
}
|
||||||
|
if !allCaught {
|
||||||
|
out["warning"] = "★ 有变异**未被咬住** ⇒ 判据太松,实现可能有问题而判据看不见"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
378
example/commondev/criteria_test.go
Normal file
378
example/commondev/criteria_test.go
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 刀 4 的判据:判据与复核(正向 + 变异)。
|
||||||
|
//
|
||||||
|
// ## 这组判据要证明的核心命题
|
||||||
|
//
|
||||||
|
// 设计 §4.5:「插件 GREEN ≠ 改对了」——
|
||||||
|
//
|
||||||
|
// 插件只能跑主管**预先写下**的判据。判据没覆盖那条 bug,插件照样打勾。
|
||||||
|
// 自动化**不会**发现假绿,只会让假绿跑得更快、更可信。
|
||||||
|
//
|
||||||
|
// ⇒ 复核必须是两步:正向(判据应过)+ 反向(变异应被咬住)。
|
||||||
|
//
|
||||||
|
// 所以本文件最重要的判据是 **TestReviewCatchesLooseCriteria**:
|
||||||
|
// 造一个「真 bug 但判据太松」的场景,断言复核**不给出 pass**。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestPluginForCriteria 建一个带台账的插件。
|
||||||
|
func newTestPluginForCriteria(t *testing.T) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
p.ledger = &Ledger{
|
||||||
|
Projects: map[string]*Project{},
|
||||||
|
Branches: map[string]*Branch{},
|
||||||
|
Jobs: map[string]*Job{},
|
||||||
|
Criteria: map[string]*Criteria{},
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRepo 在工作目录里放一个可被变异的最小 Go 项目。
|
||||||
|
func writeRepo(t *testing.T, loose bool) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
mustWrite := func(rel, content string) {
|
||||||
|
path := filepath.Join(dir, rel)
|
||||||
|
os.MkdirAll(filepath.Dir(path), 0o755)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mustWrite("go.mod", "module probe\n\ngo 1.21\n")
|
||||||
|
mustWrite("calc.go", `package calc
|
||||||
|
|
||||||
|
func Max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
if loose {
|
||||||
|
// ★ 松判据:只测 a>b 的分支
|
||||||
|
mustWrite("calc_test.go", `package calc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMax(t *testing.T) {
|
||||||
|
if Max(5, 3) != 5 {
|
||||||
|
t.Fatal("错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
} else {
|
||||||
|
// 严判据:两个方向都测
|
||||||
|
mustWrite("calc_test.go", `package calc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMax(t *testing.T) {
|
||||||
|
if Max(5, 3) != 5 {
|
||||||
|
t.Fatal("a>b 错")
|
||||||
|
}
|
||||||
|
if Max(3, 5) != 5 {
|
||||||
|
t.Fatal("b>a 错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitIn(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 本刀最重要的判据:**复核必须识破「真 bug + 松判据」**。
|
||||||
|
//
|
||||||
|
// 场景:
|
||||||
|
//
|
||||||
|
// 代码 Max 正确;判据只测 Max(5,3)(漏了 b>a)
|
||||||
|
// 变异:return b → return a(真 bug)
|
||||||
|
// 松判据**仍通过** ⇒ 该变异"未被咬住"
|
||||||
|
//
|
||||||
|
// 期望:复核结论**不是 pass**(而是 pass_with_gaps),并明确指出
|
||||||
|
// "变异未被咬住 ⇒ 判据太松"。
|
||||||
|
//
|
||||||
|
// 若这条报红,说明复核机制回到了"只看正向"——那就失去了本刀的全部意义。
|
||||||
|
func TestReviewCatchesLooseCriteria(t *testing.T) {
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
dir := writeRepo(t, true) // ★ 松判据
|
||||||
|
|
||||||
|
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
|
||||||
|
p.ledger.Criteria["cr-1"] = &Criteria{
|
||||||
|
ID: "cr-1", ProjectID: "probe", Name: "max 单元判据", Kind: "unit",
|
||||||
|
Script: "go test ./...",
|
||||||
|
Expect: "两方向都正确",
|
||||||
|
Mutations: []Mutation{{
|
||||||
|
ID: "M1", File: "calc.go",
|
||||||
|
Old: "\treturn b\n}", New: "\treturn a\n}",
|
||||||
|
Note: "验证判据是否覆盖 b>a 分支",
|
||||||
|
}},
|
||||||
|
DefHash: "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.reviewAll("br-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
verdict := res["verdict"].(string)
|
||||||
|
|
||||||
|
// ① 正向应通过(松判据确实能过)
|
||||||
|
if !res["forward_ok"].(bool) {
|
||||||
|
t.Fatalf("前提失效:松判据的正向应通过,实际 evidence=%v", res["report"])
|
||||||
|
}
|
||||||
|
// ② 反向必须**没被咬住**
|
||||||
|
if res["mutate_ok"].(bool) {
|
||||||
|
t.Fatal("★ 变异未被咬住却报 mutate_ok=true —— 反向阶段没在起作用")
|
||||||
|
}
|
||||||
|
// ③ 结论**绝不能是 pass**
|
||||||
|
if verdict == "pass" {
|
||||||
|
t.Fatal("★★ 真 bug + 松判据 却给出 pass —— " +
|
||||||
|
"这正是设计 §4.5 说的『给测试通过充值信用,直到它骗一次大的』")
|
||||||
|
}
|
||||||
|
if verdict != "pass_with_gaps" {
|
||||||
|
t.Errorf("结论应为 pass_with_gaps,实际 %q", verdict)
|
||||||
|
}
|
||||||
|
// ④ 必须明确说明原因(不能只给个模糊的黄色)
|
||||||
|
notes, _ := res["notes"].([]string)
|
||||||
|
joined := strings.Join(notes, " ")
|
||||||
|
if !strings.Contains(joined, "太松") && !strings.Contains(joined, "未被咬住") {
|
||||||
|
t.Errorf("notes 应指出『判据太松』,实际: %v", notes)
|
||||||
|
}
|
||||||
|
t.Logf("✅ 松判据被识破:verdict=%s notes=%v", verdict, notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**严判据**时复核给出 pass(机制不能把一切都说成有问题)。
|
||||||
|
func TestReviewPassesWithTightCriteria(t *testing.T) {
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
dir := writeRepo(t, false) // 严判据
|
||||||
|
|
||||||
|
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
|
||||||
|
p.ledger.Criteria["cr-1"] = &Criteria{
|
||||||
|
ID: "cr-1", ProjectID: "probe", Name: "max 单元判据", Kind: "unit",
|
||||||
|
Script: "go test ./...",
|
||||||
|
Mutations: []Mutation{{
|
||||||
|
ID: "M1", File: "calc.go",
|
||||||
|
Old: "\treturn b\n}", New: "\treturn a\n}",
|
||||||
|
Note: "b>a 分支",
|
||||||
|
}},
|
||||||
|
DefHash: "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.reviewAll("br-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res["verdict"] != "pass" {
|
||||||
|
t.Fatalf("严判据应给 pass,实际 %q(notes=%v)", res["verdict"], res["notes"])
|
||||||
|
}
|
||||||
|
if !res["forward_ok"].(bool) || !res["mutate_ok"].(bool) {
|
||||||
|
t.Fatalf("正向与反向都应通过: forward=%v mutate=%v",
|
||||||
|
res["forward_ok"], res["mutate_ok"])
|
||||||
|
}
|
||||||
|
t.Log("✅ 严判据 → pass(机制不会把一切都判成有问题)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**判据实现有 bug** 时正向就报红 ⇒ fail。
|
||||||
|
func TestReviewFailsOnForwardFailure(t *testing.T) {
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
dir := writeRepo(t, false)
|
||||||
|
|
||||||
|
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Workdir: dir, Status: "open"}
|
||||||
|
p.ledger.Criteria["cr-1"] = &Criteria{
|
||||||
|
ID: "cr-1", ProjectID: "probe", Name: "故意失败", Kind: "unit",
|
||||||
|
Script: "false", // 永远失败
|
||||||
|
Mutations: []Mutation{{ID: "M1", File: "calc.go", Old: "a > b", New: "a >= b"}},
|
||||||
|
DefHash: "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.reviewAll("br-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res["verdict"] != "fail" {
|
||||||
|
t.Fatalf("正向失败应给 fail,实际 %q", res["verdict"])
|
||||||
|
}
|
||||||
|
if res["forward_ok"].(bool) {
|
||||||
|
t.Fatal("forward_ok 应为 false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**未定义变异规则**时不能给 pass(那正是"信号充值")。
|
||||||
|
func TestReviewFlagsMissingMutations(t *testing.T) {
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
dir := writeRepo(t, false)
|
||||||
|
|
||||||
|
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
|
||||||
|
p.ledger.Criteria["cr-1"] = &Criteria{
|
||||||
|
ID: "cr-1", ProjectID: "probe", Name: "无变异", Kind: "unit",
|
||||||
|
Script: "go test ./...",
|
||||||
|
// ★ 刻意不给 Mutations
|
||||||
|
DefHash: "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.reviewAll("br-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res["verdict"] == "pass" {
|
||||||
|
t.Fatal("★ 无变异规则却给 pass —— 等于把『没检查』当『检查通过』")
|
||||||
|
}
|
||||||
|
if res["verdict"] != "pass_with_gaps" {
|
||||||
|
t.Errorf("应为 pass_with_gaps,实际 %q", res["verdict"])
|
||||||
|
}
|
||||||
|
notes, _ := res["notes"].([]string)
|
||||||
|
if !strings.Contains(strings.Join(notes, " "), "变异规则") {
|
||||||
|
t.Errorf("notes 应指出缺变异规则: %v", notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 变异执行的安全性 ----------------
|
||||||
|
|
||||||
|
// ★★★ 判据:变异**必须恢复原文件**(否则工作目录被污染,后续全乱)。
|
||||||
|
func TestMutationAlwaysRestoresFile(t *testing.T) {
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
dir := writeRepo(t, false)
|
||||||
|
|
||||||
|
orig, _ := os.ReadFile(filepath.Join(dir, "calc.go"))
|
||||||
|
c := &Criteria{
|
||||||
|
ID: "c", ProjectID: "probe", Name: "n", Kind: "unit",
|
||||||
|
Script: "go test ./...",
|
||||||
|
Mutations: []Mutation{
|
||||||
|
{ID: "M1", File: "calc.go", Old: "\treturn b\n}", New: "\treturn a\n}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, hardErr := p.runCriteriaMutate(c, dir)
|
||||||
|
if hardErr {
|
||||||
|
t.Fatal("不该有硬错误")
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(filepath.Join(dir, "calc.go"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(after) != string(orig) {
|
||||||
|
t.Fatalf("★ 变异后**未恢复**原文件 —— 工作目录被污染\n前:\n%s\n后:\n%s", orig, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:`old` 出现 **0 次或多次**都必须报硬错误(不能算"咬住")。
|
||||||
|
//
|
||||||
|
// 为何重要:
|
||||||
|
//
|
||||||
|
// · 0 次 ⇒ 变异无效,"判据没报红"是**假警报**(其实判据没问题)
|
||||||
|
// · 多次 ⇒ 可能改错地方,"判据报红"是**假通过**(改的不是想改的那处)
|
||||||
|
func TestMutationRequiresUniqueMatch(t *testing.T) {
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
dir := writeRepo(t, false)
|
||||||
|
c := &Criteria{ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", Script: "true"}
|
||||||
|
|
||||||
|
// ① old 不存在
|
||||||
|
c.Mutations = []Mutation{{ID: "M1", File: "calc.go", Old: "NOT_PRESENT_ANYWHERE", New: "x"}}
|
||||||
|
caught, note, hardErr := p.runOneMutation(c, c.Mutations[0], dir)
|
||||||
|
if !hardErr {
|
||||||
|
t.Fatal("★ old 不存在时报硬错误 —— 否则『变异无效』会被误判成『判据太松』(假警报)")
|
||||||
|
}
|
||||||
|
if caught {
|
||||||
|
t.Error("硬错误时不该算咬住")
|
||||||
|
}
|
||||||
|
if !strings.Contains(note, "找不到") {
|
||||||
|
t.Errorf("说明应指出找不到片段: %s", note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② old 出现多次
|
||||||
|
multi := filepath.Join(dir, "multi.go")
|
||||||
|
os.WriteFile(multi, []byte("package calc\n\nvar a = 1\nvar b = 1\n"), 0o644)
|
||||||
|
c.Mutations = []Mutation{{ID: "M2", File: "multi.go", Old: "= 1", New: "= 2"}}
|
||||||
|
_, note2, hardErr2 := p.runOneMutation(c, c.Mutations[0], dir)
|
||||||
|
if !hardErr2 {
|
||||||
|
t.Fatal("★ old 出现多次时报硬错误 —— 否则可能改错地方却报『咬住』(假通过)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(note2, "出现 2 次") {
|
||||||
|
t.Errorf("说明应指出出现次数: %s", note2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:恢复失败/内容不一致 ⇒ 硬错误(**响亮**,不静默继续)。
|
||||||
|
func TestMutationDetectsRestoreMismatch(t *testing.T) {
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
dir := writeRepo(t, false)
|
||||||
|
// 只读文件 ⇒ 写入变异会失败 ⇒ 硬错误
|
||||||
|
ro := filepath.Join(dir, "ro.go")
|
||||||
|
os.WriteFile(ro, []byte("package calc\n\nvar x = 1\n"), 0o444)
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root 下只读文件仍可写,跳过")
|
||||||
|
}
|
||||||
|
c := &Criteria{ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", Script: "true"}
|
||||||
|
m := Mutation{ID: "M1", File: "ro.go", Old: "= 1", New: "= 2"}
|
||||||
|
if _, _, hardErr := p.runOneMutation(c, m, dir); !hardErr {
|
||||||
|
t.Fatal("写不进去时报硬错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 真机端到端(真实 go test) ----------------
|
||||||
|
|
||||||
|
// ★★ 判据:**真实 go test** 下,正反两阶段的结果符合预期。
|
||||||
|
//
|
||||||
|
// 上面几条用真实 git/go 项目;这一条额外确认「Go 的**测试缓存**不会
|
||||||
|
// 让变异失效」—— 实测过:变异后若命中缓存,测试根本没跑,
|
||||||
|
// "报红"就无从谈起。实现里用 `-count=1` 规避。
|
||||||
|
func TestReviewDoesNotHitGoTestCache(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("go"); err != nil {
|
||||||
|
t.Skip("环境无 go")
|
||||||
|
}
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
dir := writeRepo(t, false)
|
||||||
|
|
||||||
|
// 先跑一次让缓存热起来
|
||||||
|
code, _ := runShell(dir, "go test -count=1 ./...", 120)
|
||||||
|
if code != 0 {
|
||||||
|
t.Skipf("前置 go test 未通过(环境问题),跳过: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
|
||||||
|
p.ledger.Criteria["cr-1"] = &Criteria{
|
||||||
|
ID: "cr-1", ProjectID: "probe", Name: "严判据", Kind: "unit",
|
||||||
|
Script: "go test -count=1 ./...",
|
||||||
|
Mutations: []Mutation{{
|
||||||
|
ID: "M1", File: "calc.go",
|
||||||
|
Old: "\treturn b\n}", New: "\treturn a\n}",
|
||||||
|
Note: "b>a 分支",
|
||||||
|
}},
|
||||||
|
DefHash: "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.reviewAll("br-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res["mutate_ok"].(bool) {
|
||||||
|
ev := ""
|
||||||
|
if rep, ok := res["report"].([]map[string]interface{}); ok && len(rep) > 0 {
|
||||||
|
ev = str(rep[0], "mutate_evidence")
|
||||||
|
}
|
||||||
|
t.Fatalf("★ 变异未被咬住 —— 可能是 Go 测试缓存导致变异后测试没真跑\n%s", ev)
|
||||||
|
}
|
||||||
|
t.Log("✅ 真实 go test 下变异被正确咬住(未受缓存影响)")
|
||||||
|
}
|
||||||
1
example/commondev/e2e-agentdir/auth.json
Normal file
1
example/commondev/e2e-agentdir/auth.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
example/commondev/e2e-agentdir/models-store.json
Normal file
1
example/commondev/e2e-agentdir/models-store.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
177
example/commondev/e2e_criteria_test.go
Normal file
177
example/commondev/e2e_criteria_test.go
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ★★ 真机端到端:**真实项目 + 真实 go test**,验证刀 4 能识破
|
||||||
|
// 「真 bug 藏在松判据后面」。
|
||||||
|
//
|
||||||
|
// 场景(与 /tmp/d4e2e 手工复现的一致):
|
||||||
|
//
|
||||||
|
// Clamp(v,lo,hi) 有三个分支;判据只测了 v<lo
|
||||||
|
// 代码里删掉 v>hi 分支(真 bug)⇒ 松判据**仍全绿**
|
||||||
|
//
|
||||||
|
// 复核必须给出 **pass_with_gaps** 而不是 pass,并说明"变异未被咬住"。
|
||||||
|
func TestE2E_ReviewCatchesHiddenBugBehindLooseCriteria(t *testing.T) {
|
||||||
|
if _, err := exec.Command("go", "version").Output(); err != nil {
|
||||||
|
t.Skip("环境无 go")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
write := func(rel, c string) {
|
||||||
|
p := filepath.Join(dir, rel)
|
||||||
|
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||||
|
if err := os.WriteFile(p, []byte(c), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("go.mod", "module d4demo\n\ngo 1.21\n")
|
||||||
|
write("calc.go", `package d4demo
|
||||||
|
|
||||||
|
func Clamp(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
// ★ 松判据:只测下界
|
||||||
|
write("calc_test.go", `package d4demo
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClampLoose(t *testing.T) {
|
||||||
|
if Clamp(-5, 0, 10) != 0 {
|
||||||
|
t.Fatal("低于下界时错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
if err := exec.Command("git", "init", "-q", "-b", "main", dir).Run(); err != nil {
|
||||||
|
t.Skipf("git init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
p.ledger.Projects["demo"] = &Project{Name: "demo", RemoteURL: "x"}
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{
|
||||||
|
ID: "br-1", ProjectID: "demo", Name: "feat/x", Workdir: dir, Status: "open"}
|
||||||
|
|
||||||
|
// 主管定义判据:正向用松判据;变异**删掉 v>hi 分支**
|
||||||
|
p.ledger.Criteria["cr-clamp"] = &Criteria{
|
||||||
|
ID: "cr-clamp", ProjectID: "demo", Name: "clamp 上下界", Kind: "unit",
|
||||||
|
Script: "go test -count=1 ./...",
|
||||||
|
Expect: "上下界都被限制",
|
||||||
|
Mutations: []Mutation{{
|
||||||
|
ID: "M1", File: "calc.go",
|
||||||
|
Old: " if v > hi {\n return hi\n }\n", New: "",
|
||||||
|
Note: "删掉上界分支(真 bug)——松判据看不见它",
|
||||||
|
}},
|
||||||
|
DefHash: "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.reviewAll("br-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
verdict := res["verdict"].(string)
|
||||||
|
|
||||||
|
// ① 正向必须**通过**(松判据确实能过 —— 这就是问题所在)
|
||||||
|
if !res["forward_ok"].(bool) {
|
||||||
|
t.Fatalf("前提失效:松判据正向应通过。report=%v", res["report"])
|
||||||
|
}
|
||||||
|
// ② 变异必须**未被咬住**(真 bug 躲在判据盲区里)
|
||||||
|
if res["mutate_ok"].(bool) {
|
||||||
|
t.Fatal("★ 删掉整个上界分支都没被咬住却报 mutate_ok=true —— 反向阶段失效")
|
||||||
|
}
|
||||||
|
// ③ 结论绝不能被当成 "pass"
|
||||||
|
if verdict == "pass" {
|
||||||
|
t.Fatal("★★ 真 bug + 松判据 ⇒ 给了 pass。这正是刀 4 存在的理由")
|
||||||
|
}
|
||||||
|
if verdict != "pass_with_gaps" {
|
||||||
|
t.Errorf("应为 pass_with_gaps,实际 %q", verdict)
|
||||||
|
}
|
||||||
|
// ④ 报告里要能看出是**哪条**变异没被咬住
|
||||||
|
rep, _ := res["report"].([]map[string]interface{})
|
||||||
|
if len(rep) == 0 {
|
||||||
|
t.Fatal("报告为空")
|
||||||
|
}
|
||||||
|
ev := str(rep[0], "mutate_evidence")
|
||||||
|
if !strings.Contains(ev, "未被咬住") {
|
||||||
|
t.Errorf("mutate_evidence 应指出未被咬住,实际: %s", ev)
|
||||||
|
}
|
||||||
|
// ⑤ 工作目录必须**已恢复**(变异不留脏文件)
|
||||||
|
after, _ := os.ReadFile(filepath.Join(dir, "calc.go"))
|
||||||
|
if !strings.Contains(string(after), "if v > hi {") {
|
||||||
|
t.Fatal("★ 变异后未恢复 calc.go —— 工作目录被污染")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✅ 真机识破「真 bug 藏在松判据后」: verdict=%s", verdict)
|
||||||
|
t.Logf(" notes=%v", res["notes"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 对照:**补一条严判据**后,同一个 bug 会被正向阶段直接抓到。
|
||||||
|
func TestE2E_TightCriteriaCatchesTheBugDirectly(t *testing.T) {
|
||||||
|
if _, err := exec.Command("go", "version").Output(); err != nil {
|
||||||
|
t.Skip("环境无 go")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
write := func(rel, c string) {
|
||||||
|
p := filepath.Join(dir, rel)
|
||||||
|
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||||
|
os.WriteFile(p, []byte(c), 0o644)
|
||||||
|
}
|
||||||
|
write("go.mod", "module d4demo2\n\ngo 1.21\n")
|
||||||
|
// ★ 这个项目里 bug **已经存在**(上界分支缺失)
|
||||||
|
write("calc.go", `package d4demo2
|
||||||
|
|
||||||
|
func Clamp(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
// 严判据:上下界都测
|
||||||
|
write("calc_test.go", `package d4demo2
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClampTight(t *testing.T) {
|
||||||
|
if Clamp(-5, 0, 10) != 0 {
|
||||||
|
t.Fatal("下界错")
|
||||||
|
}
|
||||||
|
if Clamp(15, 0, 10) != 10 {
|
||||||
|
t.Fatal("上界错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
exec.Command("git", "init", "-q", "-b", "main", dir).Run()
|
||||||
|
|
||||||
|
p := newTestPluginForCriteria(t)
|
||||||
|
p.ledger.Projects["demo2"] = &Project{Name: "demo2", RemoteURL: "x"}
|
||||||
|
p.ledger.Branches["br-1"] = &Branch{
|
||||||
|
ID: "br-1", ProjectID: "demo2", Name: "feat/x", Workdir: dir, Status: "open"}
|
||||||
|
p.ledger.Criteria["cr-1"] = &Criteria{
|
||||||
|
ID: "cr-1", ProjectID: "demo2", Name: "clamp 上下界", Kind: "unit",
|
||||||
|
Script: "go test -count=1 ./...",
|
||||||
|
Mutations: []Mutation{{ID: "M1", File: "calc.go",
|
||||||
|
Old: "return v\n}", New: "return lo\n}"}},
|
||||||
|
DefHash: "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.reviewAll("br-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 正向就抓到 bug ⇒ fail
|
||||||
|
if res["verdict"] != "fail" {
|
||||||
|
t.Fatalf("严判据应正向就抓到 bug(fail),实际 %q", res["verdict"])
|
||||||
|
}
|
||||||
|
t.Log("✅ 对照:严判据在正向阶段直接抓到同一个 bug → fail")
|
||||||
|
}
|
||||||
429
example/commondev/encshell.go
Normal file
429
example/commondev/encshell.go
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 加密 shell:agent 用特殊格式让插件为它**填充解密后的凭据**,再执行命令。
|
||||||
|
//
|
||||||
|
// ## 用户的要求(2026-10-14)
|
||||||
|
//
|
||||||
|
// 「插件为 agent 额外提供一个加密 shell 工具,agent 可以通过特殊的格式
|
||||||
|
// 让该插件工具为其填充加密数据」
|
||||||
|
//
|
||||||
|
// 即一个 **credential broker**:secret 加密存盘、agent 只写**引用**,
|
||||||
|
// 明文只在插件的执行边界上短暂存在。
|
||||||
|
//
|
||||||
|
// ## 三条实测得出的实现纪律(不是设计推测)
|
||||||
|
//
|
||||||
|
// ### ① secret 绝不进 argv —— 脚本必须走 **stdin**
|
||||||
|
//
|
||||||
|
// 实测(扫全系统 /proc/*/cmdline):
|
||||||
|
//
|
||||||
|
// 明文作为 argv 参数 → ★ 泄露(任意同机进程可读)
|
||||||
|
// sh -c "…{secret}…" → ★ 泄露(脚本本身就是 argv)
|
||||||
|
// **脚本经 stdin 喂给 bash** → ✅ 不泄露
|
||||||
|
//
|
||||||
|
// ⇒ 实现上:`bash` 的 argv 只有 `["bash"]`,脚本体经 stdin 管道送入。
|
||||||
|
// 这条不能妥协:`ps -eo args` 是任何本机进程都能跑的。
|
||||||
|
//
|
||||||
|
// ### ② 输出必须脱敏 —— 否则明文经 stdout 进模型上下文
|
||||||
|
//
|
||||||
|
// 实测:`echo "token is $T"` 的输出会原样回传。而工具返回值**会进模型上下文**,
|
||||||
|
// 这是最危险的泄露面。⇒ 输出回传前必须把已知 secret 值替换掉。
|
||||||
|
//
|
||||||
|
// ### 这两条只防**意外**,不防**恶意**
|
||||||
|
//
|
||||||
|
// 必须诚实:agent 可以 `echo $TOKEN | base64` 或 `xxd` 绕过字符串脱敏。
|
||||||
|
// 所以本工具**不是沙箱**,其安全性来自:
|
||||||
|
// · secret 不进 argv(防同机窥探)
|
||||||
|
// · 输出脱敏(防意外回显进上下文)
|
||||||
|
// · **不防恶意 exfiltration** —— 要防那个必须限制能执行的命令集合。
|
||||||
|
// 这条边界写进工具描述,不假装安全。
|
||||||
|
//
|
||||||
|
// ## 特殊格式
|
||||||
|
//
|
||||||
|
// 命令里用 `@{name}` 引用一条凭据(如 `@{gitea-local}`),插件在冻结的脚本里
|
||||||
|
// 把它替换为解密后的明文。为什么用 `@{...}` 而不是 `${...}`:
|
||||||
|
// · `${...}` 与 shell 自己的变量语法冲突,agent 很容易误用;
|
||||||
|
// · `@{...}` 不会与任何常见 shell 语法撞车,拼错时行为可预期。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// credRefPattern 匹配命令里的凭据引用 @{name}。
|
||||||
|
var credRefPattern = regexp.MustCompile(`@\{([A-Za-z0-9_.-]{1,64})\}`)
|
||||||
|
|
||||||
|
// ---------------- 加密存储 ----------------
|
||||||
|
//
|
||||||
|
// 为何要"加密":凭据文件虽是 0600,但它在插件的**数据目录**里,会被备份、
|
||||||
|
// 会被 `tar` 打包、可能随日志外带。加密后即使文件外泄,没有密钥也不可用。
|
||||||
|
//
|
||||||
|
// 密钥从哪来(**不落盘**):
|
||||||
|
// 1. `${COMMONDEV_CRED_KEY}` 环境变量(推荐:systemd 注入)
|
||||||
|
// 2. `<data>/master.key`(0600,自动生成)—— 便利与安全之间取后者弱一档,
|
||||||
|
// 但它至少与凭据文件**不同位置**,且本插件把整个数据目录权限视为 0700。
|
||||||
|
//
|
||||||
|
// 铁律:**没有密钥 ⇒ 响亮报错**,绝不静默退回明文存盘。
|
||||||
|
|
||||||
|
const masterKeyEnv = "COMMONDEV_CRED_KEY"
|
||||||
|
|
||||||
|
// masterKey 返回 32 字节 AES-256 密钥。
|
||||||
|
func (p *Plugin) masterKey() ([]byte, error) {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(masterKeyEnv)); v != "" {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(v)
|
||||||
|
if err != nil || len(b) != 32 {
|
||||||
|
// 也接受原始 32 字节字符串(方便人手工设)
|
||||||
|
if len(v) == 32 {
|
||||||
|
return []byte(v), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s 必须是 base64 编码的 32 字节密钥(或正好 32 字符)", masterKeyEnv)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
// 回退:数据目录下的 master.key
|
||||||
|
path := filepath.Join(filepath.Dir(p.path), "master.key")
|
||||||
|
if b, err := os.ReadFile(path); err == nil && len(b) >= 32 {
|
||||||
|
return b[:32], nil
|
||||||
|
}
|
||||||
|
// 首次:生成并落盘 0600
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, key, 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = os.Chmod(path, 0o600)
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sealSecret 用 AES-256-GCM 加密明文,返回 base64(nonce 前置)。
|
||||||
|
func sealSecret(key []byte, plain string) (string, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ct := gcm.Seal(nonce, nonce, []byte(plain), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(ct), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSecret 解密 sealSecret 的产物。
|
||||||
|
func openSecret(key []byte, enc string) (string, error) {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(enc)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("密文不是合法 base64: %w", err)
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(raw) < gcm.NonceSize() {
|
||||||
|
return "", fmt.Errorf("密文太短")
|
||||||
|
}
|
||||||
|
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||||
|
plain, err := gcm.Open(nil, nonce, ct, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解密失败(密钥不对或数据损坏): %w", err)
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sealAccounts 把所有明文凭据加密后落盘。
|
||||||
|
//
|
||||||
|
// 存储形态(credentials.json):
|
||||||
|
//
|
||||||
|
// { "accounts": { "<name>": { "sealed": "<base64>", ... } } }
|
||||||
|
//
|
||||||
|
// 兼容:旧版明文 `token` 字段仍能读(并在下次 save 时自动转为 sealed)。
|
||||||
|
func (cs *credStore) sealAllLocked() error {
|
||||||
|
key, err := cs.keyProvider()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, a := range cs.data.Accounts {
|
||||||
|
if a.Sealed != "" {
|
||||||
|
continue // 已加密
|
||||||
|
}
|
||||||
|
plain, err := resolveSecret(a.Token) // 可能是 ${ENV}
|
||||||
|
if err != nil {
|
||||||
|
// 引用解析不了:**保留原样不加密**,等使用者修好环境再加密
|
||||||
|
// (这里不报错是刻意的:否则一个未设的环境变量会让整份凭据无法保存)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enc, err := sealSecret(key, plain)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.Sealed = enc
|
||||||
|
a.Token = "" // ★ 明文立即从内存结构里清掉
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- shell 执行 ----------------
|
||||||
|
|
||||||
|
// shellRequest 是一次加密 shell 调用的参数。
|
||||||
|
type shellRequest struct {
|
||||||
|
Command string
|
||||||
|
Workdir string
|
||||||
|
Timeout time.Duration
|
||||||
|
Env map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellResult 是执行结果。
|
||||||
|
type shellResult struct {
|
||||||
|
Output string
|
||||||
|
ExitCode int
|
||||||
|
Redacted int // 脱敏替换发生次数(可观测:非 0 说明命令试图回显凭据)
|
||||||
|
Err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEncryptedShell 执行一条带凭据填充的命令。
|
||||||
|
//
|
||||||
|
// 流程(每一步都对应上面的一条纪律):
|
||||||
|
//
|
||||||
|
// ① 扫命令里的 @{name} 引用 → 解密取值
|
||||||
|
// ② 把明文填进**脚本体**(不是 argv)
|
||||||
|
// ③ 脚本经 **stdin** 喂给 bash(argv 只有 "bash")
|
||||||
|
// ④ 收集输出 → **脱敏**(把已知 secret 换成 ***)
|
||||||
|
// ⑤ 擦除内存里的明文副本
|
||||||
|
func (p *Plugin) runEncryptedShell(req shellRequest) shellResult {
|
||||||
|
res := shellResult{}
|
||||||
|
|
||||||
|
script := req.Command
|
||||||
|
// ① 收集引用
|
||||||
|
refs := credRefPattern.FindAllStringSubmatch(script, -1)
|
||||||
|
secrets := map[string]string{} // 明文值 → 用于脱敏
|
||||||
|
var missing []string
|
||||||
|
|
||||||
|
p.creds.mu.Lock()
|
||||||
|
key, kerr := p.creds.keyProvider()
|
||||||
|
p.creds.mu.Unlock()
|
||||||
|
if kerr != nil {
|
||||||
|
res.Err = "无法取得凭据密钥: " + kerr.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集本次要用到的明文(执行完必须擦)
|
||||||
|
var plaintexts []string
|
||||||
|
defer func() {
|
||||||
|
// ⑤ 尽力擦除(Go 里字符串不可变,这里擦可写的副本)
|
||||||
|
for i := range plaintexts {
|
||||||
|
plaintexts[i] = strings.Repeat("\x00", len(plaintexts[i]))
|
||||||
|
}
|
||||||
|
for k := range secrets {
|
||||||
|
delete(secrets, k)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 解密由 lookupAnySecret 负责(它会自己取锁);这里不再直接持有 accounts。
|
||||||
|
|
||||||
|
for _, m := range refs {
|
||||||
|
name := m[1]
|
||||||
|
// ★ 统一查找:先在加密信息库(enc_store 存的),再回退到平台凭据。
|
||||||
|
// 这样 @{gitea-local}(平台)与 @{gh-token}(通用 KV)在同一个 shell 里都能用。
|
||||||
|
plain, found, err := p.lookupAnySecret(name, key)
|
||||||
|
if err != nil {
|
||||||
|
res.Err = fmt.Sprintf("加密信息 %s 解密失败: %v", name, err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
missing = append(missing, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plain == "" {
|
||||||
|
res.Err = fmt.Sprintf("加密信息 %s 解析为空", name)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
script = strings.ReplaceAll(script, m[0], plain)
|
||||||
|
secrets[plain] = name
|
||||||
|
plaintexts = append(plaintexts, plain)
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
res.Err = fmt.Sprintf(
|
||||||
|
"命令引用了不存在的别名: %s(用 enc_list 看加密信息、commondev_account_list 看平台凭据)",
|
||||||
|
strings.Join(missing, ", "))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时保护
|
||||||
|
timeout := req.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 50 * time.Second // ★ 留在内核 60s 工具上限之内
|
||||||
|
} else if timeout > 55*time.Second {
|
||||||
|
timeout = 55 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// ③ argv 只有 bash —— 脚本走 stdin
|
||||||
|
cmd := exec.CommandContext(ctx, "bash")
|
||||||
|
cmd.Dir = req.Workdir
|
||||||
|
// ★ 不把 secret 放进 env(env 可被 /proc/PID/environ 读)
|
||||||
|
cmd.Env = append(os.Environ(), "COMMONDEV_ENCRYPTED_SHELL=1")
|
||||||
|
for k, v := range req.Env {
|
||||||
|
cmd.Env = append(cmd.Env, k+"="+v)
|
||||||
|
}
|
||||||
|
cmd.Stdin = strings.NewReader(script)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.Stdout = &buf
|
||||||
|
cmd.Stderr = &buf
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
// ④ 脱敏
|
||||||
|
out, n := redactSecrets(buf.String(), secrets)
|
||||||
|
res.Output = out
|
||||||
|
res.Redacted = n
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if ee, ok := err.(*exec.ExitError); ok {
|
||||||
|
res.ExitCode = ee.ExitCode()
|
||||||
|
} else if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
res.Err = fmt.Sprintf("执行超时(%s)", timeout)
|
||||||
|
} else {
|
||||||
|
res.Err = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactSecrets 把输出里的已知 secret 值替换掉。
|
||||||
|
//
|
||||||
|
// 返回替换次数:非 0 说明命令**试图回显凭据** —— 这是可观测信号,
|
||||||
|
// 应让使用者知道(而不是静默替换了事)。
|
||||||
|
func redactSecrets(s string, secrets map[string]string) (string, int) {
|
||||||
|
n := 0
|
||||||
|
// 长值优先替换:避免短 secret 是长 secret 子串时替换不干净
|
||||||
|
vals := make([]string, 0, len(secrets))
|
||||||
|
for v := range secrets {
|
||||||
|
if len(v) >= 4 { // 太短的值不做替换(会把正常文本打烂)
|
||||||
|
vals = append(vals, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(vals, func(i, j int) bool { return len(vals[i]) > len(vals[j]) })
|
||||||
|
for _, v := range vals {
|
||||||
|
c := strings.Count(s, v)
|
||||||
|
if c > 0 {
|
||||||
|
s = strings.ReplaceAll(s, v, "***REDACTED***")
|
||||||
|
n += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, n
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptAccount 取一条凭据的明文(sealed 优先,兼容旧明文/变量引用)。
|
||||||
|
func (p *Plugin) decryptAccount(a *Account, key []byte) (string, error) {
|
||||||
|
if a == nil {
|
||||||
|
return "", fmt.Errorf("凭据不存在")
|
||||||
|
}
|
||||||
|
if a.Sealed != "" {
|
||||||
|
return openSecret(key, a.Sealed)
|
||||||
|
}
|
||||||
|
// 兼容路径:尚为明文或 ${ENV} 引用
|
||||||
|
return resolveSecret(a.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 工具实现 ----------------
|
||||||
|
|
||||||
|
func (p *Plugin) handleEncryptedShell(args map[string]interface{}) (interface{}, error) {
|
||||||
|
command := str(args, "command")
|
||||||
|
if strings.TrimSpace(command) == "" {
|
||||||
|
return map[string]interface{}{"error": "command 必填"}, nil
|
||||||
|
}
|
||||||
|
workdir := strings.TrimSpace(str(args, "workdir"))
|
||||||
|
timeoutSec := 0
|
||||||
|
if v, ok := args["timeout_sec"].(float64); ok {
|
||||||
|
timeoutSec = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// workdir 必须在某个工程对话的工作目录里(防止用它去读任意目录)
|
||||||
|
if workdir != "" {
|
||||||
|
abs, err := filepath.Abs(workdir)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
if !p.isInsideProgramRoot(abs) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "workdir 必须在工程工作目录下(program/);" +
|
||||||
|
"给相对路径或省略以使用当前工作目录",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
workdir = abs
|
||||||
|
}
|
||||||
|
|
||||||
|
res := p.runEncryptedShell(shellRequest{
|
||||||
|
Command: command,
|
||||||
|
Workdir: workdir,
|
||||||
|
Timeout: time.Duration(timeoutSec) * time.Second,
|
||||||
|
})
|
||||||
|
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"output": truncate(res.Output, 6000),
|
||||||
|
"exit_code": res.ExitCode,
|
||||||
|
}
|
||||||
|
if res.Err != "" {
|
||||||
|
out["error"] = res.Err
|
||||||
|
}
|
||||||
|
if res.Redacted > 0 {
|
||||||
|
out["redacted_count"] = res.Redacted
|
||||||
|
out["warning"] = fmt.Sprintf(
|
||||||
|
"输出里有 %d 处凭据明文,已替换为 ***REDACTED***。命令不应回显凭据。",
|
||||||
|
res.Redacted)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isInsideProgramRoot 判断路径是否落在 program/ 下。
|
||||||
|
func (p *Plugin) isInsideProgramRoot(abs string) bool {
|
||||||
|
root, err := filepath.Abs(p.programRoot())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, abs)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
// listCredRefs 返回命令里引用到的凭据名(供工具描述/调试)。
|
||||||
|
func listCredRefs(cmd string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, m := range credRefPattern.FindAllStringSubmatch(cmd, -1) {
|
||||||
|
out = append(out, m[1])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureJSON 小工具(保证 json 包被使用,避免不同构建标签下的未用导入)
|
||||||
|
var _ = json.Marshal
|
||||||
243
example/commondev/encshell_test.go
Normal file
243
example/commondev/encshell_test.go
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ★ 判据:加解密往返。
|
||||||
|
func TestSealOpenRoundTrip(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i)
|
||||||
|
}
|
||||||
|
for _, plain := range []string{"token-abc", "含中文的凭据", strings.Repeat("x", 500)} {
|
||||||
|
enc, err := sealSecret(key, plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seal: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(enc, plain) {
|
||||||
|
t.Fatal("密文里出现了明文")
|
||||||
|
}
|
||||||
|
got, err := openSecret(key, enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
if got != plain {
|
||||||
|
t.Errorf("往返不一致: %q → %q", plain, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 密钥不对 ⇒ 必须报错(GCM 认证失败)
|
||||||
|
bad := make([]byte, 32)
|
||||||
|
enc, _ := sealSecret(key, "x-secret-value")
|
||||||
|
if _, err := openSecret(bad, enc); err == nil {
|
||||||
|
t.Fatal("错误密钥必须解密失败")
|
||||||
|
}
|
||||||
|
// 每次加密的密文不同(nonce 随机)
|
||||||
|
e1, _ := sealSecret(key, "same")
|
||||||
|
e2, _ := sealSecret(key, "same")
|
||||||
|
if e1 == e2 {
|
||||||
|
t.Error("相同明文两次加密应产生不同密文(nonce 必须随机)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:明文绝不进子进程 argv。
|
||||||
|
//
|
||||||
|
// 观测方法:让被测命令把**自己的 /proc/$$/cmdline 写到文件** ——
|
||||||
|
// 写文件**不经过输出脱敏**,所以这是干净的观测。
|
||||||
|
//
|
||||||
|
// 为何不用「扫全系统 /proc」:那会把 go test 自己的工具链进程也扫进来
|
||||||
|
// (它们的命令行含测试源码路径与 secret 字面量),产生误报 —— 试验了三次都是这个原因。
|
||||||
|
// 也不靠「命令打印 cmdline 再断言输出」:脱敏会把泄露的明文替换成 ***REDACTED***,
|
||||||
|
// 于是断言看不到明文、判据假绿(变异实验抓到过这个洞)。
|
||||||
|
func TestArgvNeverContainsSecret(t *testing.T) {
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
const secret = "ARGV-CANARY-9911"
|
||||||
|
addSealedAccount(t, p, "canary", secret)
|
||||||
|
|
||||||
|
out := t.TempDir() + "/cmdline.txt"
|
||||||
|
os.Remove(out)
|
||||||
|
res := p.runEncryptedShell(shellRequest{
|
||||||
|
Command: "echo @{canary} >/dev/null; cat /proc/$$/cmdline > " + out,
|
||||||
|
})
|
||||||
|
if res.Err != "" {
|
||||||
|
t.Fatalf("执行失败: %s", res.Err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("未读到 cmdline 文件: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), secret) {
|
||||||
|
t.Fatalf("明文进了 argv: %q", string(b))
|
||||||
|
}
|
||||||
|
// 且脚本确实执行了(防“改成 argv 后静默失效”)
|
||||||
|
if res.Output != "" && !strings.Contains(res.Output, "") {
|
||||||
|
t.Fatal("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充确实生效(不回显,只验变量可用)。
|
||||||
|
func TestCredentialFillingWorks(t *testing.T) {
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
addSealedAccount(t, p, "canary", "some-secret-value-xyz")
|
||||||
|
res := p.runEncryptedShell(shellRequest{
|
||||||
|
Command: `test -n "@{canary}" && echo FILLED`,
|
||||||
|
})
|
||||||
|
if res.Err != "" || !strings.Contains(res.Output, "FILLED") {
|
||||||
|
t.Fatalf("填充未生效: out=%q err=%q", res.Output, res.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 核心判据:输出脱敏。
|
||||||
|
//
|
||||||
|
// 命令回显凭据时,回传内容必须已被替换,且要有 redacted 计数(可观测)。
|
||||||
|
func TestEncryptedShellRedactsOutput(t *testing.T) {
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
const secret = "OUTPUT-LEAK-CANARY-77a3"
|
||||||
|
addSealedAccount(t, p, "canary", secret)
|
||||||
|
|
||||||
|
res := p.runEncryptedShell(shellRequest{
|
||||||
|
Command: "echo the-token-is @{canary}",
|
||||||
|
})
|
||||||
|
if strings.Contains(res.Output, secret) {
|
||||||
|
t.Fatalf("输出未脱敏,明文被回传: %q", res.Output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Output, "REDACTED") {
|
||||||
|
t.Errorf("应留下脱敏标记: %q", res.Output)
|
||||||
|
}
|
||||||
|
if res.Redacted == 0 {
|
||||||
|
t.Error("redacted 计数应 > 0(这是可观测信号:命令试图回显凭据)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引用不存在的凭据 ⇒ 响亮报错(不静默留空)。
|
||||||
|
func TestEncryptedShellRejectsUnknownRef(t *testing.T) {
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
res := p.runEncryptedShell(shellRequest{Command: "echo @{no-such-account}"})
|
||||||
|
if res.Err == "" {
|
||||||
|
t.Fatal("引用不存在的凭据必须报错")
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Err, "不存在") {
|
||||||
|
t.Errorf("错误信息应指明凭据不存在: %s", res.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 凭据引用语法:@{name}(不与 shell 的 ${...} 冲突)。
|
||||||
|
func TestCredRefPattern(t *testing.T) {
|
||||||
|
got := listCredRefs("git push https://@{gitea-local}@h/o/r.git && echo @{gh} @{missing}")
|
||||||
|
want := []string{"gitea-local", "gh", "missing"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("解析到 %v,期望 %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("第 %d 个引用: got %q want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ${...} 是 shell 变量,**不该**被当作凭据引用
|
||||||
|
if refs := listCredRefs("echo ${HOME}"); len(refs) != 0 {
|
||||||
|
t.Errorf("${HOME} 不应被当作凭据引用: %v", refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:workdir 必须限制在 program/ 下(防用它去读任意目录)。
|
||||||
|
func TestShellWorkdirMustBeInsideProgramRoot(t *testing.T) {
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
out, err := p.handleEncryptedShell(map[string]interface{}{
|
||||||
|
"command": "pwd",
|
||||||
|
"workdir": "/etc", // 明显在 program/ 之外
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]interface{})
|
||||||
|
if _, bad := m["error"]; !bad {
|
||||||
|
t.Fatalf("program/ 之外的 workdir 应被拒: %v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 测试辅助 ----------------
|
||||||
|
|
||||||
|
func newTestPluginForShell(t *testing.T) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := &Plugin{name: "commondev", path: filepath.Join(dir, "ledger.json")}
|
||||||
|
cs, err := newCredStore(filepath.Join(dir, "credentials.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cs.keyProvider = p.masterKey // 走真实密钥路径(环境变量优先/回退文件)
|
||||||
|
p.creds = cs
|
||||||
|
// ★ 装一个最小 SDK:生产里 p.sdk 永不为 nil,判据若在没有它的情况下
|
||||||
|
// 调用 handler,会因 p.sdk.Settings() 解引用 nil 而 panic —— 那是
|
||||||
|
// **判据的缺陷**(测的是不存在的状态),不是被测代码的缺陷。
|
||||||
|
p.sdk = sdk.New("commondev", newStubSettings(dir), nil, nil, nil, nil)
|
||||||
|
p.ledger = &Ledger{
|
||||||
|
Projects: map[string]*Project{}, Branches: map[string]*Branch{}, Jobs: map[string]*Job{},
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubSettings 是最小 SettingsAPI 替身(只支持判据用到的路径)。
|
||||||
|
type stubSettings struct {
|
||||||
|
dir string
|
||||||
|
vals map[string]interface{} // 需要旧全局键的判据会填它
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStubSettings(dir string) *stubSettings { return &stubSettings{dir: dir} }
|
||||||
|
|
||||||
|
// newStubSettingsWith 造一个带指定键值的 stub(供端点表判据验证旧键兼容)。
|
||||||
|
func newStubSettingsWith(dir string, vals map[string]interface{}) *stubSettings {
|
||||||
|
return &stubSettings{dir: dir, vals: vals}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubSettings) DataDir() string { return s.dir }
|
||||||
|
func (s *stubSettings) Get(key string) (interface{}, error) {
|
||||||
|
if s.vals != nil {
|
||||||
|
if v, ok := s.vals[key]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (s *stubSettings) Set(key string, value interface{}) error { return nil }
|
||||||
|
func (s *stubSettings) SetSecret(key string, v interface{}) error { return nil }
|
||||||
|
func (s *stubSettings) GetSecret(key string) (string, error) { return "", nil }
|
||||||
|
func (s *stubSettings) List(prefix string) ([]string, error) { return nil, nil }
|
||||||
|
func (s *stubSettings) GetCore(key string) (interface{}, error) { return nil, nil }
|
||||||
|
func (s *stubSettings) SetCore(key string, v interface{}) error { return nil }
|
||||||
|
func (s *stubSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
|
||||||
|
func (s *stubSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil }
|
||||||
|
func (s *stubSettings) SetPlugin(p, k string, v interface{}) error { return nil }
|
||||||
|
func (s *stubSettings) ListPlugin(p, prefix string) ([]string, error) { return nil, nil }
|
||||||
|
func (s *stubSettings) RegisterDef(def sdk.ConfigDef) {}
|
||||||
|
func (s *stubSettings) Defs(prefix string) []*sdk.ConfigDef { return nil }
|
||||||
|
func (s *stubSettings) Dump() map[string]interface{} { return map[string]interface{}{} }
|
||||||
|
func (s *stubSettings) Plugins() []string { return nil }
|
||||||
|
func (s *stubSettings) Remove(key string) error { return nil }
|
||||||
|
func (s *stubSettings) RemoveCore(key string) error { return nil }
|
||||||
|
func (s *stubSettings) RemovePlugin(p, k string) error { return nil }
|
||||||
|
func (s *stubSettings) IsSecret(key string) bool { return false }
|
||||||
|
|
||||||
|
func addSealedAccount(t *testing.T, p *Plugin, name, secret string) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := p.masterKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
enc, err := sealSecret(key, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.creds.mu.Lock()
|
||||||
|
p.creds.data.Accounts[name] = &Account{Name: name, Sealed: enc}
|
||||||
|
p.creds.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 master.key 落在临时目录(不污染真实数据目录)
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
408
example/commondev/encstore.go
Normal file
408
example/commondev/encstore.go
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 加密信息工具族(`enc_*`)—— ★ 值已**上交给内核变量库**。
|
||||||
|
//
|
||||||
|
// ## 去重背景(这次重构的原因)
|
||||||
|
//
|
||||||
|
// 本文件原先自带一整套加密存储:`secrets.json` + AES-256-GCM +
|
||||||
|
// 别名表 + 指纹,与内核的 `internal/security.Store` **完全重复**。
|
||||||
|
//
|
||||||
|
// 重复的真正代价不是代码量,而是**两套库互不可见**:
|
||||||
|
//
|
||||||
|
// · 内核拦截时登记的变量 → 插件看不到
|
||||||
|
// · 插件登记的 → 内核拦截不认识
|
||||||
|
// · 同一个凭据可能在两处各存一份,而使用者不知道哪个权威
|
||||||
|
//
|
||||||
|
// 现在值只存**一处**(内核单例),插件经 `sdk.Secrets()` 访问它。
|
||||||
|
// 两份判据钉住这个不变量:`TestE2E_PluginAndKernelShareOneVault`。
|
||||||
|
//
|
||||||
|
// ## ★ 为什么还留着这个文件与 `enc_*` 工具
|
||||||
|
//
|
||||||
|
// 因为插件有两样内核**没有**的能力,不能一起删掉:
|
||||||
|
//
|
||||||
|
// ① **可读名**:内核别名是 HMAC 派生的(`sec_0cb468e2…`),
|
||||||
|
// 这是"同明文自动合并"的前提(设计 S11),**不能改成用户自选**。
|
||||||
|
// 但人需要 `@{gitea-local}` 这种能读懂的名字 ⇒ 插件存一层
|
||||||
|
// **名字 → 内核别名**的映射。
|
||||||
|
// ★ 映射里**只有名字,没有值** —— 所以这个文件泄了也没用。
|
||||||
|
//
|
||||||
|
// ② **`${ENV}` / `@file:` 来源**:让明文**从不进会话**
|
||||||
|
// (工具参数里只有 `${GITEA_TOKEN}` 这样的引用)。
|
||||||
|
// 内核 `secret_submit` 目前只收直传明文(T10 待办)。
|
||||||
|
//
|
||||||
|
// ## 存储分工(一句话)
|
||||||
|
//
|
||||||
|
// 内核 —— 存值(加密)、派生别名、在调用插件前回填
|
||||||
|
// 插件 —— 存「可读名 → 别名」映射、解析来源、执行命令时的 argv 保护
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secretRef 是「可读名 → 内核别名」的一条映射。
|
||||||
|
//
|
||||||
|
// ★ 刻意**不含值**:值在内核变量库里,这里只有名字。
|
||||||
|
type secretRef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Alias string `json:"alias"` // 内核别名(sec_xxx)
|
||||||
|
Source string `json:"source"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretRefs 是映射集合(落盘,小文件,不含任何值)。
|
||||||
|
type secretRefs struct {
|
||||||
|
Refs map[string]*secretRef `json:"refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// refsPath 是映射文件路径。
|
||||||
|
//
|
||||||
|
// 与旧的 secrets.json 分开命名:**旧文件里是密文值**,新文件里只有名字。
|
||||||
|
// 不沿用同名可以避免"读旧文件时把密文当别名"这类混淆。
|
||||||
|
func (p *Plugin) refsPath() string {
|
||||||
|
return filepath.Join(filepath.Dir(p.path), "secret_names.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) loadSecretRefs() error {
|
||||||
|
p.refs = secretRefs{Refs: map[string]*secretRef{}}
|
||||||
|
b, err := os.ReadFile(p.refsPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil // 首次
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(p.refsPath()); err == nil {
|
||||||
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||||
|
return fmt.Errorf("映射文件权限不安全:%s 是 %04o,必须 0600", p.refsPath(), perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &p.refs); err != nil {
|
||||||
|
return fmt.Errorf("映射文件损坏(不静默重建): %w", err)
|
||||||
|
}
|
||||||
|
if p.refs.Refs == nil {
|
||||||
|
p.refs.Refs = map[string]*secretRef{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveSecretRefsLocked 落盘(调用方持锁)。
|
||||||
|
//
|
||||||
|
// ★ 0600:虽然不含值,但仍保持最紧权限 ——
|
||||||
|
// 名字本身可能透露"这台机器在管哪些凭据",对攻击者有情报价值。
|
||||||
|
func (p *Plugin) saveSecretRefsLocked() error {
|
||||||
|
b, err := json.MarshalIndent(p.refs, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p.refsPath()), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := p.refsPath() + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, p.refsPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSecretSource 解析 `value` 参数的三类来源。
|
||||||
|
//
|
||||||
|
// ${ENV_NAME} 环境变量引用 → 明文从不经工具参数(最安全)
|
||||||
|
// @file:/path 文件内容
|
||||||
|
// 其它 字面明文(会进会话记录,调用方要告警)
|
||||||
|
//
|
||||||
|
// ★ 这条能力内核没有(内核 secret_submit 只收直传明文),
|
||||||
|
// 所以它是插件**保留**这个文件的主要理由之一。
|
||||||
|
func resolveSecretSource(spec string) (plain, source string, err error) {
|
||||||
|
s := strings.TrimSpace(spec)
|
||||||
|
if s == "" {
|
||||||
|
return "", "", fmt.Errorf("值不能为空")
|
||||||
|
}
|
||||||
|
if varRefPattern.MatchString(s) {
|
||||||
|
v, err := resolveSecret(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return v, "env", nil
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(s, "@file:"); ok {
|
||||||
|
path := strings.TrimSpace(rest)
|
||||||
|
if path == "" {
|
||||||
|
return "", "", fmt.Errorf("@file: 后面要给路径")
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("读文件失败(%s): %w", path, err)
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(string(b)) // token 文件常带换行
|
||||||
|
if v == "" {
|
||||||
|
return "", "", fmt.Errorf("文件 %s 内容为空", path)
|
||||||
|
}
|
||||||
|
return v, "file", nil
|
||||||
|
}
|
||||||
|
return s, "literal", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 工具实现 ----------------
|
||||||
|
|
||||||
|
func (p *Plugin) handleEncStore(args map[string]interface{}) (interface{}, error) {
|
||||||
|
name := strings.TrimSpace(str(args, "alias"))
|
||||||
|
spec := str(args, "value")
|
||||||
|
if name == "" {
|
||||||
|
return map[string]interface{}{"error": "alias 必填"}, nil
|
||||||
|
}
|
||||||
|
if !validSecretName(name) {
|
||||||
|
return map[string]interface{}{"error": "别名只允许 [A-Za-z0-9_.-](它会进 @{别名} 引用)"}, nil
|
||||||
|
}
|
||||||
|
plain, source, err := resolveSecretSource(spec)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sec := p.secretsAPI()
|
||||||
|
if sec == nil || !sec.Available() {
|
||||||
|
// ★ 响亮失败:不静默降级成"存到别处"。插件以为存好了而实际没存,
|
||||||
|
// 比直接报错危险得多。
|
||||||
|
return map[string]interface{}{"error": "内核变量库不可用(core.security.enabled=false?)—— 拒绝以明文或私有文件降级存储"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 值交给内核(它负责加密落盘 + 派生别名)
|
||||||
|
alias, err := sec.Store(plain, strings.TrimSpace(str(args, "note")))
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": "登记进内核变量库失败: " + err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := nowRFC3339()
|
||||||
|
p.mu.Lock()
|
||||||
|
prev, existed := p.refs.Refs[name]
|
||||||
|
ref := &secretRef{
|
||||||
|
Name: name, Alias: alias, Source: source,
|
||||||
|
Note: strings.TrimSpace(str(args, "note")),
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if existed {
|
||||||
|
ref.CreatedAt = prev.CreatedAt
|
||||||
|
ref.UpdatedAt = now
|
||||||
|
}
|
||||||
|
p.refs.Refs[name] = ref
|
||||||
|
saveErr := p.saveSecretRefsLocked()
|
||||||
|
p.mu.Unlock()
|
||||||
|
if saveErr != nil {
|
||||||
|
return map[string]interface{}{"error": saveErr.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"alias": name, // 人用的可读名
|
||||||
|
"ref": alias, // 内核别名(自动合并的钥匙)
|
||||||
|
"source": source,
|
||||||
|
"usage": fmt.Sprintf("在命令里用 @{%s} 引用它(配 enc_shell)", name),
|
||||||
|
"note": "值存于**内核变量库**(进程级单例,全部插件与 agent 共享);" +
|
||||||
|
"本插件只保留「可读名 → 内核别名」映射,映射里没有值。",
|
||||||
|
}
|
||||||
|
if source == "literal" {
|
||||||
|
out["warning"] = "你直接传了明文:它会进入本次会话记录(工具调用是会话的一部分)。" +
|
||||||
|
"下次请优先用 ${ENV_NAME} 或 @file:/path —— 那两条路径明文不进会话。"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleEncList(args map[string]interface{}) (interface{}, error) {
|
||||||
|
sec := p.secretsAPI()
|
||||||
|
|
||||||
|
// 内核侧的元信息(别名 → 备注/命中)
|
||||||
|
kernelMeta := map[string]secretInfoView{}
|
||||||
|
if sec != nil && sec.Available() {
|
||||||
|
for _, in := range sec.List() {
|
||||||
|
kernelMeta[in.Alias] = secretInfoView{
|
||||||
|
Source: in.Source, Note: in.Note, Hits: in.Hits, At: in.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
type view struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
At string `json:"at"`
|
||||||
|
Present bool `json:"present_in_vault"`
|
||||||
|
}
|
||||||
|
out := []view{}
|
||||||
|
for _, r := range p.refs.Refs {
|
||||||
|
_, present := kernelMeta[r.Alias]
|
||||||
|
out = append(out, view{
|
||||||
|
Alias: r.Name, Ref: r.Alias, Source: r.Source, Note: r.Note,
|
||||||
|
At: r.UpdatedAt + r.CreatedAt, Present: present,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Alias < out[j].Alias })
|
||||||
|
|
||||||
|
res := map[string]interface{}{
|
||||||
|
"secrets": out, "count": len(out),
|
||||||
|
"vault_available": sec != nil && sec.Available(),
|
||||||
|
"note": "**不含值**(值在内核变量库里,且没有读取接口)。" +
|
||||||
|
"用 @{别名} 在 enc_shell 命令里引用。" +
|
||||||
|
"present_in_vault=false 表示该名字指向的内核变量已不存在(被删了?)。",
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretInfoView 是内核元信息的精简视图。
|
||||||
|
type secretInfoView struct {
|
||||||
|
Source string
|
||||||
|
Note string
|
||||||
|
Hits int
|
||||||
|
At string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEncShow 查看元信息(**不返回值本体**)。
|
||||||
|
//
|
||||||
|
// 为何不提供"取回明文"的工具:那等于给 agent 一个取 secret 的入口,
|
||||||
|
// 使前面所有"明文不进会话"的努力归零。要看值,就在命令里用 @{别名}。
|
||||||
|
func (p *Plugin) handleEncShow(args map[string]interface{}) (interface{}, error) {
|
||||||
|
name := strings.TrimSpace(str(args, "alias"))
|
||||||
|
p.mu.Lock()
|
||||||
|
r, ok := p.refs.Refs[name]
|
||||||
|
var ref secretRef
|
||||||
|
if ok {
|
||||||
|
ref = *r
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("别名 %s 不存在", name)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"alias": ref.Name, "ref": ref.Alias, "source": ref.Source,
|
||||||
|
"note": ref.Note, "created_at": ref.CreatedAt, "updated_at": ref.UpdatedAt,
|
||||||
|
"storage": "内核变量库(AES-256-GCM 落盘,密钥由内核管理)",
|
||||||
|
"hint": "值本体不提供读取接口(那会让明文回到会话里);" +
|
||||||
|
"请在 enc_shell 命令里用 @" + "{" + ref.Name + "} 引用。",
|
||||||
|
}
|
||||||
|
// 顺带报内核侧的命中计数(可观测:这东西被用了多少次)
|
||||||
|
if sec := p.secretsAPI(); sec != nil && sec.Available() {
|
||||||
|
for _, in := range sec.List() {
|
||||||
|
if in.Alias == ref.Alias {
|
||||||
|
out["hits"] = in.Hits
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleEncRm(args map[string]interface{}) (interface{}, error) {
|
||||||
|
name := strings.TrimSpace(str(args, "alias"))
|
||||||
|
p.mu.Lock()
|
||||||
|
ref, ok := p.refs.Refs[name]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("别名 %s 不存在", name)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 先删内核变量(值),再删映射 —— 反过来会留下无人引用的密文
|
||||||
|
warnings := []string{}
|
||||||
|
if sec := p.secretsAPI(); sec != nil && sec.Available() {
|
||||||
|
if err := sec.Forget(ref.Alias); err != nil {
|
||||||
|
// 映射仍要删(否则名单里留个死名字),但要如实告警
|
||||||
|
warnings = append(warnings,
|
||||||
|
fmt.Sprintf("内核变量 %s 删除失败(该密文可能仍在内核库里): %v", ref.Alias, err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warnings = append(warnings, "内核变量库不可用 —— 只删了映射,值可能仍在")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.refs.Refs, name)
|
||||||
|
saveErr := p.saveSecretRefsLocked()
|
||||||
|
p.mu.Unlock()
|
||||||
|
if saveErr != nil {
|
||||||
|
return map[string]interface{}{"error": saveErr.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]interface{}{"status": "ok", "removed": name, "ref": ref.Alias}
|
||||||
|
if len(warnings) > 0 {
|
||||||
|
out["warnings"] = warnings
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupAnySecret 供 enc_shell 使用:把可读名解析成明文。
|
||||||
|
//
|
||||||
|
// 解析顺序:
|
||||||
|
//
|
||||||
|
// ① 映射表里的名字 → 拿到内核别名 → 请内核 Fill 出明文
|
||||||
|
// ② 回退到**平台凭据**(credentials.json)—— 那些不是内核变量,
|
||||||
|
// 有平台/角色语义,仍由插件自己保管
|
||||||
|
//
|
||||||
|
// 为何要回退:平台凭据(gitea-local 等)本来就是"带平台语义的凭据",
|
||||||
|
// 让 @{gitea-local} 在同一个 shell 里可用,避免 agent 在两套命名间切换。
|
||||||
|
func (p *Plugin) lookupAnySecret(name string, key []byte) (string, bool, error) {
|
||||||
|
// ① 先看映射表
|
||||||
|
p.mu.Lock()
|
||||||
|
ref, ok := p.refs.Refs[name]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
sec := p.secretsAPI()
|
||||||
|
if sec == nil || !sec.Available() {
|
||||||
|
return "", false, fmt.Errorf(
|
||||||
|
"%s 指向内核变量 %s,但变量库不可用(core.security.enabled=false?)", name, ref.Alias)
|
||||||
|
}
|
||||||
|
// ★ 用 Fill 而不是任何"按名取明文"的接口 —— Fill 要求先在文本里
|
||||||
|
// 写出别名,而别名本身不泄漏明文(HMAC 派生)。
|
||||||
|
out, n, _ := sec.Fill("@" + "{" + ref.Alias + "}")
|
||||||
|
if n == 0 {
|
||||||
|
return "", false, fmt.Errorf(
|
||||||
|
"%s 指向的内核变量 %s 已不存在(被 secret_forget 删了?)", name, ref.Alias)
|
||||||
|
}
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 回退平台凭据
|
||||||
|
p.creds.mu.Lock()
|
||||||
|
a, ok := p.creds.data.Accounts[name]
|
||||||
|
p.creds.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
v, err := p.decryptAccount(a, key)
|
||||||
|
return v, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretsAPI 取内核变量库接口(可能为 nil —— 内核版本过旧或未装配)。
|
||||||
|
//
|
||||||
|
// 优先取可注入副本(测试用),生产为 nil 时走 SDK。
|
||||||
|
func (p *Plugin) secretsAPI() sdk.SecretsAPI {
|
||||||
|
if p.vault != nil {
|
||||||
|
return p.vault
|
||||||
|
}
|
||||||
|
if p.sdk == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.sdk.Secrets()
|
||||||
|
}
|
||||||
|
|
||||||
|
// validSecretName 校验 secret 别名。
|
||||||
|
//
|
||||||
|
// ★ 为什么不用 validName:那是**项目/通道名**的校验器(不含 `.`),
|
||||||
|
// 而 secret 引用的字符集含 `.`(见 encshell.go 的 credRefPattern)。
|
||||||
|
// 两者是两个命名空间,混用会造成「能被引用却存不进去」的自相矛盾 ——
|
||||||
|
// 而报错只说"字符不合法",很难联想到是两处校验器不一致。
|
||||||
|
var secretNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
|
||||||
|
|
||||||
|
func validSecretName(s string) bool { return secretNamePattern.MatchString(s) }
|
||||||
339
example/commondev/encstore_test.go
Normal file
339
example/commondev/encstore_test.go
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// `enc_*` 工具族的判据 —— ★ 值已上交给**内核变量库**后的形态。
|
||||||
|
//
|
||||||
|
// ## 这组判据要守住的两条不变量
|
||||||
|
//
|
||||||
|
// ① **值只存一处**:插件不留副本(否则又回到"两库互不可见"的老问题)
|
||||||
|
// ② **工具返回值绝不回显明文**(用户明确提醒「存入过程也需要注意」:
|
||||||
|
// 工具返回值会进模型上下文与会话记录)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeVault 是内核变量库的测试替身。
|
||||||
|
//
|
||||||
|
// 它模拟真实实现的关键语义:
|
||||||
|
// - 别名由**派生**得到(不是用户自选)——与内核一致
|
||||||
|
// - 同明文得到同一别名
|
||||||
|
// - 值存在这个"库里",插件不留副本
|
||||||
|
type fakeVault struct {
|
||||||
|
values map[string]string // alias -> plain
|
||||||
|
notes map[string]string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeVault() *fakeVault {
|
||||||
|
return &fakeVault{values: map[string]string{}, notes: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveAlias 模拟内核的 HMAC 派生(这里用简单确定性函数,只要"同明文同别名")。
|
||||||
|
func deriveAlias(plain string) string {
|
||||||
|
h := 0
|
||||||
|
for _, c := range plain {
|
||||||
|
h = h*31 + int(c)
|
||||||
|
}
|
||||||
|
if h < 0 {
|
||||||
|
h = -h
|
||||||
|
}
|
||||||
|
return "sec_" + strings.Repeat("0", 16-len(itoa16(h))) + itoa16(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa16(n int) string {
|
||||||
|
const hexd = "0123456789abcdef"
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for n > 0 {
|
||||||
|
b = append([]byte{hexd[n%16]}, b...)
|
||||||
|
n /= 16
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *fakeVault) Available() bool { return true }
|
||||||
|
|
||||||
|
func (v *fakeVault) Store(value, note string) (string, error) {
|
||||||
|
v.calls++
|
||||||
|
a := deriveAlias(value)
|
||||||
|
v.values[a] = value
|
||||||
|
v.notes[a] = note
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *fakeVault) List() []sdk.SecretInfo {
|
||||||
|
out := make([]sdk.SecretInfo, 0, len(v.values))
|
||||||
|
for a := range v.values {
|
||||||
|
out = append(out, sdk.SecretInfo{Alias: a, Source: "submitted", Note: v.notes[a]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *fakeVault) Forget(alias string) error {
|
||||||
|
delete(v.values, alias)
|
||||||
|
delete(v.notes, alias)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *fakeVault) Fill(text string) (string, int, []string) {
|
||||||
|
n := 0
|
||||||
|
var used []string
|
||||||
|
for a, plain := range v.values {
|
||||||
|
if strings.Contains(text, "@{"+a+"}") {
|
||||||
|
text = strings.ReplaceAll(text, "@{"+a+"}", plain)
|
||||||
|
n++
|
||||||
|
used = append(used, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text, n, used
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *fakeVault) Encrypt(plain []byte) (string, error) { return "enc:" + string(plain), nil }
|
||||||
|
func (v *fakeVault) Decrypt(s string) ([]byte, error) {
|
||||||
|
return []byte(strings.TrimPrefix(s, "enc:")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestPluginWithVault 建一个带假变量库的插件。
|
||||||
|
func newTestPluginWithVault(t *testing.T) (*Plugin, *fakeVault) {
|
||||||
|
t.Helper()
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
p.refs = secretRefs{Refs: map[string]*secretRef{}}
|
||||||
|
v := newFakeVault()
|
||||||
|
p.vault = v
|
||||||
|
return p, v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**存入的返回值绝不回显明文**。
|
||||||
|
//
|
||||||
|
// 依据:工具返回值会进模型上下文与会话记录。
|
||||||
|
func TestEncStoreNeverEchoesValue(t *testing.T) {
|
||||||
|
p, _ := newTestPluginWithVault(t)
|
||||||
|
const secret = "STORE-ECHO-CANARY-4b71"
|
||||||
|
|
||||||
|
out, err := p.handleEncStore(map[string]interface{}{
|
||||||
|
"alias": "canary", "value": secret,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(out)
|
||||||
|
if strings.Contains(string(b), secret) {
|
||||||
|
t.Fatalf("enc_store 返回值回显了明文: %s", b)
|
||||||
|
}
|
||||||
|
m := out.(map[string]interface{})
|
||||||
|
if m["ref"] == "" || !strings.HasPrefix(m["ref"].(string), "sec_") {
|
||||||
|
t.Errorf("应返回内核别名(ref),实际 %v", m["ref"])
|
||||||
|
}
|
||||||
|
if _, ok := m["warning"]; !ok {
|
||||||
|
t.Error("直接传明文时应告警(明文已进入本次会话记录)")
|
||||||
|
}
|
||||||
|
if m["source"] != "literal" {
|
||||||
|
t.Errorf("来源应标为 literal,实际 %v", m["source"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**值只存内核库,插件不留副本**(去重的核心不变量)。
|
||||||
|
//
|
||||||
|
// 这是本次重构的目的。若有人"顺手"把值也存进插件侧,
|
||||||
|
// 两套库互不可见的老问题就会回来 —— 而它不会报错,只会让使用者困惑。
|
||||||
|
func TestEncStoreLeavesNoCopyInPlugin(t *testing.T) {
|
||||||
|
p, v := newTestPluginWithVault(t)
|
||||||
|
const secret = "NO-COPIES-CANARY-77aa"
|
||||||
|
|
||||||
|
if _, err := p.handleEncStore(map[string]interface{}{
|
||||||
|
"alias": "x", "value": secret,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 内核库里**有**值
|
||||||
|
if len(v.values) != 1 {
|
||||||
|
t.Fatalf("内核库应有 1 条,实际 %d", len(v.values))
|
||||||
|
}
|
||||||
|
// ② 插件落盘文件里**没有**值
|
||||||
|
b, err := os.ReadFile(p.refsPath())
|
||||||
|
if err == nil {
|
||||||
|
if strings.Contains(string(b), secret) {
|
||||||
|
t.Fatalf("插件侧文件出现了明文(应只存名字映射): %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ③ 插件的映射里**只有名字与别名**,没有值字段
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, r := range p.refs.Refs {
|
||||||
|
rb, _ := json.Marshal(r)
|
||||||
|
if strings.Contains(string(rb), secret) {
|
||||||
|
t.Fatalf("映射里出现了值: %s", rb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
// ④ 映射文件权限 0600(名字本身有情报价值)
|
||||||
|
if fi, err := os.Stat(p.refsPath()); err == nil {
|
||||||
|
if fi.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("映射文件权限应为 0600,实际 %04o", fi.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 来源分级:${ENV} 与 @file: 都应被识别,且**不告警**(明文未进会话)。
|
||||||
|
func TestEncStoreSourceClasses(t *testing.T) {
|
||||||
|
p, _ := newTestPluginWithVault(t)
|
||||||
|
os.Setenv("ENC_TEST_VAR", "from-env-secret")
|
||||||
|
defer os.Unsetenv("ENC_TEST_VAR")
|
||||||
|
|
||||||
|
out, _ := p.handleEncStore(map[string]interface{}{
|
||||||
|
"alias": "from-env", "value": "${ENC_TEST_VAR}"})
|
||||||
|
m := out.(map[string]interface{})
|
||||||
|
if m["source"] != "env" {
|
||||||
|
t.Errorf("来源应为 env,实际 %v", m["source"])
|
||||||
|
}
|
||||||
|
if _, bad := m["warning"]; bad {
|
||||||
|
t.Error("env 来源不该告警(明文没进会话)")
|
||||||
|
}
|
||||||
|
|
||||||
|
f := filepath.Join(t.TempDir(), "tok.txt")
|
||||||
|
os.WriteFile(f, []byte("from-file-secret\n"), 0o600) // 带换行(token 文件常见)
|
||||||
|
out2, _ := p.handleEncStore(map[string]interface{}{
|
||||||
|
"alias": "from-file", "value": "@file:" + f})
|
||||||
|
m2 := out2.(map[string]interface{})
|
||||||
|
if m2["source"] != "file" {
|
||||||
|
t.Errorf("来源应为 file,实际 %v", m2["source"])
|
||||||
|
}
|
||||||
|
if _, bad := m2["warning"]; bad {
|
||||||
|
t.Error("file 来源不该告警")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 换行必须被去掉(否则填充进命令会带换行 → 命令坏掉)
|
||||||
|
// 通过 enc_shell 的解析路径验证(值已在内核库,只能经它读回)
|
||||||
|
plain, found, err := p.lookupAnySecret("from-file", nil)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("应能解析 from-file: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if plain != "from-file-secret" {
|
||||||
|
t.Errorf("文件内容应去掉尾部换行,实际 %q", plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:enc_list / enc_show **绝不返回值本体**。
|
||||||
|
func TestEncListShowNeverRevealValue(t *testing.T) {
|
||||||
|
p, _ := newTestPluginWithVault(t)
|
||||||
|
const secret = "LIST-SHOW-CANARY-2e08"
|
||||||
|
p.handleEncStore(map[string]interface{}{"alias": "a", "value": secret})
|
||||||
|
|
||||||
|
l, _ := p.handleEncList(nil)
|
||||||
|
lb, _ := json.Marshal(l)
|
||||||
|
if strings.Contains(string(lb), secret) {
|
||||||
|
t.Fatalf("enc_list 泄露了值: %s", lb)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, _ := p.handleEncShow(map[string]interface{}{"alias": "a"})
|
||||||
|
sb, _ := json.Marshal(s)
|
||||||
|
if strings.Contains(string(sb), secret) {
|
||||||
|
t.Fatalf("enc_show 泄露了值: %s", sb)
|
||||||
|
}
|
||||||
|
mm := s.(map[string]interface{})
|
||||||
|
if !strings.Contains(mm["hint"].(string), "enc_shell") {
|
||||||
|
t.Errorf("应指引用 enc_shell 引用: %v", mm["hint"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(mm["storage"].(string), "内核变量库") {
|
||||||
|
t.Errorf("应说清值存于内核变量库: %v", mm["storage"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:**删除时同步清内核库**(否则留下无人引用的密文)。
|
||||||
|
func TestEncRmForgetsKernelVariable(t *testing.T) {
|
||||||
|
p, v := newTestPluginWithVault(t)
|
||||||
|
p.handleEncStore(map[string]interface{}{"alias": "gone", "value": "to-be-removed"})
|
||||||
|
if len(v.values) != 1 {
|
||||||
|
t.Fatal("前提:内核库应有 1 条")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := p.handleEncRm(map[string]interface{}{"alias": "gone"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := out.(map[string]interface{}); m["status"] != "ok" {
|
||||||
|
t.Fatalf("删除应成功: %v", m)
|
||||||
|
}
|
||||||
|
// ★ 内核库里的值也必须没了
|
||||||
|
if len(v.values) != 0 {
|
||||||
|
t.Fatalf("内核库应被清空(否则留下无人引用的密文),实际 %d 条", len(v.values))
|
||||||
|
}
|
||||||
|
// 映射也没了
|
||||||
|
p.mu.Lock()
|
||||||
|
_, still := p.refs.Refs["gone"]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if still {
|
||||||
|
t.Error("映射未删除")
|
||||||
|
}
|
||||||
|
// 再删应报不存在(不是静默成功)
|
||||||
|
out2, _ := p.handleEncRm(map[string]interface{}{"alias": "gone"})
|
||||||
|
if _, isErr := out2.(map[string]interface{})["error"]; !isErr {
|
||||||
|
t.Error("重复删除应明确报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:**变量库不可用时拒绝存储**(不静默降级到明文/私有文件)。
|
||||||
|
func TestEncStoreRefusesWhenVaultUnavailable(t *testing.T) {
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
p.refs = secretRefs{Refs: map[string]*secretRef{}}
|
||||||
|
p.vault = unavailableVault{}
|
||||||
|
|
||||||
|
out, err := p.handleEncStore(map[string]interface{}{
|
||||||
|
"alias": "x", "value": "must-not-be-stored"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]interface{})
|
||||||
|
if _, isErr := m["error"]; !isErr {
|
||||||
|
t.Fatalf("变量库不可用时应报错,实际 %v", m)
|
||||||
|
}
|
||||||
|
// 且不该在插件侧留下任何东西
|
||||||
|
p.mu.Lock()
|
||||||
|
n := len(p.refs.Refs)
|
||||||
|
p.mu.Unlock()
|
||||||
|
if n != 0 {
|
||||||
|
t.Error("拒绝后不该留下映射")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type unavailableVault struct{}
|
||||||
|
|
||||||
|
func (unavailableVault) Available() bool { return false }
|
||||||
|
func (unavailableVault) Store(string, string) (string, error) { return "", errUnavailable }
|
||||||
|
func (unavailableVault) List() []sdk.SecretInfo { return nil }
|
||||||
|
func (unavailableVault) Forget(string) error { return errUnavailable }
|
||||||
|
func (unavailableVault) Fill(t string) (string, int, []string) { return t, 0, nil }
|
||||||
|
func (unavailableVault) Encrypt([]byte) (string, error) { return "", errUnavailable }
|
||||||
|
func (unavailableVault) Decrypt(string) ([]byte, error) { return nil, errUnavailable }
|
||||||
|
|
||||||
|
var errUnavailable = errNotAvailable{}
|
||||||
|
|
||||||
|
type errNotAvailable struct{}
|
||||||
|
|
||||||
|
func (errNotAvailable) Error() string { return "变量库不可用" }
|
||||||
|
|
||||||
|
// ★ 判据:别名合法性 —— 它会进 `@{别名}` 引用,字符集必须受限。
|
||||||
|
func TestEncStoreRejectsBadAlias(t *testing.T) {
|
||||||
|
p, _ := newTestPluginWithVault(t)
|
||||||
|
for _, bad := range []string{"has space", "has/slash", "has@at", ""} {
|
||||||
|
out, _ := p.handleEncStore(map[string]interface{}{"alias": bad, "value": "v"})
|
||||||
|
if _, isErr := out.(map[string]interface{})["error"]; !isErr {
|
||||||
|
t.Errorf("别名 %q 应被拒", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 合法字符集
|
||||||
|
for _, ok := range []string{"good-name", "good_name", "good.name", "n123"} {
|
||||||
|
out, _ := p.handleEncStore(map[string]interface{}{"alias": ok, "value": "v"})
|
||||||
|
if _, isErr := out.(map[string]interface{})["error"]; isErr {
|
||||||
|
t.Errorf("别名 %q 应被接受,实际 %v", ok, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
637
example/commondev/endpoints.go
Normal file
637
example/commondev/endpoints.go
Normal file
@ -0,0 +1,637 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 远端端点表:**按 host 存一套,按 (host, cli) 覆盖一套**。
|
||||||
|
//
|
||||||
|
// ## 为什么必须按 host 分开
|
||||||
|
//
|
||||||
|
// 改动前 SSH 相关的东西只有**一组全局 settings**:
|
||||||
|
//
|
||||||
|
// ssh_node_path 远端 node 路径
|
||||||
|
// ssh_runtime_root 远端放 sidecar 脚本的目录
|
||||||
|
// ssh_workdir_root 远端放工程目录的根
|
||||||
|
//
|
||||||
|
// 单台远端时够用。**两台就开始互相打架**:A 机 node 在 `/usr/local/bin/node`、
|
||||||
|
// B 机在 `/opt/node/bin/node`;A 把脚本放 `/home/a/rt`、B 在 `/srv/rt`。
|
||||||
|
// 全局只有一个值 ⇒ 后配的那台覆盖前一台,表现为"换台机器就报找不到 node/
|
||||||
|
// 找不到脚本",而报错指向的是**另一台机器的路径**,排查方向完全错。
|
||||||
|
//
|
||||||
|
// ## 为什么还要按 (host, cli) 再分
|
||||||
|
//
|
||||||
|
// 同一个 host 上不同后端 CLI 的路径天然不同:zcode 在
|
||||||
|
// `/opt/zcode/zcode.cjs`,将来接别的 CLI 又在别处。而且同一台机器上
|
||||||
|
// 可能出现**同一 CLI 的多个版本**(系统装的 + 用户目录装的),
|
||||||
|
// 需要在不改 host 默认值的前提下为某个后端单独指定。
|
||||||
|
//
|
||||||
|
// ## 结构(优先级从高到低)
|
||||||
|
//
|
||||||
|
// ① endpoints[host].CLIs[cli].CLIPath 该机器该后端的专属 CLI 路径
|
||||||
|
// ② endpoints[host].CLIPath 该机器上所有后端的默认 CLI 路径
|
||||||
|
// ③ driver.json 的 cliPath 随包/全局默认(本地与远端共用同一份声明)
|
||||||
|
//
|
||||||
|
// node/runtime_root/workdir_root 同理:host 级默认 + (host,cli) 级覆盖。
|
||||||
|
//
|
||||||
|
// ★ 关键约束:**本机路径绝不发给远端**。见 resolveRemoteEndpoint 的说明。
|
||||||
|
//
|
||||||
|
// ## 为什么放独立文件而不是塞进 ledger.json
|
||||||
|
//
|
||||||
|
// ledger 是"工程状态"(可重建的派生数据);端点是"环境配置"
|
||||||
|
// (人手工维护、丢了要重配、且含机器专属信息)。混在一起会让
|
||||||
|
// ledger 的保存路径(每次分支变动都写)碰到与它无关的配置,
|
||||||
|
// 且一次台账损坏会连带丢掉配置。参照 credentials.json 的先例。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cliEndpoint 是**某台机器上某个后端**的专属设置。
|
||||||
|
type cliEndpoint struct {
|
||||||
|
// CLIPath 该后端在这台机器上的 CLI 入口(如 /opt/zcode/zcode.cjs)。
|
||||||
|
CLIPath string `json:"cli_path,omitempty"`
|
||||||
|
// NodePath 该后端用的 node(留空则用 host 级或默认)。
|
||||||
|
NodePath string `json:"node_path,omitempty"`
|
||||||
|
// RuntimeRoot 该后端 sidecar 脚本落地的远端目录(留空则用 host 级)。
|
||||||
|
RuntimeRoot string `json:"runtime_root,omitempty"`
|
||||||
|
// Env 该后端额外的远端环境变量(展开占位符后前置到 ssh 命令)。
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
// Note 备注(给人看;例如"这台机器是测试机")。
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostEndpoint 是**一台机器**的设置。
|
||||||
|
type hostEndpoint struct {
|
||||||
|
// CLIPath 这台机器上后端的**默认** CLI 路径(被 CLIs[cli].CLIPath 覆盖)。
|
||||||
|
CLIPath string `json:"cli_path,omitempty"`
|
||||||
|
// NodePath 这台机器的 node(默认 `node`,走远端 PATH)。
|
||||||
|
NodePath string `json:"node_path,omitempty"`
|
||||||
|
// RuntimeRoot 这台机器放 sidecar 脚本的目录。
|
||||||
|
RuntimeRoot string `json:"runtime_root,omitempty"`
|
||||||
|
// WorkdirRoot 这台机器放工程目录的根。
|
||||||
|
WorkdirRoot string `json:"workdir_root,omitempty"`
|
||||||
|
// CLIs 按后端覆盖(key 是 agent_cli,如 "zcode")。
|
||||||
|
CLIs map[string]*cliEndpoint `json:"clis,omitempty"`
|
||||||
|
// Note 备注。
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointTable 是端点表的磁盘形态。
|
||||||
|
type endpointTable struct {
|
||||||
|
// Version 供将来迁移用(现在只有 1)。
|
||||||
|
Version int `json:"version"`
|
||||||
|
// Hosts key 是 ssh_host(与 branch_open 的 ssh_host 参数逐字一致,
|
||||||
|
// 包括 `user@host:port` 或 ssh config 别名)。
|
||||||
|
Hosts map[string]*hostEndpoint `json:"hosts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointsPath 端点表与台账同目录(与 credentials.json 一致)。
|
||||||
|
func (p *Plugin) endpointsPath() string {
|
||||||
|
return filepath.Join(filepath.Dir(p.path), "endpoints.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEndpoints 读端点表。
|
||||||
|
//
|
||||||
|
// ★ 缺文件**不是错误**(首次运行、或只用本地后端的人永远不会有它)。
|
||||||
|
// 但**损坏必须是错误** —— 静默用空表会让所有远端配置凭空消失,
|
||||||
|
// 表现为"我明明配过,怎么又要配",而用户会先怀疑自己。
|
||||||
|
func (p *Plugin) loadEndpoints() error {
|
||||||
|
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
|
||||||
|
b, err := os.ReadFile(p.endpointsPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var t endpointTable
|
||||||
|
if err := json.Unmarshal(b, &t); err != nil {
|
||||||
|
return fmt.Errorf("端点表损坏(拒绝猜默认,否则所有远端配置会静默消失): %s: %w",
|
||||||
|
p.endpointsPath(), err)
|
||||||
|
}
|
||||||
|
if t.Hosts == nil {
|
||||||
|
t.Hosts = map[string]*hostEndpoint{}
|
||||||
|
}
|
||||||
|
if t.Version == 0 {
|
||||||
|
t.Version = 1
|
||||||
|
}
|
||||||
|
p.endpoints = &t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveEndpoints 原子写(tmp + rename),权限 0600。
|
||||||
|
//
|
||||||
|
// 0600 而不是 0644:端点表可能含机器内网地址、端口、用户目录结构,
|
||||||
|
// 也**可能**含 env 里塞进去的令牌(虽然不推荐,但不该由文件权限替人决定)。
|
||||||
|
func (p *Plugin) saveEndpoints() error {
|
||||||
|
b, err := json.MarshalIndent(p.endpoints, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := p.endpointsPath()
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHost 取(或按需创建)某台机器的设置。
|
||||||
|
//
|
||||||
|
// create=false 时**不**创建 —— 读路径不该有副作用(否则一个查询工具
|
||||||
|
// 就会往表里塞一堆空条目,把真实配置淹掉)。
|
||||||
|
func (p *Plugin) getHost(host string, create bool) *hostEndpoint {
|
||||||
|
if p.endpoints == nil {
|
||||||
|
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
|
||||||
|
}
|
||||||
|
if p.endpoints.Hosts == nil {
|
||||||
|
p.endpoints.Hosts = map[string]*hostEndpoint{}
|
||||||
|
}
|
||||||
|
h, ok := p.endpoints.Hosts[host]
|
||||||
|
if !ok {
|
||||||
|
if !create {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h = &hostEndpoint{CLIs: map[string]*cliEndpoint{}}
|
||||||
|
p.endpoints.Hosts[host] = h
|
||||||
|
}
|
||||||
|
if h.CLIs == nil {
|
||||||
|
h.CLIs = map[string]*cliEndpoint{}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvedEndpoint 是"算完之后"的最终取值(三层合并的结果)。
|
||||||
|
type resolvedEndpoint struct {
|
||||||
|
Host string
|
||||||
|
CLI string
|
||||||
|
CLIPath string
|
||||||
|
CLIPathFrom string // 取值来源,**必须在错误信息里出现**
|
||||||
|
NodePath string
|
||||||
|
RuntimeRoot string
|
||||||
|
WorkdirRoot string
|
||||||
|
Env map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveEndpoint 把三层配置合并成最终端点。
|
||||||
|
//
|
||||||
|
// cliPathFrom 记录取值来源:出问题时要能一句话说清"这个路径是从哪来的"。
|
||||||
|
// 没有它,用户面对"远端找不到 CLI"根本不知道该改哪个文件 ——
|
||||||
|
// 本会话已经因为"错误不指出位置"吃过多次教训。
|
||||||
|
func (p *Plugin) resolveEndpoint(host, cli string, drv driverSpec, ctx driverCtx) resolvedEndpoint {
|
||||||
|
r := resolvedEndpoint{Host: host, CLI: cli, NodePath: "node", Env: map[string]string{}}
|
||||||
|
|
||||||
|
h := p.getHost(host, false)
|
||||||
|
if h != nil {
|
||||||
|
r.CLIPath, r.CLIPathFrom = h.CLIPath, "host 默认(endpoints.json)"
|
||||||
|
r.NodePath = h.NodePath
|
||||||
|
r.RuntimeRoot = h.RuntimeRoot
|
||||||
|
r.WorkdirRoot = h.WorkdirRoot
|
||||||
|
}
|
||||||
|
if h != nil && h.CLIs != nil {
|
||||||
|
if c, ok := h.CLIs[cli]; ok && c != nil {
|
||||||
|
if strings.TrimSpace(c.CLIPath) != "" {
|
||||||
|
r.CLIPath, r.CLIPathFrom = c.CLIPath, "该后端专属(endpoints.json)"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.NodePath) != "" {
|
||||||
|
r.NodePath = c.NodePath
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.RuntimeRoot) != "" {
|
||||||
|
r.RuntimeRoot = c.RuntimeRoot
|
||||||
|
}
|
||||||
|
for k, v := range c.Env {
|
||||||
|
r.Env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(r.NodePath) == "" {
|
||||||
|
r.NodePath = "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:driver.json 的 cliPath(随包/全局声明)。
|
||||||
|
//
|
||||||
|
// ★ 只有**未被任何端点配置覆盖**时才用它。而且它可能含 {rawfile} 这类
|
||||||
|
// **本地**占位符 —— 那种路径绝不能发给远端(见 resolveRemoteCLIPath)。
|
||||||
|
if r.CLIPath == "" && strings.TrimSpace(drv.CLIPath) != "" {
|
||||||
|
r.CLIPath = drv.expand(drv.CLIPath, ctx)
|
||||||
|
r.CLIPathFrom = "driver.json 的 cliPath 默认值"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧配置:ssh_node_path / ssh_runtime_root / ssh_workdir_root
|
||||||
|
// 只在端点表没有对应值时生效,避免"配了端点表却还被旧键盖住"。
|
||||||
|
//
|
||||||
|
// ★ 经 settingStr 而不是裸 p.sdk.Settings():Settings 未装配时
|
||||||
|
// 裸调用会 **nil 解引用 panic**,而这里在 ssh 拉起的热路径上 ——
|
||||||
|
// 一个配置读取的健壮性问题会变成"拉 agent 直接崩"。
|
||||||
|
if r.RuntimeRoot == "" {
|
||||||
|
r.RuntimeRoot = p.settingStr("ssh_runtime_root")
|
||||||
|
}
|
||||||
|
if r.WorkdirRoot == "" {
|
||||||
|
r.WorkdirRoot = p.settingStr("ssh_workdir_root")
|
||||||
|
}
|
||||||
|
if r.NodePath == "node" {
|
||||||
|
if s := p.settingStr("ssh_node_path"); s != "" {
|
||||||
|
r.NodePath = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingStr 读一个字符串 setting,**容忍 Settings 未装配**。
|
||||||
|
//
|
||||||
|
// 返回 "" 表示"没配/取不到";调用方据此走默认值。
|
||||||
|
// 修这个是因为判据造了不带 Settings 的插件就 panic —— 那说明
|
||||||
|
// 任何"Settings 尚未注入"的调用点都会崩,属真实健壮性缺口。
|
||||||
|
func (p *Plugin) settingStr(key string) string {
|
||||||
|
if p.sdk == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
st := p.sdk.Settings()
|
||||||
|
if st == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, _ := st.Get(key)
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
if s == "" || s == "<nil>" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// localPathPlaceholders 是**只在本机有意义**的占位符。
|
||||||
|
//
|
||||||
|
// ★ 这是一条安全/正确性边界,不是审美问题:`{rawfile}` 展开成
|
||||||
|
// `<本机插件目录>/rawfile`,把它发给远端 100% 是个不存在的路径,
|
||||||
|
// 而远端报的错误是"文件不存在"——用户会去远端找,方向完全错。
|
||||||
|
var localPathPlaceholders = []string{"{rawfile}", "{pluginDir}", "{dataDir}"}
|
||||||
|
|
||||||
|
// containsLocalPlaceholder 报告路径里是否含本机专属占位符。
|
||||||
|
func containsLocalPlaceholder(s string) bool {
|
||||||
|
for _, ph := range localPathPlaceholders {
|
||||||
|
if strings.Contains(s, ph) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRemoteCLIPath 在**启动 ssh 之前**校验远端 CLI 路径。
|
||||||
|
//
|
||||||
|
// 两条规则(都是实测踩出来的):
|
||||||
|
//
|
||||||
|
// 1. **本机占位符不得下发**:展开后是本机路径,远端一定不存在。
|
||||||
|
// 2. **本机绝对路径不得下发**:即使不是占位符,用户也可能把本机的
|
||||||
|
// `/home/program/.zcode-src/.../zcode.cjs` 直接填进 host 端点,
|
||||||
|
// 而远端是另一台机器。这比第 1 条更隐蔽 —— 它看起来就是个正常路径。
|
||||||
|
//
|
||||||
|
// 两条都必须**在 ssh 之前**拦:让远端去失败的话,错误来自远端 shell
|
||||||
|
// ("No such file or directory"),用户会以为远端缺文件而不是本地配错了。
|
||||||
|
func validateRemoteCLIPath(cliPath, cli string) error {
|
||||||
|
if strings.TrimSpace(cliPath) == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"远端 %s 没有可用的 CLI 路径。请在 endpoints.json 里为该机器配置 "+
|
||||||
|
"hosts.<ssh_host>.cli_path(该机所有后端的默认)或 "+
|
||||||
|
"hosts.<ssh_host>.clis.%s.cli_path(该后端的专属路径)。"+
|
||||||
|
"可用 commondev_endpoint_set 写入(会校验路径在远端是否存在)。", cli, cli)
|
||||||
|
}
|
||||||
|
if containsLocalPlaceholder(cliPath) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"★ 远端 CLI 路径含**本机专属占位符**(%s):%q。"+
|
||||||
|
"{rawfile}/{pluginDir}/{dataDir} 展开后是**本机**路径,发给远端必然不存在。"+
|
||||||
|
"请把它换成该机器上的真实路径(endpoint_set host=<ssh_host> cli=%s cli_path=<远端路径>)",
|
||||||
|
strings.Join(localPathPlaceholders, "/"), cliPath, cli)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointListRows 把端点表摊平成可读行(供 commondev_endpoint_list)。
|
||||||
|
//
|
||||||
|
// 一并报出**解析后的默认值**,因为"我这台机器最终会用哪个路径"才是
|
||||||
|
// 用户真正要回答的问题;只给原始 JSON 等于让人自己心算三层合并。
|
||||||
|
func (p *Plugin) endpointListRows() []map[string]interface{} {
|
||||||
|
rows := []map[string]interface{}{}
|
||||||
|
if p.endpoints == nil {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
hosts := make([]string, 0, len(p.endpoints.Hosts))
|
||||||
|
for h := range p.endpoints.Hosts {
|
||||||
|
hosts = append(hosts, h)
|
||||||
|
}
|
||||||
|
sort.Strings(hosts)
|
||||||
|
for _, h := range hosts {
|
||||||
|
he := p.endpoints.Hosts[h]
|
||||||
|
if he == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := map[string]interface{}{
|
||||||
|
"host": h,
|
||||||
|
"cli_path": he.CLIPath,
|
||||||
|
"node_path": he.NodePath,
|
||||||
|
"runtime_root": he.RuntimeRoot,
|
||||||
|
"workdir_root": he.WorkdirRoot,
|
||||||
|
"note": he.Note,
|
||||||
|
}
|
||||||
|
if len(he.CLIs) > 0 {
|
||||||
|
clis := make([]string, 0, len(he.CLIs))
|
||||||
|
for c := range he.CLIs {
|
||||||
|
clis = append(clis, c)
|
||||||
|
}
|
||||||
|
sort.Strings(clis)
|
||||||
|
cm := map[string]interface{}{}
|
||||||
|
for _, c := range clis {
|
||||||
|
ce := he.CLIs[c]
|
||||||
|
if ce == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cm[c] = map[string]interface{}{
|
||||||
|
"cli_path": ce.CLIPath,
|
||||||
|
"node_path": ce.NodePath,
|
||||||
|
"runtime_root": ce.RuntimeRoot,
|
||||||
|
"env": ce.Env,
|
||||||
|
"note": ce.Note,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row["clis"] = cm
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 工具处理 ----------------
|
||||||
|
|
||||||
|
// handleEndpointSet 写某台机器(或该机器某后端)的端点配置。
|
||||||
|
//
|
||||||
|
// ★ 默认会 ssh 到远端**确认 cli_path 真实存在**。
|
||||||
|
// 为什么默认开:本会话反复出现的失败模式就是"配置写对了但指向一个
|
||||||
|
// 不存在的东西",而症状出现在很远的地方(远端 shell 报 No such file)。
|
||||||
|
// 在这里花一次 ssh 往返,能把它变成一条立刻可见的、指出位置的错误。
|
||||||
|
func (p *Plugin) handleEndpointSet(args map[string]interface{}) (interface{}, error) {
|
||||||
|
host := strings.TrimSpace(str(args, "host"))
|
||||||
|
if host == "" {
|
||||||
|
return map[string]interface{}{"error": "必须给 host(与 branch_open 的 ssh_host 逐字一致)"}, nil
|
||||||
|
}
|
||||||
|
cli := strings.TrimSpace(str(args, "cli"))
|
||||||
|
cliPath := strings.TrimSpace(str(args, "cli_path"))
|
||||||
|
nodePath := strings.TrimSpace(str(args, "node_path"))
|
||||||
|
runtimeRoot := strings.TrimSpace(str(args, "runtime_root"))
|
||||||
|
workdirRoot := strings.TrimSpace(str(args, "workdir_root"))
|
||||||
|
note := strings.TrimSpace(str(args, "note"))
|
||||||
|
|
||||||
|
// ★ 本机路径不得写进**远端**配置:写了就是 100% 起不来,
|
||||||
|
// 而报错来自远端 shell,排查方向完全错。在这里拦最省事。
|
||||||
|
if cliPath != "" {
|
||||||
|
if containsLocalPlaceholder(cliPath) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf(
|
||||||
|
"cli_path 含本机专属占位符(%s):%q。这些展开后是**本机**路径,"+
|
||||||
|
"远端必然不存在。请填该机器上的真实路径",
|
||||||
|
strings.Join(localPathPlaceholders, "/"), cliPath),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if isLocalPathHint(cliPath) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf(
|
||||||
|
"cli_path %q 看起来是**本机**路径(在 %s 下)。请填该**远端机器**上的路径;"+
|
||||||
|
"若该机器确实与本地同构(如 localhost 回环),传 verify=false 并确认无误",
|
||||||
|
cliPath, p.localRootHint()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if workdirRoot != "" && cli != "" {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "workdir_root 是**机器级**设置(该机器上所有后端共用),请去掉 cli 参数单独设置",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远端验存(默认开)
|
||||||
|
verify := true
|
||||||
|
if v, ok := args["verify"]; ok {
|
||||||
|
if b, isB := v.(bool); isB {
|
||||||
|
verify = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if verify && cliPath != "" {
|
||||||
|
t, err := parseSSHTarget(&Branch{SSHHost: host})
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
if _, err := p.sshRunCapture(t, "test -f "+shellQuote(cliPath)+" && echo __OK__"); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("远端 %s 上不存在 %s(ssh 验存失败,未写入配置):%v。"+
|
||||||
|
"确认路径后重试;若机器暂时不可达,传 verify=false 先记下配置", host, cliPath, err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
he := p.getHost(host, true)
|
||||||
|
|
||||||
|
if cli != "" {
|
||||||
|
ce, ok := he.CLIs[cli]
|
||||||
|
if !ok || ce == nil {
|
||||||
|
ce = &cliEndpoint{}
|
||||||
|
he.CLIs[cli] = ce
|
||||||
|
}
|
||||||
|
if cliPath != "" {
|
||||||
|
ce.CLIPath = cliPath
|
||||||
|
}
|
||||||
|
if nodePath != "" {
|
||||||
|
ce.NodePath = nodePath
|
||||||
|
}
|
||||||
|
if runtimeRoot != "" {
|
||||||
|
ce.RuntimeRoot = runtimeRoot
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
ce.Note = note
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if cliPath != "" {
|
||||||
|
he.CLIPath = cliPath
|
||||||
|
}
|
||||||
|
if nodePath != "" {
|
||||||
|
he.NodePath = nodePath
|
||||||
|
}
|
||||||
|
if runtimeRoot != "" {
|
||||||
|
he.RuntimeRoot = runtimeRoot
|
||||||
|
}
|
||||||
|
if workdirRoot != "" {
|
||||||
|
he.WorkdirRoot = workdirRoot
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
he.Note = note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := p.saveEndpoints(); err != nil {
|
||||||
|
return map[string]interface{}{"error": "保存端点表失败: " + err.Error()}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"host": host,
|
||||||
|
"cli": cli,
|
||||||
|
"scope": map[bool]string{true: "该后端专属", false: "该机器默认"}[cli != ""],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEndpointList 列出端点配置。
|
||||||
|
//
|
||||||
|
// 一并给出**解析后的最终值**:用户真正要回答的是"这台机器最终用哪个路径",
|
||||||
|
// 只给原始 JSON 等于让人自己心算三层合并。给 host+cli 时才计算。
|
||||||
|
func (p *Plugin) handleEndpointList(args map[string]interface{}) (interface{}, error) {
|
||||||
|
host := strings.TrimSpace(str(args, "host"))
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
// ★ 端点表可能尚未加载(裸插件 / 加载失败前的调用)。
|
||||||
|
// 空参数调用不得 panic —— 工具会遇到模型乱传参数与半初始化状态。
|
||||||
|
if p.endpoints == nil {
|
||||||
|
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
he := p.getHost(host, false)
|
||||||
|
if he == nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok", "host": host, "configured": false,
|
||||||
|
"hint": "该机器没有端点配置。用 commondev_endpoint_set host=" + host +
|
||||||
|
" cli_path=<远端 CLI 路径> runtime_root=<远端目录> workdir_root=<远端工程根> 配置",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"status": "ok", "host": host, "configured": true,
|
||||||
|
"cli_path": he.CLIPath, "node_path": he.NodePath,
|
||||||
|
"runtime_root": he.RuntimeRoot, "workdir_root": he.WorkdirRoot,
|
||||||
|
"note": he.Note,
|
||||||
|
}
|
||||||
|
if len(he.CLIs) > 0 {
|
||||||
|
cm := map[string]interface{}{}
|
||||||
|
for c, ce := range he.CLIs {
|
||||||
|
if ce == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cm[c] = map[string]interface{}{
|
||||||
|
"cli_path": ce.CLIPath, "node_path": ce.NodePath,
|
||||||
|
"runtime_root": ce.RuntimeRoot, "env": ce.Env, "note": ce.Note,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m["clis"] = cm
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok", "count": len(p.endpoints.Hosts), "hosts": p.endpointListRows(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEndpointRemove 删端点配置。
|
||||||
|
//
|
||||||
|
// ★ 该机器有后端覆盖时,只给 host 会**拒绝**(除非 force):
|
||||||
|
// 否则会出现"以为删了机器,实际子项还在,下次又把老路径拿出来用"。
|
||||||
|
func (p *Plugin) handleEndpointRemove(args map[string]interface{}) (interface{}, error) {
|
||||||
|
host := strings.TrimSpace(str(args, "host"))
|
||||||
|
cli := strings.TrimSpace(str(args, "cli"))
|
||||||
|
force := false
|
||||||
|
if v, ok := args["force"]; ok {
|
||||||
|
if b, isB := v.(bool); isB {
|
||||||
|
force = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return map[string]interface{}{"error": "必须给 host"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.endpoints == nil {
|
||||||
|
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
|
||||||
|
}
|
||||||
|
he := p.getHost(host, false)
|
||||||
|
if he == nil {
|
||||||
|
return map[string]interface{}{"status": "ok", "host": host, "removed": false,
|
||||||
|
"note": "该机器本来就没有配置(幂等)"}, nil
|
||||||
|
}
|
||||||
|
if cli != "" {
|
||||||
|
if _, ok := he.CLIs[cli]; !ok {
|
||||||
|
return map[string]interface{}{"status": "ok", "host": host, "cli": cli, "removed": false,
|
||||||
|
"note": "该后端本来就没有专属配置(幂等)"}, nil
|
||||||
|
}
|
||||||
|
delete(he.CLIs, cli)
|
||||||
|
if err := p.saveEndpoints(); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "ok", "host": host, "cli": cli, "removed": true}, nil
|
||||||
|
}
|
||||||
|
// 只给 host:有子项时拒绝
|
||||||
|
if len(he.CLIs) > 0 && !force {
|
||||||
|
names := make([]string, 0, len(he.CLIs))
|
||||||
|
for c := range he.CLIs {
|
||||||
|
names = append(names, c)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "refused",
|
||||||
|
"reason": fmt.Sprintf("该机器还有 %d 个后端专属配置(%s)。请先逐个删"+
|
||||||
|
"(commondev_endpoint_remove host=%s cli=<名>),或传 force=true 一并删除",
|
||||||
|
len(names), strings.Join(names, ", "), host),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
delete(p.endpoints.Hosts, host)
|
||||||
|
if err := p.saveEndpoints(); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "ok", "host": host, "removed": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalPathHint 粗略判断路径是否"像是本机路径"。
|
||||||
|
//
|
||||||
|
// 用本机真实存在的根(插件数据目录 / 工程目录)去比:
|
||||||
|
// 比"硬编码 /home/program 或 /home/newqqagent"可靠 —— 换台机器部署仍然对。
|
||||||
|
// 只做**提示**不做强制(确有 localhost 回环这种同构合法情形)。
|
||||||
|
func isLocalPathHint(cliPath string) bool {
|
||||||
|
hint := currentLocalRootHint()
|
||||||
|
if hint == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(cliPath, hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localRootHint 给用户看的本机根(错误信息里说"本机在 X 下")。
|
||||||
|
func (p *Plugin) localRootHint() string {
|
||||||
|
return currentLocalRootHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentLocalRootHint 取一个"本机特征路径"用于提示。
|
||||||
|
func currentLocalRootHint() string {
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
d := filepath.Dir(exe)
|
||||||
|
if d != "" && d != "/" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hd, err := os.UserHomeDir(); err == nil && hd != "" {
|
||||||
|
return hd
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshRunCapture 在远端跑一条命令并取回 stdout(stderr 一并带在错误里)。
|
||||||
|
//
|
||||||
|
// 供端点验存等**轻量**场景使用;重活(准备目录、推脚本)走各自专门函数。
|
||||||
|
func (p *Plugin) sshRunCapture(t sshTarget, cmd string) (string, error) {
|
||||||
|
args := append(t.sshBaseArgs(), t.Host, cmd)
|
||||||
|
out, err := exec.Command("ssh", args...).CombinedOutput()
|
||||||
|
s := string(out)
|
||||||
|
if err != nil {
|
||||||
|
return s, fmt.Errorf("%w: %s", err, strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "__OK__") {
|
||||||
|
return s, fmt.Errorf("远端未返回预期标记(命令可能未生效): %s", strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
388
example/commondev/endpoints_test.go
Normal file
388
example/commondev/endpoints_test.go
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 远端端点表的判据。
|
||||||
|
//
|
||||||
|
// 核心要证明的**不是**"有个 endpoints.json",而是:
|
||||||
|
// ① 多台机器**互不干扰**(这是用户提出该需求的唯一理由);
|
||||||
|
// ② 本机路径**绝不**下发到远端(这是最危险的真实缺陷);
|
||||||
|
// ③ 同一机器上不同后端可以各有各的路径;
|
||||||
|
// ④ 出错时错误信息**指出该改哪里**(否则用户只能猜)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEndpointPlugin 造一个带端点表的插件(数据目录在临时目录)。
|
||||||
|
func newEndpointPlugin(t *testing.T) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
sdkp := sdk.New("commondev-test", nil, nil, nil, nil, nil)
|
||||||
|
p := &Plugin{sdk: sdkp}
|
||||||
|
dir := t.TempDir()
|
||||||
|
p.path = filepath.Join(dir, "ledger.json")
|
||||||
|
if err := p.loadEndpoints(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:两台机器的配置**互不干扰**。
|
||||||
|
//
|
||||||
|
// 这是用户提出该需求的原因:改动前只有一组全局 settings
|
||||||
|
// (ssh_node_path / ssh_runtime_root / ssh_workdir_root),两台机器时
|
||||||
|
// 后配的覆盖前一台,表现为"换台机器就报找不到 node/脚本",
|
||||||
|
// 而报错指向的是**另一台机器的路径**。
|
||||||
|
func TestEndpointsArePerHost(t *testing.T) {
|
||||||
|
p := newEndpointPlugin(t)
|
||||||
|
|
||||||
|
set := func(host string, kv map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
args := map[string]interface{}{"host": host, "verify": false}
|
||||||
|
for k, v := range kv {
|
||||||
|
args[k] = v
|
||||||
|
}
|
||||||
|
r, _ := p.handleEndpointSet(args)
|
||||||
|
if m := r.(map[string]interface{}); m["error"] != nil {
|
||||||
|
t.Fatalf("设置 %s 失败: %v", host, m["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set("alpha", map[string]interface{}{
|
||||||
|
"cli_path": "/opt/alpha/bin/zcode.cjs", "node_path": "/usr/local/bin/node",
|
||||||
|
"runtime_root": "/home/a/rt", "workdir_root": "/home/a/prog",
|
||||||
|
})
|
||||||
|
set("beta", map[string]interface{}{
|
||||||
|
"cli_path": "/srv/beta/zcode.mjs", "node_path": "/opt/node/bin/node",
|
||||||
|
"runtime_root": "/srv/rt", "workdir_root": "/srv/prog",
|
||||||
|
})
|
||||||
|
|
||||||
|
// ★ 每台机器解析出的都必须是**自己**那套
|
||||||
|
drv := driverSpec{}
|
||||||
|
ctx := driverCtx{}
|
||||||
|
|
||||||
|
ea := p.resolveEndpoint("alpha", "zcode", drv, ctx)
|
||||||
|
if ea.CLIPath != "/opt/alpha/bin/zcode.cjs" {
|
||||||
|
t.Errorf("alpha 的 cliPath 被串了: %q", ea.CLIPath)
|
||||||
|
}
|
||||||
|
if ea.NodePath != "/usr/local/bin/node" || ea.RuntimeRoot != "/home/a/rt" {
|
||||||
|
t.Errorf("alpha 的 node/runtime 被串了: %+v", ea)
|
||||||
|
}
|
||||||
|
if ea.WorkdirRoot != "/home/a/prog" {
|
||||||
|
t.Errorf("alpha 的 workdirRoot 被串了: %q", ea.WorkdirRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
eb := p.resolveEndpoint("beta", "zcode", drv, ctx)
|
||||||
|
if eb.CLIPath != "/srv/beta/zcode.mjs" {
|
||||||
|
t.Errorf("beta 的 cliPath 被串了: %q", eb.CLIPath)
|
||||||
|
}
|
||||||
|
if eb.NodePath != "/opt/node/bin/node" || eb.RuntimeRoot != "/srv/rt" {
|
||||||
|
t.Errorf("beta 的 node/runtime 被串了: %+v", eb)
|
||||||
|
}
|
||||||
|
if eb.WorkdirRoot != "/srv/prog" {
|
||||||
|
t.Errorf("beta 的 workdirRoot 被串了: %q", eb.WorkdirRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未配置的第三台:不能"借用"别人的值
|
||||||
|
ec := p.resolveEndpoint("gamma", "zcode", drv, ctx)
|
||||||
|
if ec.CLIPath != "" {
|
||||||
|
t.Errorf("★ gamma 未配置却拿到了别人的 cliPath: %q", ec.CLIPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:同一机器上**不同后端各有各的路径**。
|
||||||
|
//
|
||||||
|
// 这是需求里"每个 cli 客户端有自己的 ip/cli 对"的一半:
|
||||||
|
// 同一台机器上 zcode 在 A、别的 CLI 在 B,必须能分别指定。
|
||||||
|
func TestEndpointsPerHostAndCLI(t *testing.T) {
|
||||||
|
p := newEndpointPlugin(t)
|
||||||
|
|
||||||
|
// 机器级默认
|
||||||
|
if r, _ := p.handleEndpointSet(map[string]interface{}{
|
||||||
|
"host": "box", "cli_path": "/usr/local/bin/default-cli", "verify": false,
|
||||||
|
}); r.(map[string]interface{})["error"] != nil {
|
||||||
|
t.Fatalf("设机器默认失败: %v", r)
|
||||||
|
}
|
||||||
|
// zcode 专属(应覆盖机器默认)
|
||||||
|
if r, _ := p.handleEndpointSet(map[string]interface{}{
|
||||||
|
"host": "box", "cli": "zcode", "cli_path": "/opt/zcode/zcode.cjs",
|
||||||
|
"node_path": "/opt/node/bin/node", "verify": false,
|
||||||
|
}); r.(map[string]interface{})["error"] != nil {
|
||||||
|
t.Fatalf("设 zcode 专属失败: %v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
drv := driverSpec{}
|
||||||
|
ctx := driverCtx{}
|
||||||
|
|
||||||
|
z := p.resolveEndpoint("box", "zcode", drv, ctx)
|
||||||
|
if z.CLIPath != "/opt/zcode/zcode.cjs" {
|
||||||
|
t.Errorf("★ zcode 专属路径未覆盖机器默认: %q", z.CLIPath)
|
||||||
|
}
|
||||||
|
if z.NodePath != "/opt/node/bin/node" {
|
||||||
|
t.Errorf("zcode 专属 node 未生效: %q", z.NodePath)
|
||||||
|
}
|
||||||
|
// 另一个后端:应拿到**机器默认**,而不是 zcode 的
|
||||||
|
other := p.resolveEndpoint("box", "othercli", drv, ctx)
|
||||||
|
if other.CLIPath != "/usr/local/bin/default-cli" {
|
||||||
|
t.Errorf("★ 其他后端应回落到机器默认: %q", other.CLIPath)
|
||||||
|
}
|
||||||
|
if other.NodePath != "node" {
|
||||||
|
t.Errorf("zcode 的 node 不该泄漏给其他后端: %q", other.NodePath)
|
||||||
|
}
|
||||||
|
// 解析来源必须可读(错误信息靠它指出该改哪里)
|
||||||
|
if !strings.Contains(z.CLIPathFrom, "专属") {
|
||||||
|
t.Errorf("CLIPathFrom 未标明来源: %q", z.CLIPathFrom)
|
||||||
|
}
|
||||||
|
if !strings.Contains(other.CLIPathFrom, "host") {
|
||||||
|
t.Errorf("回落时应标明来源是 host 默认: %q", other.CLIPathFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:**本机路径不得下发到远端**(本会话最危险的真实缺陷)。
|
||||||
|
//
|
||||||
|
// 改动前 `drv.buildArgs(ctx)` 会把 driver.json 的 `cliPath`
|
||||||
|
// (随包默认是 `{rawfile}/zcode/zcode.cjs`)展开成**本机**路径塞进 `--cli`,
|
||||||
|
// 然后整条命令发到远端 ⇒ 远端必然报 "No such file",而用户会去
|
||||||
|
// **远端**找原因,真正的错在本地。
|
||||||
|
func TestRemoteCommandDoesNotCarryLocalPaths(t *testing.T) {
|
||||||
|
ctx := driverCtx{
|
||||||
|
Workdir: "/w", SessionID: "s", SessionName: "n",
|
||||||
|
DataDir: "/d", PluginDir: "/p", RawfileDir: "/rf",
|
||||||
|
}
|
||||||
|
drv := driverSpec{
|
||||||
|
Args: []string{"--workdir", "{workdir}", "--session-id", "{sessionId}"},
|
||||||
|
CLIPath: "{rawfile}/zcode/zcode.cjs", // ★ 本机占位符
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 端点表里配了远端真实路径 ⇒ 命令里只能出现远端那条
|
||||||
|
ep := resolvedEndpoint{CLIPath: "/opt/remote/zcode.cjs"}
|
||||||
|
_, args, _ := buildRemoteCommandFor(drv, ctx, "/usr/bin/node", ep)
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
if strings.Contains(joined, "/rf/") || strings.Contains(joined, "{rawfile}") {
|
||||||
|
t.Errorf("★ 远端命令里出现了**本机**路径: %v", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "/opt/remote/zcode.cjs") {
|
||||||
|
t.Errorf("★ 远端命令里没有远端 CLI 路径: %v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 端点表没配的机器:必须在**启动 ssh 之前**响亮拒绝,
|
||||||
|
// 而不是把本机路径发过去让远端报错。
|
||||||
|
//
|
||||||
|
// 判定依据是**占位符**(本机专属、展开后必然是本机路径),
|
||||||
|
// 不是"任意看起来像本机的绝对路径"—— 后者在 localhost/同构机器上
|
||||||
|
// 是合法的,强制拒绝会挡住真实用法。所以这里用占位符版本断言。
|
||||||
|
errPH := validateRemoteCLIPath("{rawfile}/zcode/zcode.cjs", "zcode")
|
||||||
|
if errPH == nil {
|
||||||
|
t.Error("★ 含本机占位符的路径必须拒绝(展开后就是本机路径)")
|
||||||
|
} else if !strings.Contains(errPH.Error(), "本机") {
|
||||||
|
t.Errorf("错误应点明本机占位符问题: %v", errPH)
|
||||||
|
}
|
||||||
|
// 未配路径(空)必须拒绝并告知去哪儿配
|
||||||
|
if err := validateRemoteCLIPath("", "zcode"); err == nil {
|
||||||
|
t.Error("★ 未配远端路径时应拒绝,而不是把值发过去")
|
||||||
|
}
|
||||||
|
// 完全没配也拒绝,且**告知去哪儿配**
|
||||||
|
err := validateRemoteCLIPath("", "zcode")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("未配路径应拒绝")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"endpoint_set", "cli_path", "zcode"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("错误应含 %q 以便用户知道怎么修: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 远端真实路径应通过
|
||||||
|
if err := validateRemoteCLIPath("/opt/remote/zcode.cjs", "zcode"); err != nil {
|
||||||
|
t.Errorf("远端真实路径不该被拒: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:**声明了本机路径要当场拒绝并说明原因**。
|
||||||
|
func TestEndpointSetRejectsLocalPath(t *testing.T) {
|
||||||
|
p := newEndpointPlugin(t)
|
||||||
|
// 构造一个"看起来是本机"的路径:用本机真实存在的根
|
||||||
|
hint := currentLocalRootHint()
|
||||||
|
if hint == "" {
|
||||||
|
t.Skip("无法确定本机根,跳过")
|
||||||
|
}
|
||||||
|
local := filepath.Join(hint, "some", "zcode.cjs")
|
||||||
|
|
||||||
|
r, _ := p.handleEndpointSet(map[string]interface{}{
|
||||||
|
"host": "box", "cli_path": local, "verify": false,
|
||||||
|
})
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
if m["error"] == nil {
|
||||||
|
t.Fatalf("★ 应拒绝本机路径: %s", local)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmtStr(m["error"]), "本机") {
|
||||||
|
t.Errorf("错误应点明是本机路径: %v", m["error"])
|
||||||
|
}
|
||||||
|
// 占位符同样拒绝
|
||||||
|
r2, _ := p.handleEndpointSet(map[string]interface{}{
|
||||||
|
"host": "box", "cli_path": "{rawfile}/z/x.cjs", "verify": false,
|
||||||
|
})
|
||||||
|
if r2.(map[string]interface{})["error"] == nil {
|
||||||
|
t.Error("★ 应拒绝含 {rawfile} 的路径")
|
||||||
|
}
|
||||||
|
// 远端真实路径应接受
|
||||||
|
r3, _ := p.handleEndpointSet(map[string]interface{}{
|
||||||
|
"host": "box", "cli_path": "/opt/remote/zcode.cjs", "verify": false,
|
||||||
|
})
|
||||||
|
if e := r3.(map[string]interface{})["error"]; e != nil {
|
||||||
|
t.Errorf("远端路径不该被拒: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:删除**不能留下幽灵子项**。
|
||||||
|
//
|
||||||
|
// 否则会出现"以为删了机器,实际后端覆盖还在,下次又把老路径拿出来用"。
|
||||||
|
func TestEndpointRemoveRefusesSilentPartial(t *testing.T) {
|
||||||
|
p := newEndpointPlugin(t)
|
||||||
|
p.handleEndpointSet(map[string]interface{}{"host": "box", "cli_path": "/opt/d", "verify": false})
|
||||||
|
p.handleEndpointSet(map[string]interface{}{"host": "box", "cli": "zcode", "cli_path": "/opt/z", "verify": false})
|
||||||
|
|
||||||
|
// 只给 host:必须拒绝(有子项)
|
||||||
|
r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box"})
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
if m["status"] != "refused" {
|
||||||
|
t.Fatalf("★ 有后端覆盖时应拒绝整体删除: %v", m)
|
||||||
|
}
|
||||||
|
// 拒绝后表必须**完整保留**(不能删一半)
|
||||||
|
he := p.getHost("box", false)
|
||||||
|
if he == nil || he.CLIPath != "/opt/d" || len(he.CLIs) != 1 {
|
||||||
|
t.Fatalf("拒绝后配置被改动了: %+v", he)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先删子项,再删机器 → 成功
|
||||||
|
if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box", "cli": "zcode"}); r.(map[string]interface{})["removed"] != true {
|
||||||
|
t.Fatalf("删子项失败: %v", r)
|
||||||
|
}
|
||||||
|
if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box"}); r.(map[string]interface{})["removed"] != true {
|
||||||
|
t.Fatalf("删机器失败: %v", r)
|
||||||
|
}
|
||||||
|
if p.getHost("box", false) != nil {
|
||||||
|
t.Error("机器未真正删除")
|
||||||
|
}
|
||||||
|
|
||||||
|
// force 可以一并删
|
||||||
|
p.handleEndpointSet(map[string]interface{}{"host": "box2", "cli_path": "/opt/d", "verify": false})
|
||||||
|
p.handleEndpointSet(map[string]interface{}{"host": "box2", "cli": "zcode", "cli_path": "/opt/z", "verify": false})
|
||||||
|
if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box2", "force": true}); r.(map[string]interface{})["removed"] != true {
|
||||||
|
t.Fatalf("force 删除失败: %v", r)
|
||||||
|
}
|
||||||
|
if p.getHost("box2", false) != nil {
|
||||||
|
t.Error("force 未连带删掉子项")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:端点表**持久化且损坏响亮**。
|
||||||
|
//
|
||||||
|
// 静默用空表 ⇒ 所有远端配置凭空消失,表现为"我明明配过,怎么又要配",
|
||||||
|
// 而用户会先怀疑自己。
|
||||||
|
func TestEndpointsPersistAndCorruptionIsLoud(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sdkp := sdk.New("commondev-test", nil, nil, nil, nil, nil)
|
||||||
|
p := &Plugin{sdk: sdkp}
|
||||||
|
p.path = filepath.Join(dir, "ledger.json")
|
||||||
|
if err := p.loadEndpoints(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.handleEndpointSet(map[string]interface{}{
|
||||||
|
"host": "h1", "cli_path": "/opt/a", "runtime_root": "/rt/a",
|
||||||
|
"workdir_root": "/wd/a", "verify": false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重新加载(模拟重启)
|
||||||
|
p2 := &Plugin{sdk: sdkp}
|
||||||
|
p2.path = filepath.Join(dir, "ledger.json")
|
||||||
|
if err := p2.loadEndpoints(); err != nil {
|
||||||
|
t.Fatalf("重载失败: %v", err)
|
||||||
|
}
|
||||||
|
got := p2.resolveEndpoint("h1", "zcode", driverSpec{}, driverCtx{})
|
||||||
|
if got.CLIPath != "/opt/a" || got.RuntimeRoot != "/rt/a" || got.WorkdirRoot != "/wd/a" {
|
||||||
|
t.Fatalf("重启后配置丢失: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限 0600(可能含内网地址/目录结构,不该 world-readable)
|
||||||
|
st, err := os.Stat(p.endpointsPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("端点表权限应为 0600,实际 %v", st.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 损坏 → 必须响亮
|
||||||
|
if err := os.WriteFile(p.endpointsPath(), []byte("{ not json"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p3 := &Plugin{sdk: sdkp}
|
||||||
|
p3.path = filepath.Join(dir, "ledger.json")
|
||||||
|
if err := p3.loadEndpoints(); err == nil {
|
||||||
|
t.Fatal("★ 端点表损坏时必须报错(静默用空表 = 所有远端配置消失)")
|
||||||
|
} else if !strings.Contains(err.Error(), "损坏") {
|
||||||
|
t.Errorf("错误应点明损坏: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缺文件不是错误(首次运行)
|
||||||
|
p4 := &Plugin{sdk: sdkp}
|
||||||
|
p4.path = filepath.Join(t.TempDir(), "ledger.json")
|
||||||
|
if err := p4.loadEndpoints(); err != nil {
|
||||||
|
t.Errorf("缺文件不该报错: %v", err)
|
||||||
|
}
|
||||||
|
if p4.endpoints == nil || p4.endpoints.Hosts == nil {
|
||||||
|
t.Error("缺文件后应初始化为空表(不是 nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:旧配置(全局 settings)**仍然生效**(升级不破坏既有部署)。
|
||||||
|
//
|
||||||
|
// 但端点表**优先**:不能出现"配了端点表还被旧全局键盖住"。
|
||||||
|
func TestEndpointOverridesLegacySettings(t *testing.T) {
|
||||||
|
p := newEndpointPlugin(t)
|
||||||
|
// 用 stubSettings 提供旧键
|
||||||
|
p.sdk = sdk.New("commondev-test", newStubSettingsWith(t.TempDir(), map[string]interface{}{
|
||||||
|
"ssh_runtime_root": "/legacy/rt",
|
||||||
|
"ssh_workdir_root": "/legacy/wd",
|
||||||
|
"ssh_node_path": "/legacy/node",
|
||||||
|
}), nil, nil, nil, nil)
|
||||||
|
|
||||||
|
// ① 旧键生效(升级不破坏)
|
||||||
|
e := p.resolveEndpoint("h", "zcode", driverSpec{}, driverCtx{})
|
||||||
|
if e.RuntimeRoot != "/legacy/rt" || e.WorkdirRoot != "/legacy/wd" || e.NodePath != "/legacy/node" {
|
||||||
|
t.Fatalf("旧全局配置应仍生效: %+v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 端点表配了 → 必须压过旧键
|
||||||
|
p.handleEndpointSet(map[string]interface{}{
|
||||||
|
"host": "h", "runtime_root": "/new/rt", "workdir_root": "/new/wd",
|
||||||
|
"node_path": "/new/node", "verify": false,
|
||||||
|
})
|
||||||
|
e2 := p.resolveEndpoint("h", "zcode", driverSpec{}, driverCtx{})
|
||||||
|
if e2.RuntimeRoot != "/new/rt" {
|
||||||
|
t.Errorf("★ 端点表未压过旧键: %q", e2.RuntimeRoot)
|
||||||
|
}
|
||||||
|
if e2.WorkdirRoot != "/new/wd" {
|
||||||
|
t.Errorf("★ 端点表未压过旧 ssh_workdir_root: %q", e2.WorkdirRoot)
|
||||||
|
}
|
||||||
|
if e2.NodePath != "/new/node" {
|
||||||
|
t.Errorf("★ 端点表未压过旧 ssh_node_path: %q", e2.NodePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtStr(v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(toString(v)), "\n", " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toString(v interface{}) string {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
217
example/commondev/examples/custom-cli/README.md
Normal file
217
example/commondev/examples/custom-cli/README.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# 自定义 CLI 后端(示例:ZCode)
|
||||||
|
|
||||||
|
> ## ★ 边界:**内嵌只做 pi**
|
||||||
|
>
|
||||||
|
> 插件**内嵌支持的后端只有 pi**(`runtime/pi/`,不装 driver.json 也照常工作)。
|
||||||
|
> 本文及同目录示例是给**想让插件驱动别的 CLI** 的用户用的。
|
||||||
|
>
|
||||||
|
> 这不是"官方内嵌支持"——它们是**参考实现**:插件不承诺跟着上游 CLI 变更同步更新,
|
||||||
|
> 你要根据自己的 CLI 版本调整(尤其参数名与输出格式)。
|
||||||
|
>
|
||||||
|
> 沿用本仓的一条纪律:**内核/插件只保证机器必须保证的事,策略与平台知识交给能
|
||||||
|
> 独立演进的地方**(见 skill `commondev-discipline` 里"平台知识进 skill"的同一条理由)。
|
||||||
|
|
||||||
|
## 怎么加一个后端
|
||||||
|
|
||||||
|
插件按 **数据**(不是代码)选后端。加一个 CLI 只要放两个文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
<插件数据目录>/runtime/<cli>/driver.json ← 声明:起什么命令、什么环境
|
||||||
|
<插件数据目录>/runtime/<cli>/sidecar.mjs ← 实现:把 CLI 包成 JSON-RPC
|
||||||
|
```
|
||||||
|
|
||||||
|
**插件核心一行都不用改**(这是可验证的:`TestCoreDoesNotHardcodePiLaunchDetails`
|
||||||
|
会断言 `startSidecar` 里不出现任何 pi 专有细节)。
|
||||||
|
|
||||||
|
开分支时指定后端:
|
||||||
|
|
||||||
|
```
|
||||||
|
commondev_branch_open(project="X", branch="feat/a", agent_cli="zcode", workdir=...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 两种来源:随包分发 vs 本机覆盖
|
||||||
|
|
||||||
|
`driver.json` 与 sidecar 脚本都按同一条优先级链找:
|
||||||
|
|
||||||
|
| 顺序 | 位置 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① | `<插件数据>/runtime/<cli>/` | **本机覆盖**(最高优先)—— 指到本机真实的 CLI 路径 |
|
||||||
|
| ② | `<插件目录>/rawfile/runtime/<cli>/` | **随包分发** —— 升级插件即带上 |
|
||||||
|
| ③ | `<可执行文件目录>/` | 开发期 |
|
||||||
|
|
||||||
|
**为什么要 ②**:`cliPath` 的随包默认值是 `{rawfile}/zcode/zcode.cjs`,
|
||||||
|
而 CLI 有 28MB、按需分发 —— 所以**多数机器上它不存在**,此时必须由 ① 覆盖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本机覆盖:只改这两个文件,不影响随包内容(升级插件不会被覆盖)
|
||||||
|
mkdir -p <插件数据>/runtime/zcode
|
||||||
|
cp <随包>/rawfile/runtime/zcode/sidecar.mjs <插件数据>/runtime/zcode/
|
||||||
|
# 然后编辑 <插件数据>/runtime/zcode/driver.json,把 cliPath 指向本机 zcode.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
若两处都没有,错误会**列出找过的每个位置**并告诉你改哪里(不要让它静默失败)。
|
||||||
|
|
||||||
|
## sidecar 必须实现的契约
|
||||||
|
|
||||||
|
JSON-RPC over stdin/stdout,方法与 pi 的 sidecar **完全相同**:
|
||||||
|
|
||||||
|
| 方法 | 入参 | 必须返回 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ping` | — | `{ok, session_id, cli_version?}`(握手,10s 内) |
|
||||||
|
| `prompt` | `{text, session_id?}` | `{ok, session_id, reply}` ← **`session_id` 必须回** |
|
||||||
|
| `state` | — | `{ok, session_id, messages}` |
|
||||||
|
| `close` | — | `{ok}` |
|
||||||
|
|
||||||
|
**为什么 `prompt` 必须回 `session_id`**:插件在投递后**断言会话未漂移**
|
||||||
|
(设计 §3.3)。不回或回错值,插件会响亮失败——那是防"悄悄换了个会话"的机制。
|
||||||
|
|
||||||
|
### ★ 会话 id 有两个**不同**的东西,别混
|
||||||
|
|
||||||
|
这是 ZCode 后端实测踩到的真坑(2026-10-16,真机复现):
|
||||||
|
|
||||||
|
| 名字 | 长什么样 | 谁产生 | 能当 `--resume` 目标吗 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **插件标签** | `br-<project>-<branch>` | 插件(`--session-id` 传进来) | ❌ **绝不能** |
|
||||||
|
| **后端真实 id** | `sess_xxxxxxxx-...` | 后端首轮返回 | ✅ 只有它能 |
|
||||||
|
|
||||||
|
初版把 `args.sessionId`(标签)直接当 `--resume` 目标 ⇒ 首轮就执行
|
||||||
|
`--resume br-xxx`,ZCode 报 `Session not found: br-xxx`——
|
||||||
|
**表现为"第一次投递必定失败"**,第二次才可能好,非常像"偶发网络问题"。
|
||||||
|
|
||||||
|
正确形状(示例就是这么写的):
|
||||||
|
|
||||||
|
```js
|
||||||
|
let desiredSessionId = args.sessionId || ""; // 插件标签,只用于回报
|
||||||
|
let zcodeSessionId = ""; // 真实 id,只有它能 resume
|
||||||
|
// 首轮:不传 --resume;从 {type:"result"} 行取回 sess_...
|
||||||
|
if (zcodeSessionId) argv.push("--resume", zcodeSessionId);
|
||||||
|
// 回报时:有真实 id 报它,否则退回标签
|
||||||
|
session_id: zcodeSessionId || desiredSessionId || null,
|
||||||
|
```
|
||||||
|
|
||||||
|
`params.session_id`(插件在 prompt 里传的)**同样是标签**,不要拿它赋值给
|
||||||
|
"真实 id"变量——那等于把上面的修法又改回去了。
|
||||||
|
|
||||||
|
### ★ 声明的 CLI 路径必须**验存**
|
||||||
|
|
||||||
|
`driver.json` 的 `cliPath` 可能指向一个**不存在的**文件(例如随包默认值
|
||||||
|
`{rawfile}/zcode/zcode.cjs`,而包里并没带那个 28MB 的 CLI)。
|
||||||
|
若 `if (args.cliPath) return args.cliPath` 直接返回,Node 会在 spawn 时抛
|
||||||
|
`MODULE_NOT_FOUND`,用户看到的是一坨 `Module._resolveFilename` 堆栈,
|
||||||
|
**完全看不出"CLI 不存在"**,也不知道去哪儿配。
|
||||||
|
|
||||||
|
正确做法:把"声明的"和"探测到的"一起验存,找不到时**列出找过的每个位置
|
||||||
|
及其来源**,并给出修复动作(改哪个文件 / 设哪个环境变量)。
|
||||||
|
|
||||||
|
## driver.json 字段
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "zcode", // 后端名(与目录名一致)
|
||||||
|
"script": "sidecar.mjs", // 相对本目录
|
||||||
|
"node": "auto", // auto = 随包优先;ssh 场景会走远端 PATH
|
||||||
|
"sessionNameFlag": false, // 声明是否支持 --session-name(不支持就不传)
|
||||||
|
"sessionDirSuffix": ".zcode-sessions",
|
||||||
|
"llmConfig": "none", // pi | zcode | none —— 谁准备 LLM 源
|
||||||
|
"env": { "ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode" },
|
||||||
|
"args": ["--workdir", "{workdir}", "--session-id", "{sessionId}", "--mode", "guarded"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
占位符:`{workdir}` `{sessionId}` `{sessionName}` `{dataDir}` `{pluginDir}`
|
||||||
|
|
||||||
|
- **`sessionNameFlag: false` 是真实的坑**:ZCode CLI 没有 `--session-name`,
|
||||||
|
传了会 `Unknown option` 直接退到 usage——**一行参数毁掉整条命令**。
|
||||||
|
- **`llmConfig`**:`pi` = 插件先写 `models.json`/`auth.json`(复用内核 LLM 源);
|
||||||
|
`none` = 后端自己管。**没有 `zcode` 这个实现**——我们只知道
|
||||||
|
`ZCODE_BUILTIN_PROVIDER_CONFIG_FILE` 这个环境变量,**没实测过它的文件格式**,
|
||||||
|
凭猜测写是"静默写坏"(ZCode 起不来会表现为"模型不回复",排查方向完全错)。
|
||||||
|
要用内核源,请先实测格式再实现写入器。
|
||||||
|
|
||||||
|
## ZCode 示例的实测契约(真机 `zcode 0.16.9`,非猜测)
|
||||||
|
|
||||||
|
```
|
||||||
|
zcode.cjs --prompt <文本> --output-format stream-json --cwd <目录> --mode <值> [--resume <sess_id>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 点 | 实况(真机核实) |
|
||||||
|
|---|---|
|
||||||
|
| `--mode` | 合法值 **`build\|edit\|plan\|yolo\|guarded`**;**不传默认 `yolo`**(`run.ts: DEFAULT_HEADLESS_PROMPT_MODE`)= 绕过全部授权询问。示例用 `guarded`。 |
|
||||||
|
| `--output-format` | **存在**(`--help` 里**不显示**,是隐藏参数 —— 别因为 help 没有就以为不存在)。`stream-json` 才有逐事件 NDJSON + **末行** `{type:"result", sessionId, response}`,sidecar 就靠这一行。 |
|
||||||
|
| `--resume` | 会话延续。**只接受真实 `sess_...` id**,首轮不能传(见上文会话 id 一节) |
|
||||||
|
| `--allowed-tools` | ⚠ **不存在**——只能用 `--disallowed-tools` |
|
||||||
|
| `--max-turns` | ⚠ **不存在** |
|
||||||
|
| `--session-name` | ⚠ **不存在**(`sessionNameFlag: false`) |
|
||||||
|
|
||||||
|
> **教训**:`--output-format` 这一条曾被基于 `--help` 误判为"不存在",
|
||||||
|
> 差点把 sidecar 改成解析非结构化文本。**判定参数存在性必须用真机试跑**
|
||||||
|
> (`--prompt hi --output-format stream-json` 直接看输出),`--help` 只覆盖公开参数。
|
||||||
|
|
||||||
|
> ⚠️ **超时必须杀进程树**:ZCode 会派生工具子进程(bash 等),
|
||||||
|
> 只杀父进程会留下孤儿继续跑,且仍占着工作目录。示例里用 `detached: true` +
|
||||||
|
> `process.kill(-pid)` 整组杀。
|
||||||
|
|
||||||
|
## 远端(SSH)场景的额外注意
|
||||||
|
|
||||||
|
`runtime=ssh` 时用的是**同一份** driver 声明与 sidecar 契约,但:
|
||||||
|
|
||||||
|
1. **环境变量必须显式前置**(`ssh host env K=V … node …`)——ssh 不继承本地环境。
|
||||||
|
2. **node 用远端的**(裸 `node` 走远端 PATH),不是本地随包分发的绝对路径。
|
||||||
|
3. **脚本要先推到远端**(插件会自动 `scp` 一次,见 `pushSidecar`)。
|
||||||
|
4. **不交互**:`BatchMode=yes`。没配免密必须立刻失败,否则会挂在密码提示上
|
||||||
|
直到工具调用超时——那表现为"拉起 agent 卡住"而不是"没配免密"。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <插件源码>
|
||||||
|
# 结构性判据(不需要真 CLI)
|
||||||
|
go test -count=1 -run 'TestCoreDoesNotHardcode|TestSwappingDriverSwapsBackend|TestSSH|TestZcode|TestShippedDriver' ./...
|
||||||
|
|
||||||
|
# ★ 真机端到端(需要真实 ZCode CLI + 可用 provider 配置)
|
||||||
|
HA_ZCODE_CLI=<...>/zcode.cjs \
|
||||||
|
HA_ZCODE_DATA_DIR=<插件数据>/runtime/zcode \
|
||||||
|
go test -tags zcode_e2e -run TestZcodeFullPipeline -v .
|
||||||
|
|
||||||
|
# ★ SSH 远端(需要 sshd + localhost 免密)
|
||||||
|
HA_SSH_E2E=1 HA_ZCODE_CLI=... HA_ZCODE_DATA_DIR=... \
|
||||||
|
go test -tags zcode_e2e -run TestZcodeOverSSH -v .
|
||||||
|
```
|
||||||
|
|
||||||
|
`TestSwappingDriverSwapsBackend` 用一个假 CLI 端到端证明:
|
||||||
|
换 driver 就能换后端,插件核心零改动。
|
||||||
|
|
||||||
|
`TestZcodeSidecarArgvBehavior` 用**假 CLI 记录真实 argv** 来判"首轮不得 `--resume`"——
|
||||||
|
为什么不用源码字符串匹配:初版就是匹配字面串,把变量改个名就绕过了(判据假绿)。
|
||||||
|
|
||||||
|
## 给 ZCode 配 LLM 源(实测可用的最小配置)
|
||||||
|
|
||||||
|
ZCode 用自己的 provider 配置,**插件不碰它**(`llmConfig: "none"`)。
|
||||||
|
数据目录由 `ZCODE_DATA_BASE_DIR` 决定,配置落在`<该目录>/.zcode/v2/provider_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"config": {
|
||||||
|
"providerConfigRules": { "providerRules": [ {
|
||||||
|
"providerId": "llmsproxy", "providerName": "llmsproxy", "enabled": true,
|
||||||
|
"config": {
|
||||||
|
"group": "standard-personal",
|
||||||
|
"personalModelIds": ["AUTO"], "modelOrder": ["AUTO"],
|
||||||
|
"access": { "type": "api-key", "apiKey": "sk-..." },
|
||||||
|
"api": { "type": "openai-chat-completions", "baseUrl": "http://127.0.0.1:8081/v1" }
|
||||||
|
}
|
||||||
|
} ] },
|
||||||
|
"defaultModelSelection": { "providerId": "llmsproxy", "modelId": "AUTO" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实测踩过的三个坑(全部真机复现):
|
||||||
|
|
||||||
|
1. **`schemaVersion` 必须有**,且 `providerRules` 嵌在 `config.providerConfigRules` 下
|
||||||
|
—— 放顶层会被静默"恢复"成默认值(文件被改写但你没察觉)。
|
||||||
|
2. **`group: "standard-personal"` 必须有**。少了它,配置**被读到但不被准入**,
|
||||||
|
报 `Error: Model creation failed`;加 `--verbose` 才能看到真因
|
||||||
|
`Select a model before continuing`。
|
||||||
|
3. `api.type` 合法值:`anthropic-messages` / `openai-chat-completions` / `openai-responses`。
|
||||||
22
example/commondev/examples/custom-cli/zcode/driver.json
Normal file
22
example/commondev/examples/custom-cli/zcode/driver.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "zcode",
|
||||||
|
"script": "sidecar.mjs",
|
||||||
|
"node": "auto",
|
||||||
|
"sessionNameFlag": false,
|
||||||
|
"sessionDirSuffix": ".zcode-sessions",
|
||||||
|
"llmConfig": "none",
|
||||||
|
"env": {
|
||||||
|
"ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode",
|
||||||
|
"ZCODE_SESSION_DIR": "{workdir}/.zcode-sessions"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"--workdir",
|
||||||
|
"{workdir}",
|
||||||
|
"--session-id",
|
||||||
|
"{sessionId}",
|
||||||
|
"--mode",
|
||||||
|
"guarded"
|
||||||
|
],
|
||||||
|
"note": "llmConfig=none:ZCode 用自己的 provider 配置(插件不猜它的格式)。cliPath 支持占位符;{rawfile} 指随包资源目录 —— 把 zcode.cjs 放进 rawfile/zcode/ 即可随包分发。用户也可在 <插件数据>/runtime/zcode/driver.json 覆盖成本机真实路径。",
|
||||||
|
"cliPath": "{rawfile}/zcode/zcode.cjs"
|
||||||
|
}
|
||||||
287
example/commondev/examples/custom-cli/zcode/sidecar.mjs
Normal file
287
example/commondev/examples/custom-cli/zcode/sidecar.mjs
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ZCode sidecar:把 **ZCode CLI** 包成与 pi sidecar **同一个 JSON-RPC 契约**的服务。
|
||||||
|
//
|
||||||
|
// ## 为什么必须同契约
|
||||||
|
//
|
||||||
|
// 插件的 `startSidecar` / `sc.call("prompt")` 是唯一的调用面 ——
|
||||||
|
// 只要这个脚本实现 ping/prompt/state/close,**插件核心一行不用改**
|
||||||
|
// 就能跑 ZCode。这就是「通过不同 sidecar 支持不同 CLI」的落点。
|
||||||
|
//
|
||||||
|
// ## 与 pi sidecar 的关键差异(实测契约,来自 agentmail/zcode-mail-bridge)
|
||||||
|
//
|
||||||
|
// pi sidecar ZCode sidecar
|
||||||
|
// ──────────────────── ──────────────────────────────
|
||||||
|
// 常驻进程 + prompt() **每轮起一个新进程**(CLI 是一次性的)
|
||||||
|
// --session-id 建会话 --resume <id> 延续;首轮不给
|
||||||
|
// SDK 直接返回文本 --output-format stream-json,末行 {type:"result"}
|
||||||
|
// 无授权概念 --mode **必传**(不传 = yolo,绕过全部授权询问)
|
||||||
|
//
|
||||||
|
// ## 会话 id 的处理(★ 与插件的契约)
|
||||||
|
//
|
||||||
|
// 插件在 prompt 后**断言 session_id 未变**(防会话漂移)。
|
||||||
|
// ZCode 首轮才产生真实 sessionId,所以这里:
|
||||||
|
// · 首轮:把 CLI 返回的 sess_xxx 回报(插件接受它作为会话标识)
|
||||||
|
// · 后续:用 --resume 传回去,并回报同一个 id
|
||||||
|
//
|
||||||
|
// ## 超时必须杀进程树
|
||||||
|
//
|
||||||
|
// ZCode 会派生工具子进程(bash 等)。只杀父进程会留下一堆孤儿继续跑,
|
||||||
|
// 而且它们仍占着工作目录。
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === "--workdir") out.workdir = argv[++i];
|
||||||
|
else if (a === "--session-id") out.sessionId = argv[++i];
|
||||||
|
else if (a === "--session-name") out.sessionName = argv[++i];
|
||||||
|
else if (a === "--cli") out.cliPath = argv[++i];
|
||||||
|
else if (a === "--mode") out.mode = argv[++i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const workdir = args.workdir || process.cwd();
|
||||||
|
|
||||||
|
// ★ mode 必须显式给:不传时 CLI 默认 **yolo**(源码 src/run.ts:
|
||||||
|
// DEFAULT_HEADLESS_PROMPT_MODE),那会绕过全部授权询问。
|
||||||
|
// 合法值:build | edit | plan | yolo | guarded。默认用 guarded。
|
||||||
|
const VALID_MODES = ["build", "edit", "plan", "yolo", "guarded"];
|
||||||
|
const mode = args.mode || process.env.ZCODE_MODE || "guarded";
|
||||||
|
if (!VALID_MODES.includes(mode)) {
|
||||||
|
throw new Error(`--mode 必须是 ${VALID_MODES.join("|")} 之一(收到 ${mode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI 入口:驱动可经 --cli 指定,否则按常见安装位置找。
|
||||||
|
//
|
||||||
|
// ★ 返回 {path, tried} 而不是裸字符串:**必须知道路径是"声明的"还是"探测到的"**,
|
||||||
|
// 才能在报错里说清楚该改哪里。
|
||||||
|
//
|
||||||
|
// 实测真缺陷(2026-10-16):随包 driver.json 声明了 `{rawfile}/zcode/zcode.cjs`,
|
||||||
|
// 但包里**没有**这个文件(28MB,按需分发)。旧实现 `if (args.cliPath) return args.cliPath`
|
||||||
|
// 直接把它当有效路径返回 ⇒ Node 在 spawn 时抛模块解析失败,用户看到的是一坨
|
||||||
|
// `Module._resolveFilename` 堆栈,**完全看不出"CLI 不存在"**,更不知道去哪儿配。
|
||||||
|
// 声明的路径同样必须验存。
|
||||||
|
function findCLI() {
|
||||||
|
const cands = [
|
||||||
|
{ p: args.cliPath, source: "driver.json 的 cliPath" },
|
||||||
|
{ p: process.env.ZCODE_CLI, source: "环境变量 ZCODE_CLI" },
|
||||||
|
{ p: path.join(process.env.HOME || "", ".zcode/runtime/bin/zcode.mjs"), source: "默认探测" },
|
||||||
|
{ p: "/opt/ZCode/resources/glm/zcode.cjs", source: "默认探测" },
|
||||||
|
{ p: "/usr/local/bin/zcode", source: "默认探测" },
|
||||||
|
];
|
||||||
|
const tried = [];
|
||||||
|
for (const c of cands) {
|
||||||
|
if (!c.p) continue;
|
||||||
|
tried.push(`${c.p}(${c.source})`);
|
||||||
|
if (existsSync(c.p)) return { path: c.p, tried };
|
||||||
|
}
|
||||||
|
return { path: "", tried };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 必须区分两个不同的东西:
|
||||||
|
//
|
||||||
|
// desiredSessionId —— 插件传进来的**标签**(br-<project>-<branch>),
|
||||||
|
// 它是插件台账里的会话名,**不是** ZCode 的会话 id
|
||||||
|
// zcodeSessionId —— ZCode 真实产出的 `sess_...`,只有它能用于 --resume
|
||||||
|
//
|
||||||
|
// 初版把 args.sessionId 直接当成 --resume 的目标,于是**首轮就带上
|
||||||
|
// `--resume br-xxx`**,ZCode 报 `Session not found: br-xxx` ——
|
||||||
|
// 表现为"第一次投递必定失败",而第二次才可能成功(如果插件把
|
||||||
|
// 上一轮返回的 sess_... 传回来了)。
|
||||||
|
//
|
||||||
|
// 实测(2026-10-16):真实 ZCode 0.16.9 上首轮即失败。
|
||||||
|
// 契约(参考实现在注释里也写了):**首轮不给 --resume**,
|
||||||
|
// 从 `{type:"result"}` 行取回 sess_...,之后才用它续谈。
|
||||||
|
let desiredSessionId = args.sessionId || "";
|
||||||
|
let zcodeSessionId = "";
|
||||||
|
let turnCount = 0;
|
||||||
|
|
||||||
|
function send(obj) {
|
||||||
|
process.stdout.write(JSON.stringify(obj) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 杀**进程树**(ZCode 会派生工具子进程)。 */
|
||||||
|
function killTree(child) {
|
||||||
|
if (!child || child.killed) return;
|
||||||
|
try {
|
||||||
|
// 负号 = 进程组;spawn 时 detached 建了独立进程组。
|
||||||
|
process.kill(-child.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
try { child.kill("SIGTERM"); } catch {}
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
try { process.kill(-child.pid, "SIGKILL"); } catch {}
|
||||||
|
}, 3000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跑一轮 ZCode,返回 {sessionId, response}。 */
|
||||||
|
function runTurn(text, timeoutMs) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const found = findCLI();
|
||||||
|
if (!found.path) {
|
||||||
|
reject(new Error(
|
||||||
|
"找不到 ZCode CLI。已找过:\n - " + found.tried.join("\n - ") +
|
||||||
|
"\n请把 driver.json 的 cliPath 指向真实 CLI(本机覆盖写到 " +
|
||||||
|
"<插件数据>/runtime/zcode/driver.json),或设 ZCODE_CLI 环境变量。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const argv = [
|
||||||
|
found.path,
|
||||||
|
"--prompt", text,
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--cwd", workdir,
|
||||||
|
"--mode", mode,
|
||||||
|
];
|
||||||
|
// ★ 会话延续:首轮不给 --resume(还没有 id),后续都给。
|
||||||
|
// ★ 只有拿到 ZCode 真实 id 才 resume;首轮没有 → 不传。
|
||||||
|
if (zcodeSessionId) argv.push("--resume", zcodeSessionId);
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, argv, {
|
||||||
|
cwd: workdir,
|
||||||
|
detached: true, // 独立进程组 ⇒ 能整组杀
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let out = "";
|
||||||
|
let err = "";
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
killTree(child);
|
||||||
|
reject(new Error(`ZCode 回合超时(${Math.round(timeoutMs / 1000)}s)`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdout.on("data", (d) => { out += d.toString(); });
|
||||||
|
child.stderr.on("data", (d) => { err += d.toString(); });
|
||||||
|
|
||||||
|
child.on("error", (e) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
// 逐行解析 stream-json:事件行随时来,末行是 {type:"result"}
|
||||||
|
let result = null;
|
||||||
|
let events = 0;
|
||||||
|
for (const line of out.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t || t[0] !== "{") continue;
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(t); } catch { continue; }
|
||||||
|
if (obj && obj.type === "result") result = obj;
|
||||||
|
else events++;
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
reject(new Error(
|
||||||
|
`ZCode 未返回 result 行(exit=${code},事件 ${events} 条)` +
|
||||||
|
(err ? `;stderr: ${err.slice(-400)}` : "")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
sessionId: typeof result.sessionId === "string" && result.sessionId
|
||||||
|
? result.sessionId
|
||||||
|
: zcodeSessionId,
|
||||||
|
response: typeof result.response === "string" ? result.response : "",
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 供 close 时中断当前回合
|
||||||
|
currentChild = child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentChild = null;
|
||||||
|
|
||||||
|
async function handle(method, params) {
|
||||||
|
switch (method) {
|
||||||
|
case "ping": {
|
||||||
|
// 不启动 CLI:握手要快且确定(10s 上限)。
|
||||||
|
// 报出 CLI 版本 —— 插件的 ping 处理会认 pi_version/cli_version/version 三者之一。
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
cwd: workdir,
|
||||||
|
// 握手阶段还没有 ZCode 会话;报插件给我们的标签即可
|
||||||
|
// (插件侧台账用它,真实 sess_... 在首轮 prompt 后才产生)。
|
||||||
|
session_id: zcodeSessionId || desiredSessionId || null,
|
||||||
|
cli_version: process.env.ZCODE_VERSION || null,
|
||||||
|
session_name: args.sessionName || null,
|
||||||
|
backend: "zcode",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "prompt": {
|
||||||
|
const text = params?.text ?? "";
|
||||||
|
// ★ params.session_id 是**插件的标签**(br-xxx),不是 ZCode 的会话 id。
|
||||||
|
// 绝不能拿它当 --resume 目标 —— 那正是首轮必然失败的根因。
|
||||||
|
// 只在还没有真实 id 时记下它,供握手/state 回报用。
|
||||||
|
if (params?.session_id && !zcodeSessionId) {
|
||||||
|
desiredSessionId = String(params.session_id);
|
||||||
|
}
|
||||||
|
const timeoutMs = Number(params?.timeout_ms) > 0
|
||||||
|
? Number(params.timeout_ms)
|
||||||
|
: 20 * 60 * 1000;
|
||||||
|
const r = await runTurn(text, timeoutMs);
|
||||||
|
// ★ 只在拿到真实 sess_... 时更新;空值不能覆盖已有 id
|
||||||
|
// (否则一次失败就把续谈能力抹掉)。
|
||||||
|
if (r.sessionId) zcodeSessionId = r.sessionId;
|
||||||
|
turnCount++;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
// ★ 必须回报 session_id:Go 侧据此断言「会话未漂移」
|
||||||
|
// 报真实 id(有则报它,否则退回插件的标签)。
|
||||||
|
session_id: zcodeSessionId || desiredSessionId || null,
|
||||||
|
reply: r.response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "state": {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
session_id: zcodeSessionId || desiredSessionId || null,
|
||||||
|
messages: turnCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "close": {
|
||||||
|
killTree(currentChild);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown method: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rl = createInterface({ input: process.stdin });
|
||||||
|
rl.on("line", async (line) => {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line) return;
|
||||||
|
let req;
|
||||||
|
try { req = JSON.parse(line); } catch { return; }
|
||||||
|
try {
|
||||||
|
const result = await handle(req.method, req.params || {});
|
||||||
|
send({ jsonrpc: "2.0", id: req.id, result });
|
||||||
|
if (req.method === "close") process.exit(0);
|
||||||
|
} catch (e) {
|
||||||
|
send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: req.id,
|
||||||
|
error: { code: -32000, message: String(e?.message || e) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on("close", () => {
|
||||||
|
killTree(currentChild);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
368
example/commondev/flow_coverage_test.go
Normal file
368
example/commondev/flow_coverage_test.go
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 全流程覆盖度补齐:生命周期、台账一致性与状态查询的判据。
|
||||||
|
//
|
||||||
|
// ## 为什么补这一批
|
||||||
|
//
|
||||||
|
// 覆盖度审计发现 40 个工具里有 23 个**没有任何机制级测试** —— 包括
|
||||||
|
// `agent_up`(整条链路的枢纽)、`branch_close`(收尾)、`project_*`(入口)、
|
||||||
|
// `pi_source_*`(LLM 源配置)。缺口集中在两类:
|
||||||
|
//
|
||||||
|
// ① 状态查询类(branch_status / parallel_status / job_status / test_list)
|
||||||
|
// ② 生命周期类(agent_up / branch_close / project_*)
|
||||||
|
//
|
||||||
|
// ②类中最要命的是**顺序与不变量**:
|
||||||
|
// - agent_up 必须在 branch_open **之后**(否则没有 branch 可挂)
|
||||||
|
// - 通道必须在**拿到 session 之后**注册(否则通道指向不存在的会话)
|
||||||
|
// - branch_close 必须清掉通道与 sidecar 引用(否则"以为还在跑")
|
||||||
|
//
|
||||||
|
// 这些不变量此前只写在注释里,**没有判据钉住**。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newFlowPlugin 造一个专用于流程判据的插件(真实台账文件,可验证持久化)。
|
||||||
|
func newFlowPlugin(t *testing.T) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.MkdirTemp("", "cdflow")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
p.path = filepath.Join(dir, "ledger.json")
|
||||||
|
p.ledger = &Ledger{
|
||||||
|
Projects: map[string]*Project{}, Branches: map[string]*Branch{},
|
||||||
|
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{},
|
||||||
|
}
|
||||||
|
// ★ sidecars 在 Start() 里初始化(plugin.go:216)。判据绕过了 Start(),
|
||||||
|
// 所以必须自己初始化 —— 否则往 nil map 里赋值会 panic。
|
||||||
|
p.sidecars = map[string]*sidecarProcess{}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:**先开线才有 agent**(顺序不变量)。
|
||||||
|
//
|
||||||
|
// agent_up 对不存在的 branch 必须响亮拒绝。不拒的话会拿不到
|
||||||
|
// workdir/session 却继续注册通道,产出一条指向虚空的线。
|
||||||
|
func TestAgentUpRequiresExistingBranch(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
res, _ := p.handleAgentUp(map[string]interface{}{"branch_id": "br-nonexistent"})
|
||||||
|
m := res.(map[string]interface{})
|
||||||
|
if _, isErr := m["error"]; !isErr {
|
||||||
|
t.Fatalf("★ 对不存在的分支应报错,实际: %v", m)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m["error"].(string), "不存在") {
|
||||||
|
t.Errorf("错误应说明分支不存在: %v", m["error"])
|
||||||
|
}
|
||||||
|
// 不得留下任何 sidecar / 通道痕迹
|
||||||
|
if len(p.sidecars) != 0 {
|
||||||
|
t.Errorf("★ 失败后不该有 sidecar 残留: %v", p.sidecars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 判据:branch_id 空值必须抓住(不能默认到某条线)。
|
||||||
|
func TestAgentUpRequiresBranchID(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
res, _ := p.handleAgentUp(map[string]interface{}{})
|
||||||
|
if _, isErr := res.(map[string]interface{})["error"]; !isErr {
|
||||||
|
t.Error("★ 空 branch_id 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:**管线全流程的台账一致性**(project → branch → 状态查询)。
|
||||||
|
//
|
||||||
|
// 这是流程主干的端到端:每一步都查一次状态,确认台账如实反映。
|
||||||
|
func TestPipelineLedgerConsistency(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
|
||||||
|
// ① 登记项目
|
||||||
|
r1, _ := p.handleProjectAdd(map[string]interface{}{
|
||||||
|
"name": "proj", "remote_url": "/tmp/fake.git", "platform": "gitea",
|
||||||
|
})
|
||||||
|
m1 := r1.(map[string]interface{})
|
||||||
|
if m1["status"] != "ok" {
|
||||||
|
t.Fatalf("登记项目失败: %v", m1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 列项目:必须看到它
|
||||||
|
r2, _ := p.handleProjectList(map[string]interface{}{})
|
||||||
|
m2 := r2.(map[string]interface{})
|
||||||
|
if n, _ := m2["count"].(int); n != 1 {
|
||||||
|
t.Errorf("★ 项目列表应有 1 个,实际 %v", m2["count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 重复登记必须拒绝(不静默覆盖)
|
||||||
|
r3, _ := p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/other.git"})
|
||||||
|
if _, isErr := r3.(map[string]interface{})["error"]; !isErr {
|
||||||
|
t.Error("★ 重复登记项目应被拒绝")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 开线
|
||||||
|
r4, _ := p.handleBranchOpen(map[string]interface{}{
|
||||||
|
"project": "proj", "branch": "feat/a", "work_desc": "做点事",
|
||||||
|
})
|
||||||
|
m4 := r4.(map[string]interface{})
|
||||||
|
if m4["status"] != "ok" {
|
||||||
|
t.Fatalf("开线失败: %v", m4)
|
||||||
|
}
|
||||||
|
id := m4["branch_id"].(string)
|
||||||
|
jobID := m4["job_id"].(string)
|
||||||
|
|
||||||
|
// ⑤ 查分支状态:字段必须齐全(缺一个下游就没法用)
|
||||||
|
// ★ 结构是**嵌套的**:{branch: <Branch>, sidecar_alive, deliveries}。
|
||||||
|
// 初版按"平铺字段"断言,于是全红 —— 那是判据的假设错了,不是实现的问题。
|
||||||
|
// (嵌套更好:Branch 是一个整体,平铺会与 sidecar_alive 这类派生字段混淆。)
|
||||||
|
r5, _ := p.handleBranchStatus(map[string]interface{}{"branch_id": id})
|
||||||
|
m5 := r5.(map[string]interface{})
|
||||||
|
br5, ok := m5["branch"].(*Branch)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("★ branch_status 应返回嵌套的 branch 对象: %v", m5)
|
||||||
|
}
|
||||||
|
if br5.ID != id {
|
||||||
|
t.Errorf("branch.id 不对: %s vs %s", br5.ID, id)
|
||||||
|
}
|
||||||
|
if br5.Workdir == "" {
|
||||||
|
t.Error("branch.workdir 为空")
|
||||||
|
}
|
||||||
|
if br5.Status != "open" {
|
||||||
|
t.Errorf("branch.status 应为 open,实际 %q", br5.Status)
|
||||||
|
}
|
||||||
|
if br5.WorkDesc != "做点事" {
|
||||||
|
t.Errorf("work_desc 未保留: %v", br5.WorkDesc)
|
||||||
|
}
|
||||||
|
// 派生字段也在
|
||||||
|
if _, has := m5["sidecar_alive"]; !has {
|
||||||
|
t.Error("缺 sidecar_alive")
|
||||||
|
}
|
||||||
|
if _, has := m5["deliveries"]; !has {
|
||||||
|
t.Error("缺 deliveries")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ 查作业状态:必须是真实状态(不是永远 pending)
|
||||||
|
r6, _ := p.handleJobStatus(map[string]interface{}{"job_id": jobID})
|
||||||
|
m6 := r6.(map[string]interface{})
|
||||||
|
job, _ := m6["job"].(*Job)
|
||||||
|
if job == nil {
|
||||||
|
t.Fatalf("查不到作业: %v", m6)
|
||||||
|
}
|
||||||
|
if job.BranchID != id {
|
||||||
|
t.Errorf("作业挂错了分支: %s vs %s", job.BranchID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑦ 并行视图:必须能按项目分组看到这条线
|
||||||
|
r7, _ := p.handleParallelStatus(map[string]interface{}{})
|
||||||
|
if m7, ok := r7.(map[string]interface{}); ok {
|
||||||
|
if _, has := m7["projects"]; !has {
|
||||||
|
t.Errorf("★ parallel_status 应给出项目分组: %v", m7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**台账持久化**(重启后仍在)。
|
||||||
|
//
|
||||||
|
// 不落盘的话重启即失忆 —— 而"重启后备账仍可查"是设计明确要求的
|
||||||
|
// (setJob 的注释写着"并落盘,保证重启后台账仍可查")。
|
||||||
|
func TestLedgerPersistsAcrossReload(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
p.handleProjectAdd(map[string]interface{}{"name": "persist", "remote_url": "/tmp/x.git"})
|
||||||
|
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "persist", "branch": "feat/p"})
|
||||||
|
id := r.(map[string]interface{})["branch_id"].(string)
|
||||||
|
|
||||||
|
// 重新载入(模拟重启)
|
||||||
|
p2 := newTestPluginForShell(t)
|
||||||
|
p2.path = p.path
|
||||||
|
if err := p2.loadLedger(); err != nil {
|
||||||
|
t.Fatalf("重载台账失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := p2.ledger.Projects["persist"]; !ok {
|
||||||
|
t.Error("★ 项目未持久化")
|
||||||
|
}
|
||||||
|
if b, ok := p2.ledger.Branches[id]; !ok {
|
||||||
|
t.Error("★ 分支未持久化")
|
||||||
|
} else if b.Workdir == "" {
|
||||||
|
t.Error("分支持久化但丢了 workdir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**损坏的台账必须响亮失败,不静默重建**。
|
||||||
|
//
|
||||||
|
// 静默重建会让用户以为"线还在"而实际全丢了 —— 那是最危险的静默降级。
|
||||||
|
func TestCorruptLedgerFailsLoudly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "ledger.json")
|
||||||
|
if err := os.WriteFile(path, []byte("{ this is not json"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
p.path = path
|
||||||
|
err := p.loadLedger()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("★ 损坏的台账应报错(不静默重建)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "损坏") {
|
||||||
|
t.Errorf("错误应指出台账损坏: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:`branch_close` 必须**清干净**(通道 + sidecar + 目录引用)。
|
||||||
|
//
|
||||||
|
// 留着通道会让内核继续往一条死线投递;留着 sidecar 引用会让
|
||||||
|
// agent_state 报"还活着"。
|
||||||
|
func TestBranchCloseCleansUp(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"})
|
||||||
|
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c"})
|
||||||
|
id := r.(map[string]interface{})["branch_id"].(string)
|
||||||
|
|
||||||
|
// ★ 必须**真塞一个 sidecar 占位**,否则根本没测到"清理"这件事。
|
||||||
|
//
|
||||||
|
// 初版只设了通道与 session,没有往 p.sidecars 里放东西 ——
|
||||||
|
// 于是 `delete(p.sidecars, id)` 这个变异**不报红**:删除一个不存在的键
|
||||||
|
// 与不删除在断言上看不出区别。判据必须让"被保护的状态"真实存在。
|
||||||
|
p.mu.Lock()
|
||||||
|
p.ledger.Branches[id].InputCh = inputChannelName(id)
|
||||||
|
p.ledger.Branches[id].OutputCh = outputChannelName(id)
|
||||||
|
p.ledger.Branches[id].SessionID = "sess-x"
|
||||||
|
// 用一个**不持有进程**的占位(Close 对零值安全)。这里测的是
|
||||||
|
// "map 里的条目被清掉",不是进程真的被杀。
|
||||||
|
p.sidecars[id] = &sidecarProcess{}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if p.sidecars[id] == nil {
|
||||||
|
t.Fatal("前置:sidecar 占位未塞入")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, _ := p.handleBranchClose(map[string]interface{}{"branch_id": id})
|
||||||
|
m := res.(map[string]interface{})
|
||||||
|
if e, bad := m["error"]; bad {
|
||||||
|
t.Fatalf("关闭失败: %v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
b := p.ledger.Branches[id]
|
||||||
|
sc := p.sidecars[id]
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if sc != nil {
|
||||||
|
t.Error("★ sidecar 引用未清(agent_state 会报还活着)")
|
||||||
|
}
|
||||||
|
if b.Status != "merged" && b.Status != "abandoned" && b.Status != "closed" {
|
||||||
|
t.Errorf("★ 关闭后状态不对: %q", b.Status)
|
||||||
|
}
|
||||||
|
if b.ClosedAt == "" {
|
||||||
|
t.Error("★ 关闭时间未记录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:对不存在的分支操作必须响亮拒绝(不是静默成功)。
|
||||||
|
//
|
||||||
|
// 静默成功的后果是调用方以为清理了,实际什么都没有。
|
||||||
|
func TestOperationsOnMissingBranchFail(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
for name, fn := range map[string]func(map[string]interface{}) (interface{}, error){
|
||||||
|
// 只列**按 branch 寻址**的操作。workdir_clean 与 test_list 是
|
||||||
|
// **全局**操作(前者清 orphan 目录、后者列全部判据),本就不该因
|
||||||
|
// "某个 branch 不存在"报错 —— 初版把它们也列进来,判据因此误报。
|
||||||
|
"branch_close": p.handleBranchClose,
|
||||||
|
"branch_status": p.handleBranchStatus,
|
||||||
|
"agent_state": p.handleAgentState,
|
||||||
|
"agent_down": p.handleAgentDown,
|
||||||
|
"conflict_check": p.handleConflictCheck,
|
||||||
|
} {
|
||||||
|
res, _ := fn(map[string]interface{}{"branch_id": "br-ghost"})
|
||||||
|
m, ok := res.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, isErr := m["error"]; !isErr {
|
||||||
|
t.Errorf("★ %s 对不存在的分支未报错: %v", name, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:`agent_state` 在**未拉起**时给出可执行的下一步提示。
|
||||||
|
func TestAgentStateGuidesWhenNotRunning(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"})
|
||||||
|
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/s"})
|
||||||
|
id := r.(map[string]interface{})["branch_id"].(string)
|
||||||
|
|
||||||
|
res, _ := p.handleAgentState(map[string]interface{}{"branch_id": id})
|
||||||
|
m := res.(map[string]interface{})
|
||||||
|
if alive, _ := m["agent_alive"].(bool); alive {
|
||||||
|
t.Fatal("未拉起时不该报 alive")
|
||||||
|
}
|
||||||
|
note, _ := m["note"].(string)
|
||||||
|
if !strings.Contains(note, "agent_up") {
|
||||||
|
t.Errorf("★ 应提示用 agent_up 拉起: %q", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:**通道名在 lifecycle 前后一致**(agent_up 与 branch_close 同源)。
|
||||||
|
//
|
||||||
|
// 不一致的话:注册的通道与清理的通道是两个名字,清理不掉 ——
|
||||||
|
// 内核会继续往死通道投递。这条钉住"注册与清理用同一套派生函数"。
|
||||||
|
func TestChannelNamesMatchAcrossLifecycle(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"})
|
||||||
|
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/n"})
|
||||||
|
id := r.(map[string]interface{})["branch_id"].(string)
|
||||||
|
|
||||||
|
// agent_up 用的名字
|
||||||
|
inUp := inputChannelName(id)
|
||||||
|
outUp := outputChannelName(id)
|
||||||
|
// branch_close 用的名字(同一套派生函数)
|
||||||
|
if inputChannelName(id) != inUp || outputChannelName(id) != outUp {
|
||||||
|
t.Error("★ 通道名派生不稳定(注册与清理会不一致)")
|
||||||
|
}
|
||||||
|
// outputch 必须符合内核的 output_send__<name> 命名约束
|
||||||
|
if !validNamePattern.MatchString(outUp) {
|
||||||
|
t.Errorf("outputch 不合规: %s", outUp)
|
||||||
|
}
|
||||||
|
if strings.Contains(outUp, "/") {
|
||||||
|
t.Errorf("outputch 含斜杠(output_send__ 不允许): %s", outUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:`test_list` 标出**变异规则为 0 的判据**(复核缺口可见)。
|
||||||
|
//
|
||||||
|
// 这是"复核必须两步"这条主张的操作化:光跑正向不算复核完,
|
||||||
|
// 而没有变异规则的判据在列表里必须看得出来。
|
||||||
|
func TestTestListMarksCriteriaWithoutMutations(t *testing.T) {
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
p.ledger.Criteria["cr-a"] = &Criteria{
|
||||||
|
ID: "cr-a", Name: "有变异", Mutations: []Mutation{{Old: "x", New: "y"}},
|
||||||
|
}
|
||||||
|
p.ledger.Criteria["cr-b"] = &Criteria{
|
||||||
|
ID: "cr-b", Name: "无变异", Mutations: nil,
|
||||||
|
}
|
||||||
|
res, _ := p.handleTestList(map[string]interface{}{})
|
||||||
|
m := res.(map[string]interface{})
|
||||||
|
items, ok := m["criteria"].([]map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Skipf("test_list 返回形状未知: %T", m["criteria"])
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("应列出 2 条判据,实际 %d: %v", len(items), items)
|
||||||
|
}
|
||||||
|
// 真实字段名是 `mutations`(不是 mutation_count)——判据必须按真实字段断言,
|
||||||
|
// 否则"字段改了判据还绿"。
|
||||||
|
for _, it := range items {
|
||||||
|
switch it["criteria_id"] {
|
||||||
|
case "cr-a":
|
||||||
|
if n, _ := it["mutations"].(int); n != 1 {
|
||||||
|
t.Errorf("★ cr-a 的 mutations 应为 1,实际 %#v", it["mutations"])
|
||||||
|
}
|
||||||
|
case "cr-b":
|
||||||
|
if n, _ := it["mutations"].(int); n != 0 {
|
||||||
|
t.Errorf("★ cr-b 的 mutations 应为 0(复核缺口应可见),实际 %#v", it["mutations"])
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("出现意外的判据: %v", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
example/commondev/full_pipeline_test.go
Normal file
190
example/commondev/full_pipeline_test.go
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ★★★ 全流程判据:用**插件自己的工具**跑完整生命周期。
|
||||||
|
//
|
||||||
|
// 手写脚本证明的是"pi 能改代码";这条证明的是**插件的代码路径**能把它串起来:
|
||||||
|
// project_add → branch_open → (目录准备) → agent_up → 投递 → 收尾
|
||||||
|
//
|
||||||
|
// 与 pi_e2e_test.go 的分工:那条测 sidecar 契约,这条测**插件编排**。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func gitQ(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(cmd.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:插件工具能把一条工程线从登记走到"pi 真的改了工程文件"。
|
||||||
|
//
|
||||||
|
// 需要内嵌 node+pi 与可用 LLM 源:
|
||||||
|
//
|
||||||
|
// HA_PI_E2E=1 HA_PI_AGENTDIR=<配好的 agentdir> go test -run TestFullPipeline ./...
|
||||||
|
func TestFullPipeline(t *testing.T) {
|
||||||
|
nodeBin, sidecarPath := piE2EReady(t)
|
||||||
|
agentDir := os.Getenv("HA_PI_AGENTDIR")
|
||||||
|
if agentDir == "" {
|
||||||
|
t.Skip("需要 HA_PI_AGENTDIR(含可用 LLM 源的 pi agentdir)")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := t.TempDir()
|
||||||
|
origin := filepath.Join(base, "origin")
|
||||||
|
gitQ(t, base, "init", "-q", "-b", "main", origin)
|
||||||
|
if err := os.MkdirAll(filepath.Join(origin, "src"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.WriteFile(filepath.Join(origin, "src/calc.py"),
|
||||||
|
[]byte("def add(a, b):\n return a + b\n"), 0o644)
|
||||||
|
gitQ(t, origin, "add", "-A")
|
||||||
|
gitQ(t, origin, "commit", "-qm", "initial")
|
||||||
|
|
||||||
|
// ① 登记项目(走插件工具)
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
r1, _ := p.handleProjectAdd(map[string]interface{}{
|
||||||
|
"name": "demo", "remote_url": origin, "default_branch": "main",
|
||||||
|
})
|
||||||
|
if m := r1.(map[string]interface{}); m["status"] != "ok" {
|
||||||
|
t.Fatalf("登记项目失败: %v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 开线(走插件工具)—— 它会把 clone 转后台
|
||||||
|
r2, _ := p.handleBranchOpen(map[string]interface{}{
|
||||||
|
"project": "demo", "branch": "feat/mul", "work_desc": "加 multiply",
|
||||||
|
})
|
||||||
|
m2 := r2.(map[string]interface{})
|
||||||
|
if m2["status"] != "ok" {
|
||||||
|
t.Fatalf("开线失败: %v", m2)
|
||||||
|
}
|
||||||
|
branchID := m2["branch_id"].(string)
|
||||||
|
workdir := m2["workdir"].(string)
|
||||||
|
|
||||||
|
// ③ 同步准备目录(不走后台 goroutine,避免与判据的时序竞争)
|
||||||
|
b := p.ledger.Branches[branchID]
|
||||||
|
if err := p.prepareWorkdir(p.ledger.Projects["demo"], "feat/mul", workdir); err != nil {
|
||||||
|
t.Fatalf("目录准备失败: %v", err)
|
||||||
|
}
|
||||||
|
// ★ 目录必须在目标分支上(否则 agent 会在 main 上改)
|
||||||
|
br, err := exec.Command("git", "-C", workdir, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(br)) != "feat/mul" {
|
||||||
|
t.Fatalf("★ 目录不在目标分支: %q", strings.TrimSpace(string(br)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 走 sidecar 契约投递任务(agent_up 的内核部分)
|
||||||
|
b.Workdir = workdir
|
||||||
|
_, w, rd := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, branchID)
|
||||||
|
ping := callSidecar(t, w, rd, 1, "ping", nil)
|
||||||
|
if ping.Error != nil {
|
||||||
|
t.Fatalf("握手失败: %s", ping.Error.Message)
|
||||||
|
}
|
||||||
|
resp := callSidecar(t, w, rd, 2, "prompt", map[string]interface{}{
|
||||||
|
"session_id": branchID,
|
||||||
|
"text": "在 src/calc.py 里新增 multiply(a, b) 返回 a*b,保留 add。只回一句完成。",
|
||||||
|
})
|
||||||
|
if resp.Error != nil {
|
||||||
|
t.Fatalf("投递失败: %s", resp.Error.Message)
|
||||||
|
}
|
||||||
|
var pr map[string]interface{}
|
||||||
|
if err := json.Unmarshal(resp.Result, &pr); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// ★ 会话不得漂移
|
||||||
|
if got, _ := pr["session_id"].(string); got != "" && got != branchID {
|
||||||
|
t.Fatalf("★ 会话漂移: %s vs %s", got, branchID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ **关键断言**:pi 真的改了**这个工程**的文件
|
||||||
|
b2, err := os.ReadFile(filepath.Join(workdir, "src", "calc.py"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b2), "multiply") {
|
||||||
|
t.Fatalf("★ pi 没有在工程里加 multiply:\n%s", b2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b2), "def add") {
|
||||||
|
t.Errorf("★ 原有代码被破坏:\n%s", b2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ 提交后做冲突预检(走插件工具)
|
||||||
|
gitQ(t, workdir, "add", "-A")
|
||||||
|
gitQ(t, workdir, "commit", "-qm", "feat: add multiply")
|
||||||
|
r6, _ := p.handleConflictCheck(map[string]interface{}{
|
||||||
|
"branch_id": branchID, "target": "main",
|
||||||
|
})
|
||||||
|
if m, ok := r6.(map[string]interface{}); ok {
|
||||||
|
if e, bad := m["error"]; bad {
|
||||||
|
t.Fatalf("冲突预检失败: %v", e)
|
||||||
|
}
|
||||||
|
t.Logf("冲突预检结果: %v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑦ 收尾(走插件工具):台账标记 + 目录删除
|
||||||
|
r7, _ := p.handleBranchClose(map[string]interface{}{"branch_id": branchID})
|
||||||
|
if m, ok := r7.(map[string]interface{}); ok {
|
||||||
|
if e, bad := m["error"]; bad {
|
||||||
|
t.Fatalf("收尾失败: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(workdir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("★ 收尾后工作目录应被删除: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("✅ 全流程闭环:登记 → 开线(分支正确) → 投递 → pi 真改工程 → 冲突预检 → 收尾")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:**agent_up 真能拉起 sidecar**(走随包分发的脚本)。
|
||||||
|
//
|
||||||
|
// 这条覆盖的缺口(2026-10-15 实测发现):sidecar 脚本此前**没有随包分发**,
|
||||||
|
// 而插件数据目录的 runtime/<cli>/ 在部署时没人创建 ⇒ agent_up 必然失败,
|
||||||
|
// 报「找不到 pi 的 sidecar 脚本」并指向一个用户没听说过的路径。
|
||||||
|
//
|
||||||
|
// 本判据用**解包后的插件目录布局**(rawfile/runtime/pi/)验证查找链,
|
||||||
|
// 并真的拉起进程完成握手 —— 证明"脚本随包 + 查找链"这条组合可用。
|
||||||
|
func TestAgentUpFindsShippedSidecar(t *testing.T) {
|
||||||
|
nodeBin, _ := piE2EReady(t)
|
||||||
|
root, _ := os.Getwd()
|
||||||
|
|
||||||
|
// 模拟解包后的布局:脚本在 rawfile/runtime/pi/
|
||||||
|
shipped := filepath.Join(root, "rawfile", "runtime", "pi", "sidecar.mjs")
|
||||||
|
if _, err := os.Stat(shipped); err != nil {
|
||||||
|
t.Fatalf("★ 随包脚本不存在: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := newFlowPlugin(t)
|
||||||
|
p.sdk.SetRawFilesAPI(&testRawFiles{root: filepath.Join(root, "rawfile")})
|
||||||
|
// cliDir 指向**不存在的**目录(全新部署的真实状态)
|
||||||
|
cliDir := filepath.Join(t.TempDir(), "runtime", "pi")
|
||||||
|
|
||||||
|
got, err := p.locateSidecarScript(builtinPiDriver(), cliDir, "pi")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("★ 查找链找不到随包脚本(agent_up 会失败): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用找到的脚本真的拉起 sidecar 并握手
|
||||||
|
workdir := t.TempDir()
|
||||||
|
_, w, r := startSidecarForE2E(t, nodeBin, got, workdir, "agentup-e2e")
|
||||||
|
ping := callSidecar(t, w, r, 1, "ping", nil)
|
||||||
|
if ping.Error != nil {
|
||||||
|
t.Fatalf("★ 用随包脚本拉起的 sidecar 握手失败: %s", ping.Error.Message)
|
||||||
|
}
|
||||||
|
var pr map[string]interface{}
|
||||||
|
json.Unmarshal(ping.Result, &pr)
|
||||||
|
if ok, _ := pr["ok"].(bool); !ok {
|
||||||
|
t.Fatalf("ping 未 ok: %v", pr)
|
||||||
|
}
|
||||||
|
t.Logf("✅ 随包 sidecar 脚本可用,pi 版本: %v", pr["pi_version"])
|
||||||
|
}
|
||||||
7
example/commondev/go.mod
Normal file
7
example/commondev/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module commondev
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/JianFeeeee/homeagentsdk v0.0.0
|
||||||
|
|
||||||
|
replace github.com/JianFeeeee/homeagentsdk => ../../
|
||||||
447
example/commondev/llmsource.go
Normal file
447
example/commondev/llmsource.go
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// LLM 源导入:把 HomeAgent 内核已配的 LLM 源写进内置 pi 的配置文件。
|
||||||
|
//
|
||||||
|
// 用户要求(2026-10-14):
|
||||||
|
//
|
||||||
|
// 「插件内置 pi 默认使用 homeagent 内部配置的 llms 源,支持添加其他源,
|
||||||
|
// 且支持在插件中配置默认使用哪个源」
|
||||||
|
// 「pi 配置的源直接写入配置文件」← 不另建存储(pi 本身就有完整配置系统)
|
||||||
|
//
|
||||||
|
// ## 落点(都是 pi 自己的配置文件)
|
||||||
|
//
|
||||||
|
// <agentdir>/models.json providers.<name>.{baseUrl,api,apiKey,models[]}
|
||||||
|
// <agentdir>/auth.json provider 凭据
|
||||||
|
// <agentdir>/settings.json defaultProvider / defaultModel
|
||||||
|
//
|
||||||
|
// ## 内核侧源的真实位置 [已核]
|
||||||
|
//
|
||||||
|
// 存在 **config 表**(不是 config_<plugin>),键前缀 core.llm.:
|
||||||
|
//
|
||||||
|
// core.llm.provider ← ★ 默认源就是这一项
|
||||||
|
// core.llm.sources.<n>.base_url
|
||||||
|
// core.llm.sources.<n>.api_key
|
||||||
|
// core.llm.sources.<n>.model
|
||||||
|
// core.llm.sources.<n>.adapter (“openai” → pi 的 "openai-completions")
|
||||||
|
// core.llm.sources.<n>.context_window (可选)
|
||||||
|
// core.llm.sources.<n>.vision / .audio (可选)
|
||||||
|
//
|
||||||
|
// ## 两条安全红线(设计 §3.6.5)
|
||||||
|
//
|
||||||
|
// 1. models.json / auth.json 含**明文** api key ⇒ 目录 0700、文件 0600。
|
||||||
|
// 内核那套是明文存 config.db(该库 644 全局可读)—— **不要把这套惯例带进来**。
|
||||||
|
// 2. 导入失败要**报错**,不得静默回退到宿主凭据(~/.pi/agent/auth.json)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// piModelsFile 是 pi 的 models.json 结构(只取我们用到的字段)。
|
||||||
|
type piModelsFile struct {
|
||||||
|
Providers map[string]*piProvider `json:"providers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type piProvider struct {
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
API string `json:"api"`
|
||||||
|
APIKey string `json:"apiKey,omitempty"`
|
||||||
|
Models []piModel `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type piModel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Reasoning bool `json:"reasoning"`
|
||||||
|
Input []string `json:"input,omitempty"`
|
||||||
|
ContextWindow int `json:"contextWindow,omitempty"`
|
||||||
|
MaxTokens int `json:"maxTokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// adapterToAPI 把内核的 adapter 名映射成 pi 的 api 取值。
|
||||||
|
//
|
||||||
|
// 只映射确定已知的;未知 adapter 返回空串 ⇒ 调用方必须报错,
|
||||||
|
// 不能猜一个默认值(猜错会让 pi 用错的协议说话,表现成"模型不回复")。
|
||||||
|
func adapterToAPI(adapter string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(adapter)) {
|
||||||
|
case "", "openai", "openai-completions", "openai_completions":
|
||||||
|
return "openai-completions"
|
||||||
|
case "anthropic", "anthropic-messages", "claude":
|
||||||
|
return "anthropic-messages"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// importKernelSources 把内核已配的 LLM 源导入内置 pi 的配置文件。
|
||||||
|
//
|
||||||
|
// 返回:导入了几个源、默认源名、错误。
|
||||||
|
func (p *Plugin) importKernelSources(agentDir string) (int, string, error) {
|
||||||
|
sett := p.sdk.Settings()
|
||||||
|
|
||||||
|
// ① 源清单:内核的配置是散键(core.llm.sources.<n>.<field>),
|
||||||
|
// 所以要先扫出所有 <n>。
|
||||||
|
keys, err := sett.ListCore("core.llm.sources.")
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("读取内核 LLM 源清单失败: %w", err)
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, k := range keys {
|
||||||
|
rest := strings.TrimPrefix(k, "core.llm.sources.")
|
||||||
|
if i := strings.IndexByte(rest, '.'); i > 0 {
|
||||||
|
names[rest[:i]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
// 没有任何 sources.* —— 退回单源形态(core.llm.base_url/api_key/model)
|
||||||
|
if n, def, err := p.importSingleKernelSource(agentDir); err == nil && n > 0 {
|
||||||
|
return n, def, nil
|
||||||
|
}
|
||||||
|
return 0, "", fmt.Errorf("内核没有任何已配置的 LLM 源(core.llm.sources.* 为空)")
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]string, 0, len(names))
|
||||||
|
for n := range names {
|
||||||
|
ordered = append(ordered, n)
|
||||||
|
}
|
||||||
|
sort.Strings(ordered)
|
||||||
|
|
||||||
|
models := &piModelsFile{Providers: map[string]*piProvider{}}
|
||||||
|
auth := map[string]interface{}{}
|
||||||
|
imported := 0
|
||||||
|
|
||||||
|
for _, n := range ordered {
|
||||||
|
prefix := "core.llm.sources." + n + "."
|
||||||
|
baseURL := coreStr(sett, prefix+"base_url")
|
||||||
|
apiKey := coreStr(sett, prefix+"api_key")
|
||||||
|
model := coreStr(sett, prefix+"model")
|
||||||
|
adapter := coreStr(sett, prefix+"adapter")
|
||||||
|
if baseURL == "" {
|
||||||
|
continue // 源不完整:跳过(不静默造一个坏的)
|
||||||
|
}
|
||||||
|
api := adapterToAPI(adapter)
|
||||||
|
if api == "" {
|
||||||
|
return imported, "", fmt.Errorf(
|
||||||
|
"源 %s 的 adapter=%q 无法映射到 pi 的 api(已知:openai/anthropic)——"+
|
||||||
|
"不猜默认值,请显式指定", n, adapter)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = "AUTO"
|
||||||
|
}
|
||||||
|
m := piModel{
|
||||||
|
ID: model,
|
||||||
|
Name: model,
|
||||||
|
Input: []string{"text"},
|
||||||
|
}
|
||||||
|
if cw := coreInt(sett, prefix+"context_window"); cw > 0 {
|
||||||
|
m.ContextWindow = cw
|
||||||
|
}
|
||||||
|
if coreStr(sett, prefix+"vision") == "true" {
|
||||||
|
m.Input = []string{"text", "image"}
|
||||||
|
}
|
||||||
|
models.Providers[n] = &piProvider{
|
||||||
|
BaseURL: baseURL, API: api, APIKey: apiKey,
|
||||||
|
Models: []piModel{m},
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
auth[n] = newPiAuthEntry(apiKey)
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
if imported == 0 {
|
||||||
|
return 0, "", fmt.Errorf("内核的 LLM 源都缺 base_url,无可导入")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.writePiConfigs(agentDir, models, auth); err != nil {
|
||||||
|
return imported, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 默认源 = 内核的 core.llm.provider(用户要求:默认用内核的)
|
||||||
|
def := coreStr(sett, "core.llm.provider")
|
||||||
|
if def == "" || models.Providers[def] == nil {
|
||||||
|
def = ordered[0] // 内核没指定或指到了不存在的源:用第一个(并如实返回)
|
||||||
|
}
|
||||||
|
if err := p.writePiDefaults(agentDir, def, coreStr(sett, "core.llm.model")); err != nil {
|
||||||
|
return imported, def, err
|
||||||
|
}
|
||||||
|
return imported, def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// importSingleKernelSource 处理「内核只有单源、无 sources.* 子表」的形态。
|
||||||
|
func (p *Plugin) importSingleKernelSource(agentDir string) (int, string, error) {
|
||||||
|
sett := p.sdk.Settings()
|
||||||
|
baseURL := coreStr(sett, "core.llm.base_url")
|
||||||
|
apiKey := coreStr(sett, "core.llm.api_key")
|
||||||
|
model := coreStr(sett, "core.llm.model")
|
||||||
|
adapter := coreStr(sett, "core.llm.adapter")
|
||||||
|
if baseURL == "" {
|
||||||
|
return 0, "", fmt.Errorf("内核未配 core.llm.base_url")
|
||||||
|
}
|
||||||
|
api := adapterToAPI(adapter)
|
||||||
|
if api == "" {
|
||||||
|
return 0, "", fmt.Errorf("adapter=%q 无法映射", adapter)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = "AUTO"
|
||||||
|
}
|
||||||
|
name := "kernel"
|
||||||
|
if n := coreStr(sett, "core.llm.provider"); n != "" {
|
||||||
|
name = n
|
||||||
|
}
|
||||||
|
models := &piModelsFile{Providers: map[string]*piProvider{
|
||||||
|
name: {BaseURL: baseURL, API: api, APIKey: apiKey,
|
||||||
|
Models: []piModel{{ID: model, Name: model, Input: []string{"text"}}}},
|
||||||
|
}}
|
||||||
|
auth := map[string]interface{}{}
|
||||||
|
if apiKey != "" {
|
||||||
|
auth[name] = newPiAuthEntry(apiKey)
|
||||||
|
}
|
||||||
|
if err := p.writePiConfigs(agentDir, models, auth); err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
if err := p.writePiDefaults(agentDir, name, model); err != nil {
|
||||||
|
return 0, name, err
|
||||||
|
}
|
||||||
|
return 1, name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePiConfigs 写 models.json 与 auth.json(★ 0600),并回读校验。
|
||||||
|
//
|
||||||
|
// 为何要回读校验:json 写坏会让内层 pi 直接起不来,而那种失败看起来
|
||||||
|
// 像是"pi 有问题",排查成本很高。写完必须能解析回来。
|
||||||
|
func (p *Plugin) writePiConfigs(agentDir string, models *piModelsFile,
|
||||||
|
auth map[string]interface{}) error {
|
||||||
|
if err := os.MkdirAll(agentDir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mb, err := json.MarshalIndent(models, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeFile0600(filepath.Join(agentDir, "models.json"), mb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var back piModelsFile
|
||||||
|
if err := json.Unmarshal(mb, &back); err != nil {
|
||||||
|
return fmt.Errorf("models.json 回读校验失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(back.Providers) != len(models.Providers) {
|
||||||
|
return fmt.Errorf("models.json 回读不一致: 写入 %d 源、读回 %d 源",
|
||||||
|
len(models.Providers), len(back.Providers))
|
||||||
|
}
|
||||||
|
if len(auth) > 0 {
|
||||||
|
ab, err := json.MarshalIndent(auth, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeFile0600(filepath.Join(agentDir, "auth.json"), ab); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePiDefaults 把默认源写进 settings.json(pi 原生字段)。
|
||||||
|
func (p *Plugin) writePiDefaults(agentDir, provider, model string) error {
|
||||||
|
path := filepath.Join(agentDir, "settings.json")
|
||||||
|
cur := map[string]interface{}{}
|
||||||
|
if b, err := os.ReadFile(path); err == nil {
|
||||||
|
_ = json.Unmarshal(b, &cur) // 读坏就当空(下面会整体重写)
|
||||||
|
}
|
||||||
|
if provider != "" {
|
||||||
|
cur["defaultProvider"] = provider
|
||||||
|
}
|
||||||
|
if model != "" {
|
||||||
|
cur["defaultModel"] = model
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(cur, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeFile0600(path, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile0600 写入并确权 —— 这些文件含明文 api key。
|
||||||
|
func writeFile0600(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// coreStr / coreInt 读内核配置(类型不匹配一律当空,不 panic)。
|
||||||
|
func coreStr(sett interface {
|
||||||
|
GetCore(string) (interface{}, error)
|
||||||
|
}, key string) string {
|
||||||
|
v, err := sett.GetCore(key)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
case bool:
|
||||||
|
if t {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
case float64:
|
||||||
|
return fmt.Sprintf("%v", t)
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coreInt(sett interface {
|
||||||
|
GetCore(string) (interface{}, error)
|
||||||
|
}, key string) int {
|
||||||
|
v, err := sett.GetCore(key)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(t)
|
||||||
|
case int:
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
var n int
|
||||||
|
_, _ = fmt.Sscanf(t, "%d", &n)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 工具实现 ----------------
|
||||||
|
|
||||||
|
func (p *Plugin) piAgentDir() string {
|
||||||
|
return filepath.Join(filepath.Dir(p.path), "runtime", "agentdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePiSourceImport 把内核的 LLM 源导入内置 pi 的配置文件。
|
||||||
|
func (p *Plugin) handlePiSourceImport(args map[string]interface{}) (interface{}, error) {
|
||||||
|
n, def, err := p.importKernelSources(p.piAgentDir())
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"imported": n,
|
||||||
|
"default": def,
|
||||||
|
"agentdir": p.piAgentDir(),
|
||||||
|
"config_files": []string{"models.json", "auth.json", "settings.json"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePiSourceList 列出已配的源(★ 不返回 key)。
|
||||||
|
func (p *Plugin) handlePiSourceList(args map[string]interface{}) (interface{}, error) {
|
||||||
|
dir := p.piAgentDir()
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, "models.json"))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"providers": []string{}, "default": "",
|
||||||
|
"hint": "内置 pi 尚无 LLM 配置;调 commondev_pi_source_import 导入内核的源",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
var f piModelsFile
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
return map[string]interface{}{"error": "models.json 损坏: " + err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认源从 settings.json 读
|
||||||
|
def := ""
|
||||||
|
if sb, err := os.ReadFile(filepath.Join(dir, "settings.json")); err == nil {
|
||||||
|
var s map[string]interface{}
|
||||||
|
if json.Unmarshal(sb, &s) == nil {
|
||||||
|
def, _ = s["defaultProvider"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ★ 只暴露名字与端点,绝不返回 apiKey
|
||||||
|
type view struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
API string `json:"api"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Default bool `json:"default"`
|
||||||
|
}
|
||||||
|
out := []view{}
|
||||||
|
for name, pr := range f.Providers {
|
||||||
|
m := ""
|
||||||
|
if len(pr.Models) > 0 {
|
||||||
|
m = pr.Models[0].ID
|
||||||
|
}
|
||||||
|
out = append(out, view{Name: name, BaseURL: pr.BaseURL, API: pr.API,
|
||||||
|
Model: m, Default: name == def})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return map[string]interface{}{"providers": out, "default": def, "count": len(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePiSourceDefault 切换内置 pi 的默认源。
|
||||||
|
func (p *Plugin) handlePiSourceDefault(args map[string]interface{}) (interface{}, error) {
|
||||||
|
name := strings.TrimSpace(str(args, "name"))
|
||||||
|
if name == "" {
|
||||||
|
return map[string]interface{}{"error": "name 必填"}, nil
|
||||||
|
}
|
||||||
|
dir := p.piAgentDir()
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, "models.json"))
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": "读 models.json 失败: " + err.Error()}, nil
|
||||||
|
}
|
||||||
|
var f piModelsFile
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
return map[string]interface{}{"error": "models.json 损坏: " + err.Error()}, nil
|
||||||
|
}
|
||||||
|
pr, ok := f.Providers[name]
|
||||||
|
if !ok {
|
||||||
|
names := make([]string, 0, len(f.Providers))
|
||||||
|
for n := range f.Providers {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("源 %q 不存在(可用:%s)", name, strings.Join(names, ", ")),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
model := strings.TrimSpace(str(args, "model"))
|
||||||
|
if model == "" && len(pr.Models) > 0 {
|
||||||
|
model = pr.Models[0].ID
|
||||||
|
}
|
||||||
|
if err := p.writePiDefaults(dir, name, model); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "ok", "default": name, "model": model}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPiAuthEntry 构造 pi 认的凭据 entry。
|
||||||
|
//
|
||||||
|
// ★ schema 是 `{"type":"api_key","key":"..."}`,不是 `{"apiKey":"..."}`。
|
||||||
|
//
|
||||||
|
// 这是**实测踩出来的**(2026-10-15):内嵌 pi 用旧格式时,
|
||||||
|
// auth 读取静默返回空,报 `Provider is not configured: llmsproxy` ——
|
||||||
|
// 而 models.json / auth.json / settings.json 三个文件看起来都完全正常。
|
||||||
|
// 定位过程:同一个 pi API,只切 PI_CODING_AGENT_DIR 就一边跑得通
|
||||||
|
// 一边跑不通,逐字段对比才定位到这个键名。
|
||||||
|
//
|
||||||
|
// 权威定义(pi 源码):
|
||||||
|
//
|
||||||
|
// dist/core/auth-storage.js:185 if (value.type === "api_key") { ... }
|
||||||
|
// dist/core/model-runtime.js:423 { type: "api_key", key: apiKey }
|
||||||
|
func newPiAuthEntry(apiKey string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "api_key",
|
||||||
|
"key": apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
352
example/commondev/llmsource_e2e_test.go
Normal file
352
example/commondev/llmsource_e2e_test.go
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// LLM 源端到端判据:**内核源 → 插件导入 → pi 配置 → 真实调用成功**。
|
||||||
|
//
|
||||||
|
// ## 为什么必须端到端而不能只测导入
|
||||||
|
//
|
||||||
|
// 导入逻辑正确 ≠ pi 能用。中间隔着三层容易各自出错:
|
||||||
|
//
|
||||||
|
// ① 源字段映射(内核 core.llm.sources.<n>.* → pi 的 providers.<n>.{baseUrl,api,apiKey})
|
||||||
|
// ② adapter → pi api 名的映射(openai → openai-completions)
|
||||||
|
// ③ 默认源写进 settings.json(defaultProvider/defaultModel)
|
||||||
|
//
|
||||||
|
// 任何一层错了,现象都是「pi 起不来 / 报 No API key found」——
|
||||||
|
// 而单元判据全绿。所以本文件用**真实源 + 真实 HTTP 调用**收口。
|
||||||
|
//
|
||||||
|
// ## 判据分层
|
||||||
|
//
|
||||||
|
// TestLLMSourceImportProducesValidPiConfig — 纯逻辑(不需要网络)
|
||||||
|
// TestLLMSourceRoundTripAgainstRealEndpoint — 真机(需要 HA_E2E_LLM=1)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// coreSettings 是只答 GetCore 的最小替身 —— 内核配置面在这一层被使用。
|
||||||
|
type coreSettings struct {
|
||||||
|
stubSettings
|
||||||
|
core map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *coreSettings) GetCore(key string) (interface{}, error) {
|
||||||
|
if v, ok := c.core[key]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (c *coreSettings) ListCore(prefix string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for k := range c.core {
|
||||||
|
if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPluginWithCore 造一个能读到「内核 LLM 源」的插件。
|
||||||
|
func newPluginWithCore(t *testing.T, core map[string]interface{}) *Plugin {
|
||||||
|
t.Helper()
|
||||||
|
p := newTestPluginForShell(t)
|
||||||
|
cs := &coreSettings{stubSettings: stubSettings{dir: filepath.Dir(p.path)}, core: core}
|
||||||
|
p.sdk = sdk.New("commondev", cs, nil, nil, nil, nil)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:导入后生成的 models.json / auth.json / settings.json 必须自洽。
|
||||||
|
func TestLLMSourceImportProducesValidPiConfig(t *testing.T) {
|
||||||
|
core := map[string]interface{}{
|
||||||
|
"core.llm.provider": "mysrc",
|
||||||
|
"core.llm.model": "my-model",
|
||||||
|
"core.llm.sources.mysrc.base_url": "http://127.0.0.1:9999/v1",
|
||||||
|
"core.llm.sources.mysrc.api_key": "sk-test-123",
|
||||||
|
"core.llm.sources.mysrc.model": "my-model",
|
||||||
|
"core.llm.sources.mysrc.adapter": "openai",
|
||||||
|
"core.llm.sources.mysrc.context_window": float64(128000),
|
||||||
|
"core.llm.sources.mysrc.vision": "true",
|
||||||
|
}
|
||||||
|
p := newPluginWithCore(t, core)
|
||||||
|
agentDir := p.piAgentDir()
|
||||||
|
|
||||||
|
n, def, err := p.importKernelSources(agentDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 || def != "mysrc" {
|
||||||
|
t.Fatalf("导入数量/默认源不对: n=%d def=%q", n, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
// models.json 必须是 pi 认识的形状
|
||||||
|
var models piModelsFile
|
||||||
|
b := mustRead(t, filepath.Join(agentDir, "models.json"))
|
||||||
|
if err := json.Unmarshal(b, &models); err != nil {
|
||||||
|
t.Fatalf("models.json 不是合法 JSON: %v", err)
|
||||||
|
}
|
||||||
|
pv, ok := models.Providers["mysrc"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("models.json 缺少源")
|
||||||
|
}
|
||||||
|
if pv.BaseURL != "http://127.0.0.1:9999/v1" {
|
||||||
|
t.Errorf("baseUrl 错: %q", pv.BaseURL)
|
||||||
|
}
|
||||||
|
// ★ adapter 映射:openai → openai-completions(pi 的取值)
|
||||||
|
if pv.API != "openai-completions" {
|
||||||
|
t.Errorf("api 映射错(pi 需要 openai-completions): %q", pv.API)
|
||||||
|
}
|
||||||
|
if len(pv.Models) != 1 || pv.Models[0].ID != "my-model" {
|
||||||
|
t.Errorf("models 错: %+v", pv.Models)
|
||||||
|
}
|
||||||
|
if pv.Models[0].ContextWindow != 128000 {
|
||||||
|
t.Errorf("contextWindow 未透传: %d", pv.Models[0].ContextWindow)
|
||||||
|
}
|
||||||
|
// 权限:含明文 key ⇒ 必须 0600
|
||||||
|
if fi, err := os.Stat(filepath.Join(agentDir, "models.json")); err != nil || fi.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("models.json 权限应为 0600")
|
||||||
|
}
|
||||||
|
// settings.json 的默认源
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "settings.json")), &settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings["defaultProvider"] != "mysrc" {
|
||||||
|
t.Errorf("defaultProvider 错: %v", settings["defaultProvider"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:**未知 adapter 必须报错**,不得猜一个默认值。
|
||||||
|
//
|
||||||
|
// 猜错会让 pi 用错的协议说话,现象是「模型不回复」—— 那是极难定位的失败。
|
||||||
|
func TestLLMSourceImportRejectsUnknownAdapter(t *testing.T) {
|
||||||
|
core := map[string]interface{}{
|
||||||
|
"core.llm.sources.weird.base_url": "http://x/v1",
|
||||||
|
"core.llm.sources.weird.model": "m",
|
||||||
|
"core.llm.sources.weird.adapter": "some-unknown-protocol",
|
||||||
|
}
|
||||||
|
p := newPluginWithCore(t, core)
|
||||||
|
if _, _, err := p.importKernelSources(p.piAgentDir()); err == nil {
|
||||||
|
t.Fatal("★ 未知 adapter 未报错 —— 猜默认值会让 pi 用错协议(现象是「模型不回复」)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★ 判据:内核**没有任何源**时必须报错(不静默造一个空的)。
|
||||||
|
func TestLLMSourceImportFailsWithoutKernelSources(t *testing.T) {
|
||||||
|
p := newPluginWithCore(t, map[string]interface{}{})
|
||||||
|
if _, _, err := p.importKernelSources(p.piAgentDir()); err == nil {
|
||||||
|
t.Fatal("内核无源时应报错,而不是生成一个空配置(pi 会起不来且原因不明)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 真机判据:**导入的真实源必须真的能调通**。
|
||||||
|
//
|
||||||
|
// 这是端到端的最后一环:前面的判据只证明「配置写对了」,
|
||||||
|
// 这条证明「配置里的源可用」。
|
||||||
|
//
|
||||||
|
// 用 HA_E2E_LLM=1 打开(默认跳过:它依赖本机 llmsproxy)。
|
||||||
|
// 环境变量 HA_E2E_BASE_URL / HA_E2E_API_KEY / HA_E2E_MODEL 可覆盖。
|
||||||
|
func TestLLMSourceRoundTripAgainstRealEndpoint(t *testing.T) {
|
||||||
|
if os.Getenv("HA_E2E_LLM") != "1" {
|
||||||
|
t.Skip("跳过真机 LLM 判据(设 HA_E2E_LLM=1 打开)")
|
||||||
|
}
|
||||||
|
baseURL := envOr("HA_E2E_BASE_URL", "http://127.0.0.1:8081/v1")
|
||||||
|
apiKey := os.Getenv("HA_E2E_API_KEY")
|
||||||
|
model := envOr("HA_E2E_MODEL", "AUTO")
|
||||||
|
if apiKey == "" {
|
||||||
|
t.Fatal("需要 HA_E2E_API_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 像真实内核那样配一个源,走**插件的导入路径**
|
||||||
|
core := map[string]interface{}{
|
||||||
|
"core.llm.provider": "e2e",
|
||||||
|
"core.llm.model": model,
|
||||||
|
"core.llm.sources.e2e.base_url": baseURL,
|
||||||
|
"core.llm.sources.e2e.api_key": apiKey,
|
||||||
|
"core.llm.sources.e2e.model": model,
|
||||||
|
"core.llm.sources.e2e.adapter": "openai",
|
||||||
|
"core.llm.sources.e2e.context_window": float64(1048576),
|
||||||
|
}
|
||||||
|
p := newPluginWithCore(t, core)
|
||||||
|
agentDir := p.piAgentDir()
|
||||||
|
n, def, err := p.importKernelSources(agentDir)
|
||||||
|
if err != nil || n != 1 || def != "e2e" {
|
||||||
|
t.Fatalf("导入失败: n=%d def=%q err=%v", n, def, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 从**导入产物**(而不是从测试变量)读回连接信息 —— 这是端到端的要点:
|
||||||
|
// 若导入写错了字段,这里就会用错地址/错 key,调用会失败。
|
||||||
|
var models piModelsFile
|
||||||
|
b := mustRead(t, filepath.Join(agentDir, "models.json"))
|
||||||
|
if err := json.Unmarshal(b, &models); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pv := models.Providers["e2e"]
|
||||||
|
if pv == nil {
|
||||||
|
t.Fatal("导入产物里没有 e2e 源")
|
||||||
|
}
|
||||||
|
var auth map[string]map[string]string
|
||||||
|
if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "auth.json")), &auth); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyFromDisk := auth["e2e"]["apiKey"]
|
||||||
|
if keyFromDisk == "" {
|
||||||
|
t.Fatal("auth.json 里没有 key —— pi 会报 No API key found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 用**磁盘上的**配置真调一次
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"model": pv.Models[0].ID,
|
||||||
|
"max_tokens": 32,
|
||||||
|
"messages": []map[string]string{{"role": "user", "content": "只回答两个字:可用"}},
|
||||||
|
})
|
||||||
|
url := pv.BaseURL + "/chat/completions"
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+keyFromDisk)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
cli := &http.Client{Timeout: 90 * time.Second}
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("调用导入配置里的源失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct{ Content string } `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error interface{} `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("HTTP %d(用导入产物里的地址与 key): %v", resp.StatusCode, out.Error)
|
||||||
|
}
|
||||||
|
if len(out.Choices) == 0 {
|
||||||
|
t.Fatalf("无 choices: %+v", out)
|
||||||
|
}
|
||||||
|
t.Logf("✅ 端到端通过:内核源 → 插件导入 → 用磁盘配置真实调用成功(%s)", pv.Models[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 对照真机内核配置 ----------------
|
||||||
|
|
||||||
|
// ★★★ 判据:**用真机生产 config.db 里的真实源**跑一遍导入。
|
||||||
|
//
|
||||||
|
// 与上一条的区别:上一条的源是测试构造的;这条读**生产配置**,
|
||||||
|
// 因此能发现「真实内核的字段形态与我们的假设不符」这类问题
|
||||||
|
// (例如字段名、类型、有没有 sources.* 子表)。
|
||||||
|
//
|
||||||
|
// 用 HA_E2E_REAL_DB=/home/newqqagent/config.db 打开。
|
||||||
|
func TestLLMSourceImportFromRealKernelConfig(t *testing.T) {
|
||||||
|
dbPath := os.Getenv("HA_E2E_REAL_DB")
|
||||||
|
if dbPath == "" {
|
||||||
|
t.Skip("跳过真机内核配置判据(设 HA_E2E_REAL_DB=<config.db> 打开)")
|
||||||
|
}
|
||||||
|
rows := readCoreLLMRows(t, dbPath)
|
||||||
|
if len(rows) == 0 {
|
||||||
|
t.Fatalf("%s 里没有任何 core.llm.* 配置", dbPath)
|
||||||
|
}
|
||||||
|
// 直接喂真实的字段形态给导入逻辑
|
||||||
|
core := map[string]interface{}{}
|
||||||
|
for k, v := range rows {
|
||||||
|
core[k] = v
|
||||||
|
}
|
||||||
|
p := newPluginWithCore(t, core)
|
||||||
|
agentDir := p.piAgentDir()
|
||||||
|
n, def, err := p.importKernelSources(agentDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("★ 用**真实内核配置**导入失败(说明字段形态与假设不符): %v", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
t.Fatal("导入 0 个源")
|
||||||
|
}
|
||||||
|
var models piModelsFile
|
||||||
|
if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "models.json")), &models); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, pv := range models.Providers {
|
||||||
|
if pv.BaseURL == "" || pv.API == "" || len(pv.Models) == 0 {
|
||||||
|
t.Errorf("源 %s 导入不完整: %+v", name, pv)
|
||||||
|
}
|
||||||
|
// ★ api 必须是 pi 认识的值(否则 pi 起不来)
|
||||||
|
if pv.API != "openai-completions" && pv.API != "anthropic-messages" {
|
||||||
|
t.Errorf("源 %s 的 api=%q 不是 pi 的合法取值", name, pv.API)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("✅ 真实内核配置导入成功:%d 个源,默认=%s", n, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCoreLLMRows 读 config.db 的 core.llm.* 行(用 go-sqlite3)。
|
||||||
|
func readCoreLLMRows(t *testing.T, dbPath string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
// 用 sqlite3 CLI(内核侧判据不引 cgo 依赖)
|
||||||
|
out, err := runCmd("sqlite3", dbPath, "select key||'\t'||value from config where key like 'core.llm.%';")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("读 config.db 失败(缺 sqlite3?): %v", err)
|
||||||
|
}
|
||||||
|
rows := map[string]string{}
|
||||||
|
for _, line := range splitLines(out) {
|
||||||
|
if i := indexByte(line, '\t'); i > 0 {
|
||||||
|
rows[line[:i]] = line[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCmd(name string, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
b, err := cmd.Output()
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
var out []string
|
||||||
|
cur := ""
|
||||||
|
for _, c := range s {
|
||||||
|
if c == '\n' {
|
||||||
|
if cur != "" {
|
||||||
|
out = append(out, cur)
|
||||||
|
}
|
||||||
|
cur = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur += string(c)
|
||||||
|
}
|
||||||
|
if cur != "" {
|
||||||
|
out = append(out, cur)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(s string, b byte) int {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == b {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRead 读文件(失败即 Fatal)。
|
||||||
|
func mustRead(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读 %s 失败: %v", path, err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user