// Package cmdbus 组合 Agent-Command 生态库:注册、四入口分发(button/card/slash/chat→api // 归一)、审计信封落盘与危险命令的内联确认卡收口(提案 T3 / D27、D30、D33)。 // 与 intentgate 之于 agent-intent 同构:schema 权威在生态库,宿主只做装配与执行适配。 package cmdbus import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "sync" "time" agentcommand "agent-command" core "agent-system-core" ) // AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。 // owner 是请求租户(req.Owner):审计行按租户归属,查询按 owner 过滤, // 跨租户审计不可见(信封 Actor 是发起主体,不作归属依据)。 type AuditStore interface { AppendAudit(owner string, env agentcommand.AuditEnvelope) error } // OperationRecord 是"已记录操作"的最小对账投影(R7/KTD6)。它回答的是 // “这个 operationID 在本 owner/project 名下到底是什么结果”,而不是重放入口。 // 刻意保持最小:审计信封只存状态与标识,不存理由文本,所以这里没有 Reason—— // 不为不存在的字段留位置。 type OperationRecord struct { OperationID string CommandID string State agentcommand.ResultState Time time.Time } // OperationQuery 是 operationID 的对账查询端口(组合根装配;nil = 不提供)。 // // 授权口径:owner 与 projectID 都是可信过滤条件,实现必须把它们放进查询谓词 // (SQL 的 owner/project 等值条件),不得由调用方参数扩大可见范围;查不到就 // 返回 not found,不得回退为“未执行”。 // // 诚实边界:本端口只解析“已经落成记录”的 operation。宿主侧的命令回执记录就是 // 命令审计信封(command_audit 按 owner+project 归属);handler 自己的领域操作表 // (如文档投递 document_render_deliveries)由各自领域查询负责,不经本端口。某些 // 命令根本不持久化 req.OperationID(内置的 builtin.help、runtime.mock、 // models.set、conversation.profile、conversation.create 都是无操作表的即时用例), // 对它们的 operationID 查询恒为 not found——这类命令只能按领域资源(会话、画像、 // 设置项)对账,不得声称可按 operationID 查询。 type OperationQuery interface { LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error) } var ( // ErrOperationScope 缺少可信 owner/project/operation 作用域:fail-closed,无作用域即无可见性。 ErrOperationScope = errors.New("cmdbus: operation lookup requires owner, project and operation scope") // ErrOperationNotRecorded 该作用域内没有此 operation 的记录(未记录 ≠ 未执行)。 ErrOperationNotRecorded = errors.New("cmdbus: no operation record in this owner and project scope") // ErrOperationQueryUnavailable 组合根未装配对账查询端口。 ErrOperationQueryUnavailable = errors.New("cmdbus: operation query unavailable") ) // 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) // ErrNotExecuted 标记"失败确定发生在任何副作用之前"的 handler 错误(F10/U7b)。 // 语义口径:只有带此标记的错误才可归 rejected(确定未执行、无副作用、回执不得 // 携带 operationID);未标记的 handler 错误一律按"已执行随后不确定"归 unknown, // 并带上本次分发已签发的 trusted operation ID 交由对账收敛。宿主不得据 // "处理方报了错"臆断未执行——那会把已写库的操作诱使成不安全重试。 // // 进程内插件(pkg/extension.Handler)当前还没有等价的声明位:插件命令的 // handler 错误一律按 unknown 对账,直到扩展面补上对应标记为止。 var ErrNotExecuted = errors.New("cmdbus: not executed") // NotExecutedError 是带"确定未执行"声明的错误包装。Error() 保留原始文案 // (回执 reason 不因标记而变形),Unwrap/Is 让 errors.Is 同时匹配 // ErrNotExecuted 与被包装的原始错误。 type NotExecutedError struct{ Err error } func (e *NotExecutedError) Error() string { return e.Err.Error() } func (e *NotExecutedError) Unwrap() error { return e.Err } func (e *NotExecutedError) Is(target error) bool { return target == ErrNotExecuted } // NotExecuted 声明该失败确定未执行(校验/权限/确认缺失/领域事务回滚等任何 // 副作用之前的拒绝)。nil 原样返回;重复标记幂等。 func NotExecuted(err error) error { if err == nil || IsNotExecuted(err) { return err } return &NotExecutedError{Err: err} } // IsNotExecuted 报告该错误是否声明了"确定未执行"。 func IsNotExecuted(err error) bool { return errors.Is(err, ErrNotExecuted) } // 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 OperationID string // overwritten by the trusted bus before handler execution AnchorMessageID string // 确认卡锚定的发起消息(内联渲染) Confirmed bool // 危险命令经确认卡放行后的执行 HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文) // HumanGrant is set only by the authenticated session path or the human // decision resolver. Client JSON and agent tool dispatch leave it nil. HumanGrant *HumanGrant } // RequiresProjectWorkDecision identifies formal RDM writes that must be bound // to an analyzed committed-message manifest before dispatch. Read views and // the separate exact-card structure issue/decision path are excluded. func RequiresProjectWorkDecision(id string) bool { for _, prefix := range []string{ "rdm.reqs.", "rdm.review.", "rdm.draft.", "rdm.cnode.", "rdm.cedge.", "rdm.conclusion.", "rdm.section.", "rdm.stage.", "rdm.change.", } { if strings.HasPrefix(id, prefix) { for _, read := range []string{"rdm.reqs.list", "rdm.reqs.exports", "rdm.reqs.underived", "rdm.review.status", "rdm.review.doc.status", "rdm.draft.proposals", "rdm.conclusion.list", "rdm.change.list"} { if id == read { return false } } return true } } switch id { case "rdm.structure.propose", "rdm.structure.revise", "rdm.docs.instantiate", "rdm.docs.delivery.approve", "rdm.doc.snapshot", "rdm.ingest", "rdm.upload.confirm", "rdm.knowledge.publish", "rdm.trace.confirm", "rdm.conflicts.resolve": return true default: return false } } // HumanGrant ties this dispatch to one persisted human response. // It is not a secret token and is not read from command params. type HumanGrant struct { CardID string ProofID string Actor string Decision string } // 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 projectWorkPreflight func(context.Context, Request) error hostProjectGuard func(context.Context, Request) error // operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。 operationQuery OperationQuery } func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) { b.projectWorkPreflight = fn } // SetHostProjectGuard installs the composition-root check applied to every // dispatch that carries a base project, before the project-work preflight and // before any handler runs. It is the single bus-level place where host-project // capability gates live, so every entrance (slash, exec, chat intent routing, // system.intent re-dispatch, agent tool) is covered by one decision. func (b *Bus) SetHostProjectGuard(fn func(context.Context, Request) error) { b.hostProjectGuard = fn } // 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 } // SetOperationQuery 装配 operationID 对账查询端口(组合根;启动期一次)。 func (b *Bus) SetOperationQuery(q OperationQuery) { b.operationQuery = q } // LookupOperation 按 owner/project 作用域查一个已记录 operation 的当前状态 // (R7/KTD6)。这是 unknown 回执的对账入口:拿到 operationID 的调用方先查 // 记录,再决定下一步,而不是重发命令。 // // 三重 fail-closed:作用域任一为空即拒(不猜、不按 owner=“”放行)、未装配端口 // 即拒、查不到即 ErrOperationNotRecorded(不推断“未执行”)。跨 owner 查询由 // 端口的 owner 等值谓词拒绝——本层只把 owner 当可信条件往下传。 func (b *Bus) LookupOperation(owner, projectID, operationID string) (OperationRecord, error) { if owner == "" || projectID == "" || operationID == "" { return OperationRecord{}, ErrOperationScope } if b.operationQuery == nil { return OperationRecord{}, ErrOperationQueryUnavailable } record, found, err := b.operationQuery.LookupOperation(owner, projectID, operationID) if err != nil { return OperationRecord{}, err } if !found || record.OperationID == "" { return OperationRecord{}, ErrOperationNotRecorded } return record, nil } // 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()) } // 基座项目门禁:所有带 HostProjectID 的分发都在这里过一道,命令种类与宿主 // 能力的匹配面由组合根决定(nil 时保持原行为)。 if req.HostProjectID != "" && b.hostProjectGuard != nil { if err := b.hostProjectGuard(ctx, req); err != nil { return receipt(agentcommand.StateRejected, "", "host project: "+err.Error()) } } if RequiresProjectWorkDecision(req.ID) { if b.projectWorkPreflight == nil { return receipt(agentcommand.StateRejected, "", "project source preflight unavailable") } if err := b.projectWorkPreflight(ctx, req); err != nil { return receipt(agentcommand.StateRejected, "", "project source preflight: "+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 { // 确认卡已创建(副作用已发生),挂起态却没落库:结果不确定,且 // 卡 ID 就是本次受理的操作标识——随 unknown 一并给出,供对账。 return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: "+err.Error()) } } else if b.strictAccountWrites { return receipt(agentcommand.StateUnknown, card.ID, "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) } // The domain handler can persist this trusted ID with its transaction/outbox. // Entropy, not the clock or caller Params, distinguishes concurrent operations. // A handler error keeps the ID only on the "executed then uncertain" path // (unknown): rejected must never carry one, and unknown without a queryable // handle would be a dead end for reconciliation. var opBytes [24]byte if _, err := rand.Read(opBytes[:]); err != nil { return receipt(agentcommand.StateUnknown, "", "operation ID unavailable") } req.OperationID = "op_" + hex.EncodeToString(opBytes[:]) payload, err := h(ctx, req) if err != nil { if IsNotExecuted(err) { // handler 声明失败发生在任何副作用之前:确定未执行,可安全归 // rejected(回执契约禁止 rejected 携带 operationID)。 return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) } // handler 已开始执行才报错:宿主无法断言"无副作用"(F10)。按"已执行 // 随后不确定"归 unknown,并带上本次分发已签发的 trusted operation ID, // 调用方据此按记录对账,而不是盲目重试。本路径不做任何自动重放。 return b.unknownAfterExecution(req, "handler failed after execution began: "+err.Error(), start) } return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload} } // unknownAfterExecution 记录并返回"已执行随后不确定"的回执(F10/U7b):审计 // 信封带 state=unknown 与已签发的 operationID(可查凭证),回执走库内受信 // 构造器 NewUnknownAfterExecution,保证 Validate() 通过且对账句柄不丢。 func (b *Bus) unknownAfterExecution(req Request, reason string, start time.Time) Response { return Response{Receipt: b.auditReceipt(req, agentcommand.StateUnknown, req.OperationID, reason, start)} } // 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", SideEffects: agentcommand.SideEffectsNone, 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", SideEffects: agentcommand.SideEffectsNone, 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)", SideEffects: agentcommand.SideEffectsNone, 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", SideEffects: agentcommand.SideEffectsNone, 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 { switch resp.Receipt.State { case agentcommand.StateCommitted: _, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed") case agentcommand.StateUnknown: // 已执行随后不确定:卡片必须落到终态(悬在 action_required 会诱导 // 二次确认,而挂起项已被消费,重放只会得到"已解决"),但不得断言 // 失败——tone 用 warn,note 写明不确定原因与对账用 operationID。 _, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "warn", uncertainNote(resp.Receipt)) default: _, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason) } } return resp } // uncertainNote 组成"结果未知"卡片的说明:不确定原因 + 对账句柄。 func uncertainNote(r agentcommand.ExecReceipt) string { if r.OperationID == "" { return r.Reason } return r.Reason + " (operation " + r.OperationID + ")" } 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", SideEffects: agentcommand.SideEffectsNone, 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 { // 审计不可用:结果标记未知,不臆断成功(四态语义)。已经签发过操作标识的 // 路径(committed/accepted/已执行后不确定)连同该标识一并返回——信封丢失 // 不等于操作没发生,调用方仍需按 operationID 对账;确定未执行的路径不带 // 标识(rejected 契约禁止带 ID,unknown 无 ID 时也无需对账)。 return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC()) } if state == agentcommand.StateUnknown && op != "" { // 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。 return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC()) } return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, SideEffects: sideEffectsFor(state), Time: b.now().UTC()} } // unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识 // (供对账),否则返回不带标识的 unknown。state=rejected 恒不带标识。 func unknownReceipt(commandID string, state agentcommand.ResultState, op, reason string, at time.Time) agentcommand.ExecReceipt { if op != "" && state != agentcommand.StateRejected { return agentcommand.NewUnknownAfterExecution(commandID, op, reason, at) } return agentcommand.ExecReceipt{CommandID: commandID, State: agentcommand.StateUnknown, Reason: reason, Time: at} } // sideEffectsFor 回执的副作用声明:总线自身发起的 rejected 均为任何副作用之前 // 的拒绝(未知命令/参数校验/项目前置/账号预检/无处理器/确认取消/handler 声明的 // not-executed),故断言 none;committed/accepted 维持今日语义(未声明)。 func sideEffectsFor(state agentcommand.ResultState) agentcommand.SideEffects { if state == agentcommand.StateRejected { return agentcommand.SideEffectsNone } return agentcommand.SideEffectsUnset } 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 }