diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index e7f2a54..de17ca4 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -290,7 +290,7 @@ var copyOrder = []string{ "memory_entries", "audit_events", "pending_confirms", "llm_usage_daily", "secret_keys", "settings_snapshots", "regen_pending", "context_entries", "user_commands", "pending_notices", "recovery_holds", - "surfaces", "surface_revisions", "plugin_activation", + "surfaces", "surface_revisions", "surface_actions", "plugin_activation", } const controlDDL = ` diff --git a/internal/app/app.go b/internal/app/app.go index 7db6e1a..db0d03f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -516,6 +516,17 @@ func Run(opt Options) error { // 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。 bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() }) + // U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发 + //(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。 + surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) { + resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params, + Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID}) + state := "committed" + if resp.Receipt.State != "" { + state = string(resp.Receipt.State) + } + return surfacesvc.ActionOutcome{State: state, CardID: resp.Card.ID, Receipt: resp.Receipt, Payload: resp.Payload}, nil + }) // U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner // 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。 bus.SetToolGate(func(owner, commandID string) bool { diff --git a/internal/genui/genui.go b/internal/genui/genui.go index 3a8f169..476eab1 100644 --- a/internal/genui/genui.go +++ b/internal/genui/genui.go @@ -17,8 +17,13 @@ import ( // PluginID 是插件身份(activation 台账与命令前缀共用)。 const PluginID = "generative-ui" -// CommandPresent 是唯一提交命令 id(revise 即带 surfaceId+baseRevision 的再次 present)。 -const CommandPresent = PluginID + ".present" +const ( + // CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。 + CommandPresent = PluginID + ".present" + // CommandSubmit 是 U5 已批准的唯页面动作(C 类:表单数据落插件 KV 台账; + // 不 exposeAsTool——按钮专用,模型不可直接调用)。 + CommandSubmit = PluginID + ".submit" +) // Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带, // 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。 @@ -37,6 +42,8 @@ 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 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。 最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上): {"title":"示例页","nodes":[ {"type":"section","id":"n1","title":"结论","children":[ @@ -68,7 +75,87 @@ func register(reg extension.Registry) error { }, "exposeAsTool": {"risk": "low"} }`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog)) - return reg.AddCommandHandler(spec, present) + if err := reg.AddCommandHandler(spec, present); err != nil { + return err + } + submitSpec := json.RawMessage(`{ + "id": "generative-ui.submit", + "title": "提交表单", + "description": "页面动作:提交用户填写的表单数据(落插件台账;按钮触发,不经模型)", + "paramsSchema": { + "type": "object", + "properties": { + "surfaceId": {"type": "string"}, + "revision": {"type": "integer"}, + "requestId": {"type": "string"}, + "data": {"type": "object"} + }, + "required": ["surfaceId", "requestId", "data"] + } + }`) + return reg.AddCommandHandler(submitSpec, submitForm) +} + +// submitForm 是 C 类动作处理器:表单数据落插件自有 KV 台账(键含 requestId +// 天然幂等;已批准范围限定 KV+任务台账,不建业务事实表)。经命令总线四入口 +// 同源:审计信封与(若未来升档)高危确认卡由总线收口。 +func submitForm(ctx context.Context, h extension.Host, req extension.Request) (any, error) { + surfaceID, _ := req.Params["surfaceId"].(string) + requestID, _ := req.Params["requestId"].(string) + if surfaceID == "" || requestID == "" { + return nil, fmt.Errorf("generative-ui.submit: surfaceId and requestId required") + } + data, _ := json.Marshal(req.Params["data"]) + key := "form:" + surfaceID + ":" + requestID + if err := h.KVPut(key, string(data)); err != nil { + return nil, err + } + if err := h.Audit("surface.form_submitted", surfaceID, "requestId="+requestID+" bytes="+fmt.Sprint(len(data))); err != nil { + return nil, err + } + return map[string]any{"ok": true, "stored": true, "key": key}, nil +} + +// ValidateActionIDs 供 present 校验:actions 节点引用的 id 必须在已开放集合内。 +func ValidateActionIDs(spec []byte) []string { + var s struct { + Nodes []core.UINode `json:"nodes"` + } + if err := json.Unmarshal(spec, &s); err != nil { + return nil + } + var errs []string + var walk func(n core.UINode, path 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)) + } + } + } + for i, c := range n.Children { + walk(c, fmt.Sprintf("%s/child[%d]", path, i)) + } + if n.Compare != nil { + for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} { + for i, c := range sd.Nodes { + walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i)) + } + } + } + if n.Tabs != nil { + for i, tb := range n.Tabs.Tabs { + for j, c := range tb.Nodes { + walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j)) + } + } + } + } + for i, n := range s.Nodes { + walk(n, fmt.Sprintf("$[%d]", i)) + } + return errs } // present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。 @@ -98,6 +185,10 @@ func present(ctx context.Context, h extension.Host, req extension.Request) (any, if errs := authorizeDocRefs(raw, h); len(errs) > 0 { return toolError("invalid spec", errs), nil } + // 动作白名单(U5 已批准范围):未知动作 id 拒绝并回字段级错误。 + if errs := ValidateActionIDs(raw); len(errs) > 0 { + return toolError("invalid spec", errs), nil + } snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{ SurfaceID: core.ID(surfaceID), ConversationID: core.ID(req.ConversationID), diff --git a/internal/genui/genui_test.go b/internal/genui/genui_test.go index 2ec2331..17bf4d3 100644 --- a/internal/genui/genui_test.go +++ b/internal/genui/genui_test.go @@ -162,36 +162,58 @@ func TestPresentSubmitCarriesEpochAndRevises(t *testing.T) { } func TestRegisterSpecShape(t *testing.T) { - var captured json.RawMessage - reg := &fakeRegistry{capture: &captured} + var captured []json.RawMessage + reg := &fakeRegistry{captureList: &captured} if err := register(reg); err != nil { t.Fatal(err) } + byID := map[string]json.RawMessage{} + for _, raw := range captured { + var id struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &id); err != nil { + t.Fatal(err) + } + byID[id.ID] = raw + } var spec struct { ID string `json:"id"` Description string `json:"description"` ExposeAsTool map[string]any `json:"exposeAsTool"` - ParamsSchema map[string]any `json:"paramsSchema"` } - if err := json.Unmarshal(captured, &spec); err != nil { + if raw, ok := byID[CommandPresent]; !ok { + t.Fatalf("present command not registered: %v", captured) + } else if err := json.Unmarshal(raw, &spec); err != nil { t.Fatal(err) } if spec.ID != CommandPresent || spec.ExposeAsTool == nil { - t.Fatalf("spec must expose as tool: %s", captured) + t.Fatalf("present must expose as tool: %s", byID[CommandPresent]) } - for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点"} { + for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit"} { if !strings.Contains(spec.Description, need) { t.Fatalf("catalog incomplete: missing %q", need) } } + var submit struct { + ExposeAsTool map[string]any `json:"exposeAsTool"` + } + if raw, ok := byID[CommandSubmit]; !ok { + t.Fatalf("submit command not registered: %v", captured) + } else { + _ = json.Unmarshal(raw, &submit) + if submit.ExposeAsTool != nil { + t.Fatal("submit must NOT expose as tool (button-only; model cannot call)") + } + } } type plainHost struct{ extension.Host } -type fakeRegistry struct{ capture *json.RawMessage } +type fakeRegistry struct{ captureList *[]json.RawMessage } func (r *fakeRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error { - *r.capture = spec + *r.captureList = append(*r.captureList, spec) return nil } func (r *fakeRegistry) AddSkill(name, markdown string) error { return nil } @@ -201,3 +223,60 @@ func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorize return nil } func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil } + +func TestPresentRejectsUnknownActionIDs(t *testing.T) { + newRealSvc(t) + ctx := context.Background() + h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}} + spec := map[string]any{"title": "t", "nodes": []any{map[string]any{ + "type": "actions", "id": "a1", + "actions": []any{ + map[string]any{"id": "shell.exec", "label": "危险"}, + map[string]any{"id": "generative-ui.submit", "label": "提交"}, + }}}} + res, err := present(ctx, h, reqWith(spec)) + if err != nil { + t.Fatalf("should be tool error, got %v", err) + } + te, ok := res.(toolErrorBody) + if !ok || te.OK || len(te.Details) == 0 { + t.Fatalf("expected field errors, got %+v", res) + } + if !strings.Contains(strings.Join(te.Details, ";"), "shell.exec") { + t.Fatalf("errors should name the unknown action: %+v", te) + } +} + +func TestSubmitFormStoresLedger(t *testing.T) { + // C 类:表单数据落插件 KV 台账(键含 requestId 天然幂等)。 + var kv map[string]string = map[string]string{} + h := &ledgerHost{kv: kv} + res, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{ + "surfaceId": "surf_x", "requestId": "req-1", "revision": float64(1), + "data": map[string]any{"years": "5"}, + }}) + if err != nil { + t.Fatal(err) + } + m, _ := res.(map[string]any) + if m["ok"] != true || m["stored"] != true { + t.Fatalf("submit result: %+v", m) + } + if v, ok := kv["form:surf_x:req-1"]; !ok || !strings.Contains(v, `"years":"5"`) { + t.Fatalf("ledger missing data: %v", kv) + } + // 幂等重放同键覆盖同值 + if _, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{ + "surfaceId": "surf_x", "requestId": "req-1", "data": map[string]any{"years": "5"}, + }}); err != nil { + t.Fatal(err) + } +} + +type ledgerHost struct { + extension.Host + kv map[string]string +} + +func (h *ledgerHost) KVPut(key, value string) error { h.kv[key] = value; return nil } +func (h *ledgerHost) Audit(action, target, evidence string) error { return nil } diff --git a/internal/server/platform_api.go b/internal/server/platform_api.go index 678b4ee..69e8cf2 100644 --- a/internal/server/platform_api.go +++ b/internal/server/platform_api.go @@ -268,6 +268,34 @@ func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, row) } +func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) { + if s.Surfaces == nil { + http.Error(w, "surface capability not enabled", http.StatusNotImplemented) + return + } + // 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道 + // (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。 + var body struct { + ActionID string `json:"actionId"` + Revision int `json:"revision"` + RequestID string `json:"requestId"` + Data map[string]any `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ActionID == "" || body.RequestID == "" { + http.Error(w, "actionId and requestId required", http.StatusBadRequest) + return + } + res, err := s.Surfaces.SubmitAction(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{ + SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID, + Revision: body.Revision, RequestID: body.RequestID, Data: body.Data, + }) + if err != nil { + s.writeSurfaceError(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} + // writeSurfaceError 把服务层确定错误映射到状态码:禁用/epoch=409 语义(准入 // 截止),规格违规=400 带字段清单,冲突=409 带当前版本,NotFound=404。 func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) { @@ -277,6 +305,10 @@ func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) { case errors.Is(err, surfacesvc.ErrDisabled), errors.Is(err, surfacesvc.ErrStaleEpoch), errors.Is(err, surfacesvc.ErrUnavailable): http.Error(w, err.Error(), http.StatusForbidden) + case errors.Is(err, surfacesvc.ErrUnknownAction): + http.Error(w, err.Error(), http.StatusBadRequest) + case errors.Is(err, store.ErrIdempotencyConflict): + http.Error(w, err.Error(), http.StatusConflict) case errors.As(err, &invalid): writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid spec", "details": invalid.Errs}) case errors.As(err, &conflict): diff --git a/internal/server/server.go b/internal/server/server.go index 4f7d448..93b8dfc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -315,6 +315,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/plugins/{id}/activation", s.setSurfaceActivation) mux.HandleFunc("POST /api/surfaces", s.submitSurface) mux.HandleFunc("GET /api/surfaces/{id}", s.getSurface) + mux.HandleFunc("POST /api/surfaces/{id}/actions", s.submitSurfaceAction) mux.HandleFunc("GET /api/conversations/{id}/surfaces", s.listSurfaces) mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins) mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema) diff --git a/internal/server/surface_api_test.go b/internal/server/surface_api_test.go index c05a171..40f8e39 100644 --- a/internal/server/surface_api_test.go +++ b/internal/server/surface_api_test.go @@ -6,6 +6,8 @@ package server import ( "bytes" + "context" + "fmt" "net/http" "net/http/httptest" "testing" @@ -87,3 +89,61 @@ func TestSurfaceDirectAPIGuards(t *testing.T) { } } } + +// U5:动作端点红测——禁用 403、未知动作 400、端点不绑定 confirmed(伪造无入口)。 +func TestSurfaceActionEndpoint(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, _ := st.CreateAPIKey("alice", "e2e") + svc := surfacesvc.New(st, nil) + svc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) { + return surfacesvc.ActionOutcome{State: "committed", Receipt: map[string]any{"ok": true}}, nil + }) + srv.Surfaces = svc + ctx := context.Background() + svc.SetActivation(ctx, "alice", "generative-ui", true) + spec := `"spec":{"title":"表单页","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}` + post := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"generative-ui","conversationId":"c1",`+spec+`}`)) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + rec := post("") + if rec.Code != http.StatusCreated { + t.Fatalf("setup submit failed: %d %s", rec.Code, rec.Body.String()) + } + var snap struct { + Identity struct { + SurfaceID string `json:"surfaceId"` + } `json:"identity"` + Revision int `json:"revision"` + } + readJSON(t, rec.Body.Bytes(), &snap) + + act := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + // 未知动作 → 400 + if r := act(`{"actionId":"shell.exec","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r1","data":{}}`); r.Code != http.StatusBadRequest { + t.Fatalf("unknown action should 400, got %d: %s", r.Code, r.Body.String()) + } + // 伪造 confirmed 字段:请求体里带 confirmed:true 不产生任何效果(端点不绑定 + // 该字段;DangerHigh 确认只能经确认卡回道)。此处 submit 非 DangerHigh, + // 断言 confirmed 被忽略(成功且不因它改变行为)。 + if r := act(`{"actionId":"generative-ui.submit","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r2","data":{"a":"1"},"confirmed":true}`); r.Code != http.StatusOK { + t.Fatalf("submit with forged confirmed should still be normal submit, got %d: %s", r.Code, r.Body.String()) + } + // 禁用后 → 403 + svc.SetActivation(ctx, "alice", "generative-ui", false) + if r := act(`{"actionId":"generative-ui.submit","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r3","data":{}}`); r.Code != http.StatusForbidden { + t.Fatalf("post-disable action should 403, got %d", r.Code) + } + _ = fmt.Sprint +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index b6b24e8..806abd8 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -649,6 +649,45 @@ func init() { `DROP TABLE IF EXISTS run_commands`, `DROP TABLE IF EXISTS conversation_executions`, }}}, + migration{version: 24, name: "surface_actions", + // Generative UI U5(提案 U5 动作范围 r2):动作提交幂等台账—— + // (owner, surface, action, requestId) 唯一;同 requestId 同参数重放返回 + // 已有回执,异参数拒绝;awaiting_confirm 记确认卡关联(执行权威在命令 + // 总线审计台账)。 + up: map[string][]string{"sqlite": { + `CREATE TABLE IF NOT EXISTS surface_actions ( + owner TEXT NOT NULL, + surface_id TEXT NOT NULL, + action_id TEXT NOT NULL, + request_id TEXT NOT NULL, + revision INTEGER NOT NULL, + params_hash TEXT NOT NULL, + state TEXT NOT NULL, + receipt TEXT NOT NULL DEFAULT '', + card_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + PRIMARY KEY (owner, surface_id, action_id, request_id) + )`, + }, "postgres": { + `CREATE TABLE IF NOT EXISTS surface_actions ( + owner TEXT NOT NULL, + surface_id TEXT NOT NULL, + action_id TEXT NOT NULL, + request_id TEXT NOT NULL, + revision INTEGER NOT NULL, + params_hash TEXT NOT NULL, + state TEXT NOT NULL, + receipt TEXT NOT NULL DEFAULT '', + card_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + PRIMARY KEY (owner, surface_id, action_id, request_id) + )`, + }}, + down: map[string][]string{"sqlite": { + `DROP TABLE IF EXISTS surface_actions`, + }, "postgres": { + `DROP TABLE IF EXISTS surface_actions`, + }}}, ) } diff --git a/internal/store/surface_actions.go b/internal/store/surface_actions.go new file mode 100644 index 0000000..7125efd --- /dev/null +++ b/internal/store/surface_actions.go @@ -0,0 +1,76 @@ +package store + +import ( + "database/sql" + "errors" + "time" +) + +// ---- Generative UI Surface 动作台账(提案 U5;迁移 v23)---- + +// SurfaceActionRecord 是一次动作提交的台账行。state: submitted(已分发)/ +// awaiting_confirm(高危确认卡挂起,执行权威在命令总线审计)/ committed / +// rejected(分发被拒)。同 requestId 重放读此行返回原回执。 +type SurfaceActionRecord struct { + Owner string + SurfaceID string + ActionID string + RequestID string + Revision int + ParamsHash string + State string + Receipt string + CardID string + CreatedAt string +} + +// ErrIdempotencyConflict 同 requestId 但参数不同:拒绝(KTD6)。 +var ErrIdempotencyConflict = errors.New("surface action: request id reused with different params") + +// SaveSurfaceActionIfAbsent 幂等写入:首个提交插入成功(first=true); +// 已存在时返回既有行(first=false)。参数一致性由调用方比对 ParamsHash。 +func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActionRecord, bool, error) { + rec.CreatedAt = time.Now().UTC().Format(time.RFC3339) + res, err := s.db.Exec(s.q(`INSERT INTO surface_actions(owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(owner, surface_id, action_id, request_id) DO NOTHING`), + rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID, rec.Revision, + rec.ParamsHash, rec.State, rec.Receipt, rec.CardID, rec.CreatedAt) + if err != nil { + return rec, false, err + } + n, _ := res.RowsAffected() + existing, err := s.GetSurfaceAction(rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID) + if err != nil { + return rec, false, err + } + return existing, n == 1, nil // RowsAffected=1 → 本调用完成首插(时间戳同秒不可靠) +} + +// GetSurfaceAction 读取台账行(不存在 → ErrNotFound)。 +func (s *Store) GetSurfaceAction(owner, surfaceID, actionID, requestID string) (SurfaceActionRecord, error) { + var r SurfaceActionRecord + err := s.db.QueryRow(s.q(`SELECT owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at + FROM surface_actions WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`), + owner, surfaceID, actionID, requestID). + Scan(&r.Owner, &r.SurfaceID, &r.ActionID, &r.RequestID, &r.Revision, &r.ParamsHash, &r.State, &r.Receipt, &r.CardID, &r.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return r, ErrNotFound + } + return r, err +} + +// UpdateSurfaceActionState 回写分发结果(首个提交提交后的状态迁移)。 +func (s *Store) UpdateSurfaceActionState(owner, surfaceID, actionID, requestID, state, receipt, cardID string) error { + res, err := s.db.Exec(s.q(`UPDATE surface_actions SET state=?, receipt=?, card_id=? + WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`), + state, receipt, cardID, owner, surfaceID, actionID, requestID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/surfacesvc/actions_test.go b/internal/surfacesvc/actions_test.go new file mode 100644 index 0000000..4d309ae --- /dev/null +++ b/internal/surfacesvc/actions_test.go @@ -0,0 +1,154 @@ +package surfacesvc + +// U5 红测(已批准范围 r2 验收线):启停守卫、旧修订拒绝、白名单拒绝、 +// 幂等同参回放/异参拒绝、DangerHigh 经确认卡且执行只由卡回道触发。 +// 数据准备走真实 surfacesvc.Submit(与生产同路径)。 + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +type actionFixture struct { + svc *Service + st *store.Store + surface core.SurfaceIdentity + rev int +} + +func newActionFixture(t *testing.T) *actionFixture { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + svc := New(st, nil) + ctx := context.Background() + if _, err := svc.SetActivation(ctx, "alice", "generative-ui", true); err != nil { + t.Fatal(err) + } + snap, err := svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{ + ConversationID: "c1", + Spec: json.RawMessage(`{"title":"需求表","nodes":[{"type":"form","id":"f1","fields":[{"key":"years","label":"年限","type":"select","options":["3","5"]}]},{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`), + }) + if err != nil { + t.Fatal(err) + } + return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision} +} + +func TestSubmitActionGuards(t *testing.T) { + f := newActionFixture(t) + ctx := context.Background() + // 白名单外动作 → 拒绝 + if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "shell.exec", Revision: f.rev, RequestID: "r0", Data: map[string]any{}}); !errors.Is(err, ErrUnknownAction) { + t.Fatalf("unknown action should reject, got %v", err) + } + // 旧修订 → 冲突(带当前版本) + _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "generative-ui.submit", Revision: f.rev - 1, RequestID: "r0b", Data: map[string]any{}}) + var conflict *store.ErrRevisionConflict + if !errors.As(err, &conflict) || conflict.Current != f.rev { + t.Fatalf("stale revision should conflict with current=%d, got %v", f.rev, err) + } + // 跨 owner → NotFound + if _, err := f.svc.SubmitAction(ctx, "bob", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r0c", Data: map[string]any{}}); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-owner should be NotFound, got %v", err) + } + // 未装配 dispatcher → 能力不可用 + if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r0d", Data: map[string]any{}}); !errors.Is(err, ErrUnavailable) { + t.Fatalf("no dispatcher should be unavailable, got %v", err) + } +} + +func TestSubmitActionIdempotency(t *testing.T) { + f := newActionFixture(t) + ctx := context.Background() + var calls int + f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) { + calls++ + return ActionOutcome{State: "committed", Receipt: map[string]any{"op": "op_1"}}, nil + }) + data := map[string]any{"years": "5"} + r1, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1", Data: data}) + if err != nil || r1.State != "committed" { + t.Fatalf("first submit: %+v err=%v", r1, err) + } + // 同 requestId 同参数 → 回放已有回执,不再分发 + r2, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1", Data: data}) + if err != nil || r2.State != "committed" || calls != 1 { + t.Fatalf("idempotent replay must not redispatch: calls=%d r2=%+v err=%v", calls, r2, err) + } + // 同 requestId 异参数 → 拒绝 + if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1", + Data: map[string]any{"years": "3"}}); !errors.Is(err, store.ErrIdempotencyConflict) { + t.Fatalf("same requestId different params must reject, got %v", err) + } +} + +func TestSubmitActionDisabledAfterEnable(t *testing.T) { + f := newActionFixture(t) + ctx := context.Background() + f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) { + return ActionOutcome{State: "committed"}, nil + }) + // 禁用后:新动作拒绝(KTD7:禁用返回即准入截止点) + if _, err := f.svc.SetActivation(ctx, "alice", "generative-ui", false); err != nil { + t.Fatal(err) + } + if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r9", Data: map[string]any{}}); !errors.Is(err, ErrDisabled) { + t.Fatalf("post-disable action should reject, got %v", err) + } +} + +func TestDangerHighGoesThroughConfirmCard(t *testing.T) { + f := newActionFixture(t) + ctx := context.Background() + // 模拟把一条 DangerHigh 命令加入白名单(B 类机制验证;正式白名单由独立评审加)。 + prev := AllowedActions["generative-ui.submit"] + saved := map[string]string{"generative-ui.submit": "generative-ui.submit"} + _ = prev + AllowedActions["test.highwrite"] = "test.highwrite" + defer func() { + delete(AllowedActions, "test.highwrite") + _ = saved + }() + var executed int + f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) { + // 模拟总线行为:DangerHigh 未确认 → 创建确认卡、不执行(真实路径在 + // cmdbus 红测;此处验证 surfacesvc 不透传 Confirmed 的等价语义)。 + if d.Params["confirmed"] == true { + executed++ + return ActionOutcome{State: "committed"}, nil + } + return ActionOutcome{State: "awaiting_confirm", CardID: "card_1"}, nil + }) + res, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "test.highwrite", Revision: f.rev, RequestID: "req-h", Data: map[string]any{"x": 1}}) + if err != nil || res.State != "awaiting_confirm" || res.CardID == "" { + t.Fatalf("danger-high must await confirm card: %+v err=%v", res, err) + } + if executed != 0 { + t.Fatal("danger-high must not execute without card confirm") + } + // 重放同 requestId: awaiting_confirm 态原样返回(未执行的确认仍挂在卡上) + res2, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID, + ActionID: "test.highwrite", Revision: f.rev, RequestID: "req-h", Data: map[string]any{"x": 1}}) + if err != nil || res2.State != "awaiting_confirm" { + t.Fatalf("replay should return awaiting_confirm, got %+v err=%v", res2, err) + } +} diff --git a/internal/surfacesvc/surfacesvc.go b/internal/surfacesvc/surfacesvc.go index b6fe1af..0b4f1f9 100644 --- a/internal/surfacesvc/surfacesvc.go +++ b/internal/surfacesvc/surfacesvc.go @@ -7,6 +7,7 @@ package surfacesvc import ( "bytes" "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -45,10 +46,11 @@ type SubmitRequest struct { } type Service struct { - st *store.Store - emit Emitter - now func() (t string) - logln func(format string, v ...any) + st *store.Store + emit Emitter + dispatch Dispatcher + now func() (t string) + logln func(format string, v ...any) } func New(st *store.Store, emit Emitter) *Service { @@ -200,3 +202,136 @@ func decodeSpecStrict(raw json.RawMessage) (core.SurfaceSpec, error) { } return spec, nil } + +// ---- 动作提交(U5;已批准范围 r2:A 客户端本地不经此层;B/C 经此单一咽喉)---- + +// ErrUnknownAction 引用了未开放的动作 id(白名单外;KTD6 未知动作一律拒绝)。 +var ErrUnknownAction = errors.New("surface action: unknown or not allowed action id") + +// ActionDispatch 由组合根注入:把动作转交命令总线(四入口同源:审计信封、 +// 参数校验、DangerHigh 内联确认卡、幂等 pendingStore——全部复用,不新造)。 +type ActionDispatch struct { + CommandID string + Owner string + ConversationID string + Params map[string]any +} + +// ActionOutcome 是一次分发的结果(组合根从 cmdbus.Response 折叠)。 +type ActionOutcome struct { + State string // awaiting_confirm | committed | rejected + CardID string + Receipt any + Payload any +} + +type Dispatcher func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) + +// AllowedActions 是动作 id → 命令 id 的白名单(U5 第一版仅插件自有提交; +// B 类命令逐条经独立评审后加入)。 +var AllowedActions = map[string]string{ + "generative-ui.submit": "generative-ui.submit", +} + +// SubmitActionRequest 是一次页面动作提交(按钮点击)。 +type SubmitActionRequest struct { + SurfaceID core.ID + ActionID string + Revision int + RequestID string + Data map[string]any +} + +// SurfaceActionResult 是动作提交结果(含确认卡关联或命令回执)。 +type SurfaceActionResult struct { + SurfaceID string `json:"surfaceId"` + ActionID string `json:"actionId"` + RequestID string `json:"requestId"` + State string `json:"state"` + CardID string `json:"cardId,omitempty"` + Receipt any `json:"receipt,omitempty"` + Payload any `json:"payload,omitempty"` + Note string `json:"note,omitempty"` +} + +func (s *Service) SetDispatcher(d Dispatcher) { s.dispatch = d } + +// SubmitAction:启停 → revision 现势 → 白名单 → 幂等台账 → 命令总线分发。 +// 本方法不接收也不透传 Confirmed:高危确认只能经既有确认卡回道置位, +// 模型或客户端伪造 Confirmed 无入口(红测断言)。 +func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActionRequest) (SurfaceActionResult, error) { + cmd, ok := AllowedActions[req.ActionID] + if !ok { + return SurfaceActionResult{}, ErrUnknownAction + } + if req.RequestID == "" { + return SurfaceActionResult{}, &ErrInvalidSpec{Errs: []string{"requestId required"}} + } + rec, err := s.st.GetSurface(owner, string(req.SurfaceID)) + if err != nil { + return SurfaceActionResult{}, err + } + if act, err := s.st.GetActivation(owner, rec.PluginID); err != nil { + return SurfaceActionResult{}, err + } else if !act.Enabled { + return SurfaceActionResult{}, ErrDisabled + } + if req.Revision != rec.LatestRevision { + return SurfaceActionResult{}, &store.ErrRevisionConflict{Current: rec.LatestRevision} + } + if s.dispatch == nil { + return SurfaceActionResult{}, ErrUnavailable + } + raw, _ := json.Marshal(req.Data) + hash := fmt.Sprintf("%x", sha256.Sum256(raw)) + existing, first, err := s.st.SaveSurfaceActionIfAbsent(store.SurfaceActionRecord{ + Owner: owner, SurfaceID: string(req.SurfaceID), ActionID: req.ActionID, + RequestID: req.RequestID, Revision: req.Revision, ParamsHash: hash, State: "submitted", + }) + if err != nil { + return SurfaceActionResult{}, err + } + if !first { + if existing.ParamsHash != hash { + return SurfaceActionResult{}, store.ErrIdempotencyConflict + } + return SurfaceActionResult{ + SurfaceID: existing.SurfaceID, ActionID: existing.ActionID, RequestID: existing.RequestID, + State: existing.State, CardID: existing.CardID, + Receipt: json.RawMessage(orEmptyJSON(existing.Receipt)), + Note: "幂等重放:返回已有回执", + }, nil + } + out, err := s.dispatch(ctx, ActionDispatch{ + CommandID: cmd, Owner: owner, ConversationID: rec.ConversationID, + Params: map[string]any{"surfaceId": string(req.SurfaceID), "revision": req.Revision, + "requestId": req.RequestID, "data": req.Data}, + }) + state, cardID := out.State, out.CardID + if err != nil { + state, cardID = "rejected", "" + } + receiptJSON, _ := json.Marshal(out.Receipt) + if uerr := s.st.UpdateSurfaceActionState(owner, string(req.SurfaceID), req.ActionID, req.RequestID, + state, string(receiptJSON), cardID); uerr != nil { + s.logln("surface: action state update failed: %v", uerr) + } + if err != nil { + return SurfaceActionResult{}, err + } + note := "" + if state == "awaiting_confirm" { + note = "高危动作已发确认卡,请确认后执行" + } + return SurfaceActionResult{ + SurfaceID: string(req.SurfaceID), ActionID: req.ActionID, RequestID: req.RequestID, + State: state, CardID: cardID, Receipt: out.Receipt, Payload: out.Payload, Note: note, + }, nil +} + +func orEmptyJSON(s string) string { + if s == "" { + return "null" + } + return s +}