能力自己声明配额、配置和存储
父级: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)。没有声明默认值的字段读出来是 JSONnull,绝不是""——空串曾让 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 如何按会话装配
- 输入只有会话级的东西;能力把自己的依赖当闭包持有。
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 是种子,不作为证据引用。