session-backend-image:把图片输入的信任从客户端搬到服务端

eyrie · main...session-backend-image · 2026-06-17 · 自包含,读完即弃

3 commits
20 文件
+364 / −321
62% 是测试代码
类型:功能 / 安全加固

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1TL;DR

这个 PR 改的是「用户给 agent 发图片」这条路上的一件事:客户端不再告诉服务端图片在哪、是什么类型。在 main 上,一个 turn 的图片块长这样 { type: 'image', path, mimeType }——路径和 mime 都是客户端声明的,服务端拿来就用。现在图片块只剩一个不透明的 ref(上传接口返回的句柄),服务端在 turn 开始前自己把它解析成会话内的真实文件路径,并从服务端落盘时记下的 sidecar 里读真实 mime,不信客户端声明的任何东西

动机(从 commit message 推断):原来每个 provider 各自校验图片——Claude 适配器做了一整套工作区路径围栏(resolve symlink、判 workspace 内外),而 Codex 根本不做 mime 校验。现在把校验收拢到 AgentService 一处中央闸门:路径围栏复用已有的 resolveUploadRef(按会话隔离),mime 用一个统一的 image/* 判断卡住所有 provider——包括自己不查的 Codex。坏 ref 在开 run row 之前就以 validation.failed 失败,不留孤儿 run、不漏 runner 句柄。

2变更地图(称重)

20 个文件,但设计承载的代码很集中。62% 的行数在测试里——其中大头是把旧的 Claude 工作区围栏测试整段删掉(symlink 逃逸、workspace 内外判定那几个用例),换成更小的「服务端解析 ref」测试套。源码侧真正要细读的只有三处:service.ts 的解析闸门、types.ts / schemas.ts 的契约拆分、Claude input.ts 删掉的那段路径围栏。

daemon/tests
409 行 · 60%
daemon/src
252 行 · 37%
packages/api
24 行 · 4%
设计重心(要细读)可放心略过
agent/service.ts:新增 resolveTurnInput / resolveImagePart,中央 mime 闸门 upload-store.tsroutes/ 搬到 services/:93% 是纯搬运,git 认作 rename
agent/types.ts + api/schemas.ts:public 与 resolved 两套输入形状的拆分 5 个测试文件里 AgentInputResolvedAgentInput 的 FakeRunner 类型替换(等价改名)
Claude input.ts:删掉 resolveImagePath / isPathInsideWorkspace 整段工作区围栏 f45feaf 整个 commit 只是 biome 把联合类型折成单行的格式化
Codex protocol.ts / index.ts:类型换成 resolved 形状(行为不变,只是不再自己解析) trpc/services.ts:占位文本从 [image: mime] 改成 [image](反正会被服务端重建)

3信任边界一图流

这是个安全加固,所以「图」画的是信任边界往哪挪,而不是进程结构。关键变化:路径解析与 mime 判定,从「每个 provider 各自做、且各做各的」收拢到「AgentService 一处做、provider 只拿结果」。

以前 · 校验散在 provider 里

Client
path + mimeType(客户端声明)
AgentService
AgentService
原样透传 parts
Claude
工作区围栏
AgentService
无 mime 校验
Codex
直接用 path

现在 · 中央闸门,provider 只拿结果

Client
ref(不透明句柄)
AgentService
resolveTurnInput
AgentService
已解析 path + 服务端 mime
Claude
只读 path
AgentService
已解析 path + 服务端 mime
Codex
只读 path

4数据形状先行

整个 PR 的支点是一个类型拆分:原来一套 AgentInputPart 同时当「客户端发来的线缆形状」和「provider 消费的形状」,现在拆成两套。先看清形状,旅程才好走。

public(线缆)形状——客户端能构造的,图片块只有一个 ref:

apps/daemon/src/agent/types.ts真实代码(节选)
// 一个 user turn 的内容块,as it arrives from a client;public 联合只有 text 或 image。
// image 只带不透明 upload ref:服务端在任何 provider 看到它之前,把它解析成文件路径 + 服务端可信 mime。
export type AgentInputPart = { type: 'text'; text: string } | { type: 'image'; ref: string }

resolved(内部)形状——服务端解析后才存在,是 provider runner 唯一消费的形状;带 ref 的 public 块永远到不了 provider:

apps/daemon/src/agent/types.ts真实代码(节选)
export type ResolvedAgentInputPart =
  | { type: 'text'; text: string }
  | { type: 'image'; path: string; mimeType: string }   // path 是服务端已围栏的绝对路径

export type ResolvedAgentInput = {
  text: string   // 归一化的 turn 文本;image 块贡献一个服务端 mime 占位符
  parts?: ResolvedAgentInputPart[]
}

对应地,AgentRunner.startTurn 的签名从 AgentInput 收紧到 ResolvedAgentInput——这是编译期保证:任何 provider 想拿到图片,只能拿到已解析的 path,拿不到原始 ref。线缆侧的 zod schema 同步收紧,ref 的长度上界(4000)刻意对齐 uploadResponseSchema.ref

packages/api/src/schemas.ts真实代码(节选)
// image 块只带 POST /blob 返回的不透明 upload ref;daemon 把它解析成会话内文件,
// mime 从服务端 sidecar 读,绝不信客户端声明的 path 或 mime。
z.object({ type: z.literal('image'), ref: z.string().min(1).max(4000) }),

5旅程:给 Agent 发一张图

这条旅程从「客户端上传一张图拿到 ref」走到「Claude / Codex 进程真正收到这张图」。走通后,你会知道这张图的路径是谁、在哪一步确定的,mime 是谁说了算,以及一个坏 ref 会在哪一步、以什么错误被挡下。

全景 · 涉及 5 个文件
上传产出 ref
routes/blob.ts → services/upload-store.ts
startTurn 入口
agent/service.ts
解析 + mime 闸门
agent/service.ts
provider 消费
claude/input.ts · codex/protocol.ts

A.1上传先产出一个不透明 ref

这条链的起点不在本 PR 改动里,但它定义了 ref 的形状,后面每一步都依赖这个形状,所以先看清。客户端 POST /blob 上传文件,storeUpload 落盘并返回一个 ref。这个 ref 是服务端相对路径,形如 <sessionId>/<id>-<name>,绝不是宿主机绝对路径。落盘的同时写一个 sidecar,记下这次上传的真实 mime。

apps/daemon/src/services/upload-store.ts真实代码(节选)
const id = createId()
// 返回给客户端的不透明、服务端相对引用;绝不是宿主机绝对路径。
// 消费侧拿它对着 baseDir 解析,并重新做围栏检查。
const ref = `${sessionId}/${id}-${name}`
// ... 写文件 ...
// sidecar 记下上传的原始身份,下载路由据此回显真实 name / mime,
// 而不是 id 前缀的存储名和 octet-stream。
await writeFile(uploadMetadataPath(finalPath), JSON.stringify({ name, mimeType: file.type }))

注意:storeUpload 落盘时用的是客户端声明的 file.type 写 sidecar,且这里的 mime 白名单很宽(image/*text/*application/pdf)——上传一个 .txt 是合法的。「这是不是一张图」这个判断不在上传这一步做,留到 turn 解析时的中央闸门(A.3)。这正是为什么一个合法上传仍可能在发 turn 时被拒。

A.2startTurn:先解析,后认领

核心顺序问题:解析 ref 必须发生在 beginTurn(把 turn 写进存储、占住「单活跃 run」名额)之前。否则一个坏 ref 会留下一个孤儿 run row,甚至——对那些一构造就 spawn 子进程的 provider——漏一个活的子进程句柄。

apps/daemon/src/agent/service.ts真实代码(节选)
async startTurn(sessionId: string, input: AgentInput): Promise<AgentRunRow> {
  const session = await this.getSession(sessionId)
  // 解析放在认领 turn 之前:坏 ref 直接失败,不留孤儿 run、不漏 runner 句柄
  const resolved = await this.resolveTurnInput(input, sessionId)
  const run = await this.repo.beginTurn(sessionId, {
    id: createId(),
    inputText: resolved.text,   // 存的是从 parts 重建的文本,不是客户端原始 text
    startedAt: Date.now(),
  })
  try {
    // ... 建 runner ...
    await handle.runner.startTurn(resolved)   // 传给 provider 的是 resolved 形状
  } catch (err) {
    await this.repo.terminateRun(run.id, 'startTurnFailed')
    throw err
  }
  return run
}

这里有个连带变化值得记一笔:持久化的 inputText 现在来自 resolved.text,而 resolved.text 是从 parts 重建的——image 块贡献的占位符是 [image: <服务端 mime>]。所以历史里记下的 mime 永远是服务端认定的,不是客户端声明的。tRPC 那层原来自己拼的占位文本([image: mimeType])现在退化成 [image],因为反正会被服务端整段重建丢弃。

A.3中央 mime 闸门:一处挡住所有 provider

这是整个 PR 的安全核心。resolveTurnInput 逐块处理:text 原样过,image 交给 resolveImagePart,未知变体当客户端错误拒掉。重点在 resolveImagePart 的三道关。

先把场景摆清楚:ref 是客户端给的,所以三件事都不能信——它指向的路径可能逃逸会话目录、sidecar 可能不存在(blob 被清掉了,或是直接塞进来的文件)、声明要当图片用的东西真实 mime 可能根本不是图片。三道关逐一对应:

apps/daemon/src/agent/service.ts真实代码(节选)
private async resolveImagePart(part, sessionId) {
  // 关 1:路径围栏。resolveUploadRef 拒掉畸形 / 跨会话 / 逃逸的 ref
  const path = resolveUploadRef({ baseDir: this.uploadBaseDir, sessionId, ref: part.ref })
  // 关 2:sidecar 必须在。缺 sidecar = 这个 blob 无法被服务端背书(或已被清),不能进 provider
  const meta = await readUploadMetadata(path)
  if (!meta) throw new AppError({ code: EyrieErrorCode.validation.failed })
  // 关 3:中央图片闸门。服务端 mime 不是 image/* 就不是合法图片块——
  // 这是唯一一道保护「自己不查 mime 的 provider(如 Codex)」的检查
  if (!/^image\//.test(meta.mimeType)) {
    throw new AppError({ code: EyrieErrorCode.validation.failed })
  }
  return { type: 'image', path, mimeType: meta.mimeType }
}

关 1 的 resolveUploadRef 本身不是本 PR 新写的(main 上下载路由已经在用),但这是它第一次被消费 turn 输入的路径调用。它做三件事:拆成恰好两段、第一段必须等于当前 sessionId(跨会话隔离)、存储名不能含 .. / 反斜杠 / NUL,最后再断言解析出的绝对路径仍在会话目录内。所以「一个会话读另一个会话上传的图」在这一关就被挡死。

A.4两个 provider 的消费:都只拿 path

解析完,两个 provider 收到的都是 resolved 形状,谁都不再自己解析路径。最大的删减在 Claude:它原来有一整套工作区围栏,现在整段没了。

以前 · Claude input.ts
收到客户端声明的相对 path + cwd
resolveImagePath:resolve workspace root,realpath 解 symlink
isPathInsideWorkspace:判 path 在不在 workspace 内,逃逸则抛 claude-image-path-outside-workspace
mime 检查 → 读文件 → base64
现在 · Claude input.ts
收到服务端已围栏的绝对 path(无 cwd 入参)
直接用 part.path,无路径解析
mime 检查(仍保留:白名单 png/jpeg/webp/gif)→ 读文件 → base64

注意 Claude 这边仍保留自己的 mime 白名单——它只接受 png/jpeg/webp/gif 四种,比服务端那道宽泛的 image/* 闸门更严。两道闸门叠加:服务端闸门保证「是张图」,Claude 闸门保证「是 Claude 能吃的图」。删掉的只是路径围栏,不是 mime 校验。

apps/daemon/src/agent/providers/claude/input.ts真实代码(节选)
// reads one image after MIME and size checks; path is an absolute, server-vetted upload path
async function buildClaudeImagePart(part, signal) {
  const mimeType = normalizeImageMimeType(part.mimeType)
  if (!mimeType || !SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
    throw unsupportedOperation('Claude image input MIME type is not supported.', { /* ... */ })
  }
  const path = part.path   // 以前是 await resolveImagePath(cwd, part.path),现在直接取
  const file = await stat(path).catch(/* ... */)
  // ... 大小检查、读文件、base64 ...
}

Codex 侧几乎只是类型换名:它本来就是「拿 path 直接交给 Codex 进程,mime 让 Codex 自己推断」。唯一实质区别是——现在这个 path 是服务端围栏过的绝对路径,且 Codex 不查 mime 这件事,被 A.3 的中央闸门兜住了。

apps/daemon/src/agent/providers/codex/protocol.ts真实代码(节选)
function toUserInputItem(part: ResolvedAgentInputPart): Record<string, unknown> {
  if (part.type === 'text') return { type: 'text', text: part.text, text_elements: [] }
  // Codex 按文件路径携带本地图片;服务端已把 upload ref 解析成绝对、会话内路径,
  // mime 由 Codex 进程侧推断。
  if (part.type === 'image') return { type: 'localImage', path: part.path }
  // part 此处静态为 never;未来的新 part 种类是 authoritative 内容,读运行时 type 并大声失败
}
排查路标 · 给 Agent 发图
症状从哪下手
发带图的 turn 直接 validation.failed,run 都没建agent/service.tsresolveImagePart:三道关哪一道抛的——ref 形状 / sidecar 缺失 / mime 非图片
「一个会话能看到另一个会话的图」之类越权services/upload-store.tsresolveUploadRef:跨会话 guard 与会话目录围栏
图上传成功但发 turn 说不是图片上传白名单宽(含 text/pdf),中央闸门只放 image/*;查 sidecar 里记的 mime(来自上传时的 file.type
Claude 报 claude-image-mime-unsupportedclaude/input.tsSUPPORTED_IMAGE_MIME_TYPES:只收 png/jpeg/webp/gif,比服务端闸门更严
历史里 image 占位符的 mime 不对service.ts resolveTurnInput 重建的 text,占位符用服务端 mime;客户端声明的从不入库

6心智模型补丁

turn 的 image 块带 path + mimeType,provider 拿到什么用什么。 image 块只带不透明 ref;path 和 mime 由 AgentService 在 turn 开始前解析,provider 永远拿不到原始 ref。
想给 provider 加图片输入,不要碰 provider 的路径逻辑——它只消费 ResolvedAgentInputPart
图片路径围栏是 Claude 适配器的事(resolve symlink、判 workspace 内外)。 围栏在 resolveUploadRef 一处做,按会话目录隔离;Claude 不再有 resolveImagePath / isPathInsideWorkspace
排查图片越权 / 逃逸,去 services/upload-store.ts,不再去 claude/input.ts
Codex 没有 mime 校验,是个隐性缺口(它信任传入的 path 就是图)。 中央 image/* 闸门在 resolveImagePart 兜住所有 provider,包括自己不查的 Codex。
upload-store.tsroutes/ 下,是路由层的东西。 它在 services/ 下,是中立服务;AgentService 解析 turn 输入不再需要向上依赖 routes 层。
blob 路由和 turn 解析共用 defaultUploadBaseDir() 作为缓存目录的唯一来源,保证「turn 解析的目录」和「上传写入的目录」是同一个。

7新词表

本 PR 引入 / 重新定义的词
ResolvedAgentInput(Part)服务端解析后、provider 唯一消费的输入形状:image 带绝对 path + 服务端 mime,没有 ref。与 public 的 AgentInputPart 区分。
upload ref上传接口返回的不透明句柄,形如 <sessionId>/<id>-<name>,服务端相对、非宿主机绝对路径。
sidecar每个落盘 blob 旁边的 .meta.json,记上传时的真实 name + mime;是服务端对「这是什么」的唯一可信记录。
中央 mime 闸门resolveImagePart 里那道 /^image\// 检查,跨所有 provider 统一卡住非图片上传。
defaultUploadBaseDir()本 PR 新增的函数,上传缓存目录(.eyrie/cache/uploads)的中立来源,blob 路由和 turn 解析共用。

8测试与风险地图

测试改动的实质是「换钉子」:拔掉钉住 Claude 工作区围栏的那批用例(因为围栏没了),钉上钉住服务端 ref 解析的新用例。

有兜底的(测试钉住的行为)薄冰 / 注意
image ref 解析成 path + 服务端 mime,占位文本用服务端 mime(agent-service-methods.test.ts 🟡上传白名单允许 text/* 和 pdf,但 turn 侧只有 image 块会过中央闸门——pdf/text 作为 turn 输入的路径目前没有对应消费方,属预留
sidecar 缺失 → 拒(坏 ref 在开 run 前失败,runner 不被调用) 🟡sidecar 用客户端声明的 file.type 落盘,无内容嗅探。一个真实是脚本、声明为 image/png 的文件能过中央闸门——后续真正读字节的 Claude 仍会按 mime 当图解,但「服务端 mime 可信」这层信任本质来自上传时的客户端声明
服务端 mime 非 image → 拒(上传 txt 当图发被挡) 🟡全量测试套有 136 个失败,全部是 better-sqlite3 原生绑定缺失(本 worktree 的 node_modules 没编译该 addon),与本 PR 无关——本 PR 触及的 5 个测试文件单独跑 89/89 全过
跨会话 ref → 拒(一个会话读不到另一个会话的上传)
Claude 收绝对 path 直接 base64;不支持 mime 在读文件前就拒(claude-runner-turn.test.ts
契约层:image 块只接受 ref,拒空 ref 和超长 ref(contracts.test.ts
合并前可留意(非阻断):🟡 上传的 mime 来自客户端声明的 file.type、无内容嗅探,upload-store.ts 的注释明确承认了这点(「Content sniffing is not performed」)并以 DENIED_MIME 拒掉 svg/html/js/xml 等活动内容兜底。这是设计取舍不是缺陷,但「服务端可信 mime」的可信度上界 = 上传时客户端声明的诚实度,值得在心智模型里记一笔。

9验收提示

10覆盖声明

本次为全量精读,未分发 subagent(diff ~685 行,属小 PR 档)。逐文件读了全部 20 个文件的 diff;报告中引用的代码段均亲自 Read 过所在文件(agent/service.tsservices/upload-store.tsclaude/input.tscodex/protocol.tsapi/schemas.tsagent/types.ts 及对应测试)后裁剪。已在分支上跑通 typecheck(6 包全绿)与 PR 相关 5 个测试文件(89/89)。全量测试套因 better-sqlite3 原生绑定缺失无法跑通,已单独验证该失败与本 PR 无关。未做 code review(不评风格、不提改进),第 8 节的「薄冰」为事实陈述。