Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
611 lines
22 KiB
Go
611 lines
22 KiB
Go
// Package cmdbus 组合 Agent-Command 生态库:注册、四入口分发(button/card/slash/chat→api
|
||
// 归一)、审计信封落盘与危险命令的内联确认卡收口(提案 T3 / D27、D30、D33)。
|
||
// 与 intentgate 之于 agent-intent 同构:schema 权威在生态库,宿主只做装配与执行适配。
|
||
package cmdbus
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
agentcommand "agent-command"
|
||
"agent-system-core"
|
||
)
|
||
|
||
// AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。
|
||
// owner 是请求租户(req.Owner):审计行按租户归属,查询按 owner 过滤,
|
||
// 跨租户审计不可见(信封 Actor 是发起主体,不作归属依据)。
|
||
type AuditStore interface {
|
||
AppendAudit(owner string, env agentcommand.AuditEnvelope) error
|
||
}
|
||
|
||
// CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。
|
||
type CardPort interface {
|
||
Create(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error)
|
||
SetStatus(owner, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error)
|
||
}
|
||
|
||
// Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。
|
||
type Handler func(ctx context.Context, req Request) (any, error)
|
||
|
||
// CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。
|
||
// Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。
|
||
type CommandTool struct {
|
||
ID string
|
||
Title string
|
||
Description string
|
||
Params json.RawMessage
|
||
Risk string
|
||
}
|
||
|
||
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
|
||
// ToolGate 是 owner 级工具注入闸(U3:禁用即不注入能力):返回 false 时该命令
|
||
// 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。
|
||
type ToolGate func(owner, commandID string) bool
|
||
|
||
// EffectiveAccountGuard checks the current account state before a command can
|
||
// create a confirmation or invoke a handler. The composition root supplies the
|
||
// check; nil keeps standalone and auth-off fixtures unchanged. The actor is
|
||
// derived only from trusted Request identity fields, never Params. This is an
|
||
// early check, not an atomic fence across handler databases or external calls.
|
||
type EffectiveAccountGuard func(ctx context.Context, actor string) error
|
||
|
||
// SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。
|
||
func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g }
|
||
|
||
func (b *Bus) CommandTools() []CommandTool {
|
||
return b.CommandToolsFor("")
|
||
}
|
||
|
||
// CommandToolsFor 按 owner 过滤工具投影(gate 为 nil 时不过滤;owner 为空时
|
||
// gated 命令一律不投影——fail-closed)。
|
||
func (b *Bus) CommandToolsFor(owner string) []CommandTool {
|
||
out := []CommandTool{}
|
||
for _, r := range b.reg.List() {
|
||
if !r.Enabled || r.Spec.ExposeAsTool == nil {
|
||
continue
|
||
}
|
||
if b.toolGate != nil && !b.toolGate(owner, r.Spec.ID) {
|
||
continue
|
||
}
|
||
risk := r.Spec.ExposeAsTool.Risk
|
||
if risk == "" {
|
||
risk = "low"
|
||
}
|
||
desc := r.Spec.Description
|
||
if desc == "" {
|
||
desc = r.Spec.Title
|
||
}
|
||
out = append(out, CommandTool{ID: r.Spec.ID, Title: r.Spec.Title,
|
||
Description: desc, Params: r.Spec.ParamsSchema, Risk: risk})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Request 一次分发请求(入口归一后的统一形态)。
|
||
type Request struct {
|
||
ID string
|
||
Params map[string]any
|
||
Entrance string // button | card | slash | chat | api(归一)
|
||
Actor string
|
||
Owner string
|
||
ConversationID string
|
||
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
|
||
Confirmed bool // 危险命令经确认卡放行后的执行
|
||
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
|
||
}
|
||
|
||
// Response 分发结果:回执 + 处理负载 + 可能的确认卡。
|
||
type Response struct {
|
||
Receipt agentcommand.ExecReceipt
|
||
Payload any
|
||
Card *core.ActivityCard // 高危未确认时创建的内联确认卡
|
||
}
|
||
|
||
// pending 确认卡挂起的执行(进程内;重启后确认返回 rejected expired——v1 限制)。
|
||
type pending struct {
|
||
req Request
|
||
cardID string
|
||
}
|
||
|
||
type Bus struct {
|
||
reg *agentcommand.Registry
|
||
audit AuditStore
|
||
cards CardPort
|
||
now func() time.Time
|
||
handlers map[string]Handler
|
||
slash map[string]string // "/x" → command id
|
||
pendMu sync.Mutex
|
||
pending map[string]pending
|
||
pendingStore PendingStore
|
||
intents *IntentStore // G2-2:意图声明表(自然语言路由)
|
||
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
|
||
accountGuard EffectiveAccountGuard // A1: optional command execution precheck
|
||
strictAccountWrites bool
|
||
allowImplicitLocal bool
|
||
}
|
||
|
||
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
|
||
type PendingStore interface {
|
||
SavePendingConfirm(cardID, owner, payload string) error
|
||
TakePendingConfirm(cardID string) (owner, payload string, ok bool, err error)
|
||
}
|
||
|
||
func New(audit AuditStore, cards CardPort, now func() time.Time) *Bus {
|
||
if now == nil {
|
||
now = time.Now
|
||
}
|
||
return &Bus{
|
||
reg: agentcommand.NewRegistry(), audit: audit, cards: cards, now: now,
|
||
handlers: map[string]Handler{}, slash: map[string]string{}, pending: map[string]pending{},
|
||
intents: NewIntentStore(),
|
||
}
|
||
}
|
||
|
||
// Registry 暴露注册表(列清单/插件 overlay 注册用)。
|
||
func (b *Bus) Registry() *agentcommand.Registry { return b.reg }
|
||
|
||
// SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。
|
||
func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st }
|
||
|
||
// SetEffectiveAccountGuard installs the composition-root check at startup.
|
||
// Handler Store writes still need their own final transaction fence.
|
||
func (b *Bus) SetEffectiveAccountGuard(g EffectiveAccountGuard) { b.accountGuard = g }
|
||
|
||
// RequireAccountCheckedWrites makes every Bus-owned durable sink use a final
|
||
// account transaction. The composition root enables this after wiring Store
|
||
// and Cards; standalone in-memory fixtures retain their legacy ports.
|
||
func (b *Bus) RequireAccountCheckedWrites(allowImplicitLocal bool) {
|
||
b.strictAccountWrites = true
|
||
b.allowImplicitLocal = allowImplicitLocal
|
||
}
|
||
|
||
func (b *Bus) createConfirmCard(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
|
||
if !b.strictAccountWrites {
|
||
return b.cards.Create(actor, card, ref)
|
||
}
|
||
checked, ok := b.cards.(interface {
|
||
CreateAccountChecked(string, core.ActivityCard, string, bool) (core.ActivityCard, error)
|
||
})
|
||
if !ok {
|
||
return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required")
|
||
}
|
||
return checked.CreateAccountChecked(actor, card, ref, b.allowImplicitLocal)
|
||
}
|
||
|
||
func (b *Bus) setConfirmCardStatus(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
|
||
if !b.strictAccountWrites {
|
||
return b.cards.SetStatus(actor, id, status, tone, note)
|
||
}
|
||
checked, ok := b.cards.(interface {
|
||
SetStatusAccountChecked(string, string, core.CardStatus, string, string, bool) (core.ActivityCard, error)
|
||
})
|
||
if !ok {
|
||
return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required")
|
||
}
|
||
return checked.SetStatusAccountChecked(actor, id, status, tone, note, b.allowImplicitLocal)
|
||
}
|
||
|
||
func (b *Bus) savePending(cardID, owner, payload string) error {
|
||
if !b.strictAccountWrites {
|
||
return b.pendingStore.SavePendingConfirm(cardID, owner, payload)
|
||
}
|
||
checked, ok := b.pendingStore.(interface {
|
||
SavePendingConfirmAccountChecked(string, string, string, bool) error
|
||
})
|
||
if !ok {
|
||
return fmt.Errorf("cmdbus: account-checked pending store required")
|
||
}
|
||
return checked.SavePendingConfirmAccountChecked(cardID, owner, payload, b.allowImplicitLocal)
|
||
}
|
||
|
||
func (b *Bus) takePending(cardID, owner string) (string, string, bool, error) {
|
||
if !b.strictAccountWrites {
|
||
return b.pendingStore.TakePendingConfirm(cardID)
|
||
}
|
||
checked, ok := b.pendingStore.(interface {
|
||
TakePendingConfirmAccountChecked(string, string, bool) (string, string, bool, error)
|
||
})
|
||
if !ok {
|
||
return "", "", false, fmt.Errorf("cmdbus: account-checked pending store required")
|
||
}
|
||
return checked.TakePendingConfirmAccountChecked(cardID, owner, b.allowImplicitLocal)
|
||
}
|
||
|
||
// RegisterBuiltin 登记内置命令并绑定宿主处理器与 `/` 别名。
|
||
func (b *Bus) RegisterBuiltin(spec agentcommand.CommandSpec, slash string, h Handler) error {
|
||
spec.Binding = agentcommand.Binding{Kind: agentcommand.BindBuiltin}
|
||
if err := b.reg.Register(spec, agentcommand.SourceBuiltin); err != nil {
|
||
return err
|
||
}
|
||
if h != nil {
|
||
b.handlers[spec.ID] = h
|
||
}
|
||
if slash != "" {
|
||
b.slash[strings.ToLower(slash)] = spec.ID
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RegisterUser 登记用户提示词命令。重复登记视为已存在。执行不走总线,发送时展开模板。
|
||
func (b *Bus) RegisterUser(spec agentcommand.CommandSpec, slash string) error {
|
||
spec.Binding = agentcommand.Binding{Kind: agentcommand.BindMessage}
|
||
if err := b.reg.Register(spec, agentcommand.SourceUser); err != nil && !errors.Is(err, agentcommand.ErrDuplicate) {
|
||
return err
|
||
}
|
||
if slash != "" {
|
||
b.slash[strings.ToLower(slash)] = spec.ID
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// UnregisterUser 卸下用户命令。不存在时忽略。
|
||
func (b *Bus) UnregisterUser(id, slash string) {
|
||
_ = b.reg.Unregister(id)
|
||
if slash != "" {
|
||
delete(b.slash, strings.ToLower(slash))
|
||
}
|
||
}
|
||
|
||
// RegisterPluginCommands 插件命令 overlay(manifest provides.commands;失败 fail-closed 返回)。
|
||
func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error {
|
||
for _, raw := range overlay {
|
||
var spec agentcommand.CommandSpec
|
||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||
return fmt.Errorf("cmdbus: plugin command spec: %w", err)
|
||
}
|
||
if err := b.reg.Register(spec, agentcommand.SourcePlugin); err != nil {
|
||
return fmt.Errorf("cmdbus: register %s: %w", spec.ID, err)
|
||
}
|
||
b.bindPluginSlash(spec)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// bindPluginSlash EXT-10②:插件命令规格自带的 slash 别名绑定(与内置命令同一张
|
||
// 别名表——四入口同源:slash 命中即同一命令 id、同一审计信封)。
|
||
func (b *Bus) bindPluginSlash(spec agentcommand.CommandSpec) {
|
||
if spec.Slash != "" {
|
||
b.slash[strings.ToLower(spec.Slash)] = spec.ID
|
||
}
|
||
}
|
||
|
||
// RegisterPluginCommand 注册进程内插件命令(EXT-1):登记规格并绑定宿主处理器。
|
||
// 仅登记规格的声明式路径仍走 RegisterPluginCommands(无 handler,分发即 rejected)。
|
||
func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) error {
|
||
if err := b.reg.Register(spec, agentcommand.SourcePlugin); err != nil {
|
||
return fmt.Errorf("cmdbus: register %s: %w", spec.ID, err)
|
||
}
|
||
if h != nil {
|
||
b.handlers[spec.ID] = h
|
||
}
|
||
b.bindPluginSlash(spec)
|
||
return nil
|
||
}
|
||
|
||
// Intents 意图声明表(G2-2:注册面与路由查询共用)。
|
||
func (b *Bus) Intents() *IntentStore { return b.intents }
|
||
|
||
// ResolveSlash `/` 入口解析:返回命令 id 与原始字段(参数由宿主处理器按命令语义解析)。
|
||
func (b *Bus) ResolveSlash(cmd string) (string, bool) {
|
||
id, ok := b.slash[strings.ToLower(strings.TrimSpace(cmd))]
|
||
return id, ok
|
||
}
|
||
|
||
// SlashTable slash 别名表(清单组装用;只读副本)。
|
||
func (b *Bus) SlashTable() map[string]string {
|
||
out := make(map[string]string, len(b.slash))
|
||
for k, v := range b.slash {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// HasCommand 同源判定(intentgate 命中意图后查总线是否承接)。
|
||
func (b *Bus) HasCommand(id string) bool {
|
||
_, _, err := b.reg.Resolve(id)
|
||
return err == nil
|
||
}
|
||
|
||
// List 四入口共享的命令清单(`/` 菜单、按钮、卡片 action 提示用)。
|
||
func (b *Bus) List() []agentcommand.Registered { return b.reg.List() }
|
||
|
||
// Dispatch 统一分发:账号预检→校验→危险收口→执行→审计。账号预检拒绝只返回回执,
|
||
// 避免停用账号或检查故障时继续写入审计;其余路径维持四入口审计对称。
|
||
func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||
start := b.now()
|
||
req.Entrance = agentcommand.CanonicalEntrance(req.Entrance)
|
||
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
|
||
// A rejected account cannot create a card, pending row, audit row, or
|
||
// invoke a handler. A failed read is also a denial.
|
||
return b.accountRejected(req.ID, start)
|
||
}
|
||
receipt := func(state agentcommand.ResultState, op, reason string) Response {
|
||
return Response{Receipt: b.auditReceipt(req, state, op, reason, start)}
|
||
}
|
||
spec, _, err := b.reg.Resolve(req.ID)
|
||
if err != nil {
|
||
return receipt(agentcommand.StateRejected, "", "unknown or disabled command: "+req.ID)
|
||
}
|
||
if err := validateParams(spec.ParamsSchema, req.Params); err != nil {
|
||
return receipt(agentcommand.StateRejected, "", "params: "+err.Error())
|
||
}
|
||
// 高危收口:非确认入口先落内联确认卡(committed 语义 = 卡已创建;确认后二次分发执行)。
|
||
if spec.Danger == agentcommand.DangerHigh && !req.Confirmed {
|
||
if b.cards == nil || req.ConversationID == "" {
|
||
return receipt(agentcommand.StateRejected, "", "high-danger command requires conversation context for inline confirm")
|
||
}
|
||
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
|
||
return b.accountRejected(req.ID, start)
|
||
}
|
||
card, err := b.createConfirmCard(accountActor(req), confirmCard(spec, req), "cmd:"+spec.ID)
|
||
if err != nil {
|
||
return receipt(agentcommand.StateUnknown, "", "confirm card: "+err.Error())
|
||
}
|
||
if b.pendingStore != nil {
|
||
raw, _ := json.Marshal(req)
|
||
if err := b.savePending(card.ID, accountActor(req), string(raw)); err != nil {
|
||
return receipt(agentcommand.StateUnknown, "", "confirm pending: "+err.Error())
|
||
}
|
||
} else if b.strictAccountWrites {
|
||
return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required")
|
||
}
|
||
b.pendMu.Lock()
|
||
b.pending[card.ID] = pending{req: req, cardID: card.ID}
|
||
b.pendMu.Unlock()
|
||
r := b.auditReceipt(req, agentcommand.StateAccepted, card.ID, "awaiting explicit confirm", start)
|
||
return Response{Receipt: r, Card: &card}
|
||
}
|
||
h, ok := b.handlers[req.ID]
|
||
if !ok {
|
||
return receipt(agentcommand.StateRejected, "", "no host handler bound for "+req.ID)
|
||
}
|
||
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
|
||
return b.accountRejected(req.ID, start)
|
||
}
|
||
payload, err := h(ctx, req)
|
||
if err != nil {
|
||
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
|
||
}
|
||
op := fmt.Sprintf("op_%s_%d", strings.ReplaceAll(strings.TrimPrefix(req.ID, ""), ".", "_"), b.now().UnixNano())
|
||
return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, op, "", start), Payload: payload}
|
||
}
|
||
|
||
// Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。
|
||
func (b *Bus) Confirm(owner, cardID, actionID string) Response {
|
||
return b.ConfirmContext(context.Background(), owner, cardID, actionID)
|
||
}
|
||
|
||
// ConfirmContext carries the request cancellation through the confirmed
|
||
// handler and repeats the account check before consuming durable state.
|
||
func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string) Response {
|
||
// The caller supplies the authenticated card owner. Check before even
|
||
// consuming a persisted pending request; Dispatch checks the stored
|
||
// request again immediately before executing its handler.
|
||
if err := b.checkEffectiveAccount(ctx, owner); err != nil {
|
||
return b.accountRejected("unknown", b.now())
|
||
}
|
||
b.pendMu.Lock()
|
||
p, ok := b.pending[cardID]
|
||
b.pendMu.Unlock()
|
||
if ok && p.req.Owner != "" && p.req.Owner != owner {
|
||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
|
||
}
|
||
if b.pendingStore != nil {
|
||
storedOwner, payload, found, err := b.takePending(cardID, owner)
|
||
if err != nil {
|
||
return b.accountRejected("unknown", b.now())
|
||
}
|
||
if found {
|
||
var req Request
|
||
if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil {
|
||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}}
|
||
}
|
||
p = pending{req: req, cardID: cardID}
|
||
ok = true
|
||
} else if b.strictAccountWrites {
|
||
ok = false
|
||
}
|
||
} else if b.strictAccountWrites {
|
||
ok = false
|
||
}
|
||
if !ok {
|
||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}}
|
||
}
|
||
req := p.req
|
||
if req.Owner != "" && req.Owner != owner {
|
||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
|
||
}
|
||
b.pendMu.Lock()
|
||
delete(b.pending, cardID)
|
||
b.pendMu.Unlock()
|
||
req.Confirmed = true
|
||
req.Entrance = "card"
|
||
if actionID != "confirm" {
|
||
if b.cards != nil {
|
||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "muted", "cancelled")
|
||
}
|
||
start := b.now()
|
||
return Response{Receipt: b.auditReceipt(req, agentcommand.StateRejected, "", "cancelled at confirm card", start)}
|
||
}
|
||
resp := b.Dispatch(ctx, req)
|
||
if b.cards != nil {
|
||
if resp.Receipt.State == agentcommand.StateCommitted {
|
||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed")
|
||
} else {
|
||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason)
|
||
}
|
||
}
|
||
return resp
|
||
}
|
||
|
||
func accountActor(req Request) string {
|
||
// Plugin and delegated commands use Actor for an audit label such as a
|
||
// plugin ID; Owner is the effective account whose state controls effects.
|
||
if req.Owner != "" {
|
||
return req.Owner
|
||
}
|
||
return req.Actor
|
||
}
|
||
|
||
func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if b.accountGuard == nil {
|
||
return nil
|
||
}
|
||
return b.accountGuard(ctx, actor)
|
||
}
|
||
|
||
func (b *Bus) accountRejected(commandID string, start time.Time) Response {
|
||
return Response{Receipt: agentcommand.ExecReceipt{
|
||
CommandID: commandID, State: agentcommand.StateRejected,
|
||
Reason: "effective account unavailable", Time: start.UTC(),
|
||
}}
|
||
}
|
||
|
||
func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reason string, start time.Time) agentcommand.ExecReceipt {
|
||
env := agentcommand.AuditEnvelope{
|
||
Time: start.UTC(), Actor: req.Actor, CommandID: req.ID, Entrance: agentcommand.CanonicalEntrance(req.Entrance),
|
||
ParamsDigest: digest(req.Params), State: state, DurationMs: float64(b.now().Sub(start).Milliseconds()),
|
||
OperationID: op, ConversationID: req.ConversationID,
|
||
}
|
||
if _, src, err := b.reg.Resolve(req.ID); err == nil {
|
||
env.Source = src
|
||
}
|
||
var err error
|
||
if b.strictAccountWrites {
|
||
checked, ok := b.audit.(interface {
|
||
AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error
|
||
})
|
||
if !ok {
|
||
err = fmt.Errorf("cmdbus: account-checked audit store required")
|
||
} else {
|
||
err = checked.AppendAuditAccountChecked(accountActor(req), env, b.allowImplicitLocal)
|
||
}
|
||
} else {
|
||
err = b.audit.AppendAudit(req.Owner, env)
|
||
}
|
||
if err != nil {
|
||
// 审计不可用:结果标记未知,不臆断成功(四态语义)。
|
||
return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()}
|
||
}
|
||
return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, Time: b.now().UTC()}
|
||
}
|
||
|
||
func digest(params map[string]any) string {
|
||
if len(params) == 0 {
|
||
return ""
|
||
}
|
||
raw, _ := json.Marshal(params)
|
||
sum := sha256.Sum256(raw)
|
||
return hex.EncodeToString(sum[:8])
|
||
}
|
||
|
||
// confirmCard 构造内联确认卡:锚定发起消息、双现于收件箱、动作收口回总线。
|
||
func confirmCard(spec agentcommand.CommandSpec, req Request) core.ActivityCard {
|
||
body := []core.UINode{{Type: core.UIMarkdown, Markdown: "**" + spec.Title + "** 需要确认后执行。"}}
|
||
if spec.Description != "" {
|
||
body = append(body, core.UINode{Type: core.UIMarkdown, Markdown: spec.Description})
|
||
}
|
||
rows := [][2]string{{"入口", req.Entrance}}
|
||
for k, v := range req.Params {
|
||
rows = append(rows, [2]string{k, fmt.Sprintf("%v", v)})
|
||
}
|
||
body = append(body, core.UINode{Type: core.UIKV, KV: rows})
|
||
return core.ActivityCard{
|
||
Kind: core.CardKindConfirm, Status: core.CardActionRequired, Tone: "warn",
|
||
Title: "确认执行:" + spec.Title,
|
||
Body: body,
|
||
Actions: []core.UIAction{
|
||
{ID: "confirm", Label: "确认执行", Style: "primary"},
|
||
{ID: "cancel", Label: "取消", Style: "danger"},
|
||
},
|
||
ConversationID: core.ID(req.ConversationID),
|
||
AnchorMessageID: core.ID(req.AnchorMessageID),
|
||
}
|
||
}
|
||
|
||
// validateParams JSON Schema draft-07 子集(type:object + required + 标量类型),宿主侧校验。
|
||
func validateParams(schema json.RawMessage, params map[string]any) error {
|
||
if len(schema) == 0 {
|
||
return nil
|
||
}
|
||
var sc struct {
|
||
Type string `json:"type"`
|
||
Required []string `json:"required"`
|
||
Properties map[string]any `json:"properties"`
|
||
}
|
||
if err := json.Unmarshal(schema, &sc); err != nil {
|
||
return fmt.Errorf("bad schema: %w", err)
|
||
}
|
||
if sc.Type != "" && sc.Type != "object" {
|
||
return fmt.Errorf("schema type must be object, got %s", sc.Type)
|
||
}
|
||
for _, k := range sc.Required {
|
||
if _, ok := params[k]; !ok {
|
||
return fmt.Errorf("missing required param %s", k)
|
||
}
|
||
}
|
||
for k, v := range params {
|
||
decl, ok := sc.Properties[k]
|
||
if !ok {
|
||
continue // 未声明属性放行(宿主最小校验)
|
||
}
|
||
m, _ := decl.(map[string]any)
|
||
want, _ := m["type"].(string)
|
||
if want != "" && !scalarTypeOk(want, v) {
|
||
return fmt.Errorf("param %s: want %s", k, want)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func scalarTypeOk(want string, v any) bool {
|
||
switch want {
|
||
case "string":
|
||
_, ok := v.(string)
|
||
return ok
|
||
case "number":
|
||
return isNum(v)
|
||
case "boolean":
|
||
_, ok := v.(bool)
|
||
return ok
|
||
case "integer":
|
||
if !isNum(v) {
|
||
return false
|
||
}
|
||
return float64(toFloat(v)) == float64(int64(toFloat(v)))
|
||
}
|
||
return true
|
||
}
|
||
|
||
func isNum(v any) bool {
|
||
switch v.(type) {
|
||
case float64, float32, int, int64:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func toFloat(v any) float64 {
|
||
switch x := v.(type) {
|
||
case float64:
|
||
return x
|
||
case float32:
|
||
return float64(x)
|
||
case int:
|
||
return float64(x)
|
||
case int64:
|
||
return float64(x)
|
||
}
|
||
return 0
|
||
}
|