#78 session-backend-image:把图片输入的「信任」从客户端搬到服务端
figuretu/eyrie · main...session-backend-image · 2026-06-21 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自 PR 分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是别人的 PR,没有随附设计文档,关键设计点处补一句「为什么不是直觉做法」。
1TL;DR
一个 agent turn 里要带图片时,客户端原来直接在 wire 上声明 { path, mimeType }——服务端拿到什么就信什么,路径校验落在每个 provider 各自的代码里(Claude 做,Codex 不做)。本 PR 把这套信任模型翻过来:
- wire 上图片只剩一个不透明的
ref(先POST /blob上传拿到的句柄),path和mimeType客户端再也声明不了。 AgentService在 turn 真正开跑前,把ref解析成 session 专属的绝对路径,并从服务端落盘时记下的 sidecar 里读真实 mime——一道中央 image 闸门对所有 provider 统一拒非图片(尤其救了完全不做 mime 检查的 Codex)。- containment(不许越界读别的目录 / 别的 session)从 Claude provider 里删掉,收敛到
resolveUploadRef一个地方做。 upload-store从routes/搬到services/,让 blob 路由和 turn 解析器从同一个 cache 目录真相源派生。
动机(自 commit message 与 PR 描述推断):「trusting nothing the client declares」——把对客户端输入的信任面收到最小,并堵住一个 provider 漏检 mime 的窟窿。
2变更地图(称重)
改动几乎全压在 apps/daemon,packages/api 只动了一条 schema。约三分之二的行数是测试——但要诚实:这不是「水活」,删掉的是一整组「provider 做 cwd 越界检查」的旧测试,新增的是「service 解析 ref」的新测试,测试的重写本身就是这次信任模型搬家的镜像。
| 设计重心(要细读) | 可放心略过(机械变更) |
|---|---|
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.ts — resolveUploadRef 的 containment + isSafeStoredName + defaultUploadBaseDir |
agent-provider-discovery.test.ts — 抽出 claudeBin 常量 + 多塞一个构造参数,无行为变化 |
packages/api/src/schemas.ts — agentInputPartSchema 由 {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
现在 · 信任收敛到服务端
左图里 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」钉死在类型系统里。
// 客户端发来的 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 里唯一的实质改动。
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 的全部要害。
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。
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 句柄。
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 的 resolved 是 AgentInput(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 确实是图片。
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 目录下的绝对路径,沿途四道校验。
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 refs 在 isSafeStoredName 里加了 !storedName.endsWith(METADATA_SUFFIX),fix(daemon): reject reserved upload filenames 又在 storeUpload 里禁止上传名以 .meta.json 结尾。所以 PR 描述里那条 note 现在已经是过时信息,对应的洞两头都堵上了。
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——对比最能说明问题。
part.path(cwd 相对)resolveImagePath(cwd, path)isPathInsideCwd 判越界part.path(服务端给的绝对路径)const path = part.path(直接用)// 经 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 只改了注释把新契约写清楚:
// 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.ts:resolveImagePart 三道关——先看是 sidecar 缺失还是 mime 非 image |
| 报「这个 ref 不合法」但路径看着没问题 | services/upload-store.ts:resolveUploadRef 的 4 道校验,尤其 refSession !== sessionId 跨 session 守卫 |
| 一个 session 想用另一个 session 上传的图 | 同上,跨 session 守卫本就该拒;这是设计而非 bug |
| 图传上去了但 turn 里读不到 / 路径对不上 | index.ts:确认 UPLOAD_CONFIG.baseDir 同时喂给了 blob 路由和 AgentService,两边 baseDir 必须同源 |
Claude 报 claude-image-mime-unsupported | providers/claude/input.ts:SUPPORTED_IMAGE_MIME_TYPES 白名单(png/jpeg/webp/gif)比上游 allowlist 更窄 |
| 图太大被拒 | 两道:upload-store.ts 上传 50MB 上限;claude/input.ts 的 MAX_CLAUDE_IMAGE_BYTES 10MB(Codex 无此关) |
resolveImagePart(或同级新解析器)里把 ref 解析成 resolved part;② 中央闸门已替你验过 mime/containment,provider 侧只需读 part.path;③ 若该 provider 有自己的格式/尺寸约束(如 Claude 的窄 mime 白名单),放在 provider 的 build*Part 里,别回退到「自己解析路径」。6心智模型补丁
读完这个 PR,关于 agent 输入你需要改掉的几条旧假设:
{ path, mimeType },客户端说什么是什么
wire 上 image 只带 { ref };path 和 mimeType 由服务端从 ref + sidecar 解析
resolveImagePath 守
containment 收敛到 resolveUploadRef 单点,按 session cache 目录判;provider 不再验路径
resolveTurnInput 直接构造 AgentInput 喂给 runner」的新代码,会重新打开 provider 不再守的那个洞(见 §8 🟠)。/^image\// 闸门在 resolveImagePart 里,对所有 provider 统一拒非图片
upload-store 是 routes/ 的一部分,blob 路由独享
它在 services/,blob 路由和 turn 解析器共用同一个 defaultUploadBaseDir()
service.ts 里一个同步自由函数 normalizeInput
是 AgentService 的 async 方法 resolveTurnInput,要做 I/O(读 sidecar),且发生在开 run 之前
7新词表
| 输入解析 | |
|---|---|
upload ref | <sessionId>/<id>-<name> 形式的不透明句柄,POST /blob 返回;客户端拿它代替真实路径发 turn |
AgentTurnInput / ...Part | wire 形状:客户端发来的,image 只带 ref |
AgentInput / ...Part | resolved 形状:provider 消费的,image 带 path + mimeType |
resolveTurnInput | AgentService 方法,把整条 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测试与风险地图
纯事实陈述:哪些行为被测试钉住了,哪些是薄冰。
有兜底的
- ref 解析全路径(happy + 3 条拒绝:sidecar 缺失 / mime 非图片 / 跨 session)——
agent-service-methods.test.ts新增的startTurn image ref resolution一组,且都断言runner.lastInput为 null(坏 ref 在开 run 前就失败)。 resolveUploadRef的形状/越界/跨 session/.meta.json——upload-store.test.ts,含新增的「拒 sidecar ref」「拒.meta.json文件名上传」。- wire schema 边界(空 ref / 超长 ref / 非 text-image 变体)——
contracts.test.ts。 - Claude 适配器读绝对路径 + mime 白名单拒绝——
claude-runner-turn.test.ts重写后的两条。 - 真机 Claude e2e(默认 skip,
CLAUDE_ENABLED才跑)——agent-tooling-e2e.test.ts,现在喂绝对 path。
薄冰
- 🟠「
AgentInput.path只能由resolveTurnInput产出」是个靠约定维持的不变式。provider 现在绝对信任这个 path(Claude 直接读、Codex 直接传给 CLI),唯一的 containment 保证就是它来自resolveUploadRef。没有任何测试或 lint 规则强制「AgentInput只能由解析器构造」——将来若有新代码用受攻击者影响的 path 构造AgentInput再喂 runner,就重新打开了 provider 不再守的越界洞。 - 🟡sidecar mime 不是内容嗅探出来的。它是上传时客户端声明的
file.type,经 allowlist 校验后落盘。「不信客户端」准确说是「不信 turn 里再次声明的 mime,只信上传时固化的那份」——一个把.txt改名.png且声明image/png上传的文件,仍会过中央闸门(字节是否真是 PNG 不在此校验)。这是事实,不是缺陷主张。 - 🟡resolve 阶段不做 size 检查。Claude 适配器有 10MB 上限,但 Codex 把 path 直接交给 CLI,本 PR 代码里没有 Codex 侧 size 关,依赖上传时的 50MB 天花板。
- ⚪sidecar 缺失即不可用。
readUploadMetadata对损坏/缺失 sidecar 返回 null →validation.failed。直接 seed(不走storeUpload)的 blob 当不了 image 输入。鉴于上传永远写 sidecar,可接受。
runner.startTurn 收到的 AgentInput 只能源自 resolveTurnInput。属理解所得的事实陈述,是否动手由 owner 判断。9验收提示
验收这个 PR 时,下面几处看着像问题、其实不是,别被吓到:
- wire 契约破坏性变更(
{path,mimeType}→{ref})却没改任何客户端——因为目前没有真实消费者:desktop 渲染层还没有任何代码构造 image part,唯一发 turn 的是 CLIsession.ts(只发纯文本),加上默认 skip 的 e2e 测试。所以这个「破坏性」变更此刻没有活的下游会被打破。 claude-runner-turn.test.ts的 −134 行红色删除——删的是「provider 做 cwd 越界 / symlink 逃逸检查」的一整组旧测试。那段逻辑本身已被删除(搬到resolveUploadRef),对应测试自然不再适用;containment 的等价覆盖搬到了upload-store.test.ts和agent-service-methods.test.ts。不是丢覆盖。- PR 描述里的「Note for reviewers」(
.meta.jsonsidecar 可被 ref 触达)——这条已被 PR 自己后续两个 fix commit 关闭(上传端 + 解析端都禁了.meta.json)。描述未更新,但代码已堵。别当作未决项。 upload-store.ts看着像新文件——它是从routes/搬到services/的 git rename(88% 相同),只多了defaultUploadBaseDir和isSafeStoredName两处增量。agent-provider-discovery.test.ts大段改动——只是把/usr/local/bin/claude这类字面量抽成claudeBin常量,外加AgentService构造多了一个uploadBaseDir参数(这里传undefined)。无行为变化。
10覆盖声明
本报告基于对 main...session-backend-image 全量 diff 的精读(723 行,19 文件,未抽样)。报告中引用的每段代码都来自亲自 Read 过的分支文件:agent/service.ts、agent/types.ts、services/upload-store.ts、providers/claude/input.ts、providers/codex/protocol.ts、routes/blob.ts、trpc/services.ts、index.ts、packages/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 判断。