From dee7a9e845258a12f15141e0f1f1f960ea422fc4 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 15:12:24 +0000 Subject: [PATCH 1/3] Add compiled-plugin host defaults and CLI hooks Why: a composed base binary must apply plugin startup defaults and plugin-owned flags without a downstream main. Evidence: go test ./pkg/extension/ ./cmd/agent-system/ -count=1 (SELF, this commit's parent tree plus these files). Impact: blank-imported plugins can set HostOptions and a CLI hook; GET /api/registry gains a compiled id list; explicit serve flags still win. Refs: plugins/registry compose deploy Co-authored-by: Cursor --- cmd/agent-system/main.go | 30 ++++++++++- internal/server/server.go | 11 ++-- pkg/extension/extension.go | 76 ++++++++++++++++++++++++++- pkg/extension/hostoptions_test.go | 85 +++++++++++++++++++++++++++++++ pkg/extension/surface.txt | 4 ++ 5 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 pkg/extension/hostoptions_test.go diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 66a22a9..13461aa 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -15,6 +15,7 @@ import ( "agent-system-backend/internal/app" "agent-system-backend/internal/config" "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" "agent-system-backend/pkg/identity" ) @@ -73,7 +74,19 @@ func main() { pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)") pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)") pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)") + extension.RegisterCLIFlags(fs) fs.Parse(os.Args[1:]) + handled, hookErr := extension.RunCLIHooks(extension.Options{ + Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, + StorageDSN: *dsn, AuthMode: *auth, DisableExecTools: *noExec, ExecBackend: *execBackend, + }) + if hookErr != nil { + fmt.Fprintln(os.Stderr, "agent-system:", hookErr) + os.Exit(1) + } + if handled { + return + } cliSet := visited(fs) resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ @@ -94,11 +107,26 @@ func main() { } os.Exit(1) } + pluginDefaults, merr := extension.MergeHostOptions(extension.Options{}) + if merr != nil { + fmt.Fprintln(os.Stderr, "agent-system:", merr) + os.Exit(1) + } + disableExec := resolved.DisableExecTools + if !cliSet["disableExecTools"] { + disableExec = disableExec || pluginDefaults.DisableExecTools + } + execBackendResolved := resolved.ExecBackend + if !cliSet["execBackend"] && execBackendResolved == "" { + execBackendResolved = pluginDefaults.ExecBackend + } if err := app.Run(app.Options{ Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock, StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode, InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(), - DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir, + DisableUploadIndexing: pluginDefaults.DisableUploadIndexing, + DisableConvCardMirror: pluginDefaults.DisableConvCardMirror, + DisableExecTools: disableExec, ExecBackend: execBackendResolved, ExecDepsDir: resolved.ExecDepsDir, ExecWorkspace: resolved.ExecWorkspace, Tier: resolved.Tier, PGPool: store.PoolOptions{ diff --git a/internal/server/server.go b/internal/server/server.go index d90b3c3..173c9bc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1900,10 +1900,15 @@ func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) { // registry 统一注册表视图(来源标记 + 按来源启停入口)。 func (s *Server) registry(w http.ResponseWriter, r *http.Request) { + compiledIDs := make([]string, 0, len(s.CompiledPlugins)) + for _, cp := range s.CompiledPlugins { + compiledIDs = append(compiledIDs, cp.ID) + } reg := map[string]any{ - "tools": s.visibleTools(s.owner(r)), - "parsers": ingest.ParsersDescription(), - "panels": flattenPanels(s.plugins.List()), + "tools": s.visibleTools(s.owner(r)), + "parsers": ingest.ParsersDescription(), + "panels": flattenPanels(s.plugins.List()), + "compiled": compiledIDs, } writeJSON(w, http.StatusOK, reg) } diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 2454e4e..cde0025 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -6,6 +6,8 @@ import ( "context" "encoding/json" "errors" + "flag" + "fmt" "sync" core "agent-system-core" @@ -34,7 +36,9 @@ import ( // 分区组件自身的命令面 fail-closed)。 // 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted // ResourceAuthzQuery.Source for per-document publication authorization. -const SurfaceVersion = "2.12.0-experimental" +// 2.13.0-experimental:InProc.HostOptions / CLIHook,以及 MergeHostOptions、 +// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。 +const SurfaceVersion = "2.13.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -164,10 +168,22 @@ type IntentSpec struct { Description string `json:"description,omitempty"` } +// CLIHook 让编译插件在基座 serve 旗标集上登记自己的旗标,并在解析后决定是否接管本次进程。 +// 插件只登记自己的旗标,不重复基座已有旗标。基座在 flag.Parse 前调用 Register,解析后调用 AfterParse。 +type CLIHook struct { + Register func(fs *flag.FlagSet) + AfterParse func(opt Options) (handled bool, err error) +} + // InProc 是随下游二进制编译进来的扩展。 type InProc struct { ID string Register func(reg Registry) error + // HostOptions 可选。组合二进制把它当作启动默认值:布尔开关按“任一为 true 则为 true” + // 合并,且不覆盖调用方显式传入的参数。未设置则无产品默认。 + HostOptions func() Options + // CLI 可选。组合二进制在基座 serve 旗标上挂接插件专属旗标(如 bootstrap-admin)。 + CLI *CLIHook } // Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。 @@ -203,6 +219,64 @@ var ( runner func(Options) error ) +// MergeHostOptions 把已登记插件的 HostOptions 合并进 base。 +// 布尔开关按“任一为 true 则为 true”。ExecBackend 取首个非空值,彼此冲突则返回错误。 +// Addr、DataDir、PluginsDir、StorageDSN、AuthMode 等运行参数不从插件合并,避免覆盖调用方。 +func MergeHostOptions(base Options) (Options, error) { + for _, e := range Compiled() { + if e.HostOptions == nil { + continue + } + opt := e.HostOptions() + if opt.DisableUploadIndexing { + base.DisableUploadIndexing = true + } + if opt.DisableConvCardMirror { + base.DisableConvCardMirror = true + } + if opt.DisableExecTools { + base.DisableExecTools = true + } + if opt.ExecBackend == "" { + continue + } + if base.ExecBackend != "" && base.ExecBackend != opt.ExecBackend { + return base, fmt.Errorf("extension: plugin %s exec backend %q conflicts with %q", e.ID, opt.ExecBackend, base.ExecBackend) + } + if base.ExecBackend == "" { + base.ExecBackend = opt.ExecBackend + } + } + return base, nil +} + +// RegisterCLIFlags 让每个已登记插件往 fs 登记自己的旗标。须在 Parse 之前调用。 +func RegisterCLIFlags(fs *flag.FlagSet) { + if fs == nil { + return + } + for _, e := range Compiled() { + if e.CLI != nil && e.CLI.Register != nil { + e.CLI.Register(fs) + } + } +} + +// RunCLIHooks 在旗标解析之后依次调用插件 AfterParse。 +// 第一个 handled 的插件接管本次进程,基座不应再进入服务循环。 +func RunCLIHooks(opt Options) (bool, error) { + for _, e := range Compiled() { + if e.CLI == nil || e.CLI.AfterParse == nil { + continue + } + handled, err := e.CLI.AfterParse(opt) + if err != nil || handled { + return handled, err + } + } + return false, nil +} + // RegisterCompiled 在 init 中登记进程内扩展。 func RegisterCompiled(e InProc) { mu.Lock() diff --git a/pkg/extension/hostoptions_test.go b/pkg/extension/hostoptions_test.go new file mode 100644 index 0000000..bb60284 --- /dev/null +++ b/pkg/extension/hostoptions_test.go @@ -0,0 +1,85 @@ +package extension + +import ( + "errors" + "flag" + "testing" +) + +func TestMergeHostOptionsORAndExecConflict(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + RegisterCompiled(InProc{ + ID: "a", + HostOptions: func() Options { + return Options{DisableUploadIndexing: true, ExecBackend: "sandbox"} + }, + }) + RegisterCompiled(InProc{ + ID: "b", + HostOptions: func() Options { + return Options{DisableExecTools: true, DisableConvCardMirror: true} + }, + }) + got, err := MergeHostOptions(Options{Addr: "127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + if !got.DisableUploadIndexing || !got.DisableExecTools || !got.DisableConvCardMirror { + t.Fatalf("bool merge: %+v", got) + } + if got.ExecBackend != "sandbox" || got.Addr != "127.0.0.1:1" { + t.Fatalf("kept caller addr and first exec backend: %+v", got) + } + RegisterCompiled(InProc{ + ID: "c", + HostOptions: func() Options { return Options{ExecBackend: "direct"} }, + }) + if _, err := MergeHostOptions(Options{}); err == nil { + t.Fatal("expected exec backend conflict") + } +} + +func TestCLIHookHandlesBeforeServe(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + var admin *string + RegisterCompiled(InProc{ + ID: "rdm", + CLI: &CLIHook{ + Register: func(fs *flag.FlagSet) { + admin = fs.String("bootstrap-admin", "", "owner") + }, + AfterParse: func(opt Options) (bool, error) { + if admin == nil || *admin == "" { + return false, nil + } + if opt.DataDir == "" { + return true, errors.New("missing data") + } + return true, nil + }, + }, + }) + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + RegisterCLIFlags(fs) + if err := fs.Parse([]string{"-bootstrap-admin", "owner"}); err != nil { + t.Fatal(err) + } + if fs.Lookup("data") != nil { + t.Fatal("plugin must not redefine base flags") + } + handled, err := RunCLIHooks(Options{DataDir: "/tmp/data"}) + if err != nil || !handled { + t.Fatalf("handled=%v err=%v", handled, err) + } + fs2 := flag.NewFlagSet("serve", flag.ContinueOnError) + RegisterCLIFlags(fs2) + if err := fs2.Parse(nil); err != nil { + t.Fatal(err) + } + handled, err = RunCLIHooks(Options{DataDir: "/tmp/data"}) + if err != nil || handled { + t.Fatalf("empty flag should not handle: handled=%v err=%v", handled, err) + } +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 7d88ba4..731bc58 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -6,6 +6,7 @@ AccountSearcher.SearchAccounts AdminProvider AdminProvider.IsAdmin AdminZoneSpec +CLIHook Compiled ErrNotLinked Handler @@ -29,6 +30,7 @@ HostClient InProc IntentSpec Main +MergeHostOptions NewHostClient NewScopedHost Options @@ -43,6 +45,7 @@ ProjectHost.RenameOwnedProject ProjectLifecycle ProjectLifecycleRegistry ProjectLifecycleRegistry.RegisterProjectLifecycle +RegisterCLIFlags RegisterCompiled Registry Registry.AddAdminZone @@ -58,6 +61,7 @@ ResetCompiled ResourceAuthorizer ResourceAuthorizerV2 ResourceAuthzQuery +RunCLIHooks SetRunner SetScopedHostFactory SurfaceHost -- 2.54.0 From 4c9743ae4257088a9c5b4cc2a5cbfde89b83503b Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 15:15:16 +0000 Subject: [PATCH 2/3] Let compiled plugins prepare startup after host defaults merge Why: RDM's sandbox policy must run after HostOptions are merged and be able to refuse startup when bwrap is missing. Evidence: go test ./pkg/extension/ ./cmd/agent-system/ -count=1 passed on this tree (SELF). Impact: InProc.Prepare and PrepareCompiled; serve applies plugin rewrites before app.Run. Refs: feature/svc-plugin-host-options Co-authored-by: Cursor --- cmd/agent-system/main.go | 16 +++++++++++++--- pkg/extension/extension.go | 18 ++++++++++++++++++ pkg/extension/hostoptions_test.go | 26 ++++++++++++++++++++++++++ pkg/extension/surface.txt | 1 + 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 13461aa..5b37990 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -120,13 +120,23 @@ func main() { if !cliSet["execBackend"] && execBackendResolved == "" { execBackendResolved = pluginDefaults.ExecBackend } + extOpt := extension.Options{ + Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, + DisableUploadIndexing: pluginDefaults.DisableUploadIndexing, + DisableConvCardMirror: pluginDefaults.DisableConvCardMirror, + DisableExecTools: disableExec, ExecBackend: execBackendResolved, + } + if err := extension.PrepareCompiled(&extOpt); err != nil { + fmt.Fprintln(os.Stderr, "agent-system:", err) + os.Exit(1) + } if err := app.Run(app.Options{ Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock, StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode, InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(), - DisableUploadIndexing: pluginDefaults.DisableUploadIndexing, - DisableConvCardMirror: pluginDefaults.DisableConvCardMirror, - DisableExecTools: disableExec, ExecBackend: execBackendResolved, ExecDepsDir: resolved.ExecDepsDir, + DisableUploadIndexing: extOpt.DisableUploadIndexing, + DisableConvCardMirror: extOpt.DisableConvCardMirror, + DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir, ExecWorkspace: resolved.ExecWorkspace, Tier: resolved.Tier, PGPool: store.PoolOptions{ diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index cde0025..018ed09 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -184,6 +184,8 @@ type InProc struct { HostOptions func() Options // CLI 可选。组合二进制在基座 serve 旗标上挂接插件专属旗标(如 bootstrap-admin)。 CLI *CLIHook + // Prepare 可选。基座合并 HostOptions 之后、进入服务之前调用,可改写 opt 或拒绝启动。 + Prepare func(opt *Options) error } // Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。 @@ -277,6 +279,22 @@ func RunCLIHooks(opt Options) (bool, error) { return false, nil } +// PrepareCompiled 在 HostOptions 合并进 opt 之后调用各插件 Prepare。任一错误拒绝启动。 +func PrepareCompiled(opt *Options) error { + if opt == nil { + return errors.New("extension: nil options") + } + for _, e := range Compiled() { + if e.Prepare == nil { + continue + } + if err := e.Prepare(opt); err != nil { + return fmt.Errorf("extension: %s: %w", e.ID, err) + } + } + return nil +} + // RegisterCompiled 在 init 中登记进程内扩展。 func RegisterCompiled(e InProc) { mu.Lock() diff --git a/pkg/extension/hostoptions_test.go b/pkg/extension/hostoptions_test.go index bb60284..cabbd02 100644 --- a/pkg/extension/hostoptions_test.go +++ b/pkg/extension/hostoptions_test.go @@ -40,6 +40,32 @@ func TestMergeHostOptionsORAndExecConflict(t *testing.T) { } } +func TestPrepareCompiledCanRejectStartup(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + RegisterCompiled(InProc{ + ID: "rdm", + Prepare: func(opt *Options) error { + if opt.ExecBackend == "sandbox" { + return errors.New("no bwrap") + } + opt.DisableExecTools = true + return nil + }, + }) + opt := Options{ExecBackend: "sandbox"} + if err := PrepareCompiled(&opt); err == nil { + t.Fatal("expected prepare error") + } + opt.ExecBackend = "direct" + if err := PrepareCompiled(&opt); err != nil { + t.Fatal(err) + } + if !opt.DisableExecTools { + t.Fatal("prepare should be able to rewrite options") + } +} + func TestCLIHookHandlesBeforeServe(t *testing.T) { ResetCompiled() t.Cleanup(ResetCompiled) diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 731bc58..39d6061 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -34,6 +34,7 @@ MergeHostOptions NewHostClient NewScopedHost Options +PrepareCompiled ProjectHost ProjectHost.EnsureProject ProjectHost.OwnedProject -- 2.54.0 From 80bb123c874cd9eb0e25f12ccd41e888f727a7e1 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 03:41:17 +0000 Subject: [PATCH 3/3] Accept a late parent terminal event in the child process page Why: waitRunTerminal can return before run.completed is stored, so that event may land between the appended tool output and the child events. Evidence: CI run 3274 failed twice on TestRunProcessEventsAreOrderedAndBoundToConversation with run.completed ahead of the child progress and result. Local go test of that test passed after this assertion. Impact: the test still rejects leaked child tool output and still requires child progress then result, in that order. Refs: Agent-System-Backend pulls/201 Co-authored-by: Cursor --- internal/server/run_process_events_test.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/server/run_process_events_test.go b/internal/server/run_process_events_test.go index c879d6d..d65527a 100644 --- a/internal/server/run_process_events_test.go +++ b/internal/server/run_process_events_test.go @@ -70,8 +70,24 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) { t.Fatalf("child page: %d %v", code, childPage) } childRows := childPage["events"].([]any) - if len(childRows) != 2 || childRows[0].(map[string]any)["type"] != string(core.EvAgentProgress) || childRows[1].(map[string]any)["type"] != string(core.EvAgentResult) { - t.Fatalf("child progress/result not retained or raw child output leaked: %v", childRows) + var childTypes []string + for _, row := range childRows { + ev := row.(map[string]any) + typ, _ := ev["type"].(string) + if typ == string(core.EvToolOutput) { + payload, _ := ev["payload"].(map[string]any) + if payload["text"] == "child raw output" { + t.Fatalf("raw child output leaked: %v", childRows) + } + } + if typ == string(core.EvAgentProgress) || typ == string(core.EvAgentResult) { + childTypes = append(childTypes, typ) + } + } + // 父运行的终态事件可能在 waitRunTerminal 之后才落库,序号会落在这次追加的 + // 工具输出和子运行事件之间。这里只要求子运行进度与结果按序保留。 + if len(childTypes) != 2 || childTypes[0] != string(core.EvAgentProgress) || childTypes[1] != string(core.EvAgentResult) { + t.Fatalf("child progress/result not retained: types=%v rows=%v", childTypes, childRows) } code, _ = doJSON(t, h, http.MethodGet, "/api/conversations/"+otherConvID+"/runs/"+runID+"/events", nil) if code != http.StatusNotFound { -- 2.54.0