11import { createHash } from 'node:crypto' ;
2- import { mkdtemp , rm } from 'node:fs/promises' ;
2+ import { chmod , mkdir , mkdtemp , rm } from 'node:fs/promises' ;
33import { tmpdir } from 'node:os' ;
44import { join } from 'node:path' ;
55import { 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
296299describe ( '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} ) ;
0 commit comments