Skip to content

Commit bf0ffd1

Browse files
committed
fix(engine,workbench): sniff legacy inline images before storing and refuse refs on capability-less harnesses early
1 parent cabbc72 commit bf0ffd1

10 files changed

Lines changed: 186 additions & 27 deletions

File tree

‎packages/client/workbench/src/mock/dev-mock-host.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,6 +1507,17 @@ export class DevMockHost {
15071507
session: MockSession,
15081508
content: ContentBlock[],
15091509
): Promise<void> {
1510+
// The daemon sniffs before it stores; a mislabeled inline image is refused before any echo.
1511+
for (let i = 0, len = content.length; i < len; i++) {
1512+
const block = content[i];
1513+
if (block.type !== 'image') continue;
1514+
if (!declaredMimeTypeMatches(block.mimeType, mockBase64ToBytes(block.data).subarray(0, 16))) {
1515+
this.sendFailure(replyTo, `File contents are not ${block.mimeType}`, {
1516+
code: 'invalid_request',
1517+
});
1518+
return;
1519+
}
1520+
}
15101521
const turn = this.beginTurn(session, content);
15111522
turn.readContent = await this.ingestInlineImages(session.sessionId, content);
15121523
const result = await this.streamMockReply(session, content);

‎packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('stageStoreAttachment', () => {
7777
const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
7878
const file = new File([jpegBytes], 'shot.png', { type: 'image/png' });
7979
await expect(
80-
stageStoreAttachment(client, file, pending, { unsupportedType: 'not a png' }),
80+
stageStoreAttachment(client, file, pending, { contentMismatch: 'not a png' }),
8181
).rejects.toThrow('not a png');
8282
expect(putAttachment).not.toHaveBeenCalled();
8383
});
@@ -184,6 +184,35 @@ describe('overlayPendingUserAttachments', () => {
184184
});
185185
});
186186

187+
it('paints an attachment-only prompt onto its empty echo', () => {
188+
const session = 'sess-only-attachment' as SessionId;
189+
const link: ContentBlock = {
190+
type: 'resource_link',
191+
uri: 'attachment:att-4',
192+
name: 'only.png',
193+
};
194+
noteInflightUserAttachments(session, [link]);
195+
const conversation: Conversation = {
196+
...EMPTY,
197+
items: [
198+
{
199+
kind: 'message',
200+
id: userRowMessageId('turn-4' as TurnId),
201+
turnId: 'turn-4',
202+
role: 'user',
203+
blocks: [],
204+
isStreaming: false,
205+
receivedAt: Date.now() + 1,
206+
},
207+
],
208+
};
209+
expect(
210+
overlayPendingUserAttachments(conversation, session, pendingUserAttachmentsSnapshot())
211+
.items[0],
212+
).toMatchObject({ blocks: [link] });
213+
clearInflightUserAttachments(session);
214+
});
215+
187216
it('leaves a same-window user row alone when its text is not the sent prompt', () => {
188217
const session = 'sess-author' as SessionId;
189218
const link: ContentBlock = {

‎packages/client/workbench/src/surface/prompt-attachments.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,12 @@ export async function stageStoreAttachment(
8989
client: Pick<LinkCodeClient, 'putAttachment'>,
9090
file: File,
9191
pending: ComposerAttachment,
92-
errors: { unsupportedType: string },
92+
errors: { contentMismatch: string },
9393
): Promise<ComposerAttachment> {
9494
const bytes = new Uint8Array(await file.arrayBuffer());
9595
// The daemon sniffs at commit; refusing here saves the transfer of a mislabeled file.
9696
if (!declaredMimeTypeMatches(file.type, bytes.subarray(0, 16))) {
97-
throw new Error(errors.unsupportedType);
97+
throw new Error(errors.contentMismatch);
9898
}
9999
const kind = pending.kind === 'image' ? 'image' : 'file';
100100
const { attachmentId } = await client.putAttachment({

‎packages/client/workbench/src/surface/workbench.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ function WorkbenchSessionSurface({
515515
pending: ComposerAttachment,
516516
): Promise<ComposerAttachment> {
517517
return stageStoreAttachment(client, file, pending, {
518-
unsupportedType: tComposer('attachmentUnsupportedType'),
518+
contentMismatch: tComposer('attachmentContentMismatch', { type: file.type }),
519519
});
520520
}
521521

‎packages/client/workbench/tests/integration/dev-mock-attachments.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,22 @@ describe('dev mock attachment store', () => {
205205
await sending;
206206
client.dispose();
207207
});
208+
209+
it('refuses a legacy inline image whose bytes are not the declared type', async () => {
210+
const client = await connectedClient();
211+
const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' });
212+
await expect(
213+
client.send(sessionId, {
214+
type: 'prompt',
215+
content: [
216+
{
217+
type: 'image',
218+
mimeType: 'image/png',
219+
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]).toString('base64'),
220+
},
221+
],
222+
}),
223+
).rejects.toThrow('File contents are not image/png');
224+
client.dispose();
225+
});
208226
});

‎packages/host/engine/src/__tests__/engine-attachment-submit.test.ts‎

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from 'node:crypto';
2-
import { mkdtemp, rm } from 'node:fs/promises';
2+
import { chmod, mkdir, mkdtemp, rm } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { asHistoryId } from '@linkcode/agent-adapter';
@@ -81,6 +81,7 @@ async function started(kind: 'claude-code' | 'grok-build' = 'claude-code') {
8181
conversationStore,
8282
attachmentStore,
8383
blobStore,
84+
stateDir,
8485
sessionId: startedId(h.sent, 'r1'),
8586
adapter: nullthrow(h.adapters[0]),
8687
};
@@ -125,9 +126,10 @@ describe('turn.submit attachment admit and materialize', () => {
125126
expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0);
126127
});
127128

128-
it('refuses an image on grok-build at admit', async () => {
129+
it('refuses an image on grok-build at admit without touching the store', async () => {
129130
const h = await started('grok-build');
130131
const attachmentId = await readyPng(h);
132+
const list = vi.spyOn(h.attachmentStore, 'listAttachments');
131133
await h.inject({
132134
kind: 'turn.submit',
133135
clientReqId: 's-grok',
@@ -146,6 +148,7 @@ describe('turn.submit attachment admit and materialize', () => {
146148
});
147149
expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0);
148150
expect(h.adapter.sentInputs).toEqual([]);
151+
expect(list).not.toHaveBeenCalled();
149152
});
150153

151154
it('materializes a declared image to the adapter without putting bytes on the echo or prompt row', async () => {
@@ -294,14 +297,15 @@ describe('turn.submit attachment admit and materialize', () => {
294297
});
295298

296299
describe('legacy agent.input inline images', () => {
300+
const image = {
301+
type: 'image' as const,
302+
data: PNG_1X1.toString('base64'),
303+
mimeType: 'image/png',
304+
name: 'shot.png',
305+
};
306+
297307
it('stores the image as a ref on the durable row while the adapter and echo keep it inline', async () => {
298308
const h = await started();
299-
const image = {
300-
type: 'image' as const,
301-
data: PNG_1X1.toString('base64'),
302-
mimeType: 'image/png',
303-
name: 'shot.png',
304-
};
305309
await h.inject({
306310
kind: 'agent.input',
307311
clientReqId: 'legacy',
@@ -376,4 +380,69 @@ describe('legacy agent.input inline images', () => {
376380
if (page?.kind !== 'attachment.read.result') throw new Error('no attachment.read.result');
377381
expect(page.data).toBe(image.data);
378382
});
383+
384+
it('refuses an image whose bytes are not the declared type before any echo or row', async () => {
385+
const h = await started();
386+
await h.inject({
387+
kind: 'agent.input',
388+
clientReqId: 'lie',
389+
sessionId: h.sessionId,
390+
input: {
391+
type: 'prompt',
392+
content: [
393+
{
394+
type: 'image',
395+
mimeType: 'image/png',
396+
data: Buffer.from([0xff, 0xd8, 0xff, 0xe0]).toString('base64'),
397+
},
398+
],
399+
},
400+
});
401+
expect(failure(h.sent, 'lie')).toMatchObject({
402+
code: 'invalid_request',
403+
message: 'File contents are not image/png',
404+
});
405+
expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0);
406+
expect(h.adapter.sentInputs).toEqual([]);
407+
expect(h.sent.some((p) => p.kind === 'agent.event' && p.event.type === 'user-message')).toBe(
408+
false,
409+
);
410+
});
411+
412+
it('fails typed when the store cannot take the bytes and leaves the session usable', async () => {
413+
const h = await started();
414+
const blobsDir = join(h.stateDir, 'blobs');
415+
await mkdir(blobsDir, { recursive: true });
416+
await chmod(blobsDir, 0o500);
417+
try {
418+
await h.inject({
419+
kind: 'agent.input',
420+
clientReqId: 'ro',
421+
sessionId: h.sessionId,
422+
input: { type: 'prompt', content: [{ type: 'text', text: 'look' }, image] },
423+
});
424+
await vi.waitFor(() => {
425+
expect(failure(h.sent, 'ro')).toMatchObject({
426+
code: 'operation_failed',
427+
message: 'Failed to store a prompt attachment',
428+
});
429+
});
430+
} finally {
431+
await chmod(blobsDir, 0o700);
432+
}
433+
expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0);
434+
expect(h.adapter.sentInputs).toEqual([]);
435+
436+
await h.inject({
437+
kind: 'agent.input',
438+
clientReqId: 'after',
439+
sessionId: h.sessionId,
440+
input: { type: 'prompt', content: [{ type: 'text', text: 'still here' }] },
441+
});
442+
await vi.waitFor(() => {
443+
expect(h.sent).toContainEqual(
444+
expect.objectContaining({ kind: 'request.succeeded', replyTo: 'after' }),
445+
);
446+
});
447+
});
379448
});

‎packages/host/engine/src/attachment/ingest.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { Buffer } from 'node:buffer';
22
import { createHash, randomUUID } from 'node:crypto';
33
import type { AttachmentId, ContentBlock, PromptBlock } from '@linkcode/schema';
4-
import { AttachmentIdSchema, blobIdFromSha256, MAX_ATTACHMENT_NAME_LENGTH } from '@linkcode/schema';
4+
import {
5+
AttachmentIdSchema,
6+
blobIdFromSha256,
7+
declaredMimeTypeMatches,
8+
MAX_ATTACHMENT_NAME_LENGTH,
9+
} from '@linkcode/schema';
510
import { Effect } from 'effect';
11+
import { nullthrow } from 'foxts/guard';
612
import { noop } from 'foxts/noop';
7-
import { OperationError } from '../failure';
13+
import { OperationError, RequestError } from '../failure';
814
import type { AttachmentStore } from './attachment-store';
915
import type { BlobStore } from './blob-store';
1016
import type { AttachmentIoMutex } from './io-mutex';
@@ -57,15 +63,33 @@ export class AttachmentIngest {
5763

5864
/** Durable blocks for legacy prompt content: inline images are stored and referenced, so the
5965
* row keeps them after the live echo is gone. Other binary blocks were refused at admit. */
60-
promptBlocks(content: readonly ContentBlock[]): Effect.Effect<PromptBlock[], OperationError> {
66+
promptBlocks(
67+
content: readonly ContentBlock[],
68+
): Effect.Effect<PromptBlock[], OperationError | RequestError> {
69+
const images = new Map<number, Buffer>();
70+
for (let i = 0, len = content.length; i < len; i++) {
71+
const block = content[i];
72+
if (block.type !== 'image') continue;
73+
const bytes = Buffer.from(block.data, 'base64');
74+
// Every writer into the store sniffs: a mislabeled record is trusted on every later reference.
75+
if (!declaredMimeTypeMatches(block.mimeType, bytes.subarray(0, 16))) {
76+
return Effect.fail(
77+
new RequestError({
78+
code: 'invalid_request',
79+
message: `File contents are not ${block.mimeType}`,
80+
}),
81+
);
82+
}
83+
images.set(i, bytes);
84+
}
6185
return Effect.tryPromise({
6286
try: () =>
6387
Promise.all(
64-
content.map(async (block): Promise<PromptBlock | undefined> => {
88+
content.map(async (block, index): Promise<PromptBlock | undefined> => {
6589
if (block.type === 'text') return { type: 'text', text: block.text };
6690
if (block.type !== 'image') return;
6791
// The legacy block's name is unbounded; the record's is not.
68-
const attachmentId = await this.store(Buffer.from(block.data, 'base64'), {
92+
const attachmentId = await this.store(nullthrow(images.get(index)), {
6993
kind: 'image',
7094
name: (block.name || 'image').slice(0, MAX_ATTACHMENT_NAME_LENGTH),
7195
mimeType: block.mimeType,

‎packages/host/engine/src/session/lifecycle-service.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -748,16 +748,22 @@ export class SessionLifecycleService {
748748
const ids = uniqueAttachmentIds(occurrences);
749749
if (ids.length === 0) return Effect.void;
750750
const capability = effectiveAttachmentCapability(kind);
751-
// Bound the ref count before the store load: `admitPromptAttachments` charges per occurrence,
752-
// and an unbounded id list would otherwise reach SQLite as one oversized `IN (...)`.
753-
if (capability !== undefined) {
754-
const maxCount =
755-
(capability.kinds.image?.maxCount ?? 0) + (capability.kinds.file?.maxCount ?? 0);
756-
if (occurrences.length > maxCount) {
757-
return Effect.fail(
758-
new RequestError({ code: 'limit_exceeded', message: 'Too many attachments' }),
759-
);
760-
}
751+
// Refuse before the store load: the same answers `admitPromptAttachments` gives, without an
752+
// unbounded id list reaching SQLite as one oversized `IN (...)`.
753+
if (capability === undefined) {
754+
return Effect.fail(
755+
new RequestError({
756+
code: 'unsupported_attachment',
757+
message: 'Prompt attachments are not supported by this harness',
758+
}),
759+
);
760+
}
761+
const maxCount =
762+
(capability.kinds.image?.maxCount ?? 0) + (capability.kinds.file?.maxCount ?? 0);
763+
if (occurrences.length > maxCount) {
764+
return Effect.fail(
765+
new RequestError({ code: 'limit_exceeded', message: 'Too many attachments' }),
766+
);
761767
}
762768
return Effect.tryPromise({
763769
try: () => this.attachments.listAttachments(ids),

‎packages/presentation/i18n/src/locales/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ export const en = {
370370
attachmentsTotalTooLarge: 'Attachments exceed the 12MB total limit',
371371
attachmentLimit: 'You can attach at most {count} images',
372372
attachmentUnsupportedType: 'Only JPEG / PNG / GIF / WEBP images are supported',
373+
attachmentContentMismatch: 'File contents are not {type}',
373374
attachmentUnsupportedAgent: "This agent doesn't support image attachments yet",
374375
attachmentReadFailed: 'Failed to read the file',
375376
approvalTitle: 'How should {agent} actions be approved?',

‎packages/presentation/i18n/src/locales/zh-cn.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ export const zhCN = {
360360
attachmentsTotalTooLarge: '附件总大小超过 12MB 上限',
361361
attachmentLimit: '最多添加 {count} 个图片附件',
362362
attachmentUnsupportedType: '仅支持 JPEG / PNG / GIF / WEBP 图片',
363+
attachmentContentMismatch: '文件内容与 {type} 不符',
363364
attachmentUnsupportedAgent: '当前 agent 暂不支持图片附件',
364365
attachmentReadFailed: '读取文件失败',
365366
approvalTitle: '如何审批 {agent} 的操作?',

0 commit comments

Comments
 (0)