plugin: 删除 C ABI 通道(Part 6.2 完成,-3198 行)

外部插件统一走子进程 + stdio RPC,三套独立 ABI 实现收敛为单一 RPC 实现。
用户决策:彻底舍弃 .so 能力,不保留双通道回退。

## 删除清单

internal/plugin/cabi/                     1156 行(loader.go/loader.c/types.go/output_test.go)
internal/plugin/dynamic_dll_windows.go     272 行(§9.2 记录的能力退化实现)
internal/plugin/dynamic_loader_unix.go      79 行(唯一 cabi 引用点)
internal/plugin/dynamic_dll_test.go         32 行
internal/plugin/dynamic_dll_stub.go         11 行
internal/plugin/dynamic_loader_windows.go   11 行
internal/plugin/bridge_e2e_test.go              (测的是 cabi 路径)
third_party/.../plugindev/templates.go    1296 行(取消跟踪,SDK 仓才是权威副本)

dynamic.go:entryCABI 通道删除,soEntry/dllEntry 常量删除。
registry.go:tryDynamic 探测顺序从 .so → .dll → .lua 变成 proc → lua。

## 旧 .so 给明确错误,不静默跳过

静默跳过会让「插件目录在但没加载」看起来像配置问题,而实际原因是需要
用新版 plugindev 重编。故保留 legacyCABIEntries 表专门用于识别残留:

  plugin legacy: 检测到旧 C ABI 产物(plugin.so/.dll/.dylib)。
  外部插件已改为子进程模式,请用新版 plugindev 重编产出 plugin.bin
  (业务代码无需修改)

错误消息里「业务代码无需修改」这句是有测试守着的——迁移的核心承诺就是它。

## pluginmgr 安装逻辑跟进 bundle 命名

子进程模式下各平台产物统一叫 plugin.bin(进程边界即 ABI 边界),故 zip 内
按平台加后缀 plugin.bin.<goos>.<goarch>,解包时挑当前平台那一份重命名。

platformBinary 改为按 runtime.GOOS+GOARCH 生成条目名;platformBinaries 固定表
换成 isPlatformBinary 前缀判断(平台组合会增长:linux/arm64、darwin/arm64…,
按前缀判断无需维护清单)。

新增 chmod 0755:zip 保留了原权限位,但经某些工具链/传输后可能丢失,
内核加载时会因缺执行位报错。提前补上比事后让用户 chmod 更好。

## 测试

entry_dispatch_test.go 重写(12 项):
- classifyEntry 对 .so/.dll/.dylib 现在返回 unknown
- LegacyManifestFallsBackToProbe:存量插件 manifest 仍写 "plugin.so"
  (17 个插件没人去改),须靠目录探测找到 plugin.bin —— 这是
  「外部插件零改动」的直接后果
- LegacyCABIGivesActionableError:错误消息须含 plugindev / plugin.bin / 业务代码
- PluginEntryHash_IgnoresLegacyCABI:.so 不参与 hash(内核已不认它)

upgrade_test.go 的 .hmap 构造改用 plugin.bin。

验证:go build ./... 通过;go test ./... 全仓无失败;
go test -race ./internal/plugin/... 全绿;三平台构建通过。

Ref: docs/zh/架构迁移评估.md §3.1/§9.2、docs/zh/plugin-migration-plan.md Part 6
This commit is contained in:
JianFeeeee
2026-09-02 19:26:40 +08:00
parent d027c964e2
commit b20121f703
16 changed files with 176 additions and 3318 deletions

View File

@ -1,262 +0,0 @@
//go:build windows
package plugin
import (
"encoding/json"
"os"
"path/filepath"
"syscall"
"testing"
"unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestBridgeE2E_WebPlugin(t *testing.T) {
exeDir, _ := os.Executable()
// Find web example build relative to the homeagent repo root
haRoot := findHomeAgentRoot(t, exeDir)
dllPath := filepath.Join(haRoot, "..", "homeagentsdk", "example", "web", "build", "plugin.dll")
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
t.Fatalf("web plugin DLL not found at %s\nRun: cd example/web && plugindev build --target windows/amd64", dllPath)
}
// Track captured tools and stages
var capturedTools []sdk.ToolDef
var capturedStages []sdk.Stage
regTool := func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
capturedTools = append(capturedTools, def)
t.Logf(" registered tool: %s", name)
return nil
}
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
capturedStages = append(capturedStages, stage)
t.Logf(" registered stage: %s", stage)
}
regAPI := func(name string) error {
t.Logf(" registered API: %s", name)
return nil
}
sett := sdk.NewSettings("web", nil)
psdk := sdk.New("web", sdk.SDKConfig{Settings: sett, RegTool: regTool, RegStage: regStage, RegAPI: regAPI})
plg, err := newDLLPlugin(dllPath, "web", nil)
if err != nil {
t.Fatalf("newDLLPlugin failed: %v", err)
}
defer plg.Stop()
// Start — this calls NewPlugin + StartPlugin + registerTools + registerStages
if err := plg.Start(psdk); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Verify tools were captured
if len(capturedTools) == 0 {
t.Fatal("no tools were registered by web plugin")
}
t.Logf("Captured %d tools:", len(capturedTools))
for _, d := range capturedTools {
t.Logf(" - %s: %s", d.Name, d.Description[:min(len(d.Description), 60)])
}
// Check specific expected tools
webSearch, webFetch := false, false
for _, d := range capturedTools {
if d.Name == "web_search" {
webSearch = true
if d.Description == "" {
t.Error("web_search has empty description")
}
params := d.Parameters
if params == nil {
t.Error("web_search has nil parameters")
} else {
if _, ok := params["properties"]; !ok {
t.Error("web_search parameters missing 'properties'")
}
}
}
if d.Name == "web_fetch" {
webFetch = true
}
}
if !webSearch {
t.Error("expected tool 'web_search' not registered")
}
if !webFetch {
t.Error("expected tool 'web_fetch' not registered")
}
// Verify bridge exports work via direct C ABI calls
t.Logf("Bridge exports: getTools=%x invokeTool=%x freeCStr=%x",
plg.getTools, plg.invokeTool, plg.freeCStr)
// GetToolDefsJSON
if plg.getTools != 0 {
toolDefsJSON := callGetToolDefsJSON(t, plg)
if len(toolDefsJSON) == 0 {
t.Error("GetToolDefsJSON returned empty array, expected tools")
}
for _, d := range toolDefsJSON {
t.Logf(" bridge tool: %s", d["name"])
}
}
// InvokeToolJSON — test with the search tool
if plg.invokeTool != 0 {
result := callInvokeToolJSON(t, plg, "web_search", map[string]interface{}{
"query": "test",
"count": 1,
})
t.Logf("InvokeToolJSON result keys: %v", keysOfMap(result))
// Should get a result map (might be error if no network, but should not crash)
if errStr, ok := result["error"]; ok {
t.Logf(" (expected — tool returned error: %v)", errStr)
}
}
}
func TestBridgeE2E_SanitizerStages(t *testing.T) {
exeDir, _ := os.Executable()
haRoot := findHomeAgentRoot(t, exeDir)
dllPath := filepath.Join(haRoot, "..", "homeagentsdk", "example", "sanitizer", "build", "plugin.dll")
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
t.Skip("sanitizer DLL not built")
}
var capturedStages []sdk.Stage
regStage := func(stage sdk.Stage, handler sdk.StageHandler) {
capturedStages = append(capturedStages, stage)
t.Logf(" registered stage: %s", stage)
}
sett := sdk.NewSettings("sanitizer", nil)
psdk := sdk.New("sanitizer", sdk.SDKConfig{Settings: sett,
RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { return nil },
RegStage: regStage,
RegAPI: func(name string) error { return nil },
})
plg, err := newDLLPlugin(dllPath, "sanitizer", nil)
if err != nil {
t.Fatalf("newDLLPlugin failed: %v", err)
}
defer plg.Stop()
if err := plg.Start(psdk); err != nil {
t.Fatalf("Start failed: %v", err)
}
if len(capturedStages) == 0 {
t.Fatal("no stages registered by sanitizer")
}
found := false
for _, s := range capturedStages {
if s == sdk.StagePostAction {
found = true
}
}
if !found {
t.Fatalf("expected post_action stage, got %v", capturedStages)
}
// Verify bridge GetStagesJSON
if plg.getStages != 0 {
ret, _, _ := syscall.SyscallN(plg.getStages, plg.handle)
if ret != 0 {
stagesJSON := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
t.Logf("GetStagesJSON: %s", stagesJSON)
if !contains(t, stagesJSON, "post_action") {
t.Error("GetStagesJSON missing post_action")
}
}
}
}
// --- helpers ---
func findHomeAgentRoot(t *testing.T, exeDir string) string {
t.Helper()
// Walk up from test binary directory looking for homeagent/
dir := exeDir
for i := 0; i < 10; i++ {
if _, err := os.Stat(filepath.Join(dir, "internal", "plugin")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Fatal("cannot find homeagent root")
return ""
}
func callGetToolDefsJSON(t *testing.T, plg *dllPlugin) []map[string]interface{} {
t.Helper()
ret, _, _ := syscall.SyscallN(plg.getTools, plg.handle)
if ret == 0 {
t.Fatal("GetToolDefsJSON returned nil")
}
jsonStr := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
var defs []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &defs); err != nil {
t.Fatalf("GetToolDefsJSON parse error: %v", err)
}
return defs
}
func callInvokeToolJSON(t *testing.T, plg *dllPlugin, toolName string, args map[string]interface{}) map[string]interface{} {
t.Helper()
argsJSON, _ := json.Marshal(args)
cToolName := append([]byte(toolName), 0)
cArgs := append(argsJSON, 0)
ret, _, _ := syscall.SyscallN(
plg.invokeTool,
plg.handle,
uintptr(unsafe.Pointer(&cToolName[0])),
uintptr(unsafe.Pointer(&cArgs[0])),
)
if ret == 0 {
t.Fatal("InvokeToolJSON returned nil")
}
jsonStr := cStringPtrToString(ret)
if plg.freeCStr != 0 {
syscall.SyscallN(plg.freeCStr, ret)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
t.Fatalf("InvokeToolJSON parse error: %v (json=%s)", err, jsonStr)
}
return result
}
func keysOfMap(m map[string]interface{}) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
func contains(t *testing.T, s, substr string) bool {
t.Helper()
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -1,79 +0,0 @@
//go:build linux || darwin
// HomeAgent C ABI loader — C implementation (compiled alongside Go code via cgo)
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
// HOMEAGENT_ABI_VERSION 与 internal/meta/meta.go CABINum 同步major*100+minorv0.9.x→900
// C ABI 通过 version/version_min 协商,旧插件不受影响。
#define HOMEAGENT_ABI_VERSION 900
// PluginAPI — provided by the plugin
typedef struct {
int version; int version_min;
int (*init_plugin)(char*, char*, char**);
int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**);
int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*);
} plugin_api_t;
// CoreAPI — provided by the core
typedef struct {
int version; int version_min;
int (*dispatch)(int, void*, char*, char*, char*, int, int, char**, char**);
void* ctx;
} core_api_t;
// Forward declare Go dispatch function
extern int go_core_dispatch(int, void*, char*, char*, char*, int, int, char**, char**);
// Bridge function called by CoreAPI.dispatch
static int dispatch_bridge(int id, void* ctx, char* s1, char* s2, char* s3, int i1, int i2, char** r, char** e) {
return go_core_dispatch(id, ctx, s1, s2, s3, i1, i2, r, e);
}
// Create a CoreAPI struct
core_api_t* make_core_api(void) {
core_api_t* api = (core_api_t*)malloc(sizeof(core_api_t));
if (!api) return NULL;
api->version = HOMEAGENT_ABI_VERSION;
api->version_min = HOMEAGENT_ABI_VERSION;
api->dispatch = dispatch_bridge;
api->ctx = NULL;
return api;
}
void free_core_api(core_api_t* api) { free(api); }
// dlopen helpers
typedef void* lib_handle;
lib_handle lib_open(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
}
plugin_api_t* lib_get_api(lib_handle h) {
plugin_api_t* (*fn)(void);
*(void**)(&fn) = dlsym(h, "plugin_init");
if (!fn) return NULL;
return fn();
}
void lib_close(lib_handle h) { dlclose(h); }
char* lib_err(void) { return dlerror(); }
void api_free_string(plugin_api_t* api, char* ptr) {
if (api && api->free_string) api->free_string(ptr);
}
int call_init_plugin(plugin_api_t* api, char* name, char* config, char** err) { return api->init_plugin(name, config, err); }
int call_start_plugin(plugin_api_t* api, void* core, int ver, char** err) { return api->start_plugin(core, ver, err); }
int call_stop_plugin(plugin_api_t* api, char** err) { return api->stop_plugin(err); }
int call_invoke_tool(plugin_api_t* api, char* n, char* a, char** r, char** e) { return api->invoke_tool(n, a, r, e); }
int call_invoke_stage(plugin_api_t* api, char* s, char* c, char** r, char** e) { return api->invoke_stage(s, c, r, e); }
int call_invoke_output(plugin_api_t* api, char* c, char* m, char* p, char** e) { return api->invoke_output(c, m, p, e); }

View File

@ -1,998 +0,0 @@
//go:build linux || darwin
package cabi
/*
#cgo LDFLAGS: -ldl
#include <stdlib.h>
// HOMEAGENT_ABI_VERSION 是当前内核的 C ABI 整数协商版本,由 internal/meta/meta.go CABINum 派生
// major*100 + minor随核心版本号映射v0.8.x→800v0.9.x→900
// 旧插件使用低整数版本不受影响——C ABI wrapper 通过 version/version_min 字段协商兼容。
#define HOMEAGENT_ABI_VERSION 900
// PluginAPI — provided by the plugin via plugin_init()
typedef struct {
int version; int version_min;
int (*init_plugin)(char*, char*, char**);
int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**);
int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*);
} plugin_api_t;
// CoreAPI — provided by the core via start_plugin()
typedef struct {
int version; int version_min;
int (*dispatch)(int, void*, char*, char*, char*, int, int, char**, char**);
void* ctx;
} core_api_t;
// Functions implemented in loader.c
extern core_api_t* make_core_api(void);
extern void free_core_api(core_api_t* api);
extern int call_init_plugin(plugin_api_t*, char*, char*, char**);
extern int call_start_plugin(plugin_api_t*, void*, int, char**);
extern int call_stop_plugin(plugin_api_t*, char**);
extern int call_invoke_tool(plugin_api_t*, char*, char*, char**, char**);
extern int call_invoke_stage(plugin_api_t*, char*, char*, char**, char**);
extern int call_invoke_output(plugin_api_t*, char*, char*, char*, char**);
extern void api_free_string(plugin_api_t*, char*);
extern void* lib_open(const char*);
extern plugin_api_t* lib_get_api(void*);
extern void lib_close(void*);
extern char* lib_err(void);
*/
import "C"
import (
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// outputSendTimeout 是 output_send 等待通道真实发送确认的超时。
// 超过该时间仍未收到插件确认,返回 unconfirmed结果未知而非谎报成功。
// plan.md 11.1
const outputSendTimeout = 10 * time.Second
var (
pluginMap sync.Map // int32 pluginID → *pluginState
nextID int32
)
type pluginState struct {
id int32
name string
sdk *sdk.PluginSDK
api *C.plugin_api_t
}
// Handle represents a loaded C ABI plugin.
type Handle struct {
soPath string
lib unsafe.Pointer
api *C.plugin_api_t
core *C.core_api_t
pstate *pluginState
}
// Load opens a .so plugin and initializes it via C ABI.
func Load(soPath, name string, config map[string]interface{}) (*Handle, error) {
cPath := C.CString(soPath)
defer C.free(unsafe.Pointer(cPath))
lib := C.lib_open(cPath)
if lib == nil {
return nil, fmt.Errorf("dlopen %s: %s", soPath, C.GoString(C.lib_err()))
}
api := C.lib_get_api(lib)
if api == nil {
C.lib_close(lib)
return nil, fmt.Errorf("dlsym plugin_init in %s: %s", soPath, C.GoString(C.lib_err()))
}
if int(api.version) < 1 || api.init_plugin == nil {
C.lib_close(lib)
return nil, fmt.Errorf("plugin %s: invalid PluginAPI (version=%d)", name, int(api.version))
}
if int(api.version) > CABINum {
C.lib_close(lib)
return nil, fmt.Errorf("plugin %s: ABI version %d > core %d (v%s), requires newer HomeAgent core", name, int(api.version), CABINum, ABIVersion)
}
if int(api.version) < CABINumMin {
C.lib_close(lib)
return nil, fmt.Errorf("plugin %s: ABI version %d < core min %d (v%s), plugin too old", name, int(api.version), CABINumMin, ABIVersionMin)
}
id := atomic.AddInt32(&nextID, 1)
ps := &pluginState{id: id, name: name, api: api}
pluginMap.Store(id, ps)
handle := &Handle{soPath: soPath, lib: lib, api: api, pstate: ps}
// Create CoreAPI later — done via CreateCoreAPI
// Initialize plugin
configJSON, _ := json.Marshal(config)
cName := C.CString(name)
cConfig := C.CString(string(configJSON))
var initErr *C.char
defer C.free(unsafe.Pointer(cName))
defer C.free(unsafe.Pointer(cConfig))
if ret := int(C.call_init_plugin(api, cName, cConfig, &initErr)); ret != 0 {
errMsg := ""
if initErr != nil {
errMsg = C.GoString(initErr)
C.api_free_string(api, initErr)
}
handle.Close()
return nil, fmt.Errorf("init_plugin %s: %s", name, errMsg)
}
return handle, nil
}
// CreateCoreAPI creates a CoreAPI struct for this plugin.
// The CoreAPI dispatches all SDK calls back to Go, routing to the plugin's PluginSDK.
func (h *Handle) CreateCoreAPI(s *sdk.PluginSDK) unsafe.Pointer {
core := C.make_core_api()
if core == nil {
return nil
}
h.core = core
h.pstate.sdk = s
// Store plugin ID as context (safe integer, not a Go pointer)
core.ctx = unsafe.Pointer(uintptr(h.pstate.id))
return unsafe.Pointer(core)
}
// FreeCoreAPI frees the CoreAPI struct.
func (h *Handle) FreeCoreAPI() {
if h.core != nil {
C.free_core_api(h.core)
h.core = nil
}
}
// Start calls the plugin's Start with a CoreAPI pointer.
func (h *Handle) Start(corePtr unsafe.Pointer) error {
var cErr *C.char
if ret := int(C.call_start_plugin(h.api, corePtr, C.int(CABINum), &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(h.api, cErr)
}
return fmt.Errorf("start_plugin: %s", errMsg)
}
return nil
}
// Stop calls the plugin's Stop.
func (h *Handle) Stop() error {
var cErr *C.char
if ret := int(C.call_stop_plugin(h.api, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(h.api, cErr)
}
return fmt.Errorf("stop_plugin: %s", errMsg)
}
return nil
}
// InvokeTool calls a tool handler in the plugin.
func (h *Handle) InvokeTool(name string, args map[string]interface{}) (map[string]interface{}, error) {
argsJSON, _ := json.Marshal(args)
cName := C.CString(name)
cArgs := C.CString(string(argsJSON))
var result, cErr *C.char
defer C.free(unsafe.Pointer(cName))
defer C.free(unsafe.Pointer(cArgs))
if ret := int(C.call_invoke_tool(h.api, cName, cArgs, &result, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(h.api, cErr)
}
return nil, fmt.Errorf("invoke_tool %s: %s", name, errMsg)
}
if result == nil {
return nil, nil
}
defer C.api_free_string(h.api, result)
var r map[string]interface{}
if err := json.Unmarshal([]byte(C.GoString(result)), &r); err != nil {
return nil, err
}
return r, nil
}
// Close unloads the plugin library.
func (h *Handle) Close() {
if h.lib != nil {
C.lib_close(h.lib)
h.lib = nil
}
}
// ---- plugin invocation helpers (stateless, use pluginMap lookup) ----
func pluginInvokeTool(pluginID int32, name, argsJSON string) (string, error) {
v, ok := pluginMap.Load(pluginID)
if !ok {
return "", fmt.Errorf("plugin %d not found", pluginID)
}
ps := v.(*pluginState)
if ps.api == nil {
return "", fmt.Errorf("plugin %d: nil api", pluginID)
}
cName := C.CString(name)
cArgs := C.CString(argsJSON)
var result, cErr *C.char
defer C.free(unsafe.Pointer(cName))
defer C.free(unsafe.Pointer(cArgs))
if ret := int(C.call_invoke_tool(ps.api, cName, cArgs, &result, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(ps.api, cErr)
}
return "", fmt.Errorf("invoke_tool %s: %s", name, errMsg)
}
if result == nil {
return "", nil
}
defer C.api_free_string(ps.api, result)
return C.GoString(result), nil
}
// awaitOutputResult 在 goroutine 内执行真正的 cgo 发送调用,并等待其结果:
// - 发送成功 → {status: sent}
// - 发送失败 → 返回 error模型可感知并重试不再像旧实现那样谎报成功
// - 超时未确认 → {status: unconfirmed}(结果未知,不谎报成功/失败)
//
// 为什么用 goroutine + channel 而不是直接同步调用pluginInvokeOutput 是 cgo 调用,
// 不能嵌套在 cgo 栈上执行cgo within cgo 会崩溃)。本 handler 由 executeOutputSendTool
// 从 Go 侧调起(不在 cgo 栈内),所以这里启动子 goroutine 执行 cgo 调用并等待其结果,
// 不构成嵌套。
//
// 修复 plan.md 11.1:旧实现无条件返回 {status: queued} + err=nil模型永远收到「已发送」
// 而实际失败(如 meta 缺 user_id只写日志模型无法感知、不会重试。
func awaitOutputResult(pid int32, channel, argsJSON string) (interface{}, error) {
return awaitOutputResultWith(pid, channel, argsJSON, pluginInvokeOutput, outputSendTimeout)
}
// awaitOutputResultWith 是 awaitOutputResult 的可注入版本(供单测替换 cgo 发送与超时)。
func awaitOutputResultWith(
pid int32,
channel, argsJSON string,
invoke func(pluginID int32, channel, payload string) error,
timeout time.Duration,
) (interface{}, error) {
resCh := make(chan error, 1)
go func() { resCh <- invoke(pid, channel, argsJSON) }()
select {
case err := <-resCh:
if err != nil {
log.Printf("[dispatch] output %s failed: %v", channel, err)
return nil, err
}
log.Printf("[dispatch] output %s OK", channel)
return map[string]interface{}{"status": "sent"}, nil
case <-time.After(timeout):
// 超时未确认:插件仍在后台发送,结果未知。不谎报成功,也不谎报失败。
log.Printf("[dispatch] output %s 等待确认超时(%s插件仍在后台发送", channel, timeout)
return map[string]interface{}{
"status": "unconfirmed",
"note": fmt.Sprintf("发送已提交但 %s 内未收到通道确认,结果未知;如需确认请查询该通道状态", timeout),
}, nil
}
}
func pluginInvokeOutput(pluginID int32, channel, payload string) error {
v, ok := pluginMap.Load(pluginID)
if !ok {
return fmt.Errorf("plugin %d not found", pluginID)
}
ps := v.(*pluginState)
if ps.api == nil {
return fmt.Errorf("plugin %d: nil api", pluginID)
}
cCh := C.CString(channel)
cPayload := C.CString(payload)
var cErr *C.char
defer C.free(unsafe.Pointer(cCh))
defer C.free(unsafe.Pointer(cPayload))
if ret := int(C.call_invoke_output(ps.api, cCh, nil, cPayload, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(ps.api, cErr)
}
return fmt.Errorf("invoke_output %s: %s", channel, errMsg)
}
return nil
}
// applyStageResult 将插件回传的修改后上下文应用回内核 StageContext。
// 只回写插件有权改写的字段RawMessage/LLMText/FinalText/Response/ToolResults/NoMemory
func applyStageResult(sc *sdk.StageContext, resultJSON string) {
var m map[string]interface{}
if err := json.Unmarshal([]byte(resultJSON), &m); err != nil {
return
}
sc.Lock()
defer sc.Unlock()
if v, ok := m["raw_message"].(string); ok {
sc.RawMessage = v
}
if v, ok := m["llm_text"].(string); ok {
sc.LLMText = v
}
if v, ok := m["final_text"].(string); ok {
sc.FinalText = v
}
if v, ok := m["user_id"].(string); ok {
sc.UserID = v
}
if v, ok := m["group_id"].(string); ok {
sc.GroupID = v
}
if v, ok := m["no_memory"].(bool); ok {
sc.NoMemory = v
}
if v, ok := m["response"].(string); ok {
vv := v
sc.Response = &vv
}
if v, ok := m["tool_calls"].([]interface{}); ok {
// 注意不要加 len(v)>0 条件ABI v2 diff 回传plan.md 11.3)下,插件拒绝全部
// 工具调用时会显式回传 `[]`,必须能表达「清空」。旧插件(全量回传)仅在
// len>0 时才带该键,因此不会因此变更而被误清空。
if b, err := json.Marshal(v); err == nil {
var tcs []sdk.ToolCall
if json.Unmarshal(b, &tcs) == nil {
sc.ToolCalls = tcs
}
}
}
if v, ok := m["tool_results"].([]interface{}); ok {
if b, err := json.Marshal(v); err == nil {
var trs []sdk.ToolResult
if json.Unmarshal(b, &trs) == nil {
sc.ToolResults = trs
}
}
}
}
func pluginInvokeStage(pluginID int32, stage, ctxJSON string, resultOut *string) error {
v, ok := pluginMap.Load(pluginID)
if !ok {
return fmt.Errorf("plugin %d not found", pluginID)
}
ps := v.(*pluginState)
if ps.api == nil {
return fmt.Errorf("plugin %d: nil api", pluginID)
}
cStage := C.CString(stage)
cCtx := C.CString(ctxJSON)
var cErr *C.char
var cResult *C.char
defer C.free(unsafe.Pointer(cStage))
defer C.free(unsafe.Pointer(cCtx))
// 仅当调用方要求回传时传 &cResult否则传 NULL兼容无需写回的阶段
if ret := int(C.call_invoke_stage(ps.api, cStage, cCtx, &cResult, &cErr)); ret != 0 {
errMsg := ""
if cErr != nil {
errMsg = C.GoString(cErr)
C.api_free_string(ps.api, cErr)
}
return fmt.Errorf("invoke_stage %s: %s", stage, errMsg)
}
if resultOut != nil && cResult != nil {
*resultOut = C.GoString(cResult)
C.api_free_string(ps.api, cResult)
}
return nil
}
// go_core_dispatch handles all plugin→core SDK calls.
//
//export go_core_dispatch
func go_core_dispatch(methodID C.int, ctx unsafe.Pointer, s1, s2, s3 *C.char, i1, i2 C.int, result **C.char, errorOut **C.char) C.int {
pluginID := int32(uintptr(ctx))
v, ok := pluginMap.Load(pluginID)
if !ok {
return 1
}
ps := v.(*pluginState)
s := ps.sdk
if s == nil {
return 1
}
a1, a2, a3 := goStr(s1), goStr(s2), goStr(s3)
n1, n2 := int(i1), int(i2)
switch int(methodID) {
case 1: // CORE_REGISTER_TOOL
var def sdk.ToolDef
if err := json.Unmarshal([]byte(a2), &def); err != nil {
setErr(errorOut, err)
return 1
}
def.Plugin = ps.name
pid := pluginID
toolName := a1
_ = s.RegisterTool(a1, def, func(args map[string]interface{}) (interface{}, error) {
argsJSON, _ := json.Marshal(args)
r, err := pluginInvokeTool(pid, toolName, string(argsJSON))
if err != nil {
return nil, err
}
if r == "" {
return nil, nil
}
var res map[string]interface{}
if err := json.Unmarshal([]byte(r), &res); err != nil {
return r, nil
}
return res, nil
})
return 0
case 2: // CORE_REGISTER_STAGE
pid := pluginID
st := a1
handler := func(sc *sdk.StageContext) error {
sc.RLock()
m := map[string]interface{}{
"raw_message": sc.RawMessage, "user_id": sc.UserID,
"group_id": sc.GroupID, "phase": string(sc.Phase),
"llm_text": sc.LLMText, "final_text": sc.FinalText,
"no_memory": sc.NoMemory,
}
if sc.Response != nil {
m["response"] = *sc.Response
}
if len(sc.ToolCalls) > 0 {
m["tool_calls"] = sc.ToolCalls
}
if len(sc.ToolResults) > 0 {
m["tool_results"] = sc.ToolResults
}
sc.RUnlock()
b, _ := json.Marshal(m)
// ABI v2: 插件可回传修改后的上下文写回内核 sc如 RawMessage/LLMText/Response/ToolResults
var result string
if err := pluginInvokeStage(pid, st, string(b), &result); err != nil {
return err
}
if result != "" {
applyStageResult(sc, result)
}
return nil
}
scope := sdk.StageScopeGlobal
if a3 == "own_tools" {
scope = sdk.StageScopeOwnTools
}
s.RegisterStage(sdk.Stage(st), handler, scope)
return 0
case 3: // CORE_REGISTER_OUTPUT_CH
pid := pluginID
chName := a1
chDef := sdk.ChannelDef{}
if a3 != "" {
var def sdk.ChannelDef
if err := json.Unmarshal([]byte(a3), &def); err == nil {
chDef = def
}
}
s.RegisterOutputChannel(chName, n1, a2, chDef, func(args map[string]interface{}) (interface{}, error) {
// 发送在 goroutine 内进行cgo 调用不能嵌套在 cgo 栈上,否则可能崩溃),
// 但调用方必须拿到真实结果:本 handler 由 executeOutputSendTool 从 Go 侧
// 调起,不在 cgo 栈内,因此这里等待 goroutine 的结果不构成 cgo 嵌套。
// plan.md 11.1
argsJSON, _ := json.Marshal(args)
log.Printf("[dispatch] output %s/%s args=%s", ps.name, chName, string(argsJSON))
return awaitOutputResult(pid, chName, string(argsJSON))
})
return 0
case 4: // CORE_REGISTER_PLUGIN_API
s.RegisterPluginAPI(a1)
return 0
case 5: // CORE_INJECT_TEXT
s.InjectText(a1, a2, a3)
return 0
case 6: // CORE_INJECT_INTERRUPT_TEXT
s.InjectInterruptText(a1, a2, a3)
return 0
case 7: // CORE_INJECT_TEXT_NO_MEMORY
s.InjectTextNoMemory(a1, a2, a3)
return 0
case 47: // CORE_INJECT_INPUT_SYNC
if out := s.InjectInputSync(a1, a2, "text", map[string]interface{}{"content": a3}); out != nil {
reply, _ := out.Payload["content"].(string)
setResult(result, reply)
}
return 0
case 8: // CORE_SET_AUTO_RESTART
s.SetAutoRestart(n1 != 0)
return 0
case 9: // CORE_MEMORY_RECALL
if mem := s.Memory(); mem != nil {
entities, relations, err := mem.Recall([]string{a1}, n1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(map[string]interface{}{"entities": entities, "relations": relations})
setResult(result, string(b))
}
return 0
case 10: // CORE_MEMORY_COMMIT
if mem := s.Memory(); mem != nil {
var triples []sdk.Triple
if err := json.Unmarshal([]byte(a1), &triples); err != nil {
setErr(errorOut, err)
return 1
}
if err := mem.Commit(triples); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 11: // CORE_MEMORY_INTROSPECT
if mem := s.Memory(); mem != nil {
r, err := mem.Introspect()
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(r)
setResult(result, string(b))
}
return 0
case 12: // CORE_MEMORY_MERGE
if mem := s.Memory(); mem != nil {
if _, err := mem.MergeEntities(a1, a2); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 13: // CORE_MEMORY_PURGE
if mem := s.Memory(); mem != nil {
var criteria map[string]string
if err := json.Unmarshal([]byte(a1), &criteria); err != nil {
setErr(errorOut, err)
return 1
}
mode := "soft"
if n1 != 0 {
mode = "hard"
}
if _, err := mem.Purge(criteria, mode); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 14: // CORE_DOC_QUERY
if dm := s.DocMemory(); dm != nil {
b, _ := json.Marshal(dm.Query(a1, n1))
setResult(result, string(b))
}
return 0
case 15: // CORE_KNOWLEDGE_SEARCH
if kn := s.Knowledge(); kn != nil {
results, err := kn.Search(a1, n1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(results)
setResult(result, string(b))
}
return 0
case 16: // CORE_SETTINGS_GET
if sett := s.Settings(); sett != nil {
v, err := sett.Get(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(v)
setResult(result, string(b))
}
return 0
case 17: // CORE_SETTINGS_SET
if sett := s.Settings(); sett != nil {
var v interface{}
json.Unmarshal([]byte(a2), &v)
if err := sett.Set(a1, v); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 18: // CORE_SETTINGS_REGISTER_DEF
if sett := s.Settings(); sett != nil {
var def sdk.ConfigDef
if err := json.Unmarshal([]byte(a1), &def); err != nil {
setErr(errorOut, err)
return 1
}
sett.RegisterDef(def)
}
return 0
case 19: // CORE_LLM_LIST_SOURCES
if llm := s.LLM(); llm != nil {
b, _ := json.Marshal(llm.ListSources())
setResult(result, string(b))
}
return 0
case 20: // CORE_LLM_SET_SOURCE
if llm := s.LLM(); llm != nil {
if err := llm.SetSource(a1); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 21: // CORE_SOCIAL_GET_PERSON
if social := s.Social(); social != nil {
p, err := social.GetPerson(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(p)
setResult(result, string(b))
}
return 0
case 22: // CORE_SOCIAL_GET_NETWORK
if social := s.Social(); social != nil {
profiles, err := social.GetNetwork(a1, n1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(profiles)
setResult(result, string(b))
}
return 0
case 23: // CORE_SUBSCRIBE
_ = n2
// Events API not wired for external plugins (SetEventSubscriber not called)
return 0
case 24: // CORE_UNSUBSCRIBE
return 0
case 25: // CORE_FREE_STRING
if s1 != nil {
C.free(unsafe.Pointer(s1))
}
return 0
case 26: // CORE_SETTINGS_GET_CORE
if sett := s.Settings(); sett != nil {
v, err := sett.GetCore(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(v)
setResult(result, string(b))
}
return 0
case 27: // CORE_SETTINGS_SET_CORE
if sett := s.Settings(); sett != nil {
var v interface{}
json.Unmarshal([]byte(a2), &v)
if err := sett.SetCore(a1, v); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 28: // CORE_SETTINGS_LIST_CORE
if sett := s.Settings(); sett != nil {
keys, err := sett.ListCore(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(keys)
setResult(result, string(b))
}
return 0
case 29: // CORE_SETTINGS_GET_PLUGIN
if sett := s.Settings(); sett != nil {
v, err := sett.GetPlugin(a1, a2)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(v)
setResult(result, string(b))
}
return 0
case 30: // CORE_SETTINGS_SET_PLUGIN
if sett := s.Settings(); sett != nil {
var v interface{}
json.Unmarshal([]byte(a3), &v)
if err := sett.SetPlugin(a1, a2, v); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 31: // CORE_SETTINGS_LIST_PLUGIN
if sett := s.Settings(); sett != nil {
keys, err := sett.ListPlugin(a1, a2)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(keys)
setResult(result, string(b))
}
return 0
case 32: // CORE_DOC_INSERT
if dm := s.DocMemory(); dm != nil {
var doc sdk.Doc
if err := json.Unmarshal([]byte(a1), &doc); err != nil {
setErr(errorOut, err)
return 1
}
if err := dm.Insert(&doc); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 33: // CORE_DOC_REMOVE
if dm := s.DocMemory(); dm != nil {
dm.Remove(a1)
}
return 0
case 34: // CORE_DOC_STATS
if dm := s.DocMemory(); dm != nil {
b, _ := json.Marshal(dm.Stats())
setResult(result, string(b))
}
return 0
case 35: // CORE_KNOWLEDGE_ADD
if kn := s.Knowledge(); kn != nil {
if err := kn.Add(a1, a2); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 36: // CORE_KNOWLEDGE_LIST
if kn := s.Knowledge(); kn != nil {
list, err := kn.List()
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(list)
setResult(result, string(b))
}
return 0
case 37: // CORE_LLM_CURRENT_SOURCE
if llm := s.LLM(); llm != nil {
b, _ := json.Marshal(llm.CurrentSource())
setResult(result, string(b))
}
return 0
case 38: // CORE_SOCIAL_GET_TRAIT
if social := s.Social(); social != nil {
val, ok := social.GetTrait(a1, a2)
b, _ := json.Marshal(map[string]interface{}{"value": val, "found": ok})
setResult(result, string(b))
}
return 0
case 39: // CORE_SOCIAL_GET_RELATIONS
if social := s.Social(); social != nil {
rels, err := social.GetRelations(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(rels)
setResult(result, string(b))
}
return 0
case 40: // CORE_SOCIAL_LIST_PERSONS
if social := s.Social(); social != nil {
persons, err := social.ListPersons()
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(persons)
setResult(result, string(b))
}
return 0
case 41: // CORE_TEXT_MEMORY_APPEND
if tm := s.TextMemory(); tm != nil {
var evt sdk.TextEvent
if err := json.Unmarshal([]byte(a1), &evt); err != nil {
setErr(errorOut, err)
return 1
}
if err := tm.Append(evt); err != nil {
setErr(errorOut, err)
return 1
}
}
return 0
case 42: // CORE_SETTINGS_LIST
if sett := s.Settings(); sett != nil {
keys, err := sett.List(a1)
if err != nil {
setErr(errorOut, err)
return 1
}
b, _ := json.Marshal(keys)
setResult(result, string(b))
}
return 0
case 43: // CORE_SETTINGS_DEFS
if sett := s.Settings(); sett != nil {
defs := sett.Defs(a1)
b, _ := json.Marshal(defs)
setResult(result, string(b))
}
return 0
case 44: // CORE_SETTINGS_DUMP
if sett := s.Settings(); sett != nil {
dump := sett.Dump()
b, _ := json.Marshal(dump)
setResult(result, string(b))
}
return 0
case 45: // CORE_SETTINGS_PLUGINS
if sett := s.Settings(); sett != nil {
plugins := sett.Plugins()
b, _ := json.Marshal(plugins)
setResult(result, string(b))
}
return 0
case 51: // CORE_SETTINGS_DATA_DIR插件专属数据目录内核保证存在
if sett := s.Settings(); sett != nil {
setResult(result, sett.DataDir())
}
return 0
case 46: // CORE_REGISTER_INPUT_CH
chDef := sdk.ChannelDef{}
if a2 != "" {
var def sdk.ChannelDef
if err := json.Unmarshal([]byte(a2), &def); err == nil {
chDef = def
}
}
s.RegisterInputChannel(a1, chDef)
return 0
case 48: // CORE_PLUGIN_RELOAD_ONE
if s.PluginMgr() == nil {
setErr(errorOut, fmt.Errorf("plugin manager not available"))
return 1
}
if err := s.PluginMgr().ReloadOne(a1); err != nil {
setErr(errorOut, err)
return 1
}
setResult(result, "reloaded: "+a1)
return 0
case 49: // CORE_PLUGIN_LIST_LOADED
if s.PluginMgr() == nil {
setErr(errorOut, fmt.Errorf("plugin manager not available"))
return 1
}
if b, err := json.Marshal(s.PluginMgr().ListLoadedPlugins()); err == nil {
setResult(result, string(b))
}
return 0
case 50: // CORE_PLUGIN_IS_DISABLED
if s.PluginMgr() == nil {
setErr(errorOut, fmt.Errorf("plugin manager not available"))
return 1
}
if s.PluginMgr().IsPluginDisabled(a1) {
setResult(result, "1")
} else {
setResult(result, "0")
}
return 0
}
return 0
}
func goStr(s *C.char) string {
if s == nil {
return ""
}
return C.GoString(s)
}
func setErr(errOut **C.char, err error) {
if errOut != nil && err != nil {
*errOut = C.CString(err.Error())
}
}
func setResult(result **C.char, v string) {
if result != nil {
*result = C.CString(v)
}
}

View File

@ -1,92 +0,0 @@
package cabi
import (
"errors"
"strings"
"testing"
"time"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// output_send 不再假成功plan.md 11.1sent / error / unconfirmed 三态。
func TestAwaitOutputResult_Success(t *testing.T) {
res, err := awaitOutputResultWith(0, "qq", `{"x":1}`, func(pid int32, ch, args string) error {
return nil
}, outputSendTimeout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
m, _ := res.(map[string]interface{})
if m["status"] != "sent" {
t.Fatalf("expected status=sent, got %v", m["status"])
}
}
func TestAwaitOutputResult_Failure(t *testing.T) {
_, err := awaitOutputResultWith(0, "qq", `{}`, func(pid int32, ch, args string) error {
return errors.New("meta 中需要 group_id 或 user_id 字段")
}, outputSendTimeout)
if err == nil {
t.Fatal("expected error on failed send, got nil (旧实现会谎报成功)")
}
if !strings.Contains(err.Error(), "需要 group_id") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAwaitOutputResult_Timeout(t *testing.T) {
res, err := awaitOutputResultWith(0, "qq", `{}`, func(pid int32, ch, args string) error {
time.Sleep(2 * time.Second) // 模拟插件发送迟迟不确认
return nil
}, 50*time.Millisecond)
if err != nil {
t.Fatalf("unconfirmed 不应返回 errorgot %v", err)
}
m, _ := res.(map[string]interface{})
if m["status"] != "unconfirmed" {
t.Fatalf("expected status=unconfirmed, got %v", m["status"])
}
}
// applyStageResult 必须能表达「插件清空了 tool_calls/tool_results」——
// ABI v2 diff 回传plan.md 11.3)下插件拒绝全部工具调用时会显式回传 []。
func TestApplyStageResult_ClearedSlicesAreApplied(t *testing.T) {
sc := &sdk.StageContext{
ToolCalls: []sdk.ToolCall{{ID: "t1", Name: "cmd_run"}},
ToolResults: []sdk.ToolResult{{CallID: "t1", Name: "cmd_run", Result: "x"}},
}
applyStageResult(sc, `{"tool_calls":[],"tool_results":[]}`)
if len(sc.ToolCalls) != 0 {
t.Fatalf("tool_calls 应被清空,实际 %v", sc.ToolCalls)
}
if len(sc.ToolResults) != 0 {
t.Fatalf("tool_results 应被清空,实际 %v", sc.ToolResults)
}
}
// diff 回传只带变更字段:未出现的键不得被改动(避免旧快照覆盖)。
func TestApplyStageResult_OnlyPresentKeysApplied(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "原始输入",
LLMText: "原始LLM",
FinalText: "原始最终",
ToolResults: []sdk.ToolResult{{CallID: "c1", Result: "已清洗"}},
}
// 只回传 final_text 的变更
applyStageResult(sc, `{"final_text":"新最终"}`)
if sc.FinalText != "新最终" {
t.Fatalf("final_text 应被应用,实际 %q", sc.FinalText)
}
if sc.RawMessage != "原始输入" {
t.Errorf("raw_message 未回传却被改动: %q", sc.RawMessage)
}
if sc.LLMText != "原始LLM" {
t.Errorf("llm_text 未回传却被改动: %q", sc.LLMText)
}
if len(sc.ToolResults) != 1 || sc.ToolResults[0].Result != "已清洗" {
t.Errorf("tool_results 未回传却被改动: %v", sc.ToolResults)
}
}

View File

@ -1,66 +0,0 @@
package cabi
import "gitcode.com/JianFeeeee/HomeAgent/internal/meta"
// ABI version constants — single source of truth is meta.go
// ABIVersion/ABIVersionMin 是字符串 semvervar 转发,因 meta 侧 Version 为注入变量);
// CABINum/CABINumMin 是 C 层整数协商版本。
var (
ABIVersion = meta.ABIVersion
ABIVersionMin = meta.ABIVersionMin
)
const (
CABINum = meta.CABINum
CABINumMin = meta.CABINumMin
)
// Dispatch method IDs — single source of truth is meta.go
const (
CoreRegisterTool = meta.CoreRegisterTool
CoreRegisterStage = meta.CoreRegisterStage
CoreRegisterOutputCh = meta.CoreRegisterOutputCh
CoreRegisterPluginAPI = meta.CoreRegisterPluginAPI
CoreInjectText = meta.CoreInjectText
CoreInjectInterruptText = meta.CoreInjectInterruptText
CoreInjectTextNoMemory = meta.CoreInjectTextNoMemory
CoreSetAutoRestart = meta.CoreSetAutoRestart
CoreMemoryRecall = meta.CoreMemoryRecall
CoreMemoryCommit = meta.CoreMemoryCommit
CoreMemoryIntrospect = meta.CoreMemoryIntrospect
CoreMemoryMerge = meta.CoreMemoryMerge
CoreMemoryPurge = meta.CoreMemoryPurge
CoreDocQuery = meta.CoreDocQuery
CoreKnowledgeSearch = meta.CoreKnowledgeSearch
CoreSettingsGet = meta.CoreSettingsGet
CoreSettingsSet = meta.CoreSettingsSet
CoreSettingsRegisterDef = meta.CoreSettingsRegisterDef
CoreLLMListSources = meta.CoreLLMListSources
CoreLLMSetSource = meta.CoreLLMSetSource
CoreSocialGetPerson = meta.CoreSocialGetPerson
CoreSocialGetNetwork = meta.CoreSocialGetNetwork
CoreSubscribe = meta.CoreSubscribe
CoreUnsubscribe = meta.CoreUnsubscribe
CoreFreeString = meta.CoreFreeString
CoreSettingsGetCore = meta.CoreSettingsGetCore
CoreSettingsSetCore = meta.CoreSettingsSetCore
CoreSettingsListCore = meta.CoreSettingsListCore
CoreSettingsGetPlugin = meta.CoreSettingsGetPlugin
CoreSettingsSetPlugin = meta.CoreSettingsSetPlugin
CoreSettingsListPlugin = meta.CoreSettingsListPlugin
CoreDocInsert = meta.CoreDocInsert
CoreDocRemove = meta.CoreDocRemove
CoreDocStats = meta.CoreDocStats
CoreKnowledgeAdd = meta.CoreKnowledgeAdd
CoreKnowledgeList = meta.CoreKnowledgeList
CoreLLMCurrentSource = meta.CoreLLMCurrentSource
CoreSocialGetTrait = meta.CoreSocialGetTrait
CoreSocialGetRelations = meta.CoreSocialGetRelations
CoreSocialListPersons = meta.CoreSocialListPersons
CoreTextMemoryAppend = meta.CoreTextMemoryAppend
CoreSettingsList = meta.CoreSettingsList
CoreSettingsDefs = meta.CoreSettingsDefs
CoreSettingsDump = meta.CoreSettingsDump
CoreSettingsPlugins = meta.CoreSettingsPlugins
CoreRegisterInputCh = meta.CoreRegisterInputCh
)

View File

@ -7,31 +7,34 @@ import (
)
const (
soEntry = "plugin.so"
dllEntry = "plugin.dll"
binEntry = "plugin.bin" // 子进程插件(纯 Go 二进制stdio JSON-RPC
binEntry = "plugin.bin" // 子进程插件(纯 Go 二进制stdio JSON-RPC + 共享内存)
luaEntry = "main.lua"
skillEntry = "SKILL.md"
metaEntry = "plugin.json"
)
// legacyCABIEntries 是已退场的 C ABI 产物名。
//
// 保留这张表只为**给出明确错误**:插件目录里躺着 plugin.so 而内核不再认它时,
// 静默跳过会让「目录在但插件没加载」看起来像配置问题,而实际原因是需要用
// 新版 plugindev 重编。
var legacyCABIEntries = []string{"plugin.so", "plugin.dll", "plugin.dylib"}
// entryKind 描述插件入口归属的加载通道。
// 外部插件多进程化期间 .so/.dllcabi与 .binproc**双通道共存**
// 按 plugin.json 的 entry 字段分派,使迁移可逐插件推进、随时回退。
//
// C ABI 通道(.so/.dll/.dylib已整体退场外部插件统一走子进程 + stdio RPC
// 三套独立 ABI 实现收敛为单一 RPC 实现§9.2)。
type entryKind int
const (
entryUnknown entryKind = iota
entryCABI // plugin.so / plugin.dll / plugin.dylib —— C ABI 动态库
entryProc // plugin.bin —— 子进程 + stdio JSON-RPC
entryProc // plugin.bin —— 子进程 + stdio JSON-RPC + 共享内存
entryLua // main.lua
entrySkill // SKILL.md
)
func (k entryKind) String() string {
switch k {
case entryCABI:
return "cabi"
case entryProc:
return "proc"
case entryLua:
@ -43,11 +46,12 @@ func (k entryKind) String() string {
}
// classifyEntry 把 manifest 的 entry 字段映射到加载通道。
// entry 为空时返回 entryUnknown由调用方回退到目录探测兼容无 manifest 的旧插件)。
//
// entry 为空或声明已退场的 C ABI 产物时返回 entryUnknown
// 由调用方回退到目录探测(兼容无 manifest 的旧插件),
// 并在探测到 C ABI 残留时给出明确的重编提示。
func classifyEntry(entry string) entryKind {
switch entry {
case soEntry, dllEntry, "plugin.dylib":
return entryCABI
case binEntry:
return entryProc
case luaEntry:
@ -59,8 +63,9 @@ func classifyEntry(entry string) entryKind {
}
// detectEntryKind 先读 manifest 的 entry读不到则按目录内存在的入口文件推断。
// 推断顺序:.bin 优先于 .so——迁移期间同一插件目录可能两个产物共存升级未清理
// 此时应走新通道manifest 显式声明优先级最高。
//
// 注意:存量插件的 plugin.json 可能仍写着 "plugin.so"(工具链已不再据此分派,
// 但历史产物里有),此时 classifyEntry 返回 unknown靠目录探测找到 plugin.bin。
func detectEntryKind(plgDir string) entryKind {
if mft := readManifest(plgDir); mft != nil {
if k := classifyEntry(mft.Entry); k != entryUnknown {
@ -72,9 +77,6 @@ func detectEntryKind(plgDir string) entryKind {
kind entryKind
}{
{binEntry, entryProc},
{soEntry, entryCABI},
{"plugin.dylib", entryCABI},
{dllEntry, entryCABI},
{luaEntry, entryLua},
{skillEntry, entrySkill},
} {
@ -85,6 +87,18 @@ func detectEntryKind(plgDir string) entryKind {
return entryUnknown
}
// hasLegacyCABIEntry 判断插件目录里是否只剩已退场的 C ABI 产物。
//
// 用于给出「需要重编」而非「插件不存在」的错误。
func hasLegacyCABIEntry(plgDir string) bool {
for _, name := range legacyCABIEntries {
if st, err := os.Stat(filepath.Join(plgDir, name)); err == nil && !st.IsDir() {
return true
}
}
return false
}
func readManifest(dir string) *PluginManifest {
data, err := os.ReadFile(filepath.Join(dir, metaEntry))
if err != nil {
@ -96,5 +110,3 @@ func readManifest(dir string) *PluginManifest {
}
return &m
}
var _ = json.Marshal

View File

@ -1,11 +0,0 @@
//go:build !windows
package plugin
import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func tryLoadDLL(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
}

View File

@ -1,32 +0,0 @@
//go:build windows
package plugin
import (
"os"
"path/filepath"
"testing"
)
func TestTryLoadDLL_NoFile(t *testing.T) {
dir := t.TempDir()
plg, err := tryLoadDLL(dir, "nonexistent", nil)
if err != nil {
t.Fatalf("tryLoadDLL on empty dir should not error: %v", err)
}
if plg != nil {
t.Fatal("expected nil for non-existent plugin.dll")
}
}
func TestTryLoadDLL_Invalid(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "plugin.dll"), []byte("not a real dll"), 0644)
plg, err := tryLoadDLL(dir, "baddll", nil)
t.Logf("plg=%v err=%v", plg, err)
if err == nil && plg == nil {
t.Fatal("expected error or non-nil plugin for existing file")
}
}

View File

@ -1,272 +0,0 @@
//go:build windows
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"syscall"
"unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// dllPlugin wraps a Windows DLL compiled with -buildmode=c-shared.
//
// Required exports:
//
// NewPlugin(name *C.char, configJSON *C.char) unsafe.Pointer → plugin handle
// StartPlugin(handle unsafe.Pointer) C.int
// StopPlugin(handle unsafe.Pointer) C.int
// DestroyPlugin(handle unsafe.Pointer)
//
// Optional exports (tool registration):
//
// GetToolDefsJSON(handle unsafe.Pointer) *C.char → JSON array of tool defs
// InvokeToolJSON(handle unsafe.Pointer, toolName *C.char, argsJSON *C.char) *C.char
// FreeCString(s *C.char) → free C string from DLL
// GetStagesJSON(handle unsafe.Pointer) *C.char → JSON array of stage names
// InvokeStage(handle unsafe.Pointer, stage *C.char, contextJSON *C.char) C.int
type dllPlugin struct {
name string
dll syscall.Handle
handle uintptr
sdk *sdk.PluginSDK
// cached proc addresses
newPlugin uintptr
startPlugin uintptr
stopPlugin uintptr
destroyPlugin uintptr
getTools uintptr
invokeTool uintptr
freeCStr uintptr
getStages uintptr
invokeStage uintptr
}
func findProc(dll syscall.Handle, name string) uintptr {
addr, err := syscall.GetProcAddress(dll, name)
if err != nil {
return 0
}
return addr
}
func newDLLPlugin(dllPath, name string, config map[string]interface{}) (*dllPlugin, error) {
dll, err := syscall.LoadLibrary(dllPath)
if err != nil {
return nil, fmt.Errorf("LoadLibrary %s: %w", dllPath, err)
}
np := findProc(dll, "NewPlugin")
if np == 0 {
_ = syscall.FreeLibrary(dll)
return nil, fmt.Errorf("dll %s must export NewPlugin", name)
}
return &dllPlugin{
name: name,
dll: dll,
// required
newPlugin: np,
startPlugin: findProc(dll, "StartPlugin"),
stopPlugin: findProc(dll, "StopPlugin"),
destroyPlugin: findProc(dll, "DestroyPlugin"),
// optional tool/stage API
getTools: findProc(dll, "GetToolDefsJSON"),
invokeTool: findProc(dll, "InvokeToolJSON"),
freeCStr: findProc(dll, "FreeCString"),
getStages: findProc(dll, "GetStagesJSON"),
invokeStage: findProc(dll, "InvokeStage"),
}, nil
}
func (p *dllPlugin) Name() string { return p.name }
func (p *dllPlugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
cfgJSON, _ := json.Marshal(map[string]interface{}{
"name": p.name,
"config": s.Settings().Dump(),
})
cName := append([]byte(p.name), 0)
cConfig := append(cfgJSON, 0)
ret, _, _ := syscall.SyscallN(
p.newPlugin,
uintptr(unsafe.Pointer(&cName[0])),
uintptr(unsafe.Pointer(&cConfig[0])),
)
if ret == 0 {
_ = syscall.FreeLibrary(p.dll)
return fmt.Errorf("dll NewPlugin %s returned nil", p.name)
}
p.handle = ret
if p.startPlugin != 0 {
syscall.SyscallN(p.startPlugin, p.handle)
}
// discover and register tools from DLL
if p.getTools != 0 {
if err := p.registerTools(s); err != nil {
return fmt.Errorf("dll %s register tools: %w", p.name, err)
}
}
if p.getStages != 0 {
p.registerStages(s)
}
return nil
}
func (p *dllPlugin) Stop() error {
if p.stopPlugin != 0 {
syscall.SyscallN(p.stopPlugin, p.handle)
}
if p.destroyPlugin != 0 {
syscall.SyscallN(p.destroyPlugin, p.handle)
}
_ = syscall.FreeLibrary(p.dll)
return nil
}
// --- tool registration via C ABI ---
type dllToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
}
func (p *dllPlugin) registerTools(s *sdk.PluginSDK) error {
ret, _, _ := syscall.SyscallN(p.getTools, p.handle)
if ret == 0 {
return nil // no tools
}
defsJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
var defs []dllToolDef
if err := json.Unmarshal([]byte(defsJSON), &defs); err != nil {
return fmt.Errorf("parse tool defs: %w", err)
}
for _, d := range defs {
if d.Name == "" {
continue
}
toolName := d.Name
handler := p.makeToolHandler(toolName)
s.RegisterTool(toolName, sdk.ToolDef{
Name: toolName,
Description: d.Description,
Parameters: d.Parameters,
Plugin: p.name,
}, handler)
}
return nil
}
func (p *dllPlugin) makeToolHandler(toolName string) sdk.ToolHandler {
return func(args map[string]interface{}) (interface{}, error) {
if p.invokeTool == 0 {
return nil, fmt.Errorf("dll %s does not export InvokeToolJSON", p.name)
}
argsJSON, _ := json.Marshal(args)
cToolName := append([]byte(toolName), 0)
cArgs := append(argsJSON, 0)
ret, _, _ := syscall.SyscallN(
p.invokeTool,
p.handle,
uintptr(unsafe.Pointer(&cToolName[0])),
uintptr(unsafe.Pointer(&cArgs[0])),
)
if ret == 0 {
return nil, fmt.Errorf("dll InvokeToolJSON %s returned nil", toolName)
}
resultJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(resultJSON), &result); err != nil {
return nil, fmt.Errorf("dll tool %s result parse: %w", toolName, err)
}
return result, nil
}
}
func (p *dllPlugin) registerStages(s *sdk.PluginSDK) {
ret, _, _ := syscall.SyscallN(p.getStages, p.handle)
if ret == 0 {
return
}
stagesJSON := cStringPtrToString(ret)
if p.freeCStr != 0 {
syscall.SyscallN(p.freeCStr, ret)
}
type stageEntry struct {
Stage string `json:"stage"`
}
var entries []stageEntry
if err := json.Unmarshal([]byte(stagesJSON), &entries); err != nil {
return
}
for _, e := range entries {
if e.Stage == "" {
continue
}
stageName := sdk.Stage(e.Stage)
stage := stageName
s.RegisterStage(stage, func(sc *sdk.StageContext) error {
if p.invokeStage == 0 {
return nil
}
ctxJSON, _ := json.Marshal(map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"phase": string(sc.Phase),
})
cStage := append([]byte(stage), 0)
cCtx := append(ctxJSON, 0)
syscall.SyscallN(
p.invokeStage,
p.handle,
uintptr(unsafe.Pointer(&cStage[0])),
uintptr(unsafe.Pointer(&cCtx[0])),
)
return nil
})
}
}
func cStringPtrToString(ptr uintptr) string {
if ptr == 0 {
return ""
}
var buf []byte
for i := uintptr(0); ; i++ {
b := *(*byte)(unsafe.Pointer(ptr + i))
if b == 0 {
break
}
buf = append(buf, b)
}
return string(buf)
}
// tryLoadDLL 尝试从插件目录加载 plugin.dll。
// 返回 nil,nil 表示目录中没有 plugin.dll。
func tryLoadDLL(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
dllPath := filepath.Join(dir, dllEntry)
if _, err := os.Stat(dllPath); os.IsNotExist(err) {
return nil, nil
}
return newDLLPlugin(dllPath, name, config)
}

View File

@ -1,79 +0,0 @@
//go:build linux || darwin
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/cabi"
)
type dynamicPlugin struct {
name string
impl pubsdk.Plugin
}
func (p *dynamicPlugin) Name() string { return p.name }
func (p *dynamicPlugin) Start(s *sdk.PluginSDK) error {
return p.impl.Start(s.PluginSDK)
}
func (p *dynamicPlugin) Stop() error { return p.impl.Stop() }
type cabiPlugin struct {
name string
handle *cabi.Handle
}
func (p *cabiPlugin) Name() string { return p.name }
func (p *cabiPlugin) Start(s *sdk.PluginSDK) error {
corePtr := p.handle.CreateCoreAPI(s)
if corePtr == nil {
return fmt.Errorf("cabi: failed to create CoreAPI for %s", p.name)
}
if err := p.handle.Start(corePtr); err != nil {
return fmt.Errorf("cabi: start %s: %w", p.name, err)
}
return nil
}
func (p *cabiPlugin) Stop() error {
_ = p.handle.Stop()
return nil
}
// Close 卸载动态库dlclose。卸载/重载后必须调用,否则同一路径的 dlopen
// 会复用旧句柄Linux dlopen 语义),新版本的 plugin.so 不会生效。
func (p *cabiPlugin) Close() error {
p.handle.Close()
return nil
}
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
soPath := filepath.Join(dir, soEntry)
if _, err := os.Stat(soPath); os.IsNotExist(err) {
// 回退尝试 plugin.dylib (macOS 原生扩展名)
dylibPath := filepath.Join(dir, "plugin.dylib")
if _, err2 := os.Stat(dylibPath); err2 == nil {
soPath = dylibPath
} else {
return nil, nil
}
}
handle, err := cabi.Load(soPath, name, config)
if err == nil {
return &cabiPlugin{name: name, handle: handle}, nil
}
// 本项目插件统一由 plugindev 编译为 c-shared 走 C ABI
// 对 c-shared .so 调用 Go plugin.Open 会 fatalno plugin module data
// 因此不再 fallback 到 Go plugin直接返回加载错误避免崩溃。
return nil, err
}
var _ = json.Marshal

View File

@ -1,11 +0,0 @@
//go:build windows
package plugin
import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
}

View File

@ -3,26 +3,30 @@ package plugin
import (
"os"
"path/filepath"
"strings"
"testing"
)
// entry 分派骨架docs/zh/plugin-migration-plan.md Part 1
// 外部插件多进程化期间 .so/.dllcabi与 .binproc双通道共存
// 按 plugin.json 的 entry 字段分派,使迁移可逐插件推进、随时回退
// entry 分派docs/zh/plugin-migration-plan.md Part 1/6
//
// C ABI 通道(.so/.dll/.dylib已整体退场外部插件统一走子进程 + stdio RPC
// 这些测试守住的是「旧产物给明确错误」而非「静默跳过」——后者会让
// 「插件目录在但没加载」看起来像配置问题。
func TestClassifyEntry(t *testing.T) {
cases := []struct {
entry string
want entryKind
}{
{"plugin.so", entryCABI},
{"plugin.dll", entryCABI},
{"plugin.dylib", entryCABI},
{"plugin.bin", entryProc},
{"main.lua", entryLua},
{"SKILL.md", entrySkill},
{"", entryUnknown},
{"plugin.wasm", entryUnknown},
// 已退场的 C ABI 产物不再是有效通道
{"plugin.so", entryUnknown},
{"plugin.dll", entryUnknown},
{"plugin.dylib", entryUnknown},
}
for _, c := range cases {
if got := classifyEntry(c.entry); got != c.want {
@ -34,45 +38,34 @@ func TestClassifyEntry(t *testing.T) {
// manifest 显式声明的 entry 优先级最高。
func TestDetectEntryKind_ManifestWins(t *testing.T) {
dir := t.TempDir()
// 目录里放 .so但 manifest 声明 .bin → 应走 proc
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "main.lua"), "fake lua")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
mustWrite(t, filepath.Join(dir, metaEntry), `{"name":"x","entry":"plugin.bin"}`)
mustWrite(t, filepath.Join(dir, metaEntry), `{"name":"x","entry":"main.lua"}`)
if got := detectEntryKind(dir); got != entryProc {
t.Fatalf("manifest 声明 plugin.bin 应走 proc,实际 %v", got)
if got := detectEntryKind(dir); got != entryLua {
t.Fatalf("manifest 声明 main.lua 应走 lua,实际 %v", got)
}
}
// manifest 声明 .so 时即便存在 .bin 也走 cabi —— 这是回退路径的保证。
func TestDetectEntryKind_ManifestCanForceRollback(t *testing.T) {
// 存量插件的 plugin.json 仍写着 "plugin.so"(历史产物),
// 此时 classifyEntry 返回 unknown须靠目录探测找到 plugin.bin。
//
// 这是「外部插件零改动」的直接后果17 个插件的 manifest 没人去改。
func TestDetectEntryKind_LegacyManifestFallsBackToProbe(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
mustWrite(t, filepath.Join(dir, metaEntry), `{"name":"x","entry":"plugin.so"}`)
if got := detectEntryKind(dir); got != entryCABI {
t.Fatalf("manifest 声明 plugin.so 应回退到 cabi实际 %v", got)
}
}
// 无 manifest或 entry 为空)时按目录探测,.bin 优先于 .so
// 迁移期间同目录可能两种产物共存(升级未清理),此时应走新通道。
func TestDetectEntryKind_ProbeOrderPrefersBin(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
if got := detectEntryKind(dir); got != entryProc {
t.Fatalf("manifest 时应优先 plugin.bin实际 %v", got)
t.Fatalf("manifest 写 plugin.so 但目录有 plugin.bin 时应走 proc,实际 %v", got)
}
}
func TestDetectEntryKind_ProbeFallbacks(t *testing.T) {
t.Run("only so", func(t *testing.T) {
t.Run("only bin", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "x")
if got := detectEntryKind(dir); got != entryCABI {
mustWrite(t, filepath.Join(dir, "plugin.bin"), "x")
if got := detectEntryKind(dir); got != entryProc {
t.Fatalf("got %v", got)
}
})
@ -95,15 +88,60 @@ func TestDetectEntryKind_ProbeFallbacks(t *testing.T) {
t.Fatalf("空目录应为 unknown实际 %v", got)
}
})
t.Run("only legacy so", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "x")
if got := detectEntryKind(dir); got != entryUnknown {
t.Fatalf("只有 .so 时应为 unknownC ABI 已退场),实际 %v", got)
}
})
}
// entry 声明 plugin.bin 但二进制缺失时必须报明确错误
// 不得静默回退到 cabi —— 否则"已迁移插件跑回旧通道"极难排查。
// C ABI 残留必须能被识别,供 tryDynamic 给出「需要重编」的明确错误
func TestHasLegacyCABIEntry(t *testing.T) {
for _, name := range []string{"plugin.so", "plugin.dll", "plugin.dylib"} {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, name), "x")
if !hasLegacyCABIEntry(dir) {
t.Errorf("%s 应被识别为 C ABI 残留", name)
}
}
t.Run("clean dir", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.bin"), "x")
if hasLegacyCABIEntry(dir) {
t.Error("只有 plugin.bin 的目录不应被判为 C ABI 残留")
}
})
}
// 旧 .so 插件必须报「用新 plugindev 重编」而非静默跳过。
func TestTryDynamic_LegacyCABIGivesActionableError(t *testing.T) {
r := NewRegistry()
defer r.closeProcHost()
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "old cabi binary")
_, err := r.tryDynamic(dir, "legacy", nil)
if err == nil {
t.Fatal("旧 C ABI 产物应报错,不得静默跳过")
}
// 错误消息须指向解决办法,且明确业务代码无需改
msg := err.Error()
for _, want := range []string{"plugindev", "plugin.bin", "业务代码"} {
if !strings.Contains(msg, want) {
t.Errorf("错误消息应含 %q实际: %v", want, err)
}
}
}
// entry 声明 plugin.bin 但二进制缺失时返回 nil,nil交由后续探测
func TestTryLoadProc_MissingBinaryReturnsNil(t *testing.T) {
dir := t.TempDir()
plg, err := tryLoadProc(dir, "demo", nil)
if plg != nil || err != nil {
t.Fatalf("无 plugin.bin 应返回 nil,nil(交由后续探测),实际 plg=%v err=%v", plg, err)
t.Fatalf("无 plugin.bin 应返回 nil,nil实际 plg=%v err=%v", plg, err)
}
}
@ -123,9 +161,8 @@ func TestTryLoadProc_NonExecutableRejected(t *testing.T) {
// pluginEntryHash 的候选顺序须与 detectEntryKind 一致plugin.bin 优先),
// 否则增量重载会用错文件算 hash导致"换了 .bin 但内核以为没变"。
func TestPluginEntryHash_PrefersBin(t *testing.T) {
func TestPluginEntryHash_UsesBin(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "so content")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "bin content")
h1 := pluginEntryHash(dir)
@ -133,19 +170,22 @@ func TestPluginEntryHash_PrefersBin(t *testing.T) {
t.Fatal("应算出 hash")
}
// 改 .so 不应影响 hash因为以 .bin 为准)
mustWrite(t, filepath.Join(dir, "plugin.so"), "so content CHANGED")
if h2 := pluginEntryHash(dir); h2 != h1 {
t.Error("plugin.bin 存在时 hash 不应受 plugin.so 变化影响")
}
// 改 .bin 必须改变 hash
mustWrite(t, filepath.Join(dir, "plugin.bin"), "bin content CHANGED")
if h3 := pluginEntryHash(dir); h3 == h1 {
if h2 := pluginEntryHash(dir); h2 == h1 {
t.Error("plugin.bin 变化必须反映到 hash否则增量重载失效")
}
}
// C ABI 产物不再参与 hash 计算:内核已不认它,把它算进去会让
// 「换了 .so」触发一次无意义的重载尝试。
func TestPluginEntryHash_IgnoresLegacyCABI(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "so content")
if h := pluginEntryHash(dir); h != "" {
t.Errorf("只有 .so 时应返回空串C ABI 已退场),实际 %q", h)
}
}
func TestPluginEntryHash_EmptyForFactoryOnlyPlugin(t *testing.T) {
if h := pluginEntryHash(t.TempDir()); h != "" {
t.Errorf("无入口文件应返回空串(内置纯工厂插件),实际 %q", h)

View File

@ -374,7 +374,7 @@ func (r *Registry) isDisabled(name string) bool {
// plugin.bin 排在最前:与 detectEntryKind 保持一致的优先级,迁移期间同目录
// 两种产物共存时以子进程产物为准。
func pluginEntryHash(plgDir string) string {
for _, candidate := range []string{binEntry, soEntry, dllEntry, "plugin.dylib", luaEntry, skillEntry} {
for _, candidate := range []string{binEntry, luaEntry, skillEntry} {
path := filepath.Join(plgDir, candidate)
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
sum := sha256.Sum256(data)
@ -905,8 +905,10 @@ func (r *Registry) PluginDir() string {
}
func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}) (sdk.Plugin, error) {
// 按 manifest entry 分派到对应加载通道(外部插件多进程化:.so/.dll 与 .bin 双通道共存)
// 这使迁移可逐插件推进、随时回退——把 entry 改回 plugin.so 即回到旧通道。
// 按 manifest entry 分派加载通道。
//
// C ABI 通道(.so/.dll/.dylib已整体删除外部插件统一走子进程
// 三套独立 ABI 实现收敛为单一 RPC 实现§9.2)。
if detectEntryKind(plgDir) == entryProc {
plg, err := r.loadProc(plgDir, name, config)
if err != nil {
@ -916,29 +918,21 @@ func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}
log.Printf("[plugin] %s: 经 proc 通道加载(子进程)", name)
return plg, nil
}
// entry 声明了 plugin.bin 但文件不存在/不可用 → 不隐式回退到 cabi
// 否则"已迁移插件静默跑回旧通道"极难排查。
return nil, fmt.Errorf("plugin %s: entry 声明 %s 但未找到可用二进制", name, binEntry)
}
// 既有探测顺序(保持不变):.so → .dll → .lua
for _, try := range []struct {
name string
fn func(string, string, map[string]interface{}) (sdk.Plugin, error)
}{
{"so", tryLoadSO},
{"dll", tryLoadDLL},
{"lua", tryLoadLua},
} {
plg, err := try.fn(plgDir, name, config)
if err != nil {
return nil, err
}
if plg != nil {
return plg, nil
}
// 旧 .so/.dll 插件给明确错误,不静默跳过。
// 静默跳过会让「插件目录在但没加载」看起来像配置问题,
// 而实际原因是需要用新 plugindev 重编。
if hasLegacyCABIEntry(plgDir) {
return nil, fmt.Errorf(
"plugin %s: 检测到旧 C ABI 产物plugin.so/.dll/.dylib。"+
"外部插件已改为子进程模式,请用新版 plugindev 重编产出 %s"+
"(业务代码无需修改)", name, binEntry)
}
return nil, nil
// Lua 插件仍走解释器
return tryLoadLua(plgDir, name, config)
}
func (r *Registry) readConfig(plgDir string) map[string]interface{} {

View File

@ -23,16 +23,16 @@ import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// platformBinary 按当前 OS 选择正确的插件二进制文件名。
// platformBinary 按当前 OS/ARCH 选择正确的插件二进制文件名。
// 返回 (zip内文件名, 安装后重命名).
//
// 子进程模式下各平台产物统一叫 plugin.bin进程边界即 ABI 边界,
// 不存在 .so/.dylib/.dll 的区分),故 zip 内按平台加后缀区分,
// 解包时挑当前平台那一份重命名为 plugin.bin。
func platformBinary() (zipName, canonicalName string) {
switch runtime.GOOS {
case "linux":
return "plugin.so", "plugin.so"
case "darwin":
return "plugin.dylib", "plugin.so" // dlopen 兼容 .so 名称
case "windows":
return "plugin.dll", "plugin.dll"
case "linux", "darwin", "windows", "freebsd":
return fmt.Sprintf("plugin.bin.%s.%s", runtime.GOOS, runtime.GOARCH), "plugin.bin"
default:
return "", ""
}
@ -40,18 +40,17 @@ func platformBinary() (zipName, canonicalName string) {
// validBinaries 是 .hmap 中所有可识别的文件入口(平台二进制或脚本)。
var validBinaries = map[string]bool{
"plugin.so": true,
"plugin.dylib": true,
"plugin.dll": true,
"main.lua": true,
"SKILL.md": true,
"plugin.bin": true,
"main.lua": true,
"SKILL.md": true,
}
// platformBinaries 是平台特定二进制bundle 模式下仅当前平台的被解压。
var platformBinaries = map[string]bool{
"plugin.so": true,
"plugin.dylib": true,
"plugin.dll": true,
// isPlatformBinary 判断 zip 条目是否为平台特定二进制bundle 模式下仅当前平台的被解压
//
// 形式plugin.bin.<goos>.<goarch>。不用固定表是因为平台组合会增长
// linux/arm64、darwin/arm64 等),按前缀判断无需维护清单。
func isPlatformBinary(name string) bool {
return strings.HasPrefix(name, "plugin.bin.")
}
var downloadClient = &http.Client{
@ -705,17 +704,19 @@ func validatePackage(data []byte) (*pluginPackage, error) {
}
if len(pkg.Platforms) > 0 {
// bundle mode: check each declared platform has a matching binary
// bundle mode:每个声明的平台都要有对应二进制。
// 子进程模式下条目形式为 plugin.bin.<goos>.<goarch>
// 故按前缀匹配而不枚举架构(同一 OS 可能有 amd64/arm64 两份)。
for _, plat := range pkg.Platforms {
bin, ok := map[string]string{
"linux": "plugin.so",
"darwin": "plugin.dylib",
"windows": "plugin.dll",
}[plat]
if !ok {
return nil, fmt.Errorf("unsupported platform: %q", plat)
prefix := "plugin.bin." + plat + "."
found := false
for name := range zipEntries {
if strings.HasPrefix(name, prefix) {
found = true
break
}
}
if zipEntries[bin] {
if found {
hasBinary = true
}
}
@ -795,11 +796,11 @@ func extractPackage(data []byte, pluginDir string) error {
}
// bundle mode: skip other platforms' platform-specific binaries
if isBundle && platformBinaries[f.Name] && f.Name != zipBin {
if isBundle && isPlatformBinary(f.Name) && f.Name != zipBin {
continue
}
// rename platform binary to canonical name (e.g. plugin.dylib → plugin.so)
// 平台二进制重命名为规范名plugin.bin.linux.amd64 → plugin.bin
dest := fpath
if isBundle && f.Name == zipBin && canonicalName != zipBin {
dest = filepath.Join(target, canonicalName)
@ -808,6 +809,15 @@ func extractPackage(data []byte, pluginDir string) error {
if err := copyZipEntry(f, dest); err != nil {
return err
}
// 子进程插件必须可执行。
// zip 保留了原文件权限位,但经某些工具链/传输后可能丢失;
// 内核加载时会因缺执行位报错(带 chmod +x 提示),在此提前补上。
if filepath.Base(dest) == "plugin.bin" {
if err := os.Chmod(dest, 0o755); err != nil {
return fmt.Errorf("chmod %s: %w", dest, err)
}
}
}
return nil

View File

@ -89,12 +89,12 @@ func buildHmap(t *testing.T, name, version string) []byte {
zw := zip.NewWriter(&buf)
manifest := map[string]interface{}{
"name": name, "name_zh": name, "name_en": name,
"version": version, "entry": "plugin.so",
"version": version, "entry": "plugin.bin",
}
mData, _ := json.Marshal(manifest)
f, _ := zw.Create("plugin.json")
f.Write(mData)
bin, _ := zw.Create("plugin.so")
bin, _ := zw.Create("plugin.bin")
bin.Write([]byte("binary-" + name + "-" + version))
zw.Close()
return buf.Bytes()
@ -152,12 +152,12 @@ func TestInstallThenUpgradeKeepsConfig(t *testing.T) {
t.Fatalf("StopAndUnload not called once with demo: %v", calls)
}
// 新二进制写入
soData, err := os.ReadFile(filepath.Join(dir, "demo", "plugin.so"))
binData, err := os.ReadFile(filepath.Join(dir, "demo", "plugin.bin"))
if err != nil {
t.Fatalf("read new so: %v", err)
t.Fatalf("read new bin: %v", err)
}
if string(soData) != "binary-demo-2.0.0" {
t.Fatalf("so not overwritten: %q", string(soData))
if string(binData) != "binary-demo-2.0.0" {
t.Fatalf("bin not overwritten: %q", string(binData))
}
// 4. 降级 v2.0.0 → v1.5.0
@ -187,7 +187,7 @@ func TestExtractFailureRollsBack(t *testing.T) {
// 构造损坏包zip 但缺 plugin.jsonextractPackage 会失败)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, _ := zw.Create("plugin.so")
f, _ := zw.Create("plugin.bin")
f.Write([]byte("corrupt"))
zw.Close()
@ -200,7 +200,7 @@ func TestExtractFailureRollsBack(t *testing.T) {
f2, _ := zw2.Create("../../evil")
f2.Write([]byte("x"))
mf, _ := zw2.Create("plugin.json")
mData, _ := json.Marshal(map[string]interface{}{"name": "rollback", "version": "9.9.9", "entry": "plugin.so"})
mData, _ := json.Marshal(map[string]interface{}{"name": "rollback", "version": "9.9.9", "entry": "plugin.bin"})
mf.Write(mData)
zw2.Close()
bad = rb.Bytes()