《桌面端的读写通道:Admin 接口层的设计与安全防线》

AgentHub HTTP Admin 接口层:桌面端怎么读写后端数据

引言

桌面端前端(11 号笔记)的所有数据读写,都走本地 HTTP 服务上的 /api/* 接口,由 src/channels/http/admin.ts 的 AdminRouter 承接。这个接口层表面上看是一堆 CRUD,实际藏着四个真正有设计含量的部分:

  • PUT /api/config三步保存------每一步都在解决一个具体的、如果不这么做就会出问题的场景,落盘前还有四道校验加固;
  • /api/agents/* 接口群 ------驱动 agent 的 draft -> active -> archived -> restore 生命周期,配上编辑锁、版本快照回滚;
  • 对话式创建 agent------草稿 + 画布工具 + finalize 三段式,用 ReAct 循环驱动一份结构化配置的渐进式填充;
  • 一波安全加固 ------/api/agents/** 路径穿越守卫、CORS 白名单、Sec-Fetch-Site CSRF 防线、ADMIN_API_TOKEN 门控。

这篇文章按这四块展开。/chat SSE 流式端点虽然不在 /api 前缀下,但和 Admin 接口跑在同一个 HTTP server(src/channels/http/index.ts),也在文末简要带过(细节见 02 号笔记)。


一、PUT /api/config 三步保存:为什么不能直接读写 base.yaml

如果桌面端保存配置就是简单地把整个表单序列化写进 base.yaml,会立刻撞上三个问题:密钥进 git、分 Tab 保存互相覆盖、保存了要重启才生效。三步设计分别对应解决这三个问题。

第一步:敏感字段抽到 .env,base.yaml 保留占位符

c 复制代码
// 静态表(src/channels/http/admin.ts):渠道/邮件字段------既有 secret/pass 密钥,
// 也有 from/user 这类标识字段
const ENV_FIELD_MAP: Record<string, string> = {
  "channels.wecom.secret": "WECOM_SECRET",
  "channels.wecom.bot_id": "WECOM_BOT_ID",
  "channels.feishu.app_id": "FEISHU_APP_ID",
  "channels.feishu.app_secret": "FEISHU_APP_SECRET",
  "channels.http.api_key": "HTTP_API_KEY",
  "email.from": "EMAIL_FROM",
  "email.smtp.user": "EMAIL_USER",
  "email.imap.user": "EMAIL_USER",
  "email.smtp.pass": "SMTP_PASS",
  "email.imap.pass": "IMAP_PASS",
};
// LLM key 不在静态表里,是动态规则:llm.providers.<name>.api_key
//   -> main 固定 LLM_API_KEY,其余 <NAME>_API_KEY

保存请求进来时,先按静态映射表抽渠道/邮件字段,再按动态规则逐个处理 LLM provider 的 api_key,统一写进 .env(KEY=value);base.yaml 里对应字段留成 ${WECOM_SECRET} / ${LLM_API_KEY} 这样的占位符引用------providers 的占位符是始终写回 的,新加的供应商在 yaml 里没有现成占位符,不写回的话 loader 解析不到 key。为什么必须这样:base.yaml 是要进 git 的团队共享配置基线,如果密钥直接写进去,一次 commit 就把密钥泄露进版本历史了,删了也回滚不掉。.env 在 .gitignore 里,永远不进 git。

GET 时如何避免"读出明文密钥" :返回给前端的 config 里,敏感字段不还原成真实值,而是返回一个哨兵值 ***。前端表单里密钥字段显示的就是这个 ***,用户如果没改这个字段,保存的时候后端要能识别出"这个字段是哨兵值,不是用户真的把密钥改成了字符串 ***",从而保留原值不动;只有用户真的输入了新值,才覆盖 .env 里的旧密钥。这个"GET 返回哨兵值、PUT 识别哨兵值跳过覆盖"的闸门,防止了"用户没碰这个字段,保存却把密钥意外清空成 ***"这种误清空。掩码逻辑上有个细节:按 providers 逐个 mask api_key,base_url 这类非机密地址不 mask(地址不是凭证,且前端编辑需要回显)。

这套闭环后来开了一个受控口子:POST /api/config/reveal。配置页有"显示"按钮要回显真实密钥,这个端点按白名单回显 .env 里的真实值------白名单只含静态表字段和配置里实际存在的 provider 的 api_key(main 固定 LLM_API_KEY,其余 <NAME>_API_KEY),请求不在白名单的字段直接 404。收敛白名单这一步不是过度设计:不收敛的话,任意 *_API_KEY 形态的环境变量名都能被探测,等于开了一个环境变量探测器。设计注释里明确定位是"本地桌面端场景,与配置读写在同一鉴权级别",所以它也被纳入了 ADMIN_API_TOKEN 的保护范围(见第七节)。

第二步:非敏感字段走 read-merge-write

ini 复制代码
const current = parseYaml(readFileSync("config/base.yaml", "utf-8"));
const merged = deepMerge(current, incomingNonSensitiveFields);
writeFileSync("config/base.yaml", stringifyYaml(merged));

先读当前 base.yaml 的全部内容,把这次 PUT 传来的字段深度合并进去,再整体写回。为什么不能直接用请求体覆盖整个文件:桌面端配置页是分 Tab 的(LLM/渠道/会话/日志/邮件),如果保存 Tab A 时直接用 Tab A 的表单值整体覆盖 base.yaml,Tab B 已经保存过的字段会被 Tab A 表单里那些字段的默认值/空值覆盖掉------用户刚保存了邮件配置,再去保存 LLM 配置,一读一写之间邮件配置就被抹掉了。read-merge-write 保证只有这次请求真正带的字段会变,其余字段维持原样。

第二步与落盘之间:四道校验加固

这四道都是后来补的,每道对应一个真实事故形态:

  1. .env 值换行符前置校验 ------任何要写进 .env 的值含 \r\n 直接 400,否则一行 KEY=value\nOTHER=injected 就能注入任意环境变量;
  2. 合并结果先过 ConfigSchema.safeParse 再落盘 ------此前 PUT 无校验,port: "abc" 这类类型错误会直接写坏 base.yaml,网关重启后起不来,整个应用变砖;
  3. 校验全部通过后才写 .env 和 process.env------早先的顺序是先写 .env 后校验,校验失败会留下脏 .env 和被污染的内存 env,没有回滚;
  4. llm.providers 整体替换而非 deepMerge------deepMerge 表达不了"删除某个供应商",所以 providers 段以提交内容整体覆盖,其余字段仍走合并。

第三步:reloadConfig + reloadAll,立即生效

保存完 base.yaml + .env 后,调用 reloadConfig() 让内存里的 config 对象重新加载,不需要重启网关进程。但 reloadConfig 本身只刷新了配置对象,还有一个隐藏问题:provider 实例是 agent 加载时构造并缓存在 registry 里的 ,只刷新配置不重建实例的话,LLM key/base_url/model 的修改要重启网关才生效------和"保存即生效"的承诺矛盾。所以 putConfig 的最后一步是 agentRegistry.reloadAll():重建每个 active agent 的 provider 实例,在途请求持旧实例引用跑完,新请求走新实例,会话与历史不受影响。

这里要诚实指出一个边界:这套热更新只覆盖"动态读 config"和"实例可重建"的模块。渠道连接在启动时用凭证建立了 WebSocket、邮件客户端在启动时初始化,这些模块的变更仍然需要重启才能真正应用。第五节的热更新表列出了哪些配置项属于哪一类。

二、agent 的生命周期怎么被接口驱动

多 agent 架构里,一个 agent 的状态是 draft -> active -> archived 的状态机(定义在 15 号笔记),且 archived 可以 restore 回 draft。这组状态转换都是通过 HTTP 接口触发的:

接口 触发的转换 关键约束
POST /api/agents 创建(初始 draft) name 唯一性(含 _archived 查重)、schema 校验
PUT /api/agents/:name 修改配置(draft/active 都能改) 用 raw 读写保留 ${VAR} 引用不被写回成明文;不校验状态,active 也能直接改(见局限)
POST /api/agents/:name/publish draft -> active 仅 draft 可发布;发布时打一个版本快照;受保护 agent(is_protected)不可离开 active
DELETE /api/agents/:name active -> archived 仅 active 可归档;物理上是把目录移进 _archived/(等价于 POST .../archive);受保护 agent 不可归档
POST /api/agents/:name/restore archived -> draft _archived/ 移回,状态置回 draft
POST /api/agents/:name/test 无状态转换,是"试跑" 只对 draft 开放(代码里唯一做 draft-only 校验的接口),让开发者在真正发布前先验证配置能跑通
GET /api/agents/:name/versionsPOST .../rollback?to=<v> 版本快照与回滚 每次 publish 打一个版本快照,版本号走 query 参数 ?to=;回滚前还会先快照当前状态(可回滚的回滚)
POST/DELETE /api/agents/:name/lock 编辑互斥 拿锁返回 200 + {ok, session_id},见下
POST /api/agents/:name/delete 彻底删除已归档 agent 仅归档态可删(active 误删风险高,必须先归档);连同 workspace/data 运行数据一起清理,并触发 onAgentDeleted 钩子清理运行中的定时任务等

updateAgent 用 raw 读写保留 ${VAR} 引用这个细节值得展开。agent 的渠道凭证(如企微 bot_id/secret)存的是环境变量引用而不是明文,读和写盘两个方向都要小心:

  • :用 loadDefinitionRaw() 读原始 agent.json(不做 ${VAR} 替换),普通读取路径(loadDefinition())则会做替换------如果编辑接口误用后者,改完保存时写回的就是替换后的明文,凭证从"引用"退化成"明文硬编码",agent.json 就再也进不了 git;
  • :修改基于 raw 对象做,AgentDefinitionSchema.parse 校验后原样保存,${VAR} 字符串不被破坏;
  • 返回给前端 :此时才做 substituteEnv------前端编辑框里要显示实际值,替换只发生在响应里,不落盘。

三个方向各司其职,核心原则是"引用的存续靠读写盘时都不解析,明文只在响应边界出现"。

这个接口后来还补了两个语义。一是 null 清除 :model/provider/file_accessnull 时不是把 null 写进去,而是 delete 字段、切回继承默认------因为 JSON.stringify 会丢弃 undefined,"不传"和"传 undefined"区分不了"不动"和"清除",只能用 null 显式表达清除。二是可更新字段扩到了全量:tools/skills/channels/mcp/email/shell/session/memory/file_access 现在全部能走 PUT 维护,per-agent 覆盖项不再需要手工改文件。

还有一个容易被忽略的小行为:PUT 成功后 finally 里会自动 releaseLock------提交修改本身就是一次完整的编辑会话,改完锁就该还回去;前端如果还要继续编辑,重新拿锁即可。

编辑锁的实际返回语义 (注意不是 409):一个 agent 配置同时只能被一个人编辑。POST /api/agents/:name/lock 尝试拿锁,无论成功还是失败都返回 HTTP 200 ,区别在响应体:{ ok: true, session_id } 表示拿到锁,{ ok: false, session_id } 表示锁已被别人持有(响应里的 session_id 是当前持有者的)。前端靠响应体的 ok 字段判断,不是靠 HTTP 状态码。真正返回 HTTP 409 Conflict 的是 PUT /api/agents/:name------提交修改时(updateAgent)如果发现锁被别人持有,才返回 409。这个分工是有意的:拿锁是个"试探"动作(我能不能编辑),用 200 + 语义字段就够了;提交修改是个"写"动作,冲突时用 409 让前端直接进错误处理分支提示"该 agent 正在被编辑,请稍后"。

锁的实现也值得一提:它不是内存 Map,而是 agent 目录下的 .lock 文件,内容是 {sessionId, ts}文件态有两个好处 ------网关重启后锁不丢(不会因为重启把"别人正在编辑"的状态洗掉);自带 TTL(10 分钟),持有者崩溃后锁会自动过期,别人重新拿锁时直接覆盖,不会出现"锁死 forever"。releaseLock 校验 sessionId,只能释放自己的锁。

ADMIN_API_TOKEN 门控 :部署时可以配一个 admin token,请求头带 Authorization: Bearer <token> 才能调,防止桌面端之外的匿名调用方直接操作 agent 配置(agent 配置能改工具白名单、渠道凭证,权限敏感度比普通聊天接口高得多)。保护范围是三处:/api/agents* 全部 + /api/config/reveal(回显密钥)+ /api/upload(写磁盘),Bearer 不符 401。注意 PUT /api/config 本身不在 token 保护范围内------读密钥的 reveal 受保护而写配置不受,可能是"本地桌面端回环绑定"下的取舍,但严格说写配置的危害不比读密钥小,这是当前安全模型里我认为最值得收紧的一处。

三、对话式创建 agent:草稿 + 画布工具 + finalize

多 agent 架构里创建一个新 agent,理论上可以设计成一个表单一次性提交(填好 name/description/soul/tools/skills 点提交)。实际做的是三段式接口:create-session(开一个创建会话)-> message(SSE 流式对话)-> finalize(最终落盘)。这个设计有三个具体理由:

理由一:对话是增量的,不是一次性的。 用户描述"我要一个帮业务团队审核单据的 agent",这不是一份能直接映射成表单字段的结构化输入------需要通过多轮追问("需要接哪些工具?""要不要给它配 shell 权限?")才能收敛成完整配置。一次性表单强迫用户在没想清楚细节前就填完所有字段,对话式创建允许配置随着交流逐步补全。

理由二:前端需要实时看到草稿变化。 每轮对话后端调用一组"画布工具"更新草稿对象(见下),每次更新通过 SSE 推一条 draft_update 事件给前端,前端就能像"边聊边看到配置表单被自动填充"一样实时展示进度,而不是等对话全部结束才展示最终结果。

理由三:落盘是不可逆的,要等明确确认。 整个对话过程中配置只写进一个隔离的草稿存储,不进 agents/ 目录、不会被 registry 加载成运行时 agent,直到用户明确调用 finalize 才真正调 DefinitionStore.createAgent() 落盘。这给了用户"反悔"的空间------聊了几轮觉得方向不对,可以直接放弃这个创建会话,不会留下一个不完整或错误的 agent 目录。(注意草稿本身不是纯内存态:saveDraft/loadDraft 把草稿和对话历史持久化到 data/create-sessions/<id>/,页面刷新或服务重启后 GET /api/agents/create-session/:id 都能恢复现场。"不落盘"的准确含义是不落 agents 目录、不产生运行时副作用。)

画布工具 是驱动这套对话的具体机制:buildCreateAgentTools 一次性注册了 10 个工具------init_agent_draftupdate_agent_field(field 枚举:name/description/soul/model/provider)、add_tool/remove_tooladd_skill/remove_skilllist_available_tools/list_available_skills(候选来自本地已装 + skill 市场远程仓库,未同步会自动拉取)、describe_draftfinalize_draft。这些工具的 handler 闭包捕获的是同一个 draft 对象引用,所以多轮工具调用能持续叠加效果,而不是每次从空白状态开始;每次修改触发 onDraftChange 回调,SSE 流在 tool_result 时点把最新草稿快照推给前端。这套机制本质上是"用 ReAct 循环去驱动一份结构化配置的渐进式填充",跟聊天场景的 ReAct 循环复用同一套底层能力,只是这里的"工具"操作的是内存对象而不是外部系统。

message 端点还有一个健壮性设计:MetaAgent 的 provider fallback 。创建会话默认用全局主 provider 跑,如果它报错(401/网络异常),且还没有产出任何 token(避免半截回答拼接错乱)、未跑过任何工具(避免副作用翻倍)、也确实配置了不同的备用 provider,就切到 default agent 的 provider 重跑一遍,并给用户一句"已切换到备用模型重试"。错误事件是先扣下再决定发不发------能 fallback 就不发,不能才把错误透传给前端。 fallback 链条只有一级,备用 provider 的错误直接透传。

四、三步保存的边界与热更新表

把"哪些配置改了立即生效、哪些要重启"列成表,这是使用这类配置系统时最容易产生预期偏差的地方:

配置类别 保存后是否立即生效 原因
LLM 模型/参数/key PUT 末尾 agentRegistry.reloadAll() 重建 active agent 的 provider 实例;在途请求持旧实例跑完,新请求走新实例
文件访问护栏(file_access_level 等) 部分生效 工具执行层每次动态读取最新 config(生效);但配了 per-agent file_access 的 agent 走的是快照值,全局改动不影响它
system_allowed_paths(授权目录) 部分生效 工具执行层的权限检查是动态的(生效),但 system prompt 里提示 LLM 的目录列表读的是全局 config 的启动快照,且没走 per-agent 的 getEffectiveFileAccessLevel(不生效)------这跟 03 号笔记提到的"提示层/执行层不一致"是同一类问题
log_level 是(但机制不同) 不是 handler 每次 getConfig 读取,而是 reloadConfig 末尾 logger.setLevel 一次性设置
渠道凭证(企微/飞书 bot_id/secret) 否,需要重启 渠道连接在启动时用凭证建立 WebSocket,运行中改配置不会重连
邮件 SMTP/IMAP 配置 否,需要重启 邮件客户端连接在启动时初始化

前两行"部分生效"的根因是同一个:agent 的运行时对象(工具上下文、system prompt)在加载时从 config 拷贝了一份快照,这份快照不在 reloadAll 的重建范围内。什么时候这类配置能完全热更新,取决于重建的成本和风险是否值得------这也是"保存即生效"承诺的真实边界。

五、/api/agents/* 及周边接口的完整清单(按分组)

ruby 复制代码
── 对话式创建 ──
POST   /api/agents/create-session                 开始一次对话式创建
GET    /api/agents/create-session/:id             回读草稿+对话历史(页面刷新恢复)
PUT    /api/agents/create-session/:id             手工改草稿(只允许 name/description/soul/
                                                  tools/skills/model/provider/finalized)
DELETE /api/agents/create-session/:id             放弃草稿(删 data/create-sessions/<id>)
POST   /api/agents/create-session/:id/message     SSE 流式对话,驱动画布工具更新草稿
POST   /api/agents/create-session/:id/finalize    草稿落盘,正式创建 agent

── 生命周期 ──
GET    /api/agents                          列出所有 agent(含 archived,归档定义走缓存)
POST   /api/agents                          直接创建(非对话式,一次性提交完整配置)
GET    /api/agents/:name                    读取单个 agent 配置(含 SOUL.md 内容;
                                            归档 agent 从 _archived 回退读取,只读)
PUT    /api/agents/:name                    修改配置(raw 读写保留 ${VAR},null 清除语义,
                                            draft/active 都能改,成功后自动释放锁)
DELETE /api/agents/:name                    归档(active -> archived)
POST   /api/agents/:name/delete             硬删除已归档 agent + 数据目录(带 onAgentDeleted
                                            清理钩子)------与 DELETE 的归档语义不同
POST   /api/agents/:name/publish            draft -> active(打版本快照)
POST   /api/agents/:name/archive            归档(与 DELETE 等价,语义化别名)
POST   /api/agents/:name/restore            archived -> draft(恢复回草稿)
POST   /api/agents/:name/test               试跑(仅 draft;用独立的 draft-test 会话 key +
                                            临时 SessionHistory,不污染真实会话历史)
GET    /api/agents/:name/versions           列出历史版本快照
POST   /api/agents/:name/rollback?to=<v>    回滚到指定版本(版本号走 query 参数)
POST   /api/agents/:name/lock               获取编辑锁(200 + {ok, session_id},ok=false 表示被占;
                                            .lock 文件态,10 分钟 TTL)
DELETE /api/agents/:name/lock               释放编辑锁(只能释放自己 sessionId 的)
GET/POST /api/agents/:name/subagents(/:sub) 子 agent 的列出/创建/读/改

── 会话管理 ──
GET    /api/agents/:name/sessions                     列出会话
POST   /api/agents/:name/sessions                     新建会话(archived/不存在拒绝,防脏目录)
DELETE /api/agents/:name/sessions/:chatId             删除会话(清历史 + 删 meta)
PATCH  /api/agents/:name/sessions/:chatId             重命名会话(走 PATCH,见第七节)
POST   /api/agents/:name/sessions/:chatId/pin         置顶切换
POST   /api/agents/:name/sessions/:chatId/auto-title  LLM 生成标题(取最近 10 条,剥元数据前缀)
GET    /api/agents/:name/chat-history                 聊天历史
GET    /api/chats/seen                                IM 会话自动发现(供共享机器人分配勾选)

── 欢迎语 ──
GET    /api/agents/:name/welcome            LLM 生成欢迎语 + 4 条快捷指令;缓存 agents/<name>/welcome.json,
                                            以 SOUL mtime + description 做版本戳失效;生成失败时降级为
                                            带 agent 描述的个性化兜底(避免看起来"用了 default 的欢迎语")

── 上传 ──
POST   /api/upload                          multipart 上传,50MB 上限(413),文件名消毒
                                            (去 [\/:*?"<>|] 截 120 字符),Bun.write 流式落盘
                                            到 workspace/default/uploads/,返回相对路径随 /chat 回传

── 备份 ──
GET    /api/agents/:name/backups            列出该 agent 的备份
POST   /api/agents/:name/backups/restore    恢复(校验走该 agent 自己的 file_access ctx)
POST   /api/agents/:name/backups/cleanup    清理旧备份
(旧全局 /api/backups 保留兼容,用 default agent 的 workspace)

── 孤儿数据清理 ──
GET    /api/agents/orphans                  列出无对应 agent 的运行数据目录
POST   /api/agents/orphans/cleanup          清理孤儿目录
(路由顺序敏感:必须在 /api/agents/:name 匹配之前,否则 "orphans" 被当 agent 名 404)

── skills 市场与绑定 ──
GET    /api/skills?repo=                    列出市场 skills
GET    /api/skills/content                  读 SKILL.md 内容
GET    /api/skills/install-targets          可安装目标
POST   /api/skills/sync | install | uninstall    市场同步/安装/卸载(全局卸载会同步清理
                                            各 agent 的 skills 清单,保留 agent 级同名副本)
GET/PUT /api/agents/:name/skills            agent 级 skill 绑定读写
POST   /api/agents/:name/skills/load        加载 skill 到指定 agent(装目录 + 注册名单)
DELETE /api/agents/:name/skills/:skill      卸载 skill(全局副本存在时保留清单项)

── requirements ──
POST   /api/requirements/read | write | rebuild-index    git 仓库 read-merge-write,rebase 冲突 409;
                                                            git 缺失/网络不通等 execSync 天书错误
                                                            会被翻译成可读提示

── 全局会话与状态 ──
GET    /api/status                          网关状态(uptime、workspace 目录、渠道开关)
GET    /api/sessions                        列出全部运行时会话(含锁状态)
GET    /api/sessions/:key                   查询单个运行时会话

── 其他 ──
PUT/DELETE /api/user-session                SSO 登录态落盘/清除(登录后补刷动态 API 工具)
GET    /api/tools                           工具清单(builtin + mcp)
GET    /api/mcp-servers                     MCP 服务清单
POST   /api/email/test                      SMTP/IMAP 连通性测试
POST   /api/gateway/restart                 重启网关(200ms 后 process.exit(0),
                                            由进程管理器拉起------响应先返回,退出在后台)

清单里有两条工程经验值得单独说。孤儿清理端点的路由顺序 :/api/agents/orphans 必须注册在 /api/agents/:name 之前,否则 "orphans" 会被当作 agent 名匹配走、返回 404------通配段路由和字面量路由的注册顺序是这类手写 router 的经典暗坑。归档定义的读缓存:agent 列表是高频端点,归档 agent 的定义不可变,所以读一次后缓存在内存 Map 里,避免每次列表都读磁盘。

六、/chat SSE 流式端点:桌面端聊天的主链路

/chat 不在 /api 前缀下,但和 Admin 接口跑在同一个 HTTP server(src/channels/http/index.ts),是桌面端发消息拿回复的端点。一次 SSE 流式改造把它从"挂起等整轮回复"改成了 token 级流式推送,几个设计点:

  • 单一 ReplyTarget map 合并两种模式 :json(挂起 Promise,等 done 后整体 resolve)和 sse(注册 push/close 回调逐事件推)两种挂起响应共用一个 replyTargets map,以 session_key 为键。这样 send() 出口只查一处,不用关心调用方选的是哪种模式。
  • 触发方式 :?stream=1Accept: text/event-stream 走 SSE,否则走原 json 模式,老调用方不受影响。
  • 同 session_key 并发 409:同一会话同时只允许一个挂起请求,第二个直接 409 "session busy"------否则两个挂起响应会互相顶掉,后到的永远等不到自己的回复。异常路径(json 超时/客户端 abort)都会清理 target,否则一次泄漏会让该会话后续所有请求 409。
  • SSE 帧格式 是标准的 event: <type>\ndata: <json>\n\n,事件类型 text/tool_call/tool_result/done/error,done 事件带 full_text 和 input/output token 统计;done 或 error 终结流并清理 target。
  • 长连接保活三件套 :15 秒注释行心跳(: heartbeat)、idleTimeout: 255(防 Bun 默认 10s idle 把 SSE 断开)、客户端 abort 时清理 target,防泄漏。
  • Tracer 侧完全没动(见 10 号笔记)------流式 token 不产生 trace 事件。

消息如何进入总线、如何被 GatewayCore 消费,见 02 号笔记。

七、安全:CORS/CSRF/路径守卫/token 门控

CORS 从 * 收紧为 Origin 白名单

只放行自家前端:Tauri webview 的 tauri://localhost(macOS/Linux)与 http://tauri.localhost(Windows),加 dev 服务器 localhost:5173127.0.0.1:5173。带白名单 Origin 的响应才带 CORS 头;无 Origin(curl/同源/服务端调用)不受 CORS 约束,直接放行。预检(OPTIONS)遇非白名单 Origin 直接 403、不给任何 CORS 头,浏览器端请求随之被拦。

CSRF 防线:挡住不触发预检的 simple request

光 CORS 不够,因为 simple request 不触发预检 :multipart 上传(/api/upload)和无 body 要求的 POST(/api/gateway/restart)这类请求恶意网页直接就能发,CORS 拦不住------恶意页面可以反复杀网关、倾倒文件。所以加了一道 CSRF 防线:Sec-Fetch-Site 头由浏览器强制携带、JS 不可伪造。完整判定逻辑是:

  • Sec-Fetch-Site 头(非浏览器:curl/服务端)→ 放行
  • same-origin / same-site / none → 放行
  • cross-site 且带 Origin:Origin 在白名单内(自家 webview 的跨源请求)→ 放行,否则拦
  • cross-site 且无 Origin → 拦(自家 webview 的跨源请求在 Chromium 里总是携带 Origin,含 GET;无 Origin 的 cross-site 只能是第三方页面发起)

这道防线的价值在于它和 CORS 互补:CORS 管的是"响应可不可以被读",CSRF 这道管的是"请求可不可以被发",simple request 恰好绕开了前者。

PATCH 是怎么进 Allow-Methods 的 :会话重命名走 PATCH /api/agents/:name/sessions/:chatId,而此前 Access-Control-Allow-Methods 里没有 PATCH(现为 GET, POST, PUT, PATCH, DELETE, OPTIONS)------前端发起重命名时预检直接被拒,浏览器把失败吞掉,表现为"重命名静默失败"。这个 bug 的根因不在会话管理代码,而在 CORS 配置,是"跨层因果"的典型例子:查会话重命名 bug,最后查到 CORS 头上去。新加 CORS 保护的头字段时,记得同步核对 Allow-Headers(这里还需要放行 Authorization 和 SSO 网关透传的两个自定义头)。

/api/agents/** 的路径穿越守卫

name、chatId、subagent 名这些路径段,下游都会拿去拼磁盘路径(data/<name>/sessionsagents/<name>/...),sessionKey/skills/backups 多处都拼。守卫在路由入口对 /api/agents/ 之后的全部路径段 统一校验:decode 失败(畸形 URL 编码)、空段、含 ``、含 ..、含盘符(^[a-zA-Z]:)一律 400------一处校验堵住全部下游穿越(rename/pin 穿越写 meta、deleteSession 双删 jsonl+meta 等)。/api/agents/create-session 没有 name 段,显式跳过。

这是典型的"与其每个下游各自防,不如在入口一次拦住"。配套的还有 session key 工厂(src/session/key.ts):key 会直接拼进磁盘路径,所以 agentName/chatId 的路径语义字符在构造点统一拒绝------URL 路径、query、JSON body、双重编码,入口再多,汇到这个 chokepoint 都被拦住。入口守卫管 HTTP 层,key 工厂管所有其他调用方(渠道、CLI 等)。

八、局限与可改进方向

几处明确的边界,按影响排:

  1. system_allowed_paths 注入仍读全局 ------ContextBuilder.buildSystem 注入授权目录列表用的是全局 guardrails,没走 per-agent 的 getEffectiveFileAccessLevel(ctx)。即使 agent 配了自己的 file_access,system prompt 里列出的授权目录还是全局那份,和执行层(工具权限检查走 per-agent)不一致。
  2. PUT 不校验状态 ------updateAgent 只校验编辑锁,不校验 agent 状态,draft 和 active 都能直接改。运行中的 active agent 配置能被 PUT 直接修改(虽然改完要重新 publish 才会打新版本快照),没有"修改 active 必须先转回 draft"的强约束。做 draft-only 校验的只有 test 接口。
  3. AgentDraft 字段收窄 ------对话式创建能覆盖 name/description/soul/model/provider(经 update_agent_field)加 tools/skills(经 add/remove 工具),但 shell/email/session/memory/file_access/mcp 这些更细的 per-agent 覆盖项在对话式创建流程里还接不到,得等 finalize 之后再 PUT 编辑补充------这条路现在是通的(可更新字段已是全量,还带 null 清除语义)。
  4. PUT /api/config 不在 token 保护范围------见第二节的分析,写配置的危害不比读密钥小。
  5. 编辑锁的 TTL 是权衡不是完善------.lock 文件带 10 分钟 TTL 防崩溃死锁,但反过来说,一个人编辑超过 10 分钟后,另一个人就能覆盖拿锁,两人可能同时处于"我以为我在编辑"的状态;且锁只防并发写,不防"基于过期快照的盲目保存"。
  6. 多实例部署下配置面整体失效------编辑锁(.lock 文件)、内存会话、ReplyTarget map、编辑中的草稿全是单实例语义,后端如果横向扩容,这些机制都要换共享存储。当前单机桌面端场景下不是问题,但值得知道天花板在哪。

如果继续演进,优先级大致是:把 system_allowed_paths 的提示层/执行层不一致修掉(改成读 per-agent 的 getEffectiveFileAccessLevel(ctx),和 03 号笔记的思路一样);给 PUT 补状态校验(要么禁止直接改 active,要么显式区分"可改 draft"与"可改 active 但需重新 publish");补齐 AgentDraft 能覆盖的字段范围(改动集中在画布工具层,不动接口契约);把"哪些字段热更新、哪些要重启"做成显式声明(比如给配置字段打 hot_reloadable 标记),配置页面据此实时告诉用户,比现在含糊地说"立即生效"更诚实。


小结

PUT /api/config 用三步(敏感字段抽 .env + 哨兵值防误清空、非敏感字段 read-merge-write 防分 Tab 互相覆盖、reloadConfig + reloadAll 有边界的立即生效)解决配置保存里的三个具体真实问题,落盘前还有四道加固(换行注入校验、schema 先校验后写、写盘后才碰 process.env、providers 整体替换);/api/agents/* 用一组接口驱动 draft->active->archived->restore 闭环生命周期,配上编辑锁(文件态 + 10 分钟 TTL,拿锁 200+ok 字段、提交修改冲突才 409)、版本快照回滚(rollback?to=)、ADMIN_API_TOKEN 门控(/api/agents* + reveal + upload 三处);对话式创建 agent 用草稿(持久化在 create-sessions、不产生运行时副作用)+ 画布工具(ReAct 循环驱动结构化配置渐进填充,靠 SSE draft_update 让前端实时看到)+ finalize(唯一落盘点)三段式,把"配置收敛"和"不可逆写入"明确分开;安全侧是路径穿越入口守卫、CORS 白名单、Sec-Fetch-Site CSRF 防线、token 门控的组合------CORS 管响应可读性,CSRF 管请求可发性,入口守卫管路径拼接,各管一段互为冗余。

回头看,这个接口层真正值得复用的经验有三个:配置保存把"敏感/非敏感"分离,各自用最合适的存储,靠哨兵值解决回显与误清空 ;状态机转换全部走显式接口并留版本快照,回滚就是普通操作而不是救火 ;安全防线按攻击面分层叠加,不指望单一机制兜住所有入口

相关推荐
#卢松松#1 小时前
用AI做网站,已经到了找BUG阶段了
人工智能·创业创新
ysu_03141 小时前
PINNs的参数反演——从“正问题”到“逆问题”的工程改造
人工智能·pytorch·深度学习·mcmc·参数反演·逆问题·darcy流
joinwell521 小时前
没收到回复,再点一次会启动两个 AI 吗?
人工智能·后端
hzxxxz1 小时前
2000份投稿里的共性-工具建设可复用的4条原则
人工智能
Bolt1 小时前
Agent: 将 harness 工程升级到认知工程
人工智能·架构·agent
桃西西呀1 小时前
限速 30 和限速 80 只差一个数字,卷积网络怎么分得清?
人工智能·深度学习·llm
Behaviour1 小时前
Sam Altman 预热本周重磅产品发布,或为 GPT-6 Sol
人工智能·chatgpt·aigc·openai·vibecoding
宣宣猪的小花园.1 小时前
【机器学习】过拟合与泛化:模型为什么会“刷题很强、实战失灵”
人工智能·算法·机器学习
7177771 小时前
有没有国产 GitLab?Gitee 与极狐 GitLab 等主流替代方案对比
人工智能·gitee