ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Cloudflare Agents SDK 中的只读连接设计:在 setState 边界强制实施每连接读写权限

2026/9/17 6:01:31 拓冰建站 浏览量
Cloudflare Agents SDK 中的只读连接设计:在 setState 边界强制实施每连接读写权限 Cloudflare Agents SDK 中的只读连接设计在 setState 边界强制实施每连接读写权限【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agentsAgent 天然是协作式的——多个 WebSocket 客户端连接同一个 Agent 实例并共享状态。但不是每个客户端都应有权修改这个状态仪表盘查看者不应能改配置游戏观众不应能落子免费用户不应能触发昂贵的写操作。本文基于仓库设计文档 design/readonly-connections.md 完整解读 Cloudflare Agents SDK 的只读连接readonly connections特性框架如何在setState()这一单一边界上声明式地强制只读约束、内部标志如何以不可见的方式存储并穿越 Durable Object 休眠以及该设计明确接受的四类已知限制。读完本文你能掌握在 Agents SDK 中划分读写客户端的完整 API、理解其属性包装wrapping实现的底层原理并知道何时需要自己补上认证层。问题与五个设计目标Agent 的状态是共享的但修改权不应该无差别开放。框架需要一种方式把某些连接标记为只读并且在框架层而不是用户代码层强制这个限制——不能指望每个开发者在callable()方法里记得写权限检查。对应的设计文档 design/readonly-connections.md 列出了五个目标逐条都能在源码中找到落点声明式——开发者只声明哪些连接是只读的不关心强制如何发生框架边界强制——只读检查发生在setState()内部忘记在某个 callable 里做检查也不会被绕过零样板——每个callable()方法无需手写权限检查穿越休眠——Durable Object 进入休眠再醒来后只读状态依然保留对用户代码不可见——内部标志不能被意外读取、覆写也不会泄漏到connection.state中。API 表面服务端三个方法客户端一个回调服务端Agent子类暴露三个公开成员定义在 packages/agents/src/index.ts 中方法作用shouldConnectionBeReadonly(connection, ctx)连接建立时调用的钩子返回true即把该连接标记为只读setConnectionReadonly(connection, readonly?)运行时动态切换连接的只读状态readonly默认trueisConnectionReadonly(connection)查询某连接当前的只读状态其中钩子基类实现index.ts#L2866-L2871默认返回false// packages/agents/src/index.ts shouldConnectionBeReadonly( _connection: Connection, _ctx: ConnectionContext ): boolean { return false; }测试用 Agent packages/agents/src/tests/agents/readonly.ts 展示了典型的覆写方式——根据 URL 查询参数判定shouldConnectionBeReadonly( _connection: Connection, ctx: ConnectionContext ): boolean { const url new URL(ctx.request.url); return url.searchParams.get(readonly) true; }客户端侧useAgent/AgentClient只有一个新增选项选项作用onStateUpdateError(error)客户端setState()被服务端拒绝时的回调被拦截的变更型 RPC 则表现为agent.call()返回一个 rejected 的 Promise——RPC 错误走标准的{ success: false, error }响应通道见下文。双路径强制机制只读在两个入口各自把关对应状态写入的两条路径路径一客户端 setState()——消息处理器中的前置检查在 Agent 构造函数包装后的onMessage里index.ts#L2137-L2195每条CF_AGENT_STATE消息先经过只读检查再决定是否进入_setStateInternal// packages/agents/src/index.tsonMessage 包装器节选 if (isStateUpdateMessage(parsed)) { // Check if connection is readonly if (this.isConnectionReadonly(connection)) { // Send error response back to the connection connection.send( JSON.stringify({ type: MessageType.CF_AGENT_STATE_ERROR, error: Connection is readonly }) ); return; // 注意不会调用 _setStateInternal } try { this._setStateInternal(parsed.state as State, connection); } catch (e) { console.error([Agent] State update rejected:, e); connection.send( JSON.stringify({ type: MessageType.CF_AGENT_STATE_ERROR, error: State update rejected }) ); } return; }这条路径覆盖客户端代码React hook、PartySocket 等发出的agent.setState(newState)。服务端拒绝后回发CF_AGENT_STATE_ERROR消息不会调用_setStateInternal。客户端侧收到该消息后触发onStateUpdateError回调实现在 packages/agents/src/client.ts#L438-L441react.tsx中的useAgent有对等实现if (parsedMessage.type MessageType.CF_AGENT_STATE_ERROR) { this.options.onStateUpdateError?.(parsedMessage.error as string); return; }路径二服务端 setState()——公开方法内的上下文检查当callable()方法内部调用this.setState()时公开方法先从agentContext取出当前连接并检查只读状态index.ts#L2667-L2674setState(state: State): void { // Check if the current context has a readonly connection const store agentContext.getStore(); if (store?.connection this.isConnectionReadonly(store.connection)) { throw new Error(Connection is readonly); } this._setStateInternal(state, server); }抛出的Error(Connection is readonly)沿 RPC 处理器的 try/catch 传播index.ts#L2259-L2279最终以 RPC 错误响应回给调用方{ success: false, error: Connection is readonly }客户端的agent.call()因此得到 rejected Promise。为什么选 setState()而不是 RPC 层设计文档对比了四种拦截方案方案优点缺点A. 每个 callable 手写检查当前即可行、显式样板代码多、容易遗忘、漏写即安全漏洞B.callable({ mutates: true })装饰器标志按方法声明式依赖开发者主动给每个变更方法打标签C.shouldAllowRPC(connection, method)钩子灵活性最大开发者负担重白名单/黑名单两难D. 在setState()内检查单一强制点、无需改装饰器、只读连接仍可自动调用只读 RPCsetState()之前的副作用仍会执行见注意事项最终选择D因为它匹配心智模型只读就是不能改状态。只读连接依然可以调用读数据的 RPC只是不能写任何东西且不需要在 callable 方法上加任何注解。为什么是setState()而不是_setStateInternal()状态变更有两个入口客户端路径以CF_AGENT_STATE消息到达在调用_setStateInternal(state, connection)前已有自己的只读守卫服务端路径由this.setState(state)调用_setStateInternal(state, server)。把检查放在setState()里让每个入口各自负责自己的访问控制客户端消息处理器 → 检查只读 → 调_setStateInternalsetState()→ 经 context 检查只读 → 调_setStateInternal_setStateInternal→ 专注做validateStateChange数据校验、持久化与广播这还带来一个分层好处validateStateChange数据合法性和只读检查访问控制位于不同层级——访问控制先于数据检查发生。另一个原因更隐蔽stategetter 在首次访问时会通过_setStateInternal做初始化持久化initialState这类框架级操作必须绕过只读检查所以检查只能放在公开的setState()而不是_setStateInternal()里。工作流workflow为什么不受影响_workflow_updateState也会调用this.setState()但工作流执行时agentContext的 store 中没有连接connection为undefined。从 index.ts#L2670 的条件store?.connection ...可以确认没有连接时检查直接短路通过工作流更新状态永远不受只读限制。存储设计连接状态包装这是整个特性最精巧的部分——只读标志要存进连接自身的持久化附件才能穿越休眠又必须对用户代码完全隐身。三次演进SQL 表最初实现——CREATE TABLE cf_agents_readonly_connections。可用但为一个布尔值引入了 schema、查询和清理逻辑connection.setState({ _readonly: true })第一次重构——利用 lifecycle 管理、可跨休眠的每连接状态简单得多。但有致命缺陷任何不带回调形式的connection.setState({ ... })都会整体覆写_readonly就此丢失命名空间化的连接附件包装现行方案——在每个连接上包装connection.state和connection.setState()把_cf_readonly键从用户代码视野中彻底隐藏。包装如何工作Agent 第一次遇到某个连接时onConnect或onMessage见 index.ts#L2153 与 index.ts#L2290都会先调用_ensureConnectionWrapped(connection)index.ts#L2688-L2775该方法幂等依次做五件事判别state是访问器属性getter还是数据属性——用Object.getOwnPropertyDescriptor捕获原始状态访问访问器属性直接bind原 getter数据属性则把当前值快照进闭包变量因为覆写后再读connection.state会调到自己过滤后的 getter形成循环引用存储原始访问器到WeakMapConnection, { getRaw, setRaw }即实例字段_rawStateAccessors定义于 index.ts#L1242覆写connection.stategetter——从返回值中剥离_cf_readonly覆写connection.setState——用户代码设置新状态时自动把_cf_readonly合并回原始附件。访问器与数据属性的区分很关键lifecycle 用Object.defineProperties把state定义为 getter而虚拟 facet 连接可能暴露普通数据属性。不做区分的话回退写法() connection.state会在属性被替换后调到我们的覆写 getter死循环。包装后的行为源码 index.ts#L2726-L2774 可验证connection.state返回除_cf_readonly之外的一切connection.setState({ myData: foo })值形式实际落盘{ _cf_readonly: 当前值, myData: foo }connection.setState((prev) ({ ...prev, count: 1 }))回调形式拿到的prev不含_cf_readonly但标志会在返回后自动合并回去——如果用户状态被置为null则只保留标志本身setConnectionReadonly/isConnectionReadonly通过_rawStateAccessors直接读写原始标志完全绕开用户视图。标志的写入/读取实现index.ts#L2782-L2811还有个细节取消只读时不是存false而是把键整个删除rest为空则置null避免死键在连接附件里越积越多。生命周期对 connection 的要求Partyserver 通过Object.defineProperties在连接对象上定义state和setState在补丁之前这两个描述符都是configurable: false默认值无法用Object.defineProperty重定义。lifecycle 的连接包装器把这两个描述符标记为configurable。当前仓库的类型定义 packages/agents/src/lifecycle/types.ts#L48-L70 已经把这一点写进文档注释This property is configurable, meaning it can be redefined viaObject.definePropertyby downstream consumers (e.g. the Cloudflare Agents SDK) to namespace or wrap internal state storage.默认行为没有任何变化——configurable只意味着该属性可以被重定义并不意味着行为不同。为什么是_cf_readonly而不是_readonly_cf_前缀是命名空间防键冲突。没有它用户把{ _readonly: false }存进自己的连接状态就会意外关掉整个特性加前缀后误撞的概率趋近于零。键名在模块级常量CF_READONLY_KEY中只定义一次index.ts#L771const CF_READONLY_KEY _cf_readonly;保证_ensureConnectionWrapped、setConnectionReadonly、isConnectionReadonly三处引用一致。为什么不用完全独立的命名空间如{ _cf: {...}, _user: {...} }曾考虑把用户状态整体收进_user子键以求零冲突但有两个硬伤用户状态是null或原始值时得强行包一层对象MCP 传输代码把_standaloneSse、requestIds存在连接状态里也得跟着重写。单键方案_cf_readonly与用户键平级更简单、兼容所有状态类型且不要求改动任何现有使用connection.state的代码。getConnections()与休眠恢复getConnections()返回的连接就是onConnect/onMessage里被包装过的同一个 JavaScript 对象lifecycle 连接包装器对已包装的 socket 原样返回所以Object.defineProperty的覆写持续有效。休眠后 Durable Object 会为重新水合的 WebSocket 创建新包装对象此时_rawStateAccessorsWeakMap 是空的——首次onMessage调用_ensureConnectionWrapped重新捕获原始 getterindex.ts#L2682-L2687 的注释明确说明了这一恢复语义isConnectionReadonly等谓词在休眠后依然正确。注意事项明确接受的边界设计文档毫不回避这个特性的四个局限使用时必须心里有数1. callable 中的副作用仍会执行只读检查发生在this.setState()内部而不是 callable 开头。方法在调用setState()之前做的工作照样执行callable() async processOrder(orderId: string) { await sendEmail(orderId); // 会执行 await chargePayment(orderId); // 会执行 this.setState({ ... }); // 抛错——但损害已经造成 }推荐写法是把状态写入放最前面让只读连接在第一步就失败callable() async processOrder(orderId: string) { this.setState({ ... }); // 只读连接立即抛错 await sendEmail(orderId); // setState 成功才会执行 await chargePayment(orderId); }这是在setState层而非 RPC 层强制的固有取舍换来的是开发者无需给每个 callable 加注解而大多数 callable 本就是以setState为主操作的简单状态机。2. 只读是每连接的不是每用户的框架没有只读状态到用户身份的映射。同一用户开两个标签页——一个只读、一个可写——他从第二个标签页拥有完全写权限。认证与授权是开发者的职责只读连接是传输层原语不是权限系统。3. 只读不限制this.sql等其他副作用唯一被门控的是this.setState()。callable 照样可以写 SQL、发邮件、调外部 API。只读的准确含义是不能改变 Agent 的共享状态而非通用权限系统。4. HTTP 请求完全绕过只读只读是 WebSocket 概念。HTTP 请求onRequest、agentFetch、getAgentByNameagent.fetch()在 agent context 中以connection: undefined运行——从源码也能印证onRequest包装器显式传了connection: undefined[index.ts#L2129-L2135](https://link.gitcode.com/i/4d474821df84f64b45af18175af6b28b#L2129-L2135)因此setState() 的检查永远通过。这是刻意的设计callable 只属于 WebSocket——routeAgentRequest同时转发 HTTP 与 WebSocket 请求但自动callable()分发只存在于 WebSocket 消息协议中HTTP 交给 Agent 的onRequest客户端无法通过 HTTP 直接调 callable除非应用代码自己暴露端点onRequest是开发者全权编写的——与有自动 setState/RPC 处理的 WebSocket 消息处理器不同它没有任何框架行为需要门控HTTP 请求是无状态的——没有持久的连接可标记为只读每个请求独立存在标准 HTTP 认证token、header、cookie才是正确的工具。如果你的onRequest处理器调用了this.setState()它必然成功。请在onRequest实现中自己做认证授权——这是标准实践不应由只读特性吸收。未来可以加一个shouldRequestBeReadonly(request)钩子但那本质上是 HTTP 中间件/认证多数框架都留给开发者。测试覆盖测试位于 packages/agents/src/tests/readonly-connections.test.ts测试 Agent 是 packages/agents/src/tests/agents/readonly.ts 中的TestReadonlyAgent{ count: number }状态除文档列出的incrementCount变更型、getState只读型、checkReadonly、setReadonly外还包含getMyConnectionId、getConnectionUserState、setConnectionUserState值形式、setConnectionUserStateCallback回调形式等 callable专门验证包装对用户视图的隐藏与标志保留。测试矩阵覆盖shouldConnectionBeReadonly钩子按查询参数标记连接只读连接客户端setState()被拦、可写连接放行变更型 RPCincrementCount→this.setState()对只读连接被拒非变更型 RPCgetState对只读连接放行可写连接上的变更型 RPC 放行运行时动态切换只读状态状态广播仍到达只读连接它们依然能观察重连后只读状态恢复休眠穿越多连接混合只读状态并存。小结只读连接特性用一条单键存储_cf_readonly加一层属性包装在setState()与消息处理器两个入口实现了声明式的每连接读写分离服务端覆写一个钩子shouldConnectionBeReadonly即可在连接建立时定标运行时用setConnectionReadonly动态调整客户端用onStateUpdateError感知拦截。它不解决认证、不限制副作用、不覆盖 HTTP——设计文档把这些边界写得比功能本身更直白这恰是它的价值把哪些连接能改共享状态收敛成框架内的一个可验证、可休眠存取的传输层原语而不是散落在各 callable 里的口头约定。【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考