能力自己声明配额、配置和存储

更新于 · 在 sijie.xyz 查看原条目 ↗

父级:capabilities

mcp-capability-plugins 讲的是:一个内建能力 = 一份 manifest + 一个跑在沙箱里的 MCP 进程。这一页讲的是宿主过去为每个能力手写的那部分:一张邀请码能用它几次、owner 能调哪些设置、它的行存在哪、它能向宿主要什么。现在这些都是能力自己 manifest.yaml 里的声明,由一个不含任何业务词的通用子包执行——没有 "booking",没有 "working_hours"。宿主读三行然后执行;含义归能力所有。下面每一条都引用 36789537d(v0.1.31,2026-09-07)时的代码树。这就是 external-means-fully-external 落到实处:外化的能力也拥有自己的存储,否则它只是被搬了个地方。

manifest 是数据

磁盘上的形状是 backend/capabilities/<id>/manifest.yaml,解析成 descriptor(backend/capabilities/descriptor.go:19-35),在 loader.go:50-76 里翻译一次成宿主用的 mcpplugin.Manifest(backend/internal/capabilities/mcpplugin/manifest.go)。本页关心的字段:

  • quota → Manifest.Quota *QuotaDecl(manifest.go:210;quota.go:33-49):三个字符串——config_key(主体上哪个字段存上限,如 max_bookings)、collection(能力自己存储里哪个集合存用量,如 bookings)、subject_field(文档里哪个字段记主体,subject_id)。不完整 → nil → 不闸:宿主数不了的时候,"不闸"好过"瞎闸"(Usable,quota.go:54-56)。
  • claim_gate → ClaimGate *ClaimGateDecl(manifest.go:215;claimgate.go:29-41):tool + phrases。回答里断言动作已完成,本轮就必须带那个工具的成功回执(F-A-37:四句 "Booked" 却一个工具都没调)。内核在回合结束时判;manifest 只声明。
  • config / code_config / role_config → 三组 []ConfigField,挂在 owner / 码 / role 上(manifest.go:183-206;config_field.go:32-62):key、label、type(string|int|bool|time|string_list)、JSON 字面量的 default、min/max。默认值和校验只住在一个地方——声明里。role 上的值随会话冻进 RoleSnapshot(role-snapshot-frozen);notify_owner 以前是一列内核字段,贯穿九份生成文件。
  • visitor_tools → VisitorTools []string + VisitorToolRequires map[string][]string(manifest.go:161,176):拨号之前就必须能答的"哪个工具属于哪个能力",加上按工具、带动作限定的依赖(calendar_book 要 calendar:events.insert),这样只读日历只藏写工具(F-B-8)。两种 YAML 写法都接受(descriptor.go:52-81)。
  • owner_tools → OwnerTools []OwnerTool(manifest.go:138-143,179):名字 / 内部工具名 / 描述 / 原样 JSON 的 input_schema,加载时校验(loader.go:139-152)——owner-MCP 的工具表要在装配期可枚举,不能靠启动沙箱。
  • transport.sandbox.host_ops → Transport.Sandbox.HostOps []string(manifest.go:64-81):这个能力想让宿主打开的操作名字。是名字不是 socket 路径——路径由宿主推导(/run/standmeet/<id>.sock,hostop/op.go:29-34),只在列表非空时以 STANDMEET_HOST_SOCKET 注入(loader.go:94-96)。
  • acl → ACL string(manifest.go:113-122,226):always 或 role_granted;loader 把其他任何值(包括缺失)都映射成 role_granted——缺一句声明永远不会把能力向所有人打开(loader.go:100-107)。

子包——每一个都是宿主曾经手写的机制

  • capstore——自己的 schema。 每个 (kind, id) 一个 Postgres schema——mcp_<id> / connector_<id> / microsite_<id>——里面一张通用的 records(id, collection, doc jsonb, created_at) 表,加一张 claims(collection, key, expires_at) 表,主键 (collection, key)(backend/internal/capabilities/capstore/store.go:40-68)。文档是不透明的;查询 = 集合 + JSON 包含。schema 名永远从宿主信任的那一对推导,并且拼进 DDL 之前必须过 assertDroppable(保留前缀、[a-z0-9_] 后缀、核心 schema 黑名单);Drop 是整个代码库里唯一的 DROP SCHEMA(schema.go:59-89,store.go:70-92)。Claim 是"插入或接管已过期"的单赢者,上限 5 分钟——F-B-15 重复订会的修法,靠主键冲突而不是代码顺序(claim.go:22-60)。
  • capconfig——按声明的设置。 Store 在构造时绑定 (kind, capID),一个句柄只能读自己那个能力的(capconfig.go:32-48)。四个挂载点、四个集合:capconfig(owner)、capconfig_code、capconfig_role、capconfig_key(scope.go:29-76)。声明两个方向都是权威:不再声明的已存 key 永不返回;写入未声明的 key 直接拒绝(capconfig.go:83-103,183-206)。没有声明默认值的字段读出来是 JSON null,绝不是 ""——空串曾让 booker 未设置的按码上限每次读都失败,把 calendar_book 对有权限的访客藏掉(defaultOf,:105-122)。这关掉了设置的双胞胎:在 cc5c1db47(2026-07-31)之前宿主自带一份预约策略,而且已经和沙箱那份漂开了(18:00/15 vs 17:00/0);aab8abe90(2026-08-01)把按码字段变成声明,从组合根删掉了 booker_code_config.go、booker_code_store.go、booker_quota.go。
  • capquota——活计数器在能力自己的存储里。 Counter 按 ConfigKey 从主体的配置里读上限,在能力自己的 Collection 里数 SubjectField 等于主体 id 的行(backend/internal/capabilities/capquota/capquota.go:73-139)。Allow 和 Remaining 是一个计数、两个输出——#135 外化时曾只恢复了闸门,quota_remaining 空了好几周(:12-16)。未设 / null / ≤ 0 → 不限(nil,绝不是 0)。内核自己的按码预约计数(db/queries/access_codes.sql 里的 CountBookingsByCode,f376b0432、2026-07-25 删除)和 access_codes.max_bookings 列(b73c932bb 删除)就是它退役掉的那本账:计数现在住在行所在的地方。
  • sandbox / sandboxws——两个运行器,一个工作区管理器。 sandbox/sandbox.go 是给 owner 编排的技能脚本用的一次性 docker run(--network=none --read-only --tmpfs /tmp --memory --cpus --rm;SANDBOX_DRIVER=docker 否则禁用,:96-125,205-214)。sandbox/stdio.go 为长驻的 MCP 服务器拼 bubblewrap(bwrap)参数:/plugin 只读、/workspace 在已配置时可写、除非 allow_net 否则 --unshare-net、--die-with-parent、宿主 socket 按路径 bind 进去(:37-61,78-126)。sandboxws 拥有按 conversation id 命名的每会话工作区目录,惰性创建,按 TTL(time to live)清扫、删除前重新 stat(sandboxws/manager.go:55-69,106-153)。守卫:real-third-party-mcp-sandboxed.spec.ts、sandbox-workspace-ttl-cron.spec.ts。
  • hostdesk——入站收口。 能力没有网络;它只能通过自己声明过的具名 op 触达宿主。hostdesk.Collect 从每个域的 facade 加两个按能力的来源(它绑定的存储、它绑定的配置)收集 hostop.Op{Name, Description, Invoke}(backend/internal/routes/hostdesk/hostdesk.go:74-85);Serve 在推导出的 socket 上只打开点到名的那些,点了不存在的名字就报错(:134-150),组合根把它变成启动时 panic(backend/cmd/server/wire/hostdesk.go:48-62)。什么都不点的能力连 socket 都没有——ask_visitor 完全离线。与出站 dispatcher 的镜像关系和 socket 机制在 convergence-inbound-and-outbound 与 capsocket;此处不重复。

ACL = always——那三个

三份 manifest 写着 acl: always:ask_visitor、corpus.retrieval、summarize_conversation(backend/capabilities/*/manifest.yaml,各自第 5–6 行)。calendar.book 和 mail.send 是 role_granted。注册时 capload 把 always 列表交给注册表(SetAlwaysGranted,capreg/registry.go:160-172;capload/capreg_mcp_app_register.go:59),冻结的快照用 RoleSnapshot.AllowsCapability(capID, aclAlways) 回答暴露与否——aclAlways || allowedTools 含它(access/entity/role_snapshot.go:198-203)。同一份列表喂给 DockableCapabilityIDs,管理面板不再能给出一个访客永远看不到的 dock 按钮(F-D-13)。

一个 Binding 如何按会话装配

flowchart
  inp["AssembleInput: RoleSnapshot, OwnerID, Mode, Subject(kind, id), Visitor, ConversationID"] --> g1{"mcpAppGranted: acl always or role grants it?"}
  g1 -- "no" --> hid["ErrHidden"]
  g1 -- "yes" --> g2{"SessionGate: connector connected and capquota.Allow(subject scope)?"}
  g2 -- "quota exhausted" --> qx["ErrQuotaExhausted (wraps ErrHidden)"]
  g2 -- "not connected" --> hid
  g2 -- "yes" --> dial["dialAndList: bwrap child, tools/list"]
  dial -- "dial fails" --> hid
  dial --> b["Binding: Tools, State(quota_remaining), ClaimGate, Close"]
  • 输入只有会话级的东西;能力把自己的依赖当闭包持有。AssembleInput{RoleSnapshot, OwnerID, Mode, Subject, Visitor, ConversationID}(backend/internal/capabilities/capreg/types.go:59-72)。Subject{Kind, ID} 是 code 或 api_key(subject.go:15-38)——它以前叫 CodeID,于是对外 key 订会时零闸门(F-B-11)。
  • 拨号前两道闸。 exposable 先查 ACL,再查可选的 SessionGate(capload/capreg_mcp_app.go:275-289)。配额闸按 manifest 里声明的 Quota 逐个构建,并在组合根把 capreg.Subject 翻译成 capconfig.Scope——唯一同时看得见两个包的地方,未知 kind 没有回退(backend/cmd/server/axiscap/code_config.go:125-201)。耗尽返回 ErrQuotaExhausted,它包着 ErrHidden,所以聊天面照旧藏工具,而 HTTP 面能说出原因(types.go:28-43)。
  • 结果。 Binding{Tools, State, ClaimGate, Close}(types.go:94-103):State.QuotaRemaining 由同一个计数器的 Remaining 填;ClaimGate 作为数据带着走,内核在回合结束时判一次(capreg/claimgate.go)。dialAndList 把拨号失败折成 ErrHidden,但先记下真实原因——生产环境曾经 tools:0 而日志里什么都没有(F-A-1,capreg_mcp_app.go:242-273)。

长期成立的点与诚实的天花板

  • 声明允许过时,但不允许悄悄过时。 VisitorTools 每次拨号都和真实的 tools/list 对账;不一致记日志,以真实列表为准(manifest.go:148-161)。
  • 配额按主体,不按会话。 匿名的 public/BYOAI(bring-your-own-AI)会话没有主体,永远不按用量闸(subject.go:35-38);那些层的上限是 gas,不是次数(feature-floor 两者都列了)。
  • 读失败时闸门 fail closed,并且说出来。 配额读取失败会藏掉工具并记一条 warn,写明 cap + 主体(code_config.go:182-190);在那行之前,被藏的工具和"没授权"、"没连接器"长得一模一样。
  • 占位仍然要 manifest 点名。 capstore.Claim 存在,但只有 calendar.book 点了 capstore.claim / capstore.release;任何新的"先看再做"能力都得按名字要它们。
  • 只有内建能力走这条路。 job-loop 三件套、skill runner、ext-mcp 和 openapi agent-tools 仍在进程内 MustRegister(capload/capreg_register.go:39-50),这些都没声明;那次迁移是 capabilities 上点名的未完结构工作。

已实现 2026-07-25 → 2026-08-01,延伸至 2026-08-20。 f376b0432 把内核的预约账本抽干、让能力拥有自己的存储;cc5c1db47(2026-07-31)加了 capconfig、删了宿主那份设置双胞胎;aab8abe90(2026-08-01)加了 capquota + 按码声明;5cb8d8464(2026-08-01)落了 hostdesk + hostop.Op;API key 的主体改名跟着 F-B-11(2026-08-20)。守卫:chat-book-quota-exhausted.spec.ts、api-key-booking-quota.spec.ts(对外 key 被封顶,真日历上什么都没多)、claim-needs-a-receipt.spec.ts、admin-gcal-policy-edit.spec.ts(声明的默认值 09:00–18:00 / 周一到周五 / 提前 2 天 / 缓冲 15 在编辑后仍成立)、code-member-quota-concurrent.spec.ts、sandbox-workspace-ttl-cron.spec.ts、real-third-party-mcp-sandboxed.spec.ts。

来源:#135 外化(2026-07)与 F-B-8 / F-B-11 / F-B-15 / F-A-37 发现;2026-09-07 对照 standmeet-new main 36789537d 核验。docs/design/hostdesk.md 和 capability-acl-hierarchy.md 是种子,不作为证据引用。

相关笔记