fix(session): restrict attachment discovery to declared event content

Attachment reads and ZIP export previously inferred ownership from common
field names on arbitrary events. Opaque ignorable plugin payloads could
therefore cause storage reads, while declared compaction content was missed.

Select attachment-bearing fields by current built-in event type in both
readers. Read direct image and file blocks, including flat V4 tool results
and developer messages, without interpreting extra block fields. Leave
unknown payloads and malformed inbox entries opaque. The archive preserves
the complete logical log even when its payload does not authorize a read.

V4 validates title requests as one text block, so do not treat them as an
attachment carrier. Compaction keeps its declared ContentBlock fields for
valid custom producers; the basic producer's text-only output is documented.
Historical wrappers remain the adjacent converters' responsibility rather
than a second compatibility path in attachment readers.

Adapt the original policy change to master after V4 landed, retaining its
author credit and scope. Cover tool-result export explicitly, keep Team
fixtures free of invented message roles, and verify that denied carriers
never read storage. The 889 owning tests pass with 100% statement, branch,
function, and line coverage for both changed runtime files.

Co-authored-by: Tianyi Cui <[email protected]>
This commit is contained in:
Tianyi Cui committed 2026-09-19 21:08:29 +08:00
1 parent b1dbe92356
commit 0c44e5461d
13 files changed
+338 -62

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-declared-session-attachment-carriers.md
2026-09-19-declared-session-attachment-carriers.md: 1619624800dc5e1ee8d6a4f4b64b84a6c80078c2
2026-09-19-declared-session-attachment-carriers.zh.md: bd66b1c80469e2adbd7becc1b015d390978becc6
@@ -0,0 +1,31 @@
# Agent Note: Declared Session attachment carriers
Status: implemented
English | [中文](2026-09-19-declared-session-attachment-carriers.zh.md)
## Problem
Session attachment authorization and ZIP export inferred attachment ownership from common payload field names. An unknown ignorable event could therefore authorize an image read or export file bytes. Compaction content under `summary` and `rawOutput` was outside that field-name scan.
## Decision
Both consumers select content fields by built-in event type. They inspect completed assistant blocks and direct content blocks, including flat V4 tool-role messages. Other content-block fields and unknown event payloads remain opaque, even when they contain objects resembling image or file references.
The controller applies this rule before reading image bytes. The exporter applies it before reading image or file bytes and retains the complete logical log. Historical logs reach these readers after persistence converts them to current logical events. Title requests are excluded because V4 requires a single text block. Compaction uses its declared content fields: the basic producer currently emits text, while other valid compaction producers may supply attachments there.
## Alternatives considered
**Scan common keys on every event.** This lets a field name grant attachment access without a defined content meaning. It also misses valid content stored under other declared fields.
**Interpret historical wrappers in attachment readers.** Historical conversion belongs to the adjacent format packages. Current attachment readers use flat V4 content and do not maintain a second interpretation of old blocks.
## Consequences
Plugins that store attachments only in custom events cannot use these built-in readers for those references. A plugin needs a supported content occurrence or its own reader. Adding a built-in content carrier requires updating both readers and their acceptance and refusal tests. Historical codec inventories remain version-owned; changing this policy must not change what a released decoder recognizes.
The change affects attachment lookup and archive membership, not Session encoding or the model-visible message history.
## Verification
Controller and archive tests cover declared carriers, flat tool results, opaque plugin payloads, and misleading extra fields. Refusal tests assert that attachment storage is never read; archive tests also compare the exported log text.
@@ -0,0 +1,31 @@
# Agent Note: Session 声明的附件载体
Status: implemented
[English](2026-09-19-declared-session-attachment-carriers.md) | 中文
## 问题
Session 附件授权与 ZIP 导出按常见载荷字段名推断附件归属。因此,未知可忽略事件也能授权图片读取或导出文件字节。压缩事件的 `summary` 与 `rawOutput` 内容不在这套字段名扫描范围内。
## 决策
两个消费者均按内置事件类型选择内容字段。它们检查已完成的 assistant 块与直接内容块,包括扁平的 V4 tool 角色消息。其他内容块字段与未知事件载荷保持不透明,即使其中含有类似图片或文件引用的对象。
控制器在读取图片字节前应用此规则。导出器在读取图片或文件字节前应用此规则,并保留完整逻辑日志。历史日志由持久化层转换成当前逻辑事件后交给这些读取器。标题请求不在附件载体之列,因为 V4 要求它只含一个文本块。压缩事件使用其声明的内容字段:基础生产者目前输出文本,其他合法压缩生产者可以在这些位置提供附件。
## 考虑过的替代方案
**扫描所有事件的常见键。** 这使字段名在没有明确内容语义时授予附件访问权,也会遗漏其他已声明字段中的有效内容。
**在附件读取器中解释历史包装块。** 历史转换由相邻格式包负责。当前附件读取器使用扁平的 V4 内容,不维护第二套旧块解释。
## 影响
仅在自定义事件中存储附件的插件不能通过这些内置读取器读取相关引用。插件需要使用受支持的内容位置,或提供自己的读取器。新增内置内容载体时,需要同步更新两个读取器及其接纳和拒绝测试。历史编解码器的清单仍归各版本所有;调整此策略不能改变已发布解码器识别的内容。
此变更影响附件查找与归档成员,不影响 Session 编码或模型可见消息历史。
## 验证
控制器和归档测试覆盖声明的载体、扁平工具结果、不透明插件载荷及误导性额外字段。拒绝测试断言不会读取附件存储;归档测试还比较导出的日志文本。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
README.md: 228ed88ed20b9f9581496468e47079afd1542e4f
README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a
README.md: 7ba4ebd33d94c9775afe7edc0d42ef32fc08ebf0
README.zh.md: aad2e9cc48e99a4dc7f063719edfcc717aceb025
+3 -1
View File
@@ -27,7 +27,7 @@ English | [中文](README.zh.md)
History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering.
@@ -54,6 +54,8 @@ A resume blocked by an existing write handle returns `session/writer-held` with
Queue edits replace pending content with non-empty text only.
Attachment authorization reads declared content fields of built-in Session events and completed assistant stream blocks, including flat V4 tool-role messages. Unknown event payloads and unrelated fields cannot authorize attachment reads.
<a id="client-references"></a>
## Client references
+3 -1
View File
@@ -27,7 +27,7 @@ kind: "package-reference"
历史页与 follow opening 快照为每个持久 Session 事件携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。
Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。
@@ -54,6 +54,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
队列编辑仅允许用非空文本替换待处理内容。
附件授权读取内置 Session 事件声明的内容字段与已完成的 assistant 流块,包括扁平的 V4 tool 角色消息。未知事件载荷与无关字段不能授权附件读取。
<a id="client-references"></a>
## Client 引用
+39 -18
View File
@@ -620,19 +620,16 @@ function imageBlockIn(
if (!Array.isArray(content)) return undefined
for (const value of content) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown }
const block = value as { readonly type?: unknown; readonly attachment?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
if (match(ref)) return ref
}
if (block.type === 'tool-result') {
const nested = imageBlockIn(block.content, match)
if (nested !== undefined) return nested
}
}
return undefined
}
/** Read only first-party declared content fields; unknown event payloads stay opaque. */
function imageInEvent(
event: SessionEvent,
match: (ref: ImageAttachmentRef) => boolean,
@@ -640,21 +637,45 @@ function imageInEvent(
const data = event.data as {
readonly content?: unknown
readonly message?: { readonly content?: unknown }
readonly inserted?: readonly { readonly content?: unknown }[]
readonly inserted?: unknown
readonly summary?: unknown
readonly rawOutput?: unknown
}
const direct = imageBlockIn(data.content, match)
if (direct !== undefined) return direct
const message = imageBlockIn(data.message?.content, match)
if (message !== undefined) return message
for (const inserted of data.inserted ?? []) {
const found = imageBlockIn(inserted.content, match)
if (found !== undefined) return found
}
if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
for (const chunk of assistantStreamChunks(event.data.stream, 'block-end')) {
const found = imageBlockIn([chunk.block], match)
if (found !== undefined) return found
// First-party event payloads can be present without their producer plugin mounted.
const type: string = event.type
switch (type) {
case 'user/message':
case 'tool/ptc-dispatch':
return imageBlockIn(data.content, match)
case 'system/message':
case 'developer/message':
case 'tool/result':
case 'team/message/queued':
return imageBlockIn(data.message?.content, match)
case 'agent/inbox/spliced': {
const messages = data.inserted
if (!Array.isArray(messages)) return undefined
for (const message of messages as readonly unknown[]) {
if (typeof message !== 'object' || message === null || Array.isArray(message)) continue
const found = imageBlockIn((message as { readonly content?: unknown }).content, match)
if (found !== undefined) return found
}
return undefined
}
case 'compaction/summary':
return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match)
case 'assistant/message': {
const found = imageBlockIn(data.message?.content, match)
if (found !== undefined) return found
break
}
case 'assistant/attempt': break
default: return undefined
}
const assistant = event as SessionEvent<'assistant/message' | 'assistant/attempt'>
for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) {
const found = imageBlockIn([chunk.block], match)
if (found !== undefined) return found
}
return undefined
}
@@ -316,7 +316,8 @@ function imageRef(id: string): ImageAttachmentRef {
}
function event(type: string, seq: SessionSeq, data: unknown): SessionEvent {
return { type, seq, time: seq + 1, data } as SessionEvent
const surface = ['user/message', 'system/message', 'developer/message', 'tool/result'].includes(type)
return { type, seq, time: seq + 1, data, ...surface ? { surfaceOp: 'append' } : {} } as SessionEvent
}
async function persistedController(
@@ -348,16 +349,97 @@ async function persistedController(
}
describe('Session attachment authorization', () => {
it('finds references in direct, message, inserted, nested, and streamed content', async () => {
const nested = imageRef('nested')
it.each([
['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'],
['team/message/queued', 'message'], ['tool/ptc-dispatch', 'content'],
['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'],
] as const)('reads the declared %s %s content without rewriting it', async (type, field) => {
const ref = imageRef(`${type}-${field}`)
const content = [{ type: 'image', attachment: ref }]
const message = type === 'team/message/queued' ? { content }
: type === 'tool/result' ? {
id: 'declared-message', role: 'tool', source: { kind: 'tool', callId: 'declared-call' },
toolCallId: 'declared-call', isError: false, content,
} : {
id: 'declared-message', role: type === 'developer/message' ? 'developer' : 'system',
source: { kind: 'system-prompt' }, content,
}
const data = field === 'message' ? { message } : { [field]: content }
const events = [event(type, SessionSeq(0), data)]
const saved = JSON.stringify(events)
const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) }))
const fixture = await persistedController(events, readImage)
try {
await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId }))
.resolves.toEqual({ attachment: ref, data: 'AQ==' })
expect(readImage).toHaveBeenCalledExactlyOnceWith(ref)
expect(JSON.stringify(events)).toBe(saved)
} finally {
await fixture.ctx.fiber.dispose()
}
})
it('does not authorize an attachment through malformed inbox entries', async () => {
const ref = imageRef('not-referenced')
for (const value of [undefined, null, {}, [null, 1, []]]) {
const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) }))
const fixture = await persistedController([event('agent/inbox/spliced', SessionSeq(0), { inserted: value })], readImage)
try {
await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId }))
.rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } })
expect(readImage).not.toHaveBeenCalled()
} finally {
await fixture.ctx.fiber.dispose()
}
}
})
it('ignores unrelated fields of a known message event and its content blocks', async () => {
const ref = imageRef('not-a-content-occurrence')
const content = [{ type: 'image', attachment: ref }]
const stored = event('user/message', SessionSeq(0), {
id: 'metadata', role: 'user', source: { kind: 'user' },
content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }],
message: { content }, inserted: [{ content }],
})
const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) }))
const fixture = await persistedController([stored], readImage)
try {
await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId }))
.rejects.toMatchObject({ code: 'session/attachment-invalid' })
expect(readImage).not.toHaveBeenCalled()
} finally {
await fixture.ctx.fiber.dispose()
}
})
it.each(['external/image-record', 'session/title-llm-request'])('denies attachment access from %s without an attachment carrier', async (type) => {
const ref = imageRef('opaque-event-image')
const content = [{ type: 'image', attachment: ref }]
const opaque = { ...event(type, SessionSeq(0), {
content, message: { content }, inserted: [{ content }], messages: [{ content }],
stream: [{ type: 'chunk', time: 1, chunk: { type: 'block-end', index: 0, block: content[0] } }],
}), ignorable: true as const }
const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) }))
const fixture = await persistedController([opaque], readImage)
try {
await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId }))
.rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } })
expect(readImage).not.toHaveBeenCalled()
} finally {
await fixture.ctx.fiber.dispose()
}
})
it('finds references in direct, message, inserted, and streamed content', async () => {
const direct = imageRef('direct')
const message = imageRef('message')
const inserted = imageRef('inserted')
const streamed = imageRef('streamed')
const events: SessionEvent[] = [
{ ...event('fixture/direct', SessionSeq(0), {
content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
type: 'tool-result', content: [{ type: 'image', attachment: nested }],
}],
{ ...event('user/message', SessionSeq(0), {
id: 'direct', role: 'user', source: { kind: 'user' },
content: [null, [], { type: 'text', text: 'none' }, { type: 'image', attachment: direct }],
}), ignorable: true as const },
{
type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append',
@@ -374,7 +456,7 @@ describe('Session attachment authorization', () => {
event('agent/inbox/spliced', SessionSeq(2), {
target: 'next-turn',
start: 0,
inserted: [createUserMessage({
inserted: [null, 1, [], { content: [] }, createUserMessage({
content: [{ type: 'image', attachment: inserted }],
source: { kind: 'user' },
})],
@@ -408,7 +490,7 @@ describe('Session attachment authorization', () => {
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
const { ctx, controller, sessionId } = await persistedController(events, readImage)
for (const ref of [nested, message, inserted, streamed]) {
for (const ref of [direct, message, inserted, streamed]) {
await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId }))
.resolves.toEqual({ attachment: ref, data: 'AQ==' })
}
@@ -451,7 +533,7 @@ describe('Session attachment authorization', () => {
]) {
const ref = imageRef(`failure-${thrown.name}`)
const fixture = await persistedController(
[event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })],
[event('user/message', SessionSeq(0), { id: 'failure', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })],
() => Promise.reject(thrown),
)
await expectFailure(fixture.controller.attachment({
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md
README.md: 79b8d572a75f57aa13a0dfaa823a04ad1e818f87
README.zh.md: d6229927130e7200cd21df0da64d0ce9ec80ab2f
README.md: fa77afe55a12803b4c637b66768ab8eec671edee
README.zh.md: a266d3a30426b4efe188711f8322aafca6fb654c
@@ -59,6 +59,8 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u
The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/<attachmentId>.<ext>`, and generic files use `files/<digest-prefix>/<digest>/<name>`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full.
Attachment collection reads declared content fields of built-in Session events and completed assistant stream blocks, including flat V4 tool-role messages. Unknown event payloads and unrelated fields remain unchanged in the exported log but do not cause attachment reads.
### Failures
The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog.
@@ -59,6 +59,8 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后结束时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/<attachmentId>.<ext>`,通用文件使用 `files/<digest-prefix>/<digest>/<name>`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。
附件收集读取内置 Session 事件声明的内容字段与已完成的 assistant 流块,包括扁平的 V4 tool 角色消息。未知事件载荷与无关字段在导出日志中保持不变,但不会触发附件读取。
### 失败
当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
@@ -196,9 +196,8 @@ function fileEntryPath(ref: FileAttachmentRef): string {
}
/**
* Collect every attachment reference inside one content array, descending into
* nested tool results the way the live attachment route does.
* @param content - an event content array (or nested tool-result content).
* Collect direct attachment blocks from one declared V4 content array.
* @param content - an event or message content array.
* @param images - image dedupe map keyed by attachment id.
* @param files - file dedupe map keyed by attachment id and stored name.
*/
@@ -208,12 +207,9 @@ function collectAttachmentRefs(
files: Map<string, FileAttachmentRef>,
): void {
if (!Array.isArray(content)) return
const pending: unknown[] = []
for (const item of content) pending.push(item)
while (pending.length > 0) {
const value = pending.pop()
for (const value of content) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
const block = value as { type?: unknown; attachment?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
images.set(String(ref.attachmentId), ref)
@@ -222,16 +218,12 @@ function collectAttachmentRefs(
const ref = block.attachment as FileAttachmentRef
files.set(`${String(ref.attachmentId)}\u0000${ref.name}`, ref)
}
if (Array.isArray(block.content)) {
for (const item of block.content) pending.push(item)
}
}
}
/**
* Collect every attachment reference one session event carries, across the same
* carriers the live attachment route scans (direct content, message content,
* inserted messages, and completed blocks in embedded Assistant streams).
* Collect references only from declared first-party content fields and completed
* Assistant blocks. Unknown events and unrelated payload fields remain opaque.
* @param event - one parsed JSONL event object.
* @param images - image dedupe map keyed by attachment id.
* @param files - file dedupe map keyed by attachment id and stored name.
@@ -241,18 +233,43 @@ function collectEventAttachmentRefs(
images: Map<string, ImageAttachmentRef>,
files: Map<string, FileAttachmentRef>,
): void {
const data = (event as { data?: unknown }).data
if (typeof event !== 'object' || event === null || Array.isArray(event)) return
const row = event as { type?: unknown; data?: unknown }
const data = row.data
if (typeof data !== 'object' || data === null) return
const carrier = data as {
content?: unknown
message?: { content?: unknown }
inserted?: Array<{ content?: unknown }>
inserted?: unknown
summary?: unknown
rawOutput?: unknown
stream?: Array<{ type?: unknown; chunk?: { type?: unknown; block?: unknown } }>
}
collectAttachmentRefs(carrier.content, images, files)
if (carrier.message !== undefined) collectAttachmentRefs(carrier.message.content, images, files)
if (carrier.inserted !== undefined) {
for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files)
switch (row.type) {
case 'user/message': case 'tool/ptc-dispatch':
collectAttachmentRefs(carrier.content, images, files)
return
case 'system/message': case 'developer/message': case 'tool/result': case 'team/message/queued':
collectAttachmentRefs(carrier.message?.content, images, files)
return
case 'agent/inbox/spliced': {
const messages = carrier.inserted
if (!Array.isArray(messages)) return
for (const message of messages as readonly unknown[]) {
if (typeof message !== 'object' || message === null || Array.isArray(message)) continue
collectAttachmentRefs((message as { readonly content?: unknown }).content, images, files)
}
return
}
case 'compaction/summary':
collectAttachmentRefs(carrier.summary, images, files)
collectAttachmentRefs(carrier.rawOutput, images, files)
return
case 'assistant/message':
collectAttachmentRefs(carrier.message?.content, images, files)
break
case 'assistant/attempt': break
default: return
}
if (carrier.stream !== undefined) {
for (const record of carrier.stream) {
@@ -503,6 +503,15 @@ describe('session.export download endpoint', () => {
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe('')
})
it('preserves non-event JSON and absent content carriers without discovering attachment lookalikes', async () => {
const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png' } }]
const text = [null, [], false, { type: 'agent/inbox/spliced', data: { content } },
{ type: 'session/title-llm-request', data: { content } }].map(value => JSON.stringify(value)).join('\n')
const files = await directZipFiles(text)
expect(Object.keys(files)).toEqual([exportLogName])
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(text)
})
it('waits for response pull capacity before reading the next archive entry', async () => {
const filler = {
type: 'user/message', seq: SessionSeq(2), time: 1000,
@@ -797,17 +806,88 @@ describe('session.export download endpoint', () => {
await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing')
})
it('collects media referenced from nested tool results', async () => {
const nested = {
type: 'assistant/message', seq: SessionSeq(2), time: 2000,
data: { content: [{ type: 'tool-result', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] }] },
it.each([
['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'], ['team/message/queued', 'message'],
['tool/ptc-dispatch', 'content'],
['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'],
] as const)('exports attachments from the declared %s %s content', async (type, field) => {
const content = [{ type: 'image', attachment: { attachmentId: 'declared-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }]
const message = type === 'tool/result'
? { role: 'tool', toolCallId: 'declared-call', source: { kind: 'tool', callId: 'declared-call' }, content }
: { content }
const data = field === 'message' ? { message } : { [field]: content }
const stored = log('session-root', undefined, [{ type, seq: SessionSeq(1), time: 1000, data } as unknown as SessionEvent])
const api = await buildApi({ 'session-root': stored })
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual(['media/declared-image.png', exportLogName].sort())
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
})
it('keeps malformed inbox entries opaque while exporting the complete log', async () => {
for (const value of [undefined, null, {}, [null, 1, []]]) {
const event = { type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 1000, data: { inserted: value } } as SessionEvent
const stored = log('session-root', undefined, [event])
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType)))
const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage })
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files)).toEqual([exportLogName])
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
expect(readImage).not.toHaveBeenCalled()
}
})
it('keeps unrelated event fields and content-block extension fields out of attachment collection', async () => {
const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }]
const event = { type: 'user/message', seq: SessionSeq(1), time: 1000, data: {
content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }],
message: { content }, inserted: [{ content }],
stream: [{ type: 'chunk', chunk: { type: 'block-end', block: content[0] } }],
} } as unknown as SessionEvent
const stored = log('session-root', undefined, [event])
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType)))
const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage })
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files)).toEqual([exportLogName])
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
expect(readImage).not.toHaveBeenCalled()
})
it.each(['external/image-record', 'session/title-llm-request'])('exports %s without reading attachments from its opaque payload', async (type) => {
const image = { type: 'image', attachment: { attachmentId: 'opaque-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }
const file = { type: 'file', attachment: { attachmentId: `sha256:${'e'.repeat(64)}`, name: 'opaque.txt', bytes: 5 } }
const content = [image, file]
const opaque = { type, seq: SessionSeq(1), time: 1000, ignorable: true, data: {
content, message: { content }, inserted: [{ content }], messages: [{ content }],
stream: [{ type: 'chunk', time: 1000, chunk: { type: 'block-end', index: 0, block: content[0] } }],
} } as unknown as SessionEvent
const stored = log('session-root', undefined, [opaque])
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType)))
const readFileStream = vi.fn(async function* (_ref: FileAttachmentRef) { yield new Uint8Array([1]) })
const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage, readFileStream })
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files)).toEqual([exportLogName])
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
expect(readImage).not.toHaveBeenCalled()
expect(readFileStream).not.toHaveBeenCalled()
})
it('collects media referenced by a flat tool-role result', async () => {
const result = {
type: 'tool/result', seq: SessionSeq(2), time: 2000,
data: { message: { role: 'tool', toolCallId: 'call', source: { kind: 'tool', callId: 'call' },
content: [{ type: 'image', attachment: { attachmentId: 'tool-image', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }],
} },
} as unknown as SessionEvent
const api = await buildApi({ 'session-root': log('session-root', undefined, [nested]) })
const api = await buildApi({ 'session-root': log('session-root', undefined, [result]) })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', exportLogName].sort())
expect(Object.keys(files).sort()).toEqual(['media/tool-image.webp', exportLogName].sort())
})
it('scans wrapped, inserted, and embedded-stream carriers plus non-object content items', async () => {
@@ -818,8 +898,8 @@ describe('session.export download endpoint', () => {
data: { message: { role: 'assistant', content: ['noise', block('wrapped-1', 'image/jpeg')] } },
} as unknown as SessionEvent
const inserted = {
type: 'context/inserted', seq: SessionSeq(3), time: 3000,
data: { inserted: [{ content: [block('inserted-1', 'image/gif')] }] },
type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 3000,
data: { inserted: [null, 1, [], { content: [] }, { content: [block('inserted-1', 'image/gif')] }] },
} as unknown as SessionEvent
const attempt = {
type: 'assistant/attempt', seq: SessionSeq(4), time: 4000,