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

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

8 commits
19 文件
+395 / −328
~65% 是测试代码
作者 a6d9a6m

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

1TL;DR

一个 agent turn 里要带图片时,客户端原来直接在 wire 上声明 { path, mimeType }——服务端拿到什么就信什么,路径校验落在每个 provider 各自的代码里(Claude 做,Codex 不做)。本 PR 把这套信任模型翻过来:

动机(自 commit message 与 PR 描述推断):「trusting nothing the client declares」——把对客户端输入的信任面收到最小,并堵住一个 provider 漏检 mime 的窟窿。

2变更地图(称重)

改动几乎全压在 apps/daemonpackages/api 只动了一条 schema。约三分之二的行数是测试——但要诚实:这不是「水活」,删掉的是一整组「provider 做 cwd 越界检查」的旧测试,新增的是「service 解析 ref」的新测试,测试的重写本身就是这次信任模型搬家的镜像

apps/daemon
699 行 · 97%
packages/api
24 行 · 3%
设计重心(要细读)可放心略过(机械变更)
agent/service.ts — 新增 resolveTurnInput / resolveImagePart,turn 输入的解析与闸门 routes/upload-store.ts → services/upload-store.ts,git 判定 88% 相同,是搬家不是重写
agent/types.ts — wire 与 resolved 两套类型拆分(见 §4) style(daemon): apply biome line formatting 整条 commit 只是换行
services/upload-store.tsresolveUploadRef 的 containment + isSafeStoredName + defaultUploadBaseDir agent-provider-discovery.test.ts — 抽出 claudeBin 常量 + 多塞一个构造参数,无行为变化
packages/api/src/schemas.tsagentInputPartSchema{path,mimeType} 改成 {ref} claude-runner-turn.test.ts 的 −134 行:删的是 provider cwd 越界测试,不是丢覆盖(见 §9)
agent/providers/claude/input.ts — 删掉 resolveImagePath 整段 cwd 解析 trpc/services.ts 占位文本由 [image: mime] 改成 [image],反正会被丢弃重建

3信任边界:一图流

这次真正变的是「路径在哪里被验、mime 由谁说了算」。下图把旧管线和新管线并排画出来——注意红框(containment)从 provider 内部挪到了服务层的单点。

以前 · 信任分散在 provider

Client
startTurn({path, mimeType})
AgentService
normalizeInput
原样透传 path/mime
runner
Claude
cwd 越界 + symlink 检查
resolveImagePath
Codex
无 mime 检查
(裸用 path)

现在 · 信任收敛到服务端

Client
① POST /blob → ref
blob route
Client
② startTurn({ref})
resolveTurnInput
resolveUploadRef
containment + sidecar mime
runner
Claude / Codex
直接读已验过的绝对 path
(不再各自验)

左图里 resolveImagePath 是高亮的——它是旧世界里唯一守住越界的关,而且只有 Claude 有;Codex 那条线是虚的(无 mime 检查、裸用 path)。右图里所有验证收进了 resolveUploadRef + resolveTurnInput 这一段服务端代码,两个 provider 之后拿到的都是「已经验过」的绝对路径。

另一条结构线:upload-store 搬到 services/ 后,blob 路由(写文件、出文件)和 turn 解析器(读文件)共用同一个 defaultUploadBaseDir(),所以「turn 里的 ref」一定解析到「上传时写进去的那个目录」,不会两边各算各的根。

4数据形状先行

读后面的旅程前,先把这次最核心的词汇——两套输入类型——的形状装进脑子。整个 PR 的叙事就是「同一个 turn 输入,从 wire 形状被解析成 resolved 形状」。

4.1wire / resolved 两套类型

原来只有一套 AgentInput / AgentInputPart,既是客户端发来的、也是 provider 消费的。本 PR 把它劈成两套,靠命名把「谁带 ref、谁带 path」钉死在类型系统里。

apps/daemon/src/agent/types.ts真实代码(节选)
// 客户端发来的 wire 形状:image 只带不透明 ref
export type AgentTurnInputPart = { type: 'text'; text: string } | { type: 'image'; ref: string }

export type AgentTurnInput = {
  text: string                      // 临时文本;服务端会从 parts 重建
  parts?: AgentTurnInputPart[]      // 有 parts 时为权威内容
}

// provider 消费的 resolved 形状:image 带绝对 path + 服务端 mime
export type AgentInputPart =
  | { type: 'text'; text: string }
  | { type: 'image'; path: string; mimeType: string }

export type AgentInput = {       // 带 ref 的 wire part 永远到不了 provider
  text: string
  parts?: AgentInputPart[]
}

命名上有个值得记的判断(commit refactor(agent): name wire input type instead of renaming the resolved one):没有去重命名 provider 端早已用熟的 AgentInput,而是给新来的「wire 形状」起了 AgentTurnInput 这个新名。为什么不是直觉做法——直觉会把「客户端来的」叫 AgentInput、把解析后的叫 ResolvedInput;但那样要改一大片 provider 代码,且 AgentInput 这个名在 runner 语境里指的本就是「我要喂给模型的东西」,让它继续指 resolved 形状反而更贴。

4.2wire 契约的 schema

类型只是编译期约束,真正在边界上拦住客户端的是 @eyrie/api 的 zod schema。这是本 PR 在 packages/api 里唯一的实质改动。

packages/api/src/schemas.ts真实代码(节选)
export const agentInputPartSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('text'), text: z.string().max(100_000) }),
  // image block 只带 POST /blob 返回的不透明 ref;daemon 把它解析成 session
  // 专属文件,并从服务端 sidecar 读 mime,绝不信客户端声明的 path 或 mime。
  z.object({ type: z.literal('image'), ref: z.string().min(1).max(4000) }),
])

这是一个 破坏性的 wire 契约变更:旧客户端发 {type:'image', path, mimeType} 会被这条 discriminated union 直接判 invalid。为什么这次能放心改而不留兼容——见 §9,目前没有任何真实 UI 在构造 image part。

5旅程:一次带图片的 turn

跟着一张图从「用户在某个 session 里发一条带图消息」走到「provider 真正读到图片字节」。走通这条线,你就掌握了这个 PR 的全部要害。

全景 · 涉及 6 个文件
① 上传
routes/blob.ts
② 落库前解析
agent/service.ts
③ 中央闸门
service.ts · resolveImagePart
④ 单点 containment
services/upload-store.ts
⑤ provider 读字节
providers/claude/input.ts · codex/protocol.ts

5.1上传:先拿到一个不透明 ref

图片不再随 turn 走 RPC,而是先走 HTTP 上传。POST /blob/:sessionId 把文件落到 baseDir/<sessionId>/<id>-<name>,并在旁边写一个 .meta.json sidecar 记下原始 name 和 mime,返回一个不透明 ref

apps/daemon/src/services/upload-store.ts · storeUpload真实代码(节选)
const id = createId()
// 返回给客户端的不透明、服务端相对的引用;绝不是宿主机绝对路径。
const ref = `${sessionId}/${id}-${name}`
const sessionDir = join(baseDir, sessionId)
const finalPath = join(sessionDir, `${id}-${name}`)

await mkdir(sessionDir, { recursive: true })
await writeFile(finalPath, bytes)
// sidecar 存上传时的原始身份,下载路由用它回显真名/真 mime。
await writeFile(
  uploadMetadataPath(finalPath),
  JSON.stringify({ name, mimeType: file.type } satisfies StoredUploadMetadata),
)
return { id, ref, name, mimeType: file.type, sizeBytes: bytes.byteLength }

这里有个微妙但要记牢的事实:sidecar 里的 mimeType 来自 file.type——也就是上传那一刻客户端声明的 mime,只是被 allowlist 校验过并落了盘。所以 PR 描述里说的「server-recorded mime / 不信客户端」要精确理解为:不信「turn 里」再次声明的 mime,只信「上传时」就固化在服务端的那一份;它不是内容嗅探出来的(详见 §8 薄冰)。

5.2startTurn:先解析,后落库

turn 进来后第一件事不是开 run,而是先把输入解析干净。顺序很关键:解析必须在 beginTurn(在存储里认领这个 turn)之前,否则一个坏 ref 会留下一条孤儿 run 行、甚至一个泄漏的 runner 句柄。

apps/daemon/src/agent/service.ts · startTurn真实代码(节选)
async startTurn(sessionId: string, input: AgentTurnInput): Promise<AgentRunRow> {
  const session = await this.getSession(sessionId)
  // 在认领 turn 前先把 image ref 解析成 session 专属路径 + 服务端 mime,
  // 这样坏 ref 直接让请求失败,不会留下孤儿 run 行或泄漏的 runner 句柄。
  const resolved = await this.resolveTurnInput(input, sessionId)
  const run = await this.repo.beginTurn(sessionId, {
    id: createId(),
    inputText: resolved.text,        // 落库文本由 parts 重建,不是客户端传的
    startedAt: Date.now(),
  })
  // ... 构建 runner ...
  await handle.runner.startTurn(resolved)   // runner 拿到的是 resolved 形状
}

注意入参类型现在是 AgentTurnInput(wire),返回给 runner 的 resolvedAgentInput(resolved)——§4 的两套类型在这里第一次发生「相变」。resolveTurnInput 对纯文本 turn 直接短路返回;只有当 parts 非空时才逐个处理,文本 part 原样保留,image part 交给 resolveImagePart,最后用 parts 重建 text(图片贡献一个 [image: mime] 占位),保证落库/回放的文本和 runner 看到的 parts 一致。

5.3resolveImagePart:所有 provider 共享的中央 image 闸门

这是整个 PR 的「心脏」。一个 image part 要通过三道关才会变成 provider 能用的形状:① ref 能解析成合法路径,② sidecar 存在(blob 可被服务端背书),③ 服务端 mime 确实是图片。

apps/daemon/src/agent/service.ts · resolveImagePart真实代码(节选)
private async resolveImagePart(
  part: Extract<AgentTurnInputPart, { type: 'image' }>,
  sessionId: string,
): Promise<Extract<AgentInputPart, { type: 'image' }>> {
  // resolveUploadRef 拒绝畸形、跨 session、或越界的 ref(validation.failed)。
  const path = resolveUploadRef({ baseDir: this.uploadBaseDir, sessionId, ref: part.ref })
  // sidecar 是服务端对该上传真实 mime 的记录;缺 sidecar 说明 blob 无法分类
  //(或已被清掉),绝不能到达 provider。
  const meta = await readUploadMetadata(path)
  if (!meta) throw new AppError({ code: EyrieErrorCode.validation.failed })
  // 所有 provider 共享的中央 image 闸门:服务端 mime 非 image 的上传不是合法
  // image part。这一道是保护那些自己不做 mime 校验的 provider(如 Codex)的唯一关。
  if (!/^image\//.test(meta.mimeType)) {
    throw new AppError({ code: EyrieErrorCode.validation.failed })
  }
  return { type: 'image', path, mimeType: meta.mimeType }
}

记住那行注释点名的事实:Codex 自己完全不做 mime 检查(见 §5.5 它怎么裸用 path)。在旧世界里,一个文本文件伪装成图片发给 Codex 不会在 Eyrie 这层被拦;现在这道 /^image\// 对两个 provider 一视同仁。

5.4resolveUploadRef:把 containment 收进单点

「不许越界、不许跨 session」这件事,旧世界靠 Claude provider 里的 resolveImagePath(realpath + cwd 相对判断)来守。本 PR 把它整段删掉,改由 resolveUploadRef 一个纯函数(不做 I/O)守住——它把 <sessionId>/<storedName> 反解成 cache 目录下的绝对路径,沿途四道校验。

apps/daemon/src/services/upload-store.ts · resolveUploadRef真实代码(节选)
const segments = ref.split('/')
if (segments.length !== 2) throw new AppError({ code: EyrieErrorCode.validation.failed })
const [refSession, storedName] = segments

// 跨 session 守卫:ref 必须带「正在解析的这个 session」自己的 id。
if (!refSession || refSession !== sessionId) {
  throw new AppError({ code: EyrieErrorCode.validation.failed })
}
if (!isSafeStoredName(storedName)) {       // 拒点段 / 反斜杠 / NUL / .meta.json
  throw new AppError({ code: EyrieErrorCode.validation.failed })
}

const sessionDir = join(baseDir, sessionId)
const finalPath = join(sessionDir, storedName)
// 纵深防御:任何调用者读它之前,断言解析路径仍在 session 目录之内。
if (!resolve(finalPath).startsWith(resolve(sessionDir) + sep)) {
  throw new AppError({ code: EyrieErrorCode.validation.failed })
}
return finalPath

这里藏着 PR 演进史里最值得一讲的一段。PR 描述末尾有一条「Note for reviewers」:说一个以 .meta.json 结尾的精心构造 ref 能解析到某个 blob 的 sidecar,并自评「不是泄漏,但要不要干脆排掉这个后缀」。翻 commit 历史会发现:这条疑问后来被两个 fix commit 自己关掉了——fix(daemon): reject upload metadata refsisSafeStoredName 里加了 !storedName.endsWith(METADATA_SUFFIX)fix(daemon): reject reserved upload filenames 又在 storeUpload 里禁止上传名以 .meta.json 结尾。所以 PR 描述里那条 note 现在已经是过时信息,对应的洞两头都堵上了。

apps/daemon/src/services/upload-store.ts · isSafeStoredName真实代码(节选)
function isSafeStoredName(storedName: string | undefined): storedName is string {
  return (
    !!storedName &&
    storedName !== '.' &&
    storedName !== '..' &&
    !storedName.includes('\\') &&
    !storedName.includes('\0') &&
    !storedName.endsWith(METADATA_SUFFIX)   // 后补的:堵掉 .meta.json sidecar 读取
  )
}

顺带一提,isSafeStoredName 这个具名 helper 本身是最后一个 commit(fix(daemon): reduce upload ref resolver complexity)从一坨内联布尔里抽出来的——纯结构整理,无行为变化。

5.5provider 端:直接信任已验过的 path

到了 provider,世界变得很简单:路径已经是服务端验过的绝对路径,provider 不再自己解析。先看 Claude——对比最能说明问题。

以前 · Claude 自己守 cwd
收到 part.path(cwd 相对)
resolveImagePath(cwd, path)
realpath 解 symlink + isPathInsideCwd 判越界
读字节 base64
现在 · path 已被服务端背书
收到 part.path(服务端给的绝对路径)
const path = part.path(直接用)
mime 白名单 + size 检查(保留)
读字节 base64
apps/daemon/src/agent/providers/claude/input.ts · buildClaudeImagePart真实代码(节选)
// 经 MIME 与 size 检查后读一张图;path 是绝对的、服务端已校验的上传路径
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, signal)
const file = await stat(path).catch(/* ... 不可读则报错 ... */)
// ... size 上限 10MB 检查 ...
const data = await readFile(path, { signal })
return {
  type: 'image',
  source: { type: 'base64', media_type: mimeType, data: data.toString('base64') },
}

整段 resolveImagePath / isPathInsideCwd(realpath 解 symlink、判 cwd 越界)连同它们的 import(realpath, isAbsolute, relative, resolve, sep)都被删了。normalizeClaudeTurnInput 和它的下游函数也都不再需要 cwd 参数——runner 调用处随之少传一个 this.config.cwd

Codex 那边更直白——它从来就只把 path 透传给 CLI,本 PR 只改了注释把新契约写清楚:

apps/daemon/src/agent/providers/codex/protocol.ts · toUserInputItem真实代码(节选)
// Codex 用文件系统路径携带本地图片;service 已把 upload ref 解析成绝对的、
// session 专属路径,mime 由 Codex 自己 provider 侧推断。
if (part.type === 'image') return { type: 'localImage', path: part.path }

「mime 由 Codex 自己推断」正是 §5.3 那道中央闸门存在的理由——Eyrie 这层不替 Codex 验 mime,就必须在更上游统一验。

排查路标 · 带图 turn
症状从哪下手
客户端发带图 turn 报 validation.failed,但图确实上传成功agent/service.tsresolveImagePart 三道关——先看是 sidecar 缺失还是 mime 非 image
报「这个 ref 不合法」但路径看着没问题services/upload-store.tsresolveUploadRef 的 4 道校验,尤其 refSession !== sessionId 跨 session 守卫
一个 session 想用另一个 session 上传的图同上,跨 session 守卫本就该拒;这是设计而非 bug
图传上去了但 turn 里读不到 / 路径对不上index.ts:确认 UPLOAD_CONFIG.baseDir 同时喂给了 blob 路由和 AgentService,两边 baseDir 必须同源
Claude 报 claude-image-mime-unsupportedproviders/claude/input.tsSUPPORTED_IMAGE_MIME_TYPES 白名单(png/jpeg/webp/gif)比上游 allowlist 更窄
图太大被拒两道:upload-store.ts 上传 50MB 上限;claude/input.tsMAX_CLAUDE_IMAGE_BYTES 10MB(Codex 无此关)
新增一个「带文件输入的 provider」的配方:① 在 resolveImagePart(或同级新解析器)里把 ref 解析成 resolved part;② 中央闸门已替你验过 mime/containment,provider 侧只需读 part.path;③ 若该 provider 有自己的格式/尺寸约束(如 Claude 的窄 mime 白名单),放在 provider 的 build*Part 里,别回退到「自己解析路径」。

6心智模型补丁

读完这个 PR,关于 agent 输入你需要改掉的几条旧假设:

image part 在 wire 上带 { path, mimeType },客户端说什么是什么 wire 上 image 只带 { ref }pathmimeType 由服务端从 ref + sidecar 解析
路径越界 / symlink 逃逸由 Claude provider 的 resolveImagePath containment 收敛到 resolveUploadRef 单点,按 session cache 目录判;provider 不再验路径
推论:任何「绕过 resolveTurnInput 直接构造 AgentInput 喂给 runner」的新代码,会重新打开 provider 不再守的那个洞(见 §8 🟠)。
Codex 的图片输入不做 mime 检查,只有 Claude 检查 一道中央 /^image\// 闸门在 resolveImagePart 里,对所有 provider 统一拒非图片
upload-storeroutes/ 的一部分,blob 路由独享 它在 services/,blob 路由和 turn 解析器共用同一个 defaultUploadBaseDir()
turn 输入的 normalize 是 service.ts 里一个同步自由函数 normalizeInput AgentService 的 async 方法 resolveTurnInput,要做 I/O(读 sidecar),且发生在开 run 之前

7新词表

输入解析
upload ref<sessionId>/<id>-<name> 形式的不透明句柄,POST /blob 返回;客户端拿它代替真实路径发 turn
AgentTurnInput / ...Partwire 形状:客户端发来的,image 只带 ref
AgentInput / ...Partresolved 形状:provider 消费的,image 带 path + mimeType
resolveTurnInputAgentService 方法,把整条 turn 的 wire 输入解析成 resolved,开 run 前调一次
中央 image 闸门resolveImagePart 里那道 /^image\//,所有 provider 共享的非图片拒绝点
存储
sidecar / .meta.json每个 blob 旁边写的 {name, mimeType} JSON,存上传时的原始身份;服务端 mime 真相源
resolveUploadRef纯函数,把 ref 反解成 session cache 目录下的绝对路径,单点做 containment(不做 I/O)
defaultUploadBaseDir~/.eyrie/cache/uploads,blob 路由和 turn 解析器的共同根目录
isSafeStoredName守 storedName 只能是 storeUpload 能产出的安全 basename(拒点段/反斜杠/NUL/.meta.json

8测试与风险地图

纯事实陈述:哪些行为被测试钉住了,哪些是薄冰。

有兜底的

薄冰

合并前可考虑的一件事(非阻塞):🟠 那条信任不变式目前只有注释守着。若想让它更难被未来代码破坏,可以考虑一个 lint/boundary 约束或类型手段,确保 runner.startTurn 收到的 AgentInput 只能源自 resolveTurnInput。属理解所得的事实陈述,是否动手由 owner 判断。

9验收提示

验收这个 PR 时,下面几处看着像问题、其实不是,别被吓到:

10覆盖声明

本报告基于对 main...session-backend-image 全量 diff 的精读(723 行,19 文件,未抽样)。报告中引用的每段代码都来自亲自 Read 过的分支文件:agent/service.tsagent/types.tsservices/upload-store.tsproviders/claude/input.tsproviders/codex/protocol.tsroutes/blob.tstrpc/services.tsindex.tspackages/api/src/schemas.ts,以及全部测试文件的 diff。commit 演进(8 个)逐条扫过 subject 并据此还原了「.meta.json 疑问被后续 fix 关闭」这条线。

未深入:blob.ts 的 range/HEAD/content-disposition 逻辑本 PR 几乎未动(仅一行 import 路径改动),只读未细讲;desktop/client 侧确认「无 image part 构造者」是通过 grep 而非逐文件通读得出。本报告只做理解,不含 code review 判断。