feat(proc): Cleaner 跨进程修复(tool/input/output) + 共享内存安全模式 + plan §13

- Cleaner 协议统一为 cleaner.invoke(scope,name,text),覆盖工具/输入/输出三类
- 新增 ShmSecurityMode (safe/debug/full),Linux 审计探针,Windows crypto nonce
- plan.md 追加 §13 步骤分组
This commit is contained in:
JianFeeeee
2026-09-10 10:21:50 +08:00
parent 580d5f5501
commit 7a328679da
12 changed files with 645 additions and 45 deletions

View File

@ -19,12 +19,13 @@ func TestCapability_AllMethodsClassified(t *testing.T) {
// 与 protocol.go 的 method 常量对齐。内核→插件的 7 个调用不经 Handle
// 故不需要能力归属。
kernelToPlugin := map[string]bool{
MethodPluginInit: true,
MethodPluginStart: true,
MethodPluginStop: true,
MethodToolInvoke: true,
MethodStageInvoke: true,
MethodOutputInvoke: true,
MethodPluginInit: true,
MethodPluginStart: true,
MethodPluginStop: true,
MethodToolInvoke: true,
MethodCleanerInvoke: true,
MethodStageInvoke: true,
MethodOutputInvoke: true,
}
// 插件→内核的全部 method手工清单与 protocol.go 对照)

View File

@ -31,9 +31,10 @@ type coreHandler struct {
// locks 是 host.locks 的引用,供 stage.lock/unlock 路由。
locks *lockRegistry
// invokeTool/invokeStageFn/invokeOutput 反向调用插件(内核 → 插件)。
// invokeTool/invokeCleaner/invokeStageFn/invokeOutput 反向调用插件(内核 → 插件)。
// 由 Plugin 注入,注册回调时用它们构造 handler。
invokeTool func(name string, args map[string]interface{}) (interface{}, error)
invokeCleaner func(scope, name, text string) (string, error)
invokeStageFn func(ctx context.Context, stage string, seq uint64) error
invokeOutput func(channel string, args map[string]interface{}) (interface{}, error)
@ -128,15 +129,24 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
return nil, h.sdk.RegisterPluginAPI(p.Name)
case MethodInputRegister:
var p struct {
Name string `json:"name"`
Def pubsdk.ChannelDef `json:"def"`
Name string `json:"name"`
Def pubsdk.ChannelDef `json:"def"`
HasCleaner bool `json:"has_cleaner"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
// 注意 ChannelDef.Cleaner 是函数无法跨进程传递§3.5 回调型资源)。
// NoMemory 可传Cleaner 若插件需要,须在插件侧对文本预处理后再注入。
return nil, h.sdk.RegisterInputChannel(p.Name, pubsdk.ChannelDef{NoMemory: p.Def.NoMemory})
if p.Name == "" {
return nil, fmt.Errorf("input.register: 缺少 name")
}
cleaner, err := h.cleanerProxy(CleanerScopeInput, p.Name, p.HasCleaner)
if err != nil {
return nil, fmt.Errorf("input.register: %w", err)
}
return nil, h.sdk.RegisterInputChannel(p.Name, pubsdk.ChannelDef{
NoMemory: p.Def.NoMemory,
Cleaner: cleaner,
})
// ---- IO 注入(原 case 5/6/7/47----
case MethodIOInjectText:
@ -580,11 +590,30 @@ func errUnavailable(what string) error {
return fmt.Errorf("%s 能力在当前内核实例中不可用", what)
}
// cleanerProxy 把进程内函数式 Cleaner 恢复成内核侧透明代理。
// RPC 失败时返回原文:清洗是计算层优化,不能因插件暂时离线而丢失内容。
func (h *coreHandler) cleanerProxy(scope, name string, enabled bool) (func(string) string, error) {
if !enabled {
return nil, nil
}
if h.invokeCleaner == nil {
return nil, fmt.Errorf("%s %s 声明 Cleaner但清洗回调通道未就绪", scope, name)
}
return func(text string) string {
cleaned, err := h.invokeCleaner(scope, name, text)
if err != nil {
return text
}
return cleaned
}, nil
}
// toolRegister 注册插件工具handler 反向调用插件执行(原 case 1
func (h *coreHandler) toolRegister(params json.RawMessage) (interface{}, error) {
var p struct {
Name string `json:"name"`
Def pubsdk.ToolDef `json:"def"`
Name string `json:"name"`
Def pubsdk.ToolDef `json:"def"`
HasCleaner bool `json:"has_cleaner"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
@ -593,8 +622,12 @@ func (h *coreHandler) toolRegister(params json.RawMessage) (interface{}, error)
return nil, fmt.Errorf("tool.register: 缺少 name")
}
p.Def.Plugin = h.name
// ToolDef.Cleaner 是函数跨进程无法传递§3.5)——与 C ABI 路径行为一致
p.Def.Cleaner = nil
// 函数本身不进 JSONhas_cleaner 只声明其存在,实际执行回到插件进程
cleaner, err := h.cleanerProxy(CleanerScopeTool, p.Name, p.HasCleaner)
if err != nil {
return nil, fmt.Errorf("tool.register: %w", err)
}
p.Def.Cleaner = cleaner
name := p.Name
return nil, h.sdk.RegisterTool(name, p.Def, func(args map[string]interface{}) (interface{}, error) {
@ -637,10 +670,11 @@ func (h *coreHandler) stageRegister(params json.RawMessage) (interface{}, error)
// 永远返回成功§9.4,现网 2 次消息发不出而模型以为成功)。
func (h *coreHandler) outputRegister(params json.RawMessage) (interface{}, error) {
var p struct {
Name string `json:"name"`
Caps int `json:"caps"`
Desc string `json:"desc"`
Def pubsdk.ChannelDef `json:"def"`
Name string `json:"name"`
Caps int `json:"caps"`
Desc string `json:"desc"`
Def pubsdk.ChannelDef `json:"def"`
HasCleaner bool `json:"has_cleaner"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
@ -649,8 +683,12 @@ func (h *coreHandler) outputRegister(params json.RawMessage) (interface{}, error
return nil, fmt.Errorf("output.register: 缺少 name")
}
channel := p.Name
cleaner, err := h.cleanerProxy(CleanerScopeOutput, channel, p.HasCleaner)
if err != nil {
return nil, fmt.Errorf("output.register: %w", err)
}
return nil, h.sdk.RegisterOutputChannel(channel, p.Caps, p.Desc,
pubsdk.ChannelDef{NoMemory: p.Def.NoMemory},
pubsdk.ChannelDef{NoMemory: p.Def.NoMemory, Cleaner: cleaner},
func(args map[string]interface{}) (interface{}, error) {
return h.invokeOutput(channel, args)
})

View File

@ -119,6 +119,7 @@ func (p *Plugin) Start(core CoreSDK) error {
}
// 反向调用闭包:注册回调时捕获,运行期经 RPC 打到插件进程。
p.handler.invokeTool = p.invokeTool
p.handler.invokeCleaner = p.invokeCleaner
p.handler.invokeStageFn = p.invokeStage
p.handler.invokeOutput = p.invokeOutput
@ -220,6 +221,26 @@ func (p *Plugin) invokeTool(name string, args map[string]interface{}) (interface
return res.Result, nil
}
// invokeCleaner 在插件进程内执行工具或通道注册时提供的 Cleaner 函数。
func (p *Plugin) invokeCleaner(scope, name, text string) (string, error) {
if p.proc == nil {
return "", ErrProcessExited
}
raw, err := p.proc.Call(MethodCleanerInvoke, CleanerInvokeParams{
Scope: scope,
Name: name,
Text: text,
})
if err != nil {
return "", err
}
var res CleanerInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
return "", fmt.Errorf("proc: %s %s Cleaner %s 应答解析失败: %w", p.name, scope, name, err)
}
return res.Text, nil
}
func (p *Plugin) invokeStage(ctx context.Context, stage string, seq uint64) error {
if p.proc == nil {
return ErrProcessExited

View File

@ -17,20 +17,26 @@ import (
// fakeCoreSDK 是最简 CoreSDK 实现,记录注册行为。
type fakeCoreSDK struct {
mu sync.Mutex
tools map[string]pubsdk.ToolHandler
stages map[pubsdk.Stage][]pubsdk.StageHandler
outputs map[string]pubsdk.ToolHandler
settings map[string]interface{}
autoStart bool
mu sync.Mutex
tools map[string]pubsdk.ToolHandler
toolDefs map[string]pubsdk.ToolDef
stages map[pubsdk.Stage][]pubsdk.StageHandler
outputs map[string]pubsdk.ToolHandler
outputDefs map[string]pubsdk.ChannelDef
inputDefs map[string]pubsdk.ChannelDef
settings map[string]interface{}
autoStart bool
}
func newFakeCore() *fakeCoreSDK {
return &fakeCoreSDK{
tools: map[string]pubsdk.ToolHandler{},
stages: map[pubsdk.Stage][]pubsdk.StageHandler{},
outputs: map[string]pubsdk.ToolHandler{},
settings: map[string]interface{}{},
tools: map[string]pubsdk.ToolHandler{},
toolDefs: map[string]pubsdk.ToolDef{},
stages: map[pubsdk.Stage][]pubsdk.StageHandler{},
outputs: map[string]pubsdk.ToolHandler{},
outputDefs: map[string]pubsdk.ChannelDef{},
inputDefs: map[string]pubsdk.ChannelDef{},
settings: map[string]interface{}{},
}
}
@ -59,6 +65,7 @@ func (f *fakeCoreSDK) RegisterTool(name string, def pubsdk.ToolDef, h pubsdk.Too
f.mu.Lock()
defer f.mu.Unlock()
f.tools[name] = h
f.toolDefs[name] = def
return nil
}
@ -72,10 +79,16 @@ func (f *fakeCoreSDK) RegisterOutputChannel(name string, caps int, desc string,
f.mu.Lock()
defer f.mu.Unlock()
f.outputs[name] = h
f.outputDefs[name] = def
return nil
}
func (f *fakeCoreSDK) RegisterInputChannel(name string, def pubsdk.ChannelDef) error { return nil }
func (f *fakeCoreSDK) RegisterInputChannel(name string, def pubsdk.ChannelDef) error {
f.mu.Lock()
defer f.mu.Unlock()
f.inputDefs[name] = def
return nil
}
func (f *fakeCoreSDK) stageHandlers(stage pubsdk.Stage) []pubsdk.StageHandler {
f.mu.Lock()
@ -232,6 +245,36 @@ func TestPlugin_RegisteredToolInvokable(t *testing.T) {
if res != "ABC" {
t.Fatalf("工具结果应为 ABC实际 %v", res)
}
core.mu.Lock()
def, ok := core.toolDefs["demo_upper"]
core.mu.Unlock()
if !ok {
t.Fatal("内核未保存 demo_upper 的 ToolDef")
}
if def.Cleaner == nil {
t.Fatal("跨进程注册后 Cleaner 不应丢失")
}
if got := def.Cleaner("raw-output"); got != "tool-cleaned:raw-output" {
t.Fatalf("跨进程工具 Cleaner 结果错误got %q, want %q", got, "tool-cleaned:raw-output")
}
core.mu.Lock()
inputDef, inputOK := core.inputDefs["demo_in"]
outputDef, outputOK := core.outputDefs["demo_ch"]
core.mu.Unlock()
if !inputOK || inputDef.Cleaner == nil {
t.Fatal("跨进程注册后输入通道 Cleaner 不应丢失")
}
if got := inputDef.Cleaner("raw-input"); got != "input-cleaned:raw-input" {
t.Fatalf("跨进程输入 Cleaner 结果错误got %q", got)
}
if !outputOK || outputDef.Cleaner == nil {
t.Fatal("跨进程注册后输出通道 Cleaner 不应丢失")
}
if got := outputDef.Cleaner("raw-output"); got != "output-cleaned:raw-output" {
t.Fatalf("跨进程输出 Cleaner 结果错误got %q", got)
}
}
// 输出通道**同步等真实结果**失败必须上报§9.4 根治)。

View File

@ -39,7 +39,7 @@ type Response struct {
Error string `json:"error,omitempty"`
}
// ---- kernel → plugin内核调用插件,对应今日 7 个 //export----
// ---- kernel → plugin内核调用插件工具执行、Cleaner、stage、输出等----
const (
// MethodPluginInit 传插件名与配置,插件构造实例但不启动。
MethodPluginInit = "plugin.init"
@ -49,6 +49,10 @@ const (
MethodPluginStop = "plugin.stop"
// MethodToolInvoke 执行插件工具。
MethodToolInvoke = "tool.invoke"
// MethodCleanerInvoke 在插件进程内执行工具或通道声明的 Cleaner。
// Cleaner 是函数,不能随注册请求 JSON 序列化;内核保留 RPC 回调闭包,
// 需要参与向量化/蒸馏时把原文送回插件执行真正的 Cleaner。
MethodCleanerInvoke = "cleaner.invoke"
// MethodStageInvoke 执行阶段处理器。数据经共享段传递,参数只带阶段名与段世代号。
MethodStageInvoke = "stage.invoke"
// MethodOutputInvoke 经插件输出通道发送。
@ -207,6 +211,24 @@ type ToolInvokeResult struct {
Result interface{} `json:"result,omitempty"`
}
// CleanerInvokeParams / CleanerInvokeResult跨进程计算层清洗。
// Scope 取 tool / input / outputName 是工具名或通道名。
type CleanerInvokeParams struct {
Scope string `json:"scope"`
Name string `json:"name"`
Text string `json:"text"`
}
type CleanerInvokeResult struct {
Text string `json:"text"`
}
const (
CleanerScopeTool = "tool"
CleanerScopeInput = "input"
CleanerScopeOutput = "output"
)
// OutputInvokeParams输出通道发送原 go_invoke_output
//
// 与 C ABI 路径的关键差异:**可同步等待真实结果**。

View File

@ -0,0 +1,81 @@
package proc
import (
"log"
"os"
"strings"
"sync"
)
// ShmSecurityMode 控制共享内存段的可见性与审计强度。
//
// 设计前提(已确认):
// - UnixLinux/macOS默认走匿名 memfd / unlink 临时文件,经 fork 继承 fd。
// 没有文件系统名字,只有内核主动 spawn 的子进程能映射 → 安全几乎免费。
// - Windows 没有 fd 继承语义,段是命名 FileMapping任何拿到名字的进程都能打开。
// 此处安全边界弱,需要额外措施。
//
// 映射进程数 ≠ 加载插件数:它不是安全判据,只作异常告警。
// 合法时也可能不等一个插件映射两次、debug 工具挂载),非法时可能恰好相等。
// 它的价值在「发现泄漏句柄或未授权挂载」,不在「授权访问」。
type ShmSecurityMode string
const (
// ShmModeSafe 默认。Unix 匿名 memfdfork 继承即唯一访问);
// Windows 命名段用 crypto 随机 nonce 名 + 仅记录期望子 PID。
ShmModeSafe ShmSecurityMode = "safe"
// ShmModeDebug 暴露 /proc 反查与稳定名字,便于 strace/lldb 挂载。
ShmModeDebug ShmSecurityMode = "debug"
// ShmModeFull 允许命名段(/dev/shm 或固定名),多实例可共享。
ShmModeFull ShmSecurityMode = "full"
)
const envShmSecurityMode = "HOMEAGENT_SHM_SECURITY"
var (
shmModeOnce sync.Once
shmModeVal ShmSecurityMode
)
// ShmSecurityMode 返回当前模式(从 HOMEAGENT_SHM_SECURITY 读,默认 safe
func ShmSecurityModeOf() ShmSecurityMode {
shmModeOnce.Do(func() {
shmModeVal = ShmModeSafe
switch strings.ToLower(strings.TrimSpace(os.Getenv(envShmSecurityMode))) {
case "debug":
shmModeVal = ShmModeDebug
case "full":
shmModeVal = ShmModeFull
}
})
return shmModeVal
}
// ShmAudit 记录一次映射数审计的期望与实测。
//
// expectedPlugins 是内核已 spawn 且握手的插件子进程数actualMappings 是
// 当前能观测到的、映射了本段的对象数Linux 下读 /procWindows 计数句柄)。
// delta = actual - expected > 0 时可能有未授权挂载或泄漏句柄。
//
// 本结果只用于告警,不用于授权决策:真正阻止未授权访问的是 Unix 匿名 memfd
// + 仅 fork 继承,而非计数相等。
type ShmAudit struct {
ExpectedPlugins int
ActualMappings int
Delta int
}
// AuditShmMappings 在 Host 上做一次映射数审计。
//
// 平台无探针时返回 ok=false调用方应跳过告警不要误报
func (h *Host) AuditShmMappings(expectedPlugins int) (ShmAudit, bool) {
a := ShmAudit{ExpectedPlugins: expectedPlugins}
var ok bool
a.ActualMappings, ok = h.countOurShmMappings()
a.Delta = a.ActualMappings - a.ExpectedPlugins
if ok && a.Delta > 0 {
log.Printf("[proc] 共享内存映射审计:期望 %d 实测 %d+delta=%d—— 可能有泄漏句柄或未授权挂载",
a.ExpectedPlugins, a.ActualMappings, a.Delta)
}
return a, ok
}

View File

@ -0,0 +1,62 @@
//go:build linux
package proc
import (
"fmt"
"os"
"strings"
)
// countOurShmMappings 在 Linux 上审计本 Host 的共享段被多少进程映射。
//
// 原理memfd 是匿名对象,文件系统无名,但在每个映射它的进程的
// /proc/<pid>/maps 里会有一行形如:
//
// 7f.. 7f.. rw-s 00000000 00:00 7312 .../memfd:hastagectx (deleted)
//
// 我们遍历 Supervisor.List() 给出的全部子进程 PID统计它们的 maps 中
// 出现 "memfd:hastagectx" 的行数。
// homed 自身的映射不算(它本来就该有一份)。
//
// 局限:/proc/<pid>/maps 需要同 UID/同命名空间读。读不到的 PID 直接跳过,
// 返回 ok=true 但少计——审计是告警不是判据,宁缺毋滥。
func (h *Host) countOurShmMappings() (int, bool) {
if h.sup == nil {
return 0, false
}
procs := h.sup.List()
if len(procs) == 0 {
return 0, true
}
// memfd 的"文件名"在 shmalloc_linux.go 里是 "hastagectx"。
const memfdTag = "memfd:hastagectx"
total := 0
for _, p := range procs {
if !p.Alive {
continue
}
if n, err := countMemfdLinesForPID(p.PID, memfdTag); err == nil {
total += n
}
}
return total, true
}
// countMemfdLinesForPID 读 /proc/<pid>/maps统计含 tag 的行数。
//
// 一个进程对同一 memfd 多次 mmap 会产生多行;我们计数"行数"而非
// "是否出现",这样能发现一个插件进程泄漏了多余映射的情况。
func countMemfdLinesForPID(pid int, tag string) (int, error) {
data, err := os.ReadFile(procMapsPath(pid))
if err != nil {
return 0, fmt.Errorf("读 /proc/%d/maps: %w", pid, err)
}
return strings.Count(string(data), tag), nil
}
// procMapsPath 组装 /proc/<pid>/maps 路径。pid 是内核自己 spawn 的进程号,
// 无用户输入注入面。
func procMapsPath(pid int) string {
return fmt.Sprintf("/proc/%d/maps", pid)
}

View File

@ -0,0 +1,11 @@
//go:build !linux
package proc
// countOurShmMappings 在非 Linux 平台返回 ok=false无 /proc 探针)。
//
// macOS 无 /procWindows 无 fd 继承,命名对象的映射计数需走 Win32 API
// 当前阶段不实现,留给调用方跳过告警。
func (h *Host) countOurShmMappings() (int, bool) {
return 0, false
}

View File

@ -0,0 +1,63 @@
package proc
import (
"testing"
)
func TestShmSecurityMode_DefaultsToSafe(t *testing.T) {
if ShmSecurityModeOf() != ShmModeSafe {
t.Fatalf("默认应为 safe实际 %q", ShmSecurityModeOf())
}
}
func TestShmAudit_ZeroPluginsNoMapping(t *testing.T) {
h, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer h.Close()
a, ok := h.AuditShmMappings(0)
if !ok {
// 非 Linux 平台无探针,本测试断言"平台探针不误报"
t.Logf("平台无探针ok=false跳过")
return
}
// 无子进程时实际映射数应为 0
if a.ActualMappings != 0 {
t.Fatalf("无子进程时应无映射,实际 %d", a.ActualMappings)
}
if a.Delta != 0 {
t.Fatalf("Delta 应为 0实际 %d", a.Delta)
}
}
// 子进程挂载共享段后Linux 探针应能看到对应映射。
func TestShmAudit_SeesChildMapping(t *testing.T) {
bin := buildTestPlugin(t, "stageplugin.go")
core := newFakeCore()
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
p := New("audit", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
a, ok := host.AuditShmMappings(1)
if !ok {
t.Logf("平台无探针ok=false跳过")
return
}
// 至少应看到 1 个儿童映射
if a.ActualMappings < 1 {
t.Fatalf("子进程应映射共享段,实际映射数 %d", a.ActualMappings)
}
// 期望 1、实际 ≥1 → delta 不应为负且不应报警delta>0 才告警)
_ = a
}

View File

@ -3,6 +3,7 @@
package proc
import (
"crypto/rand"
"fmt"
"os"
"sync"
@ -64,7 +65,7 @@ var (
// 返回的 *os.File 为 nilWindows 不经 fd 传递段,插件按名字打开。
// 名字通过 procEnvForShm 注入子进程环境变量。
func allocShm(size int) (*os.File, []byte, error) {
name := fmt.Sprintf("%s_%d_%d", shmNamePrefix, os.Getpid(), shmNameSeq.Add(1))
name := shmNameForMode()
shm, data, err := createNamedMapping(name, size)
if err != nil {
return nil, nil, err
@ -75,6 +76,25 @@ func allocShm(size int) (*os.File, []byte, error) {
return nil, data, nil
}
// shmNameForMode 按安全模式生成命名段名。
//
// safe/debug随机 nonce 名,防猜测;唯一在 debug 下额外暴露到 stderr。
// full固定后缀便于多实例按名共享。
func shmNameForMode() string {
seq := shmNameSeq.Add(1)
switch ShmSecurityModeOf() {
case ShmModeFull:
return fmt.Sprintf("%s_%d_%d", shmNamePrefix, os.Getpid(), seq)
default: // safe / debug
var b [12]byte
if _, err := cryptorand.Read(b[:]); err != nil {
// 退化为 PID+seq极端情况crypto rand 几乎不会失败)
return fmt.Sprintf("%s_%d_%d", shmNamePrefix, os.Getpid(), seq)
}
return fmt.Sprintf("%s_%d_%x", shmNamePrefix, os.Getpid(), b)
}
}
// createNamedMapping 建命名段并映射为 []byte。
func createNamedMapping(name string, size int) (*namedShm, []byte, error) {
namePtr, err := windows.UTF16PtrFromString(name)

View File

@ -243,17 +243,20 @@ func main() {
go func(id uint64) {
callKernel("lifecycle.autoRestart", map[string]interface{}{"enabled": true})
callKernel("tool.register", map[string]interface{}{
"name": "demo_upper",
"def": map[string]interface{}{"name": "demo_upper", "description": "转大写"},
"name": "demo_upper",
"def": map[string]interface{}{"name": "demo_upper", "description": "转大写"},
"has_cleaner": true,
})
callKernel("stage.register", map[string]interface{}{
"stage": "after_toolcall",
"scope": "global",
})
callKernel("input.register", map[string]interface{}{
"name": "demo_in", "def": map[string]interface{}{}, "has_cleaner": true,
})
callKernel("output.register", map[string]interface{}{
"name": "demo_ch",
"caps": 1,
"desc": "测试通道",
"name": "demo_ch", "caps": 1, "desc": "测试通道",
"def": map[string]interface{}{}, "has_cleaner": true,
})
send(response{ID: id})
}(req.ID)
@ -274,6 +277,24 @@ func main() {
"result": strings.ToUpper(text),
}})
case "cleaner.invoke":
var p struct {
Scope string `json:"scope"`
Name string `json:"name"`
Text string `json:"text"`
}
json.Unmarshal(req.Params, &p)
valid := (p.Scope == "tool" && p.Name == "demo_upper") ||
(p.Scope == "input" && p.Name == "demo_in") ||
(p.Scope == "output" && p.Name == "demo_ch")
if !valid {
send(response{ID: req.ID, Error: "未注册 Cleaner"})
continue
}
send(response{ID: req.ID, Result: map[string]interface{}{
"text": p.Scope + "-cleaned:" + p.Text,
}})
case "stage.invoke":
go func(id uint64) {
if shm == nil {