2026-09-23·by Sijie Wang#standmeet#architecture#design

capability-owns-quota-config-and-store

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

父级: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,解析成 descriptorbackend/capabilities/descriptor.go:19-35),在 loader.go:50-76 里翻译一次成宿主用的 mcpplugin.Manifestbackend/internal/capabilities/mcpplugin/manifest.go)。本页关心的字段:

  • quotaManifest.Quota *QuotaDeclmanifest.go:210quota.go:33-49):三个字符串——config_key(主体上哪个字段存上限,如 max_bookings)、collection(能力自己存储里哪个集合存用量,如 bookings)、subject_field(文档里哪个字段记主体,subject_id)。不完整 → nil → 不闸:宿主数不了的时候,"不闸"好过"瞎闸"(Usablequota.go:54-56)。
  • claim_gateClaimGate *ClaimGateDeclmanifest.go:215claimgate.go:29-41):tool + phrases。回答里断言动作已完成,本轮就必须带那个工具的成功回执(F-A-37:四句 "Booked" 却一个工具都没调)。内核在回合结束时判;manifest 只声明。
  • config / code_config / role_config → 三组 []ConfigField,挂在 owner / 码 / role 上(manifest.go:183-206config_field.go:32-62):keylabeltypestring|int|bool|time|string_list)、JSON 字面量的 defaultmin/max。默认值和校验只住在一个地方——声明里。role 上的值随会话冻进 RoleSnapshot(role-snapshot-frozen);notify_owner 以前是一列内核字段,贯穿九份生成文件。
  • visitor_toolsVisitorTools []string + VisitorToolRequires map[string][]stringmanifest.go:161,176):拨号之前就必须能答的"哪个工具属于哪个能力",加上按工具、带动作限定的依赖(calendar_bookcalendar:events.insert),这样只读日历只藏写工具(F-B-8)。两种 YAML 写法都接受(descriptor.go:52-81)。
  • owner_toolsOwnerTools []OwnerToolmanifest.go:138-143,179):名字 / 内部工具名 / 描述 / 原样 JSON 的 input_schema,加载时校验(loader.go:139-152)——owner-MCP 的工具表要在装配期可枚举,不能靠启动沙箱。
  • transport.sandbox.host_opsTransport.Sandbox.HostOps []stringmanifest.go:64-81):这个能力想让宿主打开的操作名字。是名字不是 socket 路径——路径由宿主推导(/run/standmeet/<id>.sockhostop/op.go:29-34),只在列表非空时以 STANDMEET_HOST_SOCKET 注入(loader.go:94-96)。
  • aclACL stringmanifest.go:113-122,226):alwaysrole_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 SCHEMAschema.go:59-89store.go:70-92)。Claim 是"插入或接管已过期"的单赢者,上限 5 分钟——F-B-15 重复订会的修法,靠主键冲突而不是代码顺序(claim.go:22-60)。
  • capconfig——按声明的设置。 Store 在构造时绑定 (kind, capID),一个句柄只能读自己那个能力的(capconfig.go:32-48)。四个挂载点、四个集合:capconfig(owner)、capconfig_codecapconfig_rolecapconfig_keyscope.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.gobooker_code_store.gobooker_quota.go
  • capquota——活计数器在能力自己的存储里。 CounterConfigKey 从主体的配置里读上限,在能力自己的 Collection 里数 SubjectField 等于主体 id 的行(backend/internal/capabilities/capquota/capquota.go:73-139)。AllowRemaining一个计数、两个输出——#135 外化时曾只恢复了闸门,quota_remaining 空了好几周(:12-16)。未设 / null / ≤ 0 → 不限(nil,绝不是 0)。内核自己的按码预约计数(db/queries/access_codes.sql 里的 CountBookingsByCodef376b0432、2026-07-25 删除)和 access_codes.max_bookings 列(b73c932bb 删除)就是它退役掉的那本账:计数现在住在行所在的地方。
  • sandbox / sandboxws——两个运行器,一个工作区管理器。 sandbox/sandbox.go 是给 owner 编排的技能脚本用的一次性 docker run--network=none --read-only --tmpfs /tmp --memory --cpus --rmSANDBOX_DRIVER=docker 否则禁用,:96-125,205-214)。sandbox/stdio.go 为长驻的 MCP 服务器拼 bubblewrapbwrap)参数:/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.tssandbox-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),组合根把它变成启动时 panicbackend/cmd/server/wire/hostdesk.go:48-62)。什么都不点的能力连 socket 都没有——ask_visitor 完全离线。与出站 dispatcher 的镜像关系和 socket 机制在 convergence-inbound-and-outboundcapsocket;此处不重复。

ACL = always——那三个

三份 manifest 写着 acl: alwaysask_visitorcorpus.retrievalsummarize_conversationbackend/capabilities/*/manifest.yaml,各自第 5–6 行)。calendar.bookmail.sendrole_granted。注册时 capload 把 always 列表交给注册表(SetAlwaysGrantedcapreg/registry.go:160-172capload/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}codeapi_keysubject.go:15-38)——它以前叫 CodeID,于是对外 key 订会时零闸门(F-B-11)。
  • 拨号前两道闸。 exposable 先查 ACL,再查可选的 SessionGatecapload/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 仍在进程内 MustRegistercapload/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.tsapi-key-booking-quota.spec.ts(对外 key 被封顶,真日历上什么都没多)、claim-needs-a-receipt.spec.tsadmin-gcal-policy-edit.spec.ts(声明的默认值 09:00–18:00 / 周一到周五 / 提前 2 天 / 缓冲 15 在编辑后仍成立)、code-member-quota-concurrent.spec.tssandbox-workspace-ttl-cron.spec.tsreal-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.mdcapability-acl-hierarchy.md 是种子,不作为证据引用。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

capability-owns-quota-config-and-store