session-backend-image:把图片输入的信任从客户端搬到服务端
eyrie · main...session-backend-image · 2026-06-17 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
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 删掉的那段路径围栏。
| 设计重心(要细读) | 可放心略过 |
|---|---|
agent/service.ts:新增 resolveTurnInput / resolveImagePart,中央 mime 闸门 |
upload-store.ts 从 routes/ 搬到 services/:93% 是纯搬运,git 认作 rename |
agent/types.ts + api/schemas.ts:public 与 resolved 两套输入形状的拆分 |
5 个测试文件里 AgentInput → ResolvedAgentInput 的 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 里
工作区围栏
直接用 path
现在 · 中央闸门,provider 只拿结果
resolveTurnInput
只读 path
只读 path
4数据形状先行
整个 PR 的支点是一个类型拆分:原来一套 AgentInputPart 同时当「客户端发来的线缆形状」和「provider 消费的形状」,现在拆成两套。先看清形状,旅程才好走。
public(线缆)形状——客户端能构造的,图片块只有一个 ref:
// 一个 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:
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:
// 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 会在哪一步、以什么错误被挡下。
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。
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——漏一个活的子进程句柄。
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 可能根本不是图片。三道关逐一对应:
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:它原来有一整套工作区围栏,现在整段没了。
path + cwdresolveImagePath:resolve workspace root,realpath 解 symlinkisPathInsideWorkspace:判 path 在不在 workspace 内,逃逸则抛 claude-image-path-outside-workspacepath(无 cwd 入参)part.path,无路径解析注意 Claude 这边仍保留自己的 mime 白名单——它只接受 png/jpeg/webp/gif 四种,比服务端那道宽泛的 image/* 闸门更严。两道闸门叠加:服务端闸门保证「是张图」,Claude 闸门保证「是 Claude 能吃的图」。删掉的只是路径围栏,不是 mime 校验。
// 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 的中央闸门兜住了。
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.ts 的 resolveImagePart:三道关哪一道抛的——ref 形状 / sidecar 缺失 / mime 非图片 |
| 「一个会话能看到另一个会话的图」之类越权 | services/upload-store.ts 的 resolveUploadRef:跨会话 guard 与会话目录围栏 |
| 图上传成功但发 turn 说不是图片 | 上传白名单宽(含 text/pdf),中央闸门只放 image/*;查 sidecar 里记的 mime(来自上传时的 file.type) |
Claude 报 claude-image-mime-unsupported | claude/input.ts 的 SUPPORTED_IMAGE_MIME_TYPES:只收 png/jpeg/webp/gif,比服务端闸门更严 |
| 历史里 image 占位符的 mime 不对 | service.ts resolveTurnInput 重建的 text,占位符用服务端 mime;客户端声明的从不入库 |
6心智模型补丁
path + mimeType,provider 拿到什么用什么。
image 块只带不透明 ref;path 和 mime 由 AgentService 在 turn 开始前解析,provider 永远拿不到原始 ref。
ResolvedAgentInputPart。resolveUploadRef 一处做,按会话目录隔离;Claude 不再有 resolveImagePath / isPathInsideWorkspace。
services/upload-store.ts,不再去 claude/input.ts。image/* 闸门在 resolveImagePart 兜住所有 provider,包括自己不查的 Codex。
upload-store.ts 在 routes/ 下,是路由层的东西。
它在 services/ 下,是中立服务;AgentService 解析 turn 输入不再需要向上依赖 routes 层。
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) |
file.type、无内容嗅探,upload-store.ts 的注释明确承认了这点(「Content sniffing is not performed」)并以 DENIED_MIME 拒掉 svg/html/js/xml 等活动内容兜底。这是设计取舍不是缺陷,但「服务端可信 mime」的可信度上界 = 上传时客户端声明的诚实度,值得在心智模型里记一笔。
9验收提示
upload-store.ts显示为「新文件」是 git 的呈现:它是routes/upload-store.ts→services/upload-store.ts的 93% rename,resolveUploadRef/readUploadMetadata/storeUpload都是 main 上已有的代码,本 PR 只新增了defaultUploadBaseDir()并改了 import 路径。别当成 217 行全新逻辑审。f45feaf整个 commit 是 biome 格式化:把联合类型和一个 map 折成单行以过 CI,无行为变化。- 5 个测试文件里大量
AgentInput→ResolvedAgentInput的改动是等价改名:FakeRunner 的startTurn签名跟着接口收紧而已,不是逻辑变化。 claude-runner-turn.test.ts净删 100 行:删的是工作区围栏的几个用例(symlink 逃逸、workspace 内外、dot-dot 路径),因为对应实现被删了,不是丢测试覆盖。- 全量
bun run test红一片别慌:136 个失败全是better-sqlite3原生绑定没编译,环境问题;typecheck 全绿,PR 相关 5 个测试文件 89/89 过。
10覆盖声明
本次为全量精读,未分发 subagent(diff ~685 行,属小 PR 档)。逐文件读了全部 20 个文件的 diff;报告中引用的代码段均亲自 Read 过所在文件(agent/service.ts、services/upload-store.ts、claude/input.ts、codex/protocol.ts、api/schemas.ts、agent/types.ts 及对应测试)后裁剪。已在分支上跑通 typecheck(6 包全绿)与 PR 相关 5 个测试文件(89/89)。全量测试套因 better-sqlite3 原生绑定缺失无法跑通,已单独验证该失败与本 PR 无关。未做 code review(不评风格、不提改进),第 8 节的「薄冰」为事实陈述。