ZeroClaw 工具共享状态所有权契约(ADR-004)深度解析:多客户端环境下的 Handle 模式、ClientId 隔离与配置重载语义
【免费下载链接】zeroclawFast, small, and fully autonomous AI personal assistant infrastructure, any OS, any platform — deploy anywhere, swap anything 🦀项目地址: https://gitcode.com/gh_mirrors/ze/zeroclaw
导读:ZeroClaw 是一个单守护进程(daemon)同时服务多个客户端的自治 AI 助手基础设施,其工具(Tool)系统天然需要持有跨请求存活的共享状态。ADR-004 为"工具能否拥有长期共享状态、身份由谁分配、校验在何时运行、哪些状态必须按客户端隔离、配置重载如何使缓存失效"这五个问题确立了正式契约。读完本文,你将掌握 ZeroClaw 工具系统的
Arc<RwLock<T>>Handle 模式、ClientId身份抽象、四阶段生命周期,以及配置变更驱动的重校验语义,并能在新增工具时直接套用这套规范避免多租户数据泄漏。
背景:单守护进程多客户端架构下的状态管理难题
ZeroClaw 的工具在一个多客户端环境中执行:一个守护进程同时为多个连接的客户端提供服务。这意味着任何工具持有的共享状态,都可能被不同客户端并发访问。在 ADR-004 之前,仓库中已经自然生长出三个典型的"长期共享状态"实现,它们遵循了相似但未成文的模式:
| 共享状态句柄 | 代码位置 | 内部类型 |
|---|---|---|
DelegateParentToolsHandle | src/tools/mod.rs | Vec<Arc<dyn Tool>> |
ChannelMapHandle | src/tools/reaction.rs | HashMap<String, Arc<dyn Channel>> |
CanvasStore | src/tools/canvas.rs | HashMap<String, CanvasEntry> |
这三个实现暴露了四个未解决的问题:
- 缺少按客户端隔离:
DelegateParentToolsHandle持有委托代理的父工具列表,但没有任何 per-client 命名空间;ChannelMapHandle是全局共享的通道映射;CanvasStore的 canvas ID 是纯字符串,同样没有客户端命名空间。 - 客户端身份仅依赖 IP:当前网关层通过
parse_client_ip/forwarded_client_ip/client_key_from_request从请求头或对端地址推导客户端标识(见 src/gateway/mod.rs),这在多客户端共享 NAT 地址或经过代理转发时无法可靠区分不同客户端。 - 安全策略粒度不匹配:
SecurityPolicy是按 agent 而不是按客户端作用域的(见 src/security/policy.rs),客户端隔离与 agent 级策略是正交的两个维度。 - 工作区切换是全局的:
WorkspaceManager提供了一定隔离,但工作区切换作用于全局,无法覆盖需要 per-client 状态的场景。
随着工具面不断扩张、并发客户端增多,ADR-004 将这些松散的模式固化为一份明确契约,覆盖**所有权(ownership)、身份(identity)、隔离(isolation)、生命周期(lifecycle)与重载行为(reload)**五个方面,防止新工具引入客户端间数据泄漏、配置重载后的过期状态或不一致的初始化时序。
决策一:所有权 —— 工具可以持有共享状态,但必须遵循 Handle 模式
结论:可以。工具可以(MAY)拥有长期存活的共享状态,前提是遵循既定的handle 模式:用Arc<RwLock<T>>(或Arc<parking_lot::RwLock<T>>)包裹状态,并暴露一个可克隆的句柄类型。
这一模式在仓库中已有三个独立实现作为先例。例如ChannelMapHandle在 src/tools/poll.rs、src/tools/reaction.rs 和 src/tools/ask_user.rs 中被一致地定义为:
pub type ChannelMapHandle = Arc<RwLock<HashMap<String, Arc<dyn Channel>>>>;而CanvasStore则把锁内层更进一步封装成结构体,隐藏Arc<RwLock<HashMap<String, CanvasEntry>>>的实现细节,同时通过#[derive(Clone)]让句柄可以廉价克隆共享(src/tools/canvas.rs):
/// Shared canvas store — holds all active canvases. /// /// Thread-safe and cheaply cloneable (wraps `Arc`). #[derive(Clone)] pub struct CanvasStore { inner: Arc<RwLock<HashMap<String, CanvasEntry>>>, }ADR-004 为需要共享状态的工具规定了三条 MUST:
- 定义具名句柄类型别名,例如
pub type FooHandle = Arc<RwLock<T>>; - 在构造时接收句柄,而不是在工具内部创建全局状态。这一点在
all_tools_with_runtime()中体现得非常典型:PollTool通过PollTool::new(security.clone(), Arc::clone(&channel_map_handle))注入共享句柄,且注释明确说明"使用晚期绑定的 channel map handle"(src/tools/mod.rs);ReactionTool和AskUserTool同样在构造后通过channel_map_handle()获取句柄,稍后由start_channels填充(src/tools/mod.rs); - 在句柄类型的 doc 注释中记录并发契约。例如
DelegateParentToolsHandle的注释写明"调用方可以在构造后推送额外工具(如 MCP 包装器)"(src/tools/mod.rs)。
严禁(MUST NOT)使用静态可变状态(lazy_static!、带内部可变性的OnceCell)存放 per-request 或 per-client 数据。静态可变状态无法被测试替换、无法被注入、也无法在配置重载时重新构建,与"构造时注入句柄"的契约直接冲突。
决策二:身份 —— ClientId 由守护进程分配,工具不得自造身份键
守护进程(daemon)应当(SHOULD)提供客户端身份,工具不得自行构造客户端身份键。
当前实现用原始 IP 作为客户端键:client_key_from_request在trust_forwarded_headers时优先解析X-Forwarded-For/X-Real-IP头,否则回退到peer_addr的 IP,最后兜底为"unknown"(src/gateway/mod.rs)。这个方案的缺陷正是 ADR 指出的两点:多个客户端共享 NAT 地址时无法区分;经过代理的连接中转发头可被伪造。
ADR-004 提出引入一个新的ClientId类型,要求满足Clone + Eq + Hash + Send + Sync,由网关层在连接建立时生成。其契约包括四条:
- 由网关层在连接建立时生成;
- 对工具不透明——工具不得解析或从值中推导含义;
- 在单个客户端会话生命周期内保持稳定;
- 通过执行上下文(execution context)传递,而非全局存储。
ClientId会作为工具执行上下文的一部分,传给那些需要 per-client 状态命名空间的工具;不需要按客户端隔离的工具(例如启动后不可变的工具注册表)可以直接忽略它。该抽象的价值在于:把工具与传输层细节解耦,未来无论引入 token、证书还是其他身份机制,工具代码都无需变动(见"影响评估"中的可演化性)。
决策三:生命周期 —— 校验只在注册期和配置变更时运行
ADR-004 将工具生命周期划分为四个明确阶段,消除了"校验究竟何时运行"的歧义:
- 构造(Construction):工具携带句柄和配置被实例化。此阶段不得进行任何 I/O 或校验。在 all_tools_with_runtime() 中可以看到,所有工具都是通过
XxxTool::new(...)形式同步构造后统一放入tool_arcs向量的。 - 注册(Registration):工具通过
all_tools_with_runtime()注册进工具注册表。此时工具可以(MAY)执行一次性启动校验——例如检查必需凭据是否存在、验证外部服务连通性。仓库中已存在这类"注册期快速失败"的实例:Microsoft 365 工具在client_credentials认证流程下若client_secret为空,会直接输出错误日志拒绝注册(src/tools/mod.rs)。 - 执行(Execution):工具处理单个请求。不得(MUST NOT)在执行阶段调用中进行阻塞式校验;校验结果应缓存在句柄状态中,执行时走快速路径(fast path)检查。
- 关闭(Shutdown):守护进程停止。持有开放资源的工具应当(SHOULD)通过
Drop或显式 shutdown 方法优雅清理。
配置变更信号触发时,工具需要回到注册期语义重新执行校验(详见决策五)。
决策四:隔离 —— 哪些状态必须按客户端隔离?
状态被划分为两类,隔离要求截然不同。
必须按客户端隔离(MUST be isolated per client):
- 安全敏感状态:凭据、API 密钥、配额、限流计数器、per-client 授权决策;
- 用户专属会话数据:对话上下文、用户偏好、工作区作用域内的文件路径。
隔离机制:持有 per-client 状态的工具必须用ClientId作为内部 map 的键。Handle 模式天然支持这一点——只需在RwLock内部使用HashMap<ClientId, T>即可,例如:
pub type ClientScopedHandle = Arc<RwLock<HashMap<ClientId, ClientState>>>;可以跨客户端共享(MAY be shared),但需要命名空间前缀:
- 广播/展示类状态:canvas 帧(
CanvasStore)、通知通道(ChannelMapHandle); - 只读参考数据:工具注册表、静态配置、模型元数据。
当共享状态使用字符串键(如 canvas ID、通道名)时,工具应当(SHOULD)支持可选的命名空间前缀(例如{client_id}:{canvas_name}格式),以便在需要时提供 per-client 隔离,同时不强制广播场景也必须隔离。
工具不得(MUST NOT)将 per-client 密钥存入共享(非隔离)状态结构。
以CanvasStore为例,其现状是纯字符串键的HashMap<String, CanvasEntry>(src/tools/canvas.rs),每个CanvasEntry内部包含当前帧、最多 50 帧的历史记录和容量为 64 的broadcast::Sender。canvas 帧内容通过 WebSocket 广播给所有订阅客户端——这正是"广播/展示状态可跨客户端共享"的典型场景,但若要支持多客户端隔离渲染,就需要按 ADR 建议改为{client_id}:{canvas_id}的键结构。
决策五:重载语义 —— 配置变更如何使缓存失效?
通过哈希比较检测到的配置变更,必须使缓存的校验状态失效。
重载契约如下:
- 守护进程在启动时和每次配置重载事件后,对工具相关的配置片段计算哈希;
- 当哈希变化时,守护进程向受影响的工具发出信号,要求其重新运行注册期校验;
- 工具在收到信号后必须(MUST)将缓存的校验结果视为过期,并在下一次执行前重新校验。
ADR-004 给出了具体的失效范围映射表:
| 配置变更 | 失效范围 |
|---|---|
| 凭据/密钥轮换 | 每工具校验缓存;per-client 凭据状态 |
| 工具启用/禁用 | 通过all_tools_with_runtime()全量重建工具注册表 |
| 安全策略变更 | 重新推导SecurityPolicy;per-agent 策略状态 |
| 工作区目录变更 | WorkspaceManager状态;依赖文件路径的工具状态 |
| Provider 配置变更 | 依赖 Provider 的工具重新校验连通性 |
同时,工具可以(MAY)在配置重载期间保留非安全性的共享状态(如 canvas 内容、通道订阅),除非该重载明确影响到这些状态的有效性。
仓库中已存在"配置变更检测"的工程先例:通道层通过config_file_stamp(记录文件的修改时间modified与长度len,见 src/channels/mod.rs)比对配置文件是否变化,变化后重新解析并解密密钥、应用环境变量覆盖(load_runtime_defaults_from_config_file,src/channels/mod.rs)。这与 ADR 中"启动时与重载后各计算一次指纹、变化即触发重校验"的思路一致——差异在于 ADR 将指纹从文件元数据升级为"工具相关配置片段的哈希",粒度更精细。SecurityPolicy的from_config(src/security/policy.rs)每次都会基于AutonomyConfig与workspace_dir重建完整策略(含新的ActionTracker),正是"安全策略变更 → 重新推导SecurityPolicy"这条规则的落地实现。
影响评估:契约带来的收益与代价
正面影响(Positive)
- 一致性:所有新工具遵循同一 Handle 模式,共享状态可被发现、可被审计;
- 安全性:安全敏感状态的 per-client 隔离防止多租户场景下的数据泄漏;
- 清晰性:显式的生命周期阶段消除了"校验何时运行"的歧义;
- 可演化性:
ClientId抽象将工具与传输层细节解耦,为未来支持 token、证书等身份机制铺路。
负面影响(Negative)
- 迁移成本:现有工具(
CanvasStore、ReactionTool)可能需要重构以接受ClientId并为其状态加命名空间; - 复杂度:原本是简单单例的工具,即使当前只有一个客户端,也必须考虑多客户端语义;
- 性能:per-client 键控在每次访问时增加一次哈希查找,但与 I/O 成本相比可忽略。
中性影响(Neutral)
- 工具注册表在启动后保持不可变——本 ADR不改变这一不变式;
SecurityPolicy仍按 agent 作用域存在——本 ADR 明确记录:客户端隔离与 agent 级策略是正交的,不应混为一谈。
给工具开发者的落地清单
结合 ADR-004 与仓库现状,新增需要共享状态的工具时应依次回答五个问题:
- 状态是共享还是客户端私有?共享状态 → 定义具名
FooHandle = Arc<RwLock<T>>;客户端私有 →HashMap<ClientId, T>作为锁内结构。 - 句柄从哪里来?在
all_tools_with_runtime()中通过构造函数注入,或先创建空句柄再延迟填充(参考PollTool/ReactionTool的晚期绑定模式,src/tools/mod.rs)。 - 校验何时做?构造阶段零 I/O;一次性校验放在注册期;执行期只做缓存的快速路径检查。
- 字符串键是否可能冲突?支持
{client_id}:{name}命名空间前缀;绝不在共享结构中存放 per-client 密钥。 - 配置重载后什么会过期?遵循失效范围表:密钥轮换清空凭据缓存、工具开关全量重建注册表、策略/工作区/Provider 变更分别触发对应重校验。
这套契约的完整规范原文见 docs/architecture/adr-004-tool-shared-state-ownership.md,相关的工具特质定义见 src/tools/traits.rs(Tooltrait 的name/description/parameters_schema/execute接口),共享状态实现的三个样本分别位于 src/tools/mod.rs、src/tools/reaction.rs 与 src/tools/canvas.rs。
【免费下载链接】zeroclawFast, small, and fully autonomous AI personal assistant infrastructure, any OS, any platform — deploy anywhere, swap anything 🦀项目地址: https://gitcode.com/gh_mirrors/ze/zeroclaw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考