第 7 章 · 01 API 纪律与 CI 守卫


第 7 章 · 01 API 纪律与 CI 守卫

本节摘要:本节精读 Agent Canvas 的一条工程铁律——前端禁止直接用 axios/fetch 调 agent-server,必须走 @openhands/typescript-client。这条纪律不靠口头约定,而是由一个 80 行的 CI 守卫测试 src/api/no-direct-agent-server-calls.test.ts 强制执行:它递归扫描 src/ 下全部 .ts/.tsx 源码,用正则匹配"直接用共享 axios 实例、直接 new HttpClient、裸调 axios/fetch 打 /api/ 路径"等违反模式,发现一处整个测试失败、CI 红灯。我们逐段拆解这个测试的扫描、排除与例外清单机制,再对照 AGENTS.md 中"API Access Rules"的原文,理解这条纪律背后的三个设计动机:防接口漂移、类型安全、统一缓存与重试层。

内容来源:原项目源码 src/api/no-direct-agent-server-calls.test.ts(80 行)与 AGENTS.md 的"API Access Rules"章节(第 368-462 行),逐段精读。

⚠️ 注意:本节的"CI 守卫"不是 ESLint 规则,而是一个普通 Vitest 单元测试。它跑在 npm test 里,和业务测试同一套流水线——意味着任何人本地跑测试就能提前发现违规,不必等 CI。把架构约束写成测试,是本仓库最值得抄走的一招。

学习目标

阅读完本节,你应当能够:

  1. 复述 API 铁律的原文表述:agent-server 的调用必须走 @openhands/typescript-client,禁止裸 axios/fetch。
  2. 逐段讲清守卫测试怎么收集源码文件、怎么用六组正则识别违规模式、怎么处理例外白名单。
  3. 列出三个被允许直连 HTTP 的例外文件,并解释它们为什么例外。
  4. 解释这条纪律的三个设计动机:接口漂移风险、类型安全、统一基础设施层。
  5. 对照 AGENTS.md 的两条规则(Rule 1 本地 agent-server、Rule 2 云端代理),理解"本地走 client、云端走 callCloudProxy"的分流。

一、铁律是什么:一句话与它的执行者

AGENTS.md 的"API Access Rules"开头写道:

Two strict conventions govern every REST call in the frontend. Violations break CI via src/api/no-direct-agent-server-calls.test.ts.

翻译过来:两条严格约定管住前端每一个 REST 调用,违反者由守卫测试在 CI 里当场击落。Rule 1 的核心表述:

All calls that target the local agent-server (/api/*, /server_info, /sockets) must go through typed client classes from @openhands/typescript-client, never raw axios, fetch, or the legacy shared openHands axios instance.

也就是说,凡是打向本地 agent-server 的请求——/api/* 路径、/server_info 信息端点、/sockets WebSocket 入口——都必须经过类型化 client 类。裸的 axiosfetch、以及历史遗留的共享 openHands axios 实例,全部禁止。

AGENTS.md 还给出了正反对照的代码示例:

// CORRECT const data = await new ConversationClient( getAgentServerClientOptions(), ).getConversation(id); const file = await new FileClient( getAgentServerClientOptions(), ).downloadTextFile(path); // WRONG -- raw axios/fetch calls fail the no-direct-agent-server-calls.test.ts guard const data = await axios.get(`${host}/api/conversations/${id}`); const data = await fetch(`/api/conversations/${id}`);

注意正确写法的两个细节:client 的构造参数不手拼,统一来自 getAgentServerClientOptions()(定义在 src/api/agent-server-client-options.ts);这个 helper 从活动后端注册表(getEffectiveLocalBackend())读 host、会话 API key 与工作目录,调用方从不硬编码 URL 或令牌。第 3 章接入坞的后端切换能力,正是靠这一层才得以对业务代码透明。

💡 驾驶舱要点:铁律管的不只是"用不用 fetch",更是"URL 和凭据从哪来"。就算你绕过 fetch 手写了 XMLHttpRequest,只要 URL 不是从后端注册表解析来的,多后端切换就会在你这里断掉。守卫测试拦的是最常见的三种违规形态,架构约束则靠 helper 函数收口。

二、80 行守卫测试:扫描、正则、例外清单

2.1 文件收集:递归遍历 + 三层过滤

守卫测试的第一段是"找出所有要检查的文件":

const SRC_ROOT = join(process.cwd(), "src"); const EXCLUDED_SEGMENTS = new Set(["mocks", "routeTree.gen.ts"]); const ALLOWED_AD_HOC_HTTP_FILES = new Set([ "api/automation-service/automation-service.api.ts", "api/cloud/proxy.ts", "api/main-app-auth.ts", ]); function collectSourceFiles(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const fullPath = join(dir, entry.name); // Normalize to forward slashes so the path matches ALLOWED_AD_HOC_HTTP_FILES // entries on Windows where path.relative() returns backslash-separated paths. const relPath = relative(SRC_ROOT, fullPath).replace(/\\/g, "/"); if (entry.isDirectory()) { if (EXCLUDED_SEGMENTS.has(entry.name)) return []; return collectSourceFiles(fullPath); } if (EXCLUDED_SEGMENTS.has(entry.name)) return []; if (!/\.(ts|tsx)$/.test(entry.name)) return []; if (/\.(test|spec)\.(ts|tsx)$/.test(entry.name)) return []; return [relPath]; }); }

三层过滤各有用意:

过滤 对象 原因
EXCLUDED_SEGMENTS mocks/ 目录、routeTree.gen.ts mock 处理器本来就靠 http.get/post 描述拦截规则;路由树是 TanStack Router 生成的代码,人说了不算
只留 .ts/.tsx 非 TS 文件 源码纪律只管 TypeScript
排除 .test/.spec 测试文件 测试里 mock axios 是家常便饭,不该被拦

还有一处跨平台细节值得咀嚼:第 18 行把 path.relative() 的结果里的反斜杠统一替换成 /。Windows 上 relative() 返回 api\cloud\proxy.ts,直接和白名单里的 api/cloud/proxy.ts 比较永远不相等——白名单会静默失效。一行 replace(/\\/g, "/") 守住了 Windows 开发者的例外路径。这种"正则匹配路径先归一化分隔符"的坑,任何做全仓库扫描工具的团队都会踩一次。

2.2 六组正则:识别每一种"绕过 client"的姿势

测试主体对每个文件跑六组正则,每组对应一种违规形态:

describe("agent-server API access", () => { it("uses typed @openhands/typescript-client access instead of ad-hoc HTTP", () => { const violations = collectSourceFiles(SRC_ROOT).flatMap((relPath) => { const source = readFileSync(join(SRC_ROOT, relPath), "utf8"); const fileViolations: string[] = []; if (/openHands\s*\./.test(source)) { fileViolations.push("uses the shared axios instance directly"); } if (/\bcreateHttpClient\s*\(/.test(source)) { fileViolations.push("uses createHttpClient directly"); } if ( /from\s+["']@openhands\/typescript-client\/client\/http-client["']/.test( source, ) ) { fileViolations.push("imports the low-level SDK HttpClient directly"); } if (/\bnew\s+HttpClient\s*\(/.test(source)) { fileViolations.push("constructs HttpClient directly"); } if ( (/\baxios\s*\(/.test(source) || /\baxios\s*\.\s*(?:create|get|post|put|patch|delete|request)\s*\(/.test( source, )) && !ALLOWED_AD_HOC_HTTP_FILES.has(relPath) ) { fileViolations.push("uses axios directly for HTTP calls"); } if ( /\bfetch\s*\([\s\S]{0,200}['"`]\/api\//.test(source) && !ALLOWED_AD_HOC_HTTP_FILES.has(relPath) ) { fileViolations.push("calls an /api path with fetch directly"); } return fileViolations.map((violation) => `${relPath}: ${violation}`); }); expect(violations).toEqual([]); }); });

逐组解读:

  1. /openHands\s*\./——使用历史遗留的共享 axios 实例。openHands 是旧代码里全局共享的 axios 封装,新代码一律不许碰。
  2. /\bcreateHttpClient\s*\(/——直接调用工厂函数造 HTTP 客户端,绕过统一配置。
  3. 导入语句正则——从 @openhands/typescript-client/client/http-client 这个深层子路径导入底层 HttpClient。SDK 暴露的低层入口,应用代码不许 import。
  4. /\bnew\s+HttpClient\s*\(/——就算换种方式 import,直接 new HttpClient() 也拦。第 3、4 条组合拳封死了"拿到低层客户端自己玩"的路径。
  5. axios 正则——axios(...) 调用式与 axios.get/post/put/patch/delete/request(...) 方法式都匹配;匹配后查白名单,例外文件放行。
  6. fetch 正则——只拦 fetch( 后 200 个字符内出现 "/api/ 字符串字面量的情形。这是精准打击:fetch 本身是合法的(比如请求第三方源、静态资源),只有用它打本仓库 /api/ 路径才算抢 client 的活。[\s\S]{0,200} 允许跨行——URL 拼接常折行写。

最后 expect(violations).toEqual([]) 一锤定音:任何一条违规都会让断言失败,错误信息里带完整文件路径与违规原因(如 api/foo.ts: uses axios directly for HTTP calls),开发者一眼定位。

💡 驾驶舱要点:这个测试的聪明之处在于成本极低、覆盖极广。80 行代码、纯字符串正则、不启动浏览器不发请求,却能约束几万行源码的架构一致性。对比"用 ESLint 自定义规则 + 插件"的方案,一个普通测试文件不需要发布、不需要配 parser,任何人 clone 仓库即得同样的约束力。架构纪律最怕"写在 wiki 里没人看",写成测试就变成了"每次 npm test 都看一遍"。

三、例外路径清单:谁被允许直连,为什么

白名单 ALLOWED_AD_HOC_HTTP_FILES 只放了三个文件,每一个都是"基础设施自身"而非业务调用:

例外文件 直连原因
api/automation-service/automation-service.api.ts automation 后端是独立的 FastAPI 服务(挂载在 /api/automation 前缀下),不在 agent-server 的 OpenAPI 契约内,typescript-client 天生管不到它,只能自建 axios 封装
api/cloud/proxy.ts 云代理的信封 POST 本身——它就是 callCloudProxy 的实现,是"被授权直连"的那一个函数
api/main-app-auth.ts 本地主机应用(main-app)的认证端点,属于宿主环境基础设施,同样不在 agent-server 契约内

规律很清晰:例外只给"代理/基础设施层",业务代码零例外。automation、云代理、宿主认证三者共同点是——它们不是 agent-server 的 API 面,typescript-client 的契约里没有它们,强行套用反而要造假类型。

另外要澄清一个容易误解的点:守卫测试拦的是前端源码对本地 agent-server 的直连,不等于"这些域名全走 typescript-client"。AGENTS.md 的 Rule 2 补上了另一半:浏览器调云端后端(app.all-hands.dev*.prod-runtime.all-hands.dev)也禁止直连——因为这些源对 localhost 不开 CORS——必须走 callCloudProxy(),由本地 agent-server 在服务端转发:

import { callCloudProxy } from "../cloud/proxy"; // CORRECT -- cloud endpoint const result = await callCloudProxy<ResponseType>({ backend, method: "GET", path: `/api/v1/app-conversations/search?${params}`, });

于是整个前端的服务层形成统一分流模式,AGENTS.md 称之为 standard cloud/local branch pattern:

if (getActiveBackend().backend.kind === "cloud") { return callCloudProxy({ backend: active, ... }); } return new ConversationClient(getAgentServerClientOptions()).someMethod(...);

云后端走代理信封,本地后端走类型化 client——两条路都收在服务层,组件代码永远只面对这两行分支。

四、为什么这样设计:三个动机

4.1 接口漂移风险

Agent Canvas 是多仓库体系的一环:Python 端的 software-agent-sdk 定义并演进 agent-server 的 REST 接口,前端仓库只是消费方。一旦允许 axios.get(\{host}/api/conversations/{id}`)这种字符串拼接,后端某天把路径改成/api/conversations/{id}/events`、或把响应字段改名,散落各处的直连调用点不会有任何编译期信号——直到用户在运行时撞上 404 或 undefined。而所有调用都收口在 client 里时,后端契约变更 → client 重新生成 → 用到旧方法的代码 TS 编译直接报错,漂移在编译期暴露(下一节详述这条生成链路)。

4.2 类型安全

typescript-client 的每个方法都带完整的请求/响应 TS 类型。走 client,响应数据是 Conversation 类型;走 fetch,响应是 any,后续每个属性访问都在裸奔。守卫测试第 3、4 条正则专门拦"导入底层 HttpClient"——因为那正是从"类型化方法"退回"手拼请求"的滑坡第一步。

4.3 统一的 host/凭据/超时解析

getAgentServerClientOptions() 是所有 client 的唯一参数来源:host 从活动后端注册表解析、会话 key 从环境配置注入、工作目录默认值统一导出(AGENTS.md 特别强调默认工作目录用 DEFAULT_WORKING_DIR 常量,不许硬编码 /workspace/project)。第 3 章讲过 Canvas 能同时管理多个本地后端并即时切换——这要求"换后端"不能靠每个调用点自己改 URL,必须在一处收口。直连 = 绕过注册表 = 多后端功能在你的代码路径上失效。

💡 驾驶舱要点:三个动机其实是一个:把"前后端契约"从文档里的口头协议升级为编译期的机器协议。CI 守卫保证收口不被侵蚀,client 生成保证类型不过期,helper 收口保证运行时配置有唯一来源。三层配合,才有"后端随便改,前端编译器替你找齐所有要改的地方"的工程体验。

本节要点回顾

  1. 铁律:本地 agent-server 调用(/api/*/server_info/sockets)必须走 @openhands/typescript-client 的类型化 client;裸 axios/fetch/共享 openHands 实例一律禁止;云端调用另走 callCloudProxy()
  2. 守卫测试:no-direct-agent-server-calls.test.ts 递归扫描 src/ 下所有非测试 .ts/.tsx,六组正则分别拦共享实例、createHttpClient、深层导入 HttpClient、new HttpClient、axios 直调、fetch 打 /api/ 路径;违规即 expect(violations).toEqual([]) 失败。
  3. 三层过滤:排除 mocks/ 与生成文件;只查 .ts/.tsx;排除 .test/.spec
  4. 跨平台细节:relative() 结果的反斜杠归一化为 /,否则 Windows 上白名单失配。
  5. 例外白名单仅三个基础设施文件:automation API(独立服务不在契约内)、云代理 proxy.ts 本身、本地 main-app 认证;业务代码零例外。
  6. 设计动机:防接口漂移(契约变更在编译期暴露)、类型安全(拒绝 any 响应)、统一 host/凭据/工作目录解析(支撑多后端切换)。
  7. 分流模式:云后端 callCloudProxy,本地后端 new XxxClient(getAgentServerClientOptions()),服务层统一分支。

下一节:第 7 章 · 02 OpenAPI 契约与 typescript-client——顺着本节的"收口"往上走,看这条管线是怎么从 Python SDK 的 OpenAPI 契约自动生成 TS client,又如何用版本钉扎保证前后端同步。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U