Files
deepseek-harness/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
T
Tianyi Cui 0c44e5461d 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]>
2026-09-19 21:08:29 +08:00

564 lines
24 KiB
TypeScript

import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import SessionStore, {
SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq,
} from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
import { describe, expect, it, vi } from 'vitest'
import { ApiSessionAgentController } from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'test': { kind: 'test' } & ContextFormed
}
}
async function commandHarness(
childMode?: 'continuable' | 'seeded-continuable' | 'seed-only' | 'one-shot' | 'unknown' | 'corrupt',
): Promise<{
ctx: Context
controller: SessionCommandController
agent: Agent
inbox: Inbox
steer: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const sessionId = SessionId('commands-session')
const ancestor = Session.create(SessionId('ancestor'))
ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({
mode: 'continuable', provider: 'test', label: 'ancestor',
}))
// A seeded child inherits exactly the ancestor prefix; its own descriptor
// is appended after creation, as the continuation manager does. `seed-only`
// never appends one: the identity folds as continuable, but from the
// inherited prefix rather than this Session's own suffix.
const lineage = childMode === 'seeded-continuable' || childMode === 'seed-only'
? ancestor.snapshotEvents()
: undefined
const session = ctx.sessions.create(sessionId, {
...lineage === undefined ? {} : { seed: lineage, inheritedEventCount: SessionLogOffset(lineage.length) },
meta: {
cwd: '/workspace',
...(childMode === undefined ? {} : {
origin: 'subagent' as const,
parentSession: SessionId('offline-parent'),
}),
...lineage === undefined ? {} : { isSeeded: true },
},
})
if (childMode === 'continuable' || childMode === 'seeded-continuable') {
session.append('subagent/descriptor', snapshotSubagentDescriptor({
mode: 'continuable', provider: 'test', label: 'child',
}))
} else if (childMode === 'one-shot') {
session.append('subagent/descriptor', snapshotSubagentDescriptor({
mode: 'one-shot', provider: 'test', label: 'child',
}))
} else if (childMode === 'corrupt') {
session.append('subagent/descriptor', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 1,
} as never)
}
const inbox = createInboxStub()
const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) })
const cancel = vi.fn()
const agent = {
id: session.id,
session,
inbox,
status: 'running',
ctx,
steer,
followup: vi.fn(),
cancel,
} as unknown as Agent
await ctx.agents.register(agent)
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
const selection: ModelSelectionRef = {
current: { provider: 'fixture', model: 'fixture-model' },
assembled: undefined,
}
const agents = {
resolveAgent: (id: SessionId) => Promise.resolve(id === agent.id
? { agent }
: { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) }),
selectionFor: () => selection,
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
composeAgent: () => Promise.resolve({ setup: () => {} }),
} as unknown as ApiSessionAgentController
return {
ctx,
controller: new SessionCommandController(ctx, agents, '/workspace'),
agent,
inbox,
steer,
cancel,
}
}
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
await expect(operation).rejects.toMatchObject({ code })
}
describe('Session queue commands', () => {
it('preserves the cold Agent resolver rejection', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const error = new RemoteError('session/agent-busy', 'owned by a child', { reason: 'subagent-owned' })
const controller = new SessionCommandController(ctx, {
resolveAgent: () => Promise.resolve({ error }),
} as unknown as ApiSessionAgentController, '/workspace')
try {
await expect(controller.updateQueue({
sessionId: SessionId('cold-child'), itemId: MessageId('pending'), action: { kind: 'remove' },
})).rejects.toBe(error)
} finally {
await ctx.fiber.dispose()
}
})
it('edits, removes, steers, and rejects stale queue occurrences', async () => {
const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness()
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
inbox.append('next-turn', queued)
inbox.append('next-step', nextStep)
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id,
itemId: queued.id,
action: {
kind: 'edit',
content: [{
// @ts-expect-error -- remote edit payloads can carry unsupported image blocks.
type: 'image',
attachment: {
attachmentId: AttachmentId('att-edit'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
},
}],
},
})), 'session/attachment-invalid')
for (const content of [[], [{ type: 'text' as const, text: ' \t\n' }]]) {
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id,
itemId: queued.id,
action: { kind: 'edit', content },
})), 'gateway/bad-request')
}
expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'queued' }])
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' },
})), 'session/queue-item-not-found')
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' },
})), 'session/queue-item-not-found')
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' },
})), 'session/steer-unavailable')
Object.assign(agent, { status: 'idle' })
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
})), 'session/steer-unavailable')
expect(await controller.updateQueue({
sessionId: agent.id,
itemId: queued.id,
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
})).toEqual({ accepted: true })
expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
// An edit rewrites content in place, so the occurrence a client addressed
// by id stays addressable.
expect(inbox.nextTurn[0]?.id).toBe(queued.id)
expect(await controller.updateQueue({
sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
})).toEqual({ accepted: true })
Object.assign(agent, { status: 'running' })
const steered = inbox.nextTurn[0]
if (steered === undefined) throw new Error('missing edited queue item')
expect(await controller.updateQueue({
sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' },
})).toEqual({ accepted: true })
expect(steer).toHaveBeenCalledWith(steered)
const queuedFile = createUserMessage({
content: [{
type: 'file',
attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 },
}],
source: { kind: 'user', rpcId: 'file-rpc' as never },
})
inbox.append('next-turn', queuedFile)
expect(await controller.updateQueue({
sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' },
})).toEqual({ accepted: true })
expect(steer).toHaveBeenLastCalledWith(queuedFile)
expect(queuedFile).toMatchObject({
source: { kind: 'user', rpcId: 'file-rpc' },
content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }],
})
await expectFailure(Promise.resolve().then(() => controller.cancel({
sessionId: SessionId('missing'),
})), 'session/not-found')
expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true })
expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
await ctx.fiber.dispose()
})
it.each(['continuable', 'seeded-continuable'] as const)(
'mutates both inbox destinations of a live %s child while its parent is offline',
async (childMode) => {
const { ctx, controller, agent, inbox, steer } = await commandHarness(childMode)
const queued = createUserMessage({
content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
})
const context = createUserMessage({
content: [{ type: 'text', text: 'context' }], source: { kind: 'test' },
})
inbox.append('next-turn', queued)
inbox.append('next-step', context)
expect(await controller.updateQueue({
sessionId: agent.id,
itemId: context.id,
action: { kind: 'edit', content: [{ type: 'text', text: 'edited context' }] },
})).toEqual({ accepted: true })
const editedContext = inbox.nextStep[0]
expect(editedContext).toMatchObject({
content: [{ type: 'text', text: 'edited context' }],
source: context.source,
})
expect(editedContext?.id).toBe(context.id)
if (editedContext === undefined) throw new Error('missing edited context')
expect(await controller.updateQueue({
sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' },
})).toEqual({ accepted: true })
expect(await controller.updateQueue({
sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
})).toEqual({ accepted: true })
expect(steer).toHaveBeenCalledWith(queued)
await ctx.fiber.dispose()
},
)
it('removes the selected message before handing it to Agent steering', async () => {
const { ctx, controller, agent, inbox, steer } = await commandHarness('continuable')
const first = createUserMessage({
content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
})
const second = createUserMessage({
content: [{ type: 'text', text: 'second' }], source: { kind: 'user' },
})
inbox.append('next-turn', first)
inbox.append('next-turn', second)
// Stand in for the Agent's cancellation-convergence destination; the
// command must accept whichever boundary `Agent.steer()` selects.
steer.mockImplementation((message: UserMessage) => { inbox.append('next-turn', message) })
expect(await controller.updateQueue({
sessionId: agent.id, itemId: first.id, action: { kind: 'steer' },
})).toEqual({ accepted: true })
expect(steer).toHaveBeenCalledWith(first)
// Ordering proves the removal happened before delivery rather than after.
expect(inbox.nextTurn).toEqual([second, first])
expect(inbox.nextStep).toEqual([])
await ctx.fiber.dispose()
})
it('keeps one-shot, seed-only, missing, and malformed child descriptors behind the ownership fence', async () => {
for (const mode of ['one-shot', 'seed-only', 'unknown', 'corrupt'] as const) {
const { ctx, controller, agent, inbox } = await commandHarness(mode)
const queued = createUserMessage({
content: [{ type: 'text', text: mode }], source: { kind: 'user' },
})
inbox.append('next-turn', queued)
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id, itemId: queued.id, action: { kind: 'remove' },
})), 'session/agent-busy')
expect(inbox.nextTurn).toEqual([queued])
await ctx.fiber.dispose()
}
})
})
function imageRef(id: string): ImageAttachmentRef {
return {
attachmentId: AttachmentId(id),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
}
function event(type: string, seq: SessionSeq, data: unknown): 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(
events: SessionEvent[],
readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>,
): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = SessionId('cold-attachment')
const meta: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
cwd: '/workspace',
isSeeded: false,
}
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({
meta,
inheritedEventCount: SessionLogOffset(0),
events,
}),
}) as never)
installSessionReadTestServices(ctx)
ctx.provide('attachments', { readImage } as never)
const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
}
describe('Session attachment authorization', () => {
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('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',
data: {
turn: 1,
step: 1,
stream: [],
message: createAssistantMessage({
content: [{ type: 'image', attachment: message }],
source: { provider: 'fixture', model: 'fixture' },
}),
},
},
event('agent/inbox/spliced', SessionSeq(2), {
target: 'next-turn',
start: 0,
inserted: [null, 1, [], { content: [] }, createUserMessage({
content: [{ type: 'image', attachment: inserted }],
source: { kind: 'user' },
})],
}),
event('assistant/attempt', SessionSeq(3), {
turn: 1,
step: 1,
stream: [
{
type: 'chunk',
time: 3,
chunk: { type: 'block-start', index: 0, blockType: 'text' },
},
{
type: 'chunk',
time: 3,
chunk: { type: 'block-end', index: 0, block: { type: 'text', text: '' } },
},
],
}),
event('assistant/attempt', SessionSeq(4), {
turn: 1,
step: 1,
stream: [{
type: 'chunk',
time: 4,
chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
}],
}),
]
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 [direct, message, inserted, streamed]) {
await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId }))
.resolves.toEqual({ attachment: ref, data: 'AQ==' })
}
expect(readImage).toHaveBeenCalledTimes(4)
await ctx.fiber.dispose()
})
it('maps missing persistence identities and attachment backend failures', async () => {
const noPersistence = new Context()
await noPersistence.plugin(SessionStore)
installSessionReadTestServices(noPersistence)
const noPersistenceController = new SessionCommandController(
noPersistence,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
'/workspace',
)
await expectFailure(noPersistenceController.attachment({
sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
}), 'session/not-found')
const missing = new Context()
await missing.plugin(SessionStore)
missing.provide('sessionPersistence', testSessionPersistence(missing, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
}) as never)
installSessionReadTestServices(missing)
const missingController = new SessionCommandController(
missing,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
'/workspace',
)
await expectFailure(missingController.attachment({
sessionId: SessionId('missing'), attachmentId: 'att' as never,
}), 'session/not-found')
for (const thrown of [
new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'),
new Error('backend offline'),
]) {
const ref = imageRef(`failure-${thrown.name}`)
const fixture = await persistedController(
[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({
sessionId: fixture.sessionId,
attachmentId: ref.attachmentId,
}), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal')
await fixture.ctx.fiber.dispose()
}
})
it('maps a cold observation failure to an internal authorization error', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
const controller = new SessionCommandController(
ctx,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
'/workspace',
)
await expectFailure(controller.attachment({
sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
}), 'gateway/internal')
await ctx.fiber.dispose()
})
})