Merge pull request 'Add the surface action pipeline: ledger, guards, dispatch, submit (U5)' (#155) from feature/generative-ui-u5 into main
ci / vet-and-test (push) Successful in 1m6s

This commit was merged in pull request #155.
This commit is contained in:
2026-10-11 14:24:34 +00:00
11 changed files with 694 additions and 16 deletions
+1 -1
View File
@@ -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 = `
+11
View File
@@ -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 {
+94 -3
View File
@@ -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),
+87 -8
View File
@@ -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 }
+32
View File
@@ -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):
+1
View File
@@ -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)
+60
View File
@@ -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
}
+39
View File
@@ -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`,
}}},
)
}
+76
View File
@@ -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
}
+154
View File
@@ -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)
}
}
+139 -4
View File
@@ -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
}