diff --git a/internal/app/app.go b/internal/app/app.go index 9054420..a2b493e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -378,6 +378,8 @@ func Run(opt Options) error { // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 authzSlot := &projectAuthz{} caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot} + // EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。 + wireScopedHostFactory(caps) applyCompiled(skillsRoot, bus, caps) mgr.OnUsage = func(owner string, tokens int) { budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0)) diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 7032ff2..bffb81a 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -51,6 +51,15 @@ func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) return fn(owner, projectID) } +// wireScopedHostFactory EXT-11:把「按 owner+pluginID 构造受限 Host」注入公开面 +// (extension.SetScopedHostFactory)。组合根在 hostcaps 就绪后调用;重建句柄与 +// 分发期同构(owner 作用域、来源前缀强制、KV/任务台账隔离)。 +func wireScopedHostFactory(caps hostcaps) { + extension.SetScopedHostFactory(func(owner, pluginID string) extension.Host { + return inprocHost{caps: caps, pluginID: pluginID, owner: owner} + }) +} + // search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。 func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}) diff --git a/internal/app/scoped_host_test.go b/internal/app/scoped_host_test.go new file mode 100644 index 0000000..d284723 --- /dev/null +++ b/internal/app/scoped_host_test.go @@ -0,0 +1,57 @@ +package app + +import ( + "context" + "path/filepath" + "testing" + + "agent-system-backend/internal/rag" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +// EXT-11 红测:注入前 NewScopedHost fail-closed;注入后重建句柄与分发期同构 +// (owner 作用域、PluginID、KV 隔离、知识删除前缀强制)。 +func TestScopedHostFactory(t *testing.T) { + // 注入前:fail-closed。 + if _, err := extension.NewScopedHost("alice", "rdm"); err == nil { + t.Fatal("before wiring, NewScopedHost must fail closed(工厂未注入)") + } + + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + set := settings.NewService(st) + pipe := rag.NewPipeline(st, set, nil, nil) + caps := hostcaps{st: st, pipe: pipe, set: set} + + // 组合根同款注入(app.Run 内以 wireScopedHostFactory 接线;此处直接调内部函数)。 + wireScopedHostFactory(caps) + t.Cleanup(func() { extension.SetScopedHostFactory(nil) }) + + h, err := extension.NewScopedHost("alice", "rdm") + if err != nil { + t.Fatalf("NewScopedHost: %v", err) + } + if h.Owner() != "alice" || h.PluginID() != "rdm" { + t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID()) + } + // KV 往返落 owner 作用域。 + if err := h.KVPut("k1", "v1"); err != nil { + t.Fatal(err) + } + if v, ok, err := h.KVGet("k1"); err != nil || !ok || v != "v1" { + t.Fatalf("KVGet = %q ok=%v err=%v", v, ok, err) + } + // 同构性:知识删除前缀强制(manual 不可及)。 + if err := h.KnowledgeDelete(context.Background(), "manual"); err == nil { + t.Fatal("scoped host must keep the plugin: prefix enforcement") + } + // 空 owner/pluginID:拒绝。 + if _, err := extension.NewScopedHost("", "rdm"); err == nil { + t.Fatal("empty owner must be rejected") + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index f0860f9..ce94ed7 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -16,7 +16,9 @@ import ( // Options.DisableUploadIndexing。 // 2.3.0-experimental:EXT-12 最小开关 Options.DisableConvCardMirror(会话卡不镜像 // 进项目公共流)。 -const SurfaceVersion = "2.3.0-experimental" +// 2.4.0-experimental:EXT-11 新增 SetScopedHostFactory/NewScopedHost(按 owner+pluginID +// 重建受限宿主句柄,供下游恢复路径)。 +const SurfaceVersion = "2.4.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -99,6 +101,37 @@ func SetRunner(fn func(Options) error) { mu.Unlock() } +// scopedHostFactory 由组合根在装配 hostcaps 后注入(EXT-11):按 owner+pluginID +// 构造与分发期同构的受限 Host。下游不要调用 SetScopedHostFactory。 +var scopedHostFactory func(owner, pluginID string) Host + +// SetScopedHostFactory 组合根注入(EXT-11)。下游不要调用。 +func SetScopedHostFactory(fn func(owner, pluginID string) Host) { + mu.Lock() + scopedHostFactory = fn + mu.Unlock() +} + +// NewScopedHost 按 owner+pluginID 重建受限宿主句柄(EXT-11):供下游任务恢复等 +// 非分发期路径获取合法句柄;能力与分发期同构(owner 作用域、来源前缀强制、 +// KV/任务台账隔离)。未 link 或未注入时返回错误(fail-closed,不降级)。 +func NewScopedHost(owner, pluginID string) (Host, error) { + mu.Lock() + fn := scopedHostFactory + mu.Unlock() + if fn == nil { + return nil, ErrNotLinked + } + if owner == "" || pluginID == "" { + return nil, errors.New("extension: scoped host requires owner and pluginID") + } + h := fn(owner, pluginID) + if h == nil { + return nil, errors.New("extension: scoped host factory returned nil") + } + return h, nil +} + // Main 启动带有已登记扩展的服务。 func Main(opt Options) error { mu.Lock() diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index f056733..bf82ae8 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -21,6 +21,7 @@ HostClient InProc Main NewHostClient +NewScopedHost Options RegisterCompiled Registry @@ -33,4 +34,5 @@ Request ResetCompiled ResourceAuthorizer SetRunner +SetScopedHostFactory SurfaceVersion