Add the surface action pipeline: ledger, guards, dispatch, submit (U5) #155
@@ -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 = `
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user