1- import { execFile } from 'node:child_process'
2- import { mkdtemp , readFile , realpath , rm , stat , symlink , writeFile } from 'node:fs/promises'
1+ import { spawnSync } from 'node:child_process'
2+ import {
3+ chmod ,
4+ mkdir ,
5+ mkdtemp ,
6+ readFile ,
7+ realpath ,
8+ rm ,
9+ stat ,
10+ symlink ,
11+ writeFile ,
12+ } from 'node:fs/promises'
13+ import { createServer } from 'node:net'
314import { tmpdir } from 'node:os'
415import { join } from 'node:path'
5- import { promisify } from 'node:util'
616import {
717 remoteSandboxProviderMock ,
818 remoteSandboxProviderMockFns ,
919} from '@sim/testing/mocks/remote-sandbox-provider.mock'
10- import { getErrorMessage } from '@sim/utils/errors'
11- import { afterEach , beforeEach , expect , it , vi } from 'vitest'
20+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
1221
1322const mocks = vi . hoisted ( ( ) => ( {
1423 find : vi . fn ( ) ,
@@ -27,13 +36,13 @@ vi.mock('@/lib/execution/remote-sandbox/session-lock', () => ({
2736} ) )
2837
2938import { openSessionFileSnapshot } from '@/lib/execution/remote-sandbox/session-file-snapshot'
39+ import { fileReadFailure } from '@/lib/mothership/agent-cli/engines/file-view'
3040
3141remoteSandboxProviderMockFns . mockResolveProvider . mockImplementation ( ( ) => ( {
3242 id : 'e2b' ,
3343 findSessionSandbox : mocks . find ,
3444} ) )
3545
36- const execute = promisify ( execFile )
3746let directory = ''
3847let staged = ''
3948beforeEach ( async ( ) => {
@@ -47,14 +56,11 @@ beforeEach(async () => {
4756 getFileSize : mocks . size ,
4857 } )
4958 mocks . command . mockImplementation ( async ( command , options ) => {
50- try {
51- const result = await execute ( '/bin/sh' , [ '-c' , command ] , {
52- env : { ...process . env , ...options . envs , SIM_UPLOAD_SNAPSHOT : staged } ,
53- } )
54- return { ...result , exitCode : 0 }
55- } catch ( error ) {
56- return { exitCode : 1 , stderr : getErrorMessage ( error , 'failed' ) }
57- }
59+ const result = spawnSync ( '/bin/sh' , [ '-c' , command ] , {
60+ encoding : 'utf8' ,
61+ env : { ...process . env , ...options . envs , SIM_UPLOAD_SNAPSHOT : staged } ,
62+ } )
63+ return { exitCode : result . status ?? 1 , stdout : result . stdout , stderr : result . stderr }
5864 } )
5965 mocks . size . mockImplementation ( async ( ) => ( await stat ( staged ) ) . size )
6066 mocks . read . mockImplementation ( async ( ) => new Blob ( [ await readFile ( staged ) ] ) . stream ( ) )
@@ -83,7 +89,7 @@ it('rejects symlink escape before any bytes are streamed', async () => {
8389 allowedRoots : [ directory ] ,
8490 maxBytes : 100 ,
8591 } )
86- ) . rejects . toThrow ( 'outside the permitted' )
92+ ) . rejects . toThrow ( 'outside the permitted workbench directories ' )
8793 expect ( mocks . read ) . not . toHaveBeenCalled ( )
8894} )
8995it ( 'bounds the remote copy itself, not just the eventual response stream' , async ( ) => {
@@ -127,3 +133,107 @@ it('never creates a replacement machine for a missing read', async () => {
127133 )
128134 expect ( mocks . command ) . not . toHaveBeenCalled ( )
129135} )
136+
137+ describe ( 'copy failures reach the model as their actionable cause' , ( ) => {
138+ const snapshot = ( path : string , maxBytes = 100 ) =>
139+ openSessionFileSnapshot ( 'chat' , path , undefined , undefined , {
140+ allowedRoots : [ directory ] ,
141+ maxBytes,
142+ } )
143+ const read = ( path : string , maxBytes ?: number ) =>
144+ snapshot ( path , maxBytes ) . then (
145+ ( ) => {
146+ throw new Error ( 'expected the snapshot to fail' )
147+ } ,
148+ ( error : unknown ) => fileReadFailure ( error ) . stderr
149+ )
150+
151+ it ( 'names a missing file and the race with the step that writes it' , async ( ) => {
152+ const path = join ( directory , 'missing.csv' )
153+ expect ( await read ( path ) ) . toBe (
154+ `Error: No file exists at ${ path } on this chat's workbench. Check the path, or wait for the step that writes it to finish.`
155+ )
156+ } )
157+ it ( 'names a directory as not a regular file' , async ( ) => {
158+ const path = join ( directory , 'folder' )
159+ await mkdir ( path )
160+ expect ( await read ( path ) ) . toContain ( `${ path } is not a regular file` )
161+ } )
162+ /** Linux, the workbench OS, refuses to open a socket with ENXIO before any stat. */
163+ it . skipIf ( process . platform !== 'linux' ) ( 'names a socket as not a regular file' , async ( ) => {
164+ const path = join ( directory , 'agent.sock' )
165+ const server = createServer ( )
166+ await new Promise < void > ( ( resolve ) => server . listen ( path , resolve ) )
167+ try {
168+ expect ( await read ( path ) ) . toContain ( `${ path } is not a regular file` )
169+ } finally {
170+ await new Promise ( ( resolve ) => server . close ( resolve ) )
171+ }
172+ } )
173+ it . skipIf ( process . getuid ?.( ) === 0 ) ( 'names a permission failure' , async ( ) => {
174+ const path = join ( directory , 'locked.txt' )
175+ await writeFile ( path , 'secret' )
176+ await chmod ( path , 0o000 )
177+ expect ( await read ( path ) ) . toBe (
178+ `Error: The workbench user does not have permission to read ${ path } .`
179+ )
180+ } )
181+ it ( 'names the read size limit' , async ( ) => {
182+ const path = join ( directory , 'large.txt' )
183+ await writeFile ( path , '123456' )
184+ expect ( await read ( path , 3 ) ) . toBe ( `Error: ${ path } exceeds the 3-byte size limit.` )
185+ } )
186+ it ( 'names a path outside the permitted roots' , async ( ) => {
187+ const path = join ( directory , 'escape' )
188+ await symlink ( '/etc/passwd' , path )
189+ expect ( await read ( path ) ) . toBe (
190+ `Error: ${ path } resolves outside the permitted workbench directories.`
191+ )
192+ } )
193+ it . skipIf ( process . getuid ?.( ) === 0 ) (
194+ 'does not blame the source for a staging file it cannot create' ,
195+ async ( ) => {
196+ const path = join ( directory , 'readable.txt' )
197+ await writeFile ( path , 'content' )
198+ const locked = join ( directory , 'locked' )
199+ await mkdir ( locked , { mode : 0o500 } )
200+ staged = join ( locked , 'snapshot' )
201+ expect ( await read ( path ) ) . toBe (
202+ 'Error: The file could not be read. Retry this read before making content claims.'
203+ )
204+ await chmod ( locked , 0o700 )
205+ }
206+ )
207+ it ( 'names a full workbench disk' , async ( ) => {
208+ mocks . command . mockResolvedValueOnce ( {
209+ exitCode : 3 ,
210+ stdout : 'SIM_SNAPSHOT_FAILURE no_space OSError\n' ,
211+ stderr : '' ,
212+ } )
213+ expect ( await read ( join ( directory , 'any.txt' ) ) ) . toBe (
214+ "Error: This chat's workbench is out of disk space. Delete files it no longer needs, then retry."
215+ )
216+ } )
217+ it ( 'names a chat with no workbench' , async ( ) => {
218+ mocks . find . mockResolvedValueOnce ( null )
219+ expect ( await read ( '/tmp/out.csv' ) ) . toBe (
220+ 'Error: No workbench exists for this chat; write "/tmp/out.csv" first.'
221+ )
222+ } )
223+ it ( 'keeps raw stderr out of an unclassified failure, reporting only exit code and class' , async ( ) => {
224+ mocks . command . mockResolvedValueOnce ( {
225+ exitCode : 3 ,
226+ stdout : 'SIM_SNAPSHOT_FAILURE unknown MemoryError\n' ,
227+ stderr : 'Traceback: /home/user/private-name.csv' ,
228+ } )
229+ await expect ( snapshot ( join ( directory , 'any.txt' ) ) ) . rejects . toThrow (
230+ / ^ C o u l d n o t p r e p a r e t h e w o r k b e n c h u p l o a d f i l e \( e x i t 3 , M e m o r y E r r o r \) $ /
231+ )
232+ } )
233+ it ( 'gives the model the generic retry message for a copy killed without a marker' , async ( ) => {
234+ mocks . command . mockResolvedValueOnce ( { exitCode : 137 , stdout : '' , stderr : 'Killed' } )
235+ expect ( await read ( join ( directory , 'any.txt' ) ) ) . toBe (
236+ 'Error: The file could not be read. Retry this read before making content claims.'
237+ )
238+ } )
239+ } )
0 commit comments