From 5e0077dc115a6acd9da20cb8f7c1336fd95d26f0 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 03:18:57 +0000 Subject: [PATCH] Whitelist B-class surface actions for help and intent Why: Generative UI U5 left the B-class action allowlist empty; owner authorized first entries builtin.help and system.intent. Evidence: go test ./internal/genui/ ./internal/surfacesvc/ ./internal/server/ Impact: Surface buttons may bind help/intent; button-entrance intent matches only and does not nest-dispatch write commands. Refs: docs/proposals/2026-10-12-generative-ui-bclass-help-intent.md Co-authored-by: Cursor --- internal/genui/genui.go | 21 ++++++-- internal/genui/genui_test.go | 15 +++++- internal/server/intent_button_test.go | 76 +++++++++++++++++++++++++++ internal/server/server.go | 58 +++++++++++--------- internal/surfacesvc/actions_test.go | 23 ++++++++ internal/surfacesvc/surfacesvc.go | 6 ++- 6 files changed, 167 insertions(+), 32 deletions(-) create mode 100644 internal/server/intent_button_test.go diff --git a/internal/genui/genui.go b/internal/genui/genui.go index 1c0ae1e..69b0d81 100644 --- a/internal/genui/genui.go +++ b/internal/genui/genui.go @@ -21,11 +21,21 @@ const PluginID = "generative-ui" const ( // CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。 CommandPresent = PluginID + ".present" - // CommandSubmit 是 U5 已批准的唯页面动作(C 类:表单数据落插件 KV 台账; + // CommandSubmit 是 U5 已批准的 C 类页面动作(表单数据落插件 KV 台账; // 不 exposeAsTool——按钮专用,模型不可直接调用)。 CommandSubmit = PluginID + ".submit" + // ActionHelp / ActionIntent 是 B 类首批加白(独立评审:builtin.help、system.intent)。 + ActionHelp = "builtin.help" + ActionIntent = "system.intent" ) +// AllowedActionIDs 是 present 校验与 surfacesvc.AllowedActions 的开放动作集合(须同步)。 +var AllowedActionIDs = map[string]struct{}{ + CommandSubmit: {}, + ActionHelp: {}, + ActionIntent: {}, +} + // Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带, // 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。 const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。 @@ -46,8 +56,9 @@ tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title 分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知 doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table; 风险→按严重度组织;顺序→steps/timeline;收集输入→form。 -actions 节点纪律:action 的 id 当前仅允许 "generative-ui.submit"(表单提交,携带整页表单草稿); -引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。 +actions 节点纪律:action 的 id 仅允许开放白名单——generative-ui.submit(C 类表单提交)、 +builtin.help(B 类只读帮助)、system.intent(B 类意图匹配;button 入口只返回匹配结果、 +不嵌套分发业务命令)。引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。 最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上): {"title":"示例页","nodes":[ {"type":"section","id":"n1","title":"结论","children":[ @@ -133,8 +144,8 @@ func ValidateActionIDs(spec []byte) []string { walk = func(n core.UINode, path string) { if n.Type == core.UIActions { for _, a := range n.Actions { - if a.ID != CommandSubmit { - errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(当前仅允许 %s)", path, a.ID, CommandSubmit)) + if _, ok := AllowedActionIDs[a.ID]; !ok { + errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(不在开放白名单)", path, a.ID)) } } } diff --git a/internal/genui/genui_test.go b/internal/genui/genui_test.go index 1857ab8..ee007eb 100644 --- a/internal/genui/genui_test.go +++ b/internal/genui/genui_test.go @@ -190,7 +190,7 @@ func TestRegisterSpecShape(t *testing.T) { if spec.ID != CommandPresent || spec.ExposeAsTool == nil { t.Fatalf("present must expose as tool: %s", byID[CommandPresent]) } - for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit"} { + for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit", "builtin.help", "system.intent"} { if !strings.Contains(spec.Description, need) { t.Fatalf("catalog incomplete: missing %q", need) } @@ -248,6 +248,19 @@ func TestPresentRejectsUnknownActionIDs(t *testing.T) { } } +func TestValidateActionIDsAllowsBClassHelpAndIntent(t *testing.T) { + raw, _ := json.Marshal(map[string]any{"title": "t", "nodes": []any{map[string]any{ + "type": "actions", "id": "a1", + "actions": []any{ + map[string]any{"id": ActionHelp, "label": "帮助"}, + map[string]any{"id": ActionIntent, "label": "意图"}, + map[string]any{"id": CommandSubmit, "label": "提交"}, + }}}}) + if errs := ValidateActionIDs(raw); len(errs) != 0 { + t.Fatalf("B-class help/intent + submit should pass, got %v", errs) + } +} + func TestSubmitFormStoresLedger(t *testing.T) { // C 类:表单数据落插件 KV 台账(键含 requestId 天然幂等)。 var kv map[string]string = map[string]string{} diff --git a/internal/server/intent_button_test.go b/internal/server/intent_button_test.go new file mode 100644 index 0000000..7d7c03e --- /dev/null +++ b/internal/server/intent_button_test.go @@ -0,0 +1,76 @@ +package server + +import ( + "context" + "testing" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" +) + +// B 类硬化红测:button 入口命中写意图时不嵌套分发(无目标命令 receipt)。 +func TestSystemIntentButtonNoNestedDispatch(t *testing.T) { + var writes int + b := cmdbus.New(nopAudit{}, nil, nil) + if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话"}, + "/new", func(ctx context.Context, req cmdbus.Request) (any, error) { + writes++ + return map[string]any{"ok": true}, nil + }); err != nil { + t.Fatal(err) + } + if err := b.Intents().Declare(cmdbus.Intent{ + ID: "demo.new", Phrases: []string{"新建会话"}, Command: "conversation.create", Plugin: "test", + }); err != nil { + t.Fatal(err) + } + + res, err := handleSystemIntent(context.Background(), b, cmdbus.Request{ + ID: "system.intent", Entrance: "button", Owner: "alice", + Params: map[string]any{"text": "请新建会话"}, + }) + if err != nil { + t.Fatal(err) + } + m, ok := res.(map[string]any) + if !ok || m["matched"] != true || m["dispatched"] != false { + t.Fatalf("button match must not dispatch: %+v", res) + } + if writes != 0 { + t.Fatalf("nested write command must not run, writes=%d", writes) + } + + // chat 入口仍可嵌套分发(既有行为) + res2, err := handleSystemIntent(context.Background(), b, cmdbus.Request{ + ID: "system.intent", Entrance: "chat", Owner: "alice", + Params: map[string]any{"text": "请新建会话"}, + }) + if err != nil { + t.Fatal(err) + } + m2, _ := res2.(map[string]any) + if m2["dispatched"] != true || writes != 1 { + t.Fatalf("chat entrance should nest-dispatch: %+v writes=%d", res2, writes) + } +} + +func TestSystemIntentUnmatchedCandidates(t *testing.T) { + b := cmdbus.New(nopAudit{}, nil, nil) + _ = b.Intents().Declare(cmdbus.Intent{ + ID: "demo.x", Phrases: []string{"特定短语"}, Command: "builtin.help", Plugin: "test", + }) + res, err := handleSystemIntent(context.Background(), b, cmdbus.Request{ + Entrance: "button", Params: map[string]any{"text": "无关内容"}, + }) + if err != nil { + t.Fatal(err) + } + m := res.(map[string]any) + if m["matched"] != false { + t.Fatalf("want unmatched, got %+v", res) + } + cands, _ := m["candidates"].([]map[string]any) + if len(cands) != 1 { + t.Fatalf("want 1 candidate, got %+v", m["candidates"]) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index dd7acd2..6ed0b90 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -167,32 +167,10 @@ func (s *Server) WireBuiltinCommands() { return map[string]any{"reply": fmt.Sprintf("可用命令(注册表 %d 条)——/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile 切换画像 · /help 帮助", len(b.List()))}, nil })) must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "system.intent", Title: "意图路由", - Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单", + Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单;button 入口只读匹配、不嵌套分发", ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)}, "/intent", func(ctx context.Context, req cmdbus.Request) (any, error) { - text, _ := req.Params["text"].(string) - in, ok := b.Intents().Match(text) - if !ok { - list := b.Intents().List() - cands := make([]map[string]any, 0, len(list)) - for _, x := range list { - cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command}) - } - return map[string]any{"matched": false, "candidates": cands}, nil - } - params := map[string]any{} - for k, v := range in.Params { - params[k] = v - } - for k, v := range req.Params { - if k != "text" { - params[k] = v // 调用方可覆盖模板参数 - } - } - resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat", - Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID}) - return map[string]any{"matched": true, "intent": in.ID, "command": in.Command, - "receipt": resp.Receipt, "payload": resp.Payload}, nil + return handleSystemIntent(ctx, b, req) })) must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话", ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)}, @@ -236,6 +214,38 @@ func (s *Server) WireBuiltinCommands() { })) } +// handleSystemIntent 实现 system.intent:命中后默认嵌套分发目标命令。 +// B 类硬化:Entrance=button(页面动作)只返回匹配结果,禁止嵌套业务写路径。 +func handleSystemIntent(ctx context.Context, b *cmdbus.Bus, req cmdbus.Request) (any, error) { + text, _ := req.Params["text"].(string) + in, ok := b.Intents().Match(text) + if !ok { + list := b.Intents().List() + cands := make([]map[string]any, 0, len(list)) + for _, x := range list { + cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command}) + } + return map[string]any{"matched": false, "candidates": cands}, nil + } + if agentcommand.CanonicalEntrance(req.Entrance) == "button" { + return map[string]any{"matched": true, "intent": in.ID, "command": in.Command, + "dispatched": false, "note": "button entrance: match only, no nested dispatch"}, nil + } + params := map[string]any{} + for k, v := range in.Params { + params[k] = v + } + for k, v := range req.Params { + if k != "text" { + params[k] = v + } + } + resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat", + Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID}) + return map[string]any{"matched": true, "intent": in.ID, "command": in.Command, + "dispatched": true, "receipt": resp.Receipt, "payload": resp.Payload}, nil +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() diff --git a/internal/surfacesvc/actions_test.go b/internal/surfacesvc/actions_test.go index a96dcd1..68fd042 100644 --- a/internal/surfacesvc/actions_test.go +++ b/internal/surfacesvc/actions_test.go @@ -76,6 +76,29 @@ func newActionFixture(t *testing.T) *actionFixture { return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision} } +func TestAllowedActionsIncludesBClassHelpAndIntent(t *testing.T) { + for _, id := range []string{"builtin.help", "system.intent", "generative-ui.submit"} { + if cmd, ok := AllowedActions[id]; !ok || cmd != id { + t.Fatalf("AllowedActions missing 1:1 binding for %q: %q ok=%v", id, cmd, ok) + } + } +} + +func TestSubmitActionBClassHelpDispatches(t *testing.T) { + f := newActionFixture(t) + ctx := context.Background() + var got string + f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) { + got = d.CommandID + return ActionOutcome{State: "committed", Payload: map[string]any{"reply": "ok"}}, nil + }) + res, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "builtin.help", Revision: f.rev, RequestID: "help-1", Data: map[string]any{}}) + if err != nil || res.State != "committed" || got != "builtin.help" { + t.Fatalf("help action: got=%q res=%+v err=%v", got, res, err) + } +} + func TestSubmitActionGuards(t *testing.T) { f := newActionFixture(t) ctx := context.Background() diff --git a/internal/surfacesvc/surfacesvc.go b/internal/surfacesvc/surfacesvc.go index d305900..515c6a9 100644 --- a/internal/surfacesvc/surfacesvc.go +++ b/internal/surfacesvc/surfacesvc.go @@ -235,10 +235,12 @@ type ActionOutcome struct { type Dispatcher func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) -// AllowedActions 是动作 id → 命令 id 的白名单(U5 第一版仅插件自有提交; -// B 类命令逐条经独立评审后加入)。 +// AllowedActions 是动作 id → 命令 id 的白名单(U5:C 类 submit + 经独立评审的 B 类)。 +// 与 genui.AllowedActionIDs 保持同步;每加一条须 Documents 独立评审提案。 var AllowedActions = map[string]string{ "generative-ui.submit": "generative-ui.submit", + "builtin.help": "builtin.help", + "system.intent": "system.intent", } // SubmitActionRequest 是一次页面动作提交(按钮点击)。 -- 2.54.0