Skip to content

Commit ce1612a

Browse files
authored
fix(mothership): surface actionable workbench file read failures (#9048)
* fix(mothership): surface actionable workbench file read failures * chore(lint): drop VfsPathError from unused-exports baseline * fix(mothership): classify workbench staging failures apart from source reads * fix(mothership): report a workbench socket path as not a regular file
1 parent 6c30b71 commit ce1612a

6 files changed

Lines changed: 255 additions & 41 deletions

File tree

‎apps/sim/lib/execution/remote-sandbox/session-file-snapshot.test.ts‎

Lines changed: 125 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
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'
314
import { tmpdir } from 'node:os'
415
import { join } from 'node:path'
5-
import { promisify } from 'node:util'
616
import {
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

1322
const mocks = vi.hoisted(() => ({
1423
find: vi.fn(),
@@ -27,13 +36,13 @@ vi.mock('@/lib/execution/remote-sandbox/session-lock', () => ({
2736
}))
2837

2938
import { openSessionFileSnapshot } from '@/lib/execution/remote-sandbox/session-file-snapshot'
39+
import { fileReadFailure } from '@/lib/mothership/agent-cli/engines/file-view'
3040

3141
remoteSandboxProviderMockFns.mockResolveProvider.mockImplementation(() => ({
3242
id: 'e2b',
3343
findSessionSandbox: mocks.find,
3444
}))
3545

36-
const execute = promisify(execFile)
3746
let directory = ''
3847
let staged = ''
3948
beforeEach(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
})
8995
it('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+
/^Could not prepare the workbench upload file \(exit 3, MemoryError\)$/
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+
})

‎apps/sim/lib/execution/remote-sandbox/session-file-snapshot.ts‎

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { generateShortId } from '@sim/utils/id'
33
import type { EmbeddedFileSnapshot } from 'sim/embed'
4+
import { OrchestrationError } from '@/lib/core/orchestration/types'
45
import { prepareSandboxSessionAccess } from '@/lib/execution/remote-sandbox/observer'
56
import { resolveProvider } from '@/lib/execution/remote-sandbox/provider'
67
import { SESSION_SANDBOX_IDLE_MS } from '@/lib/execution/remote-sandbox/session'
@@ -12,34 +13,102 @@ import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
1213
const logger = createLogger('SessionFileSnapshot')
1314
const SNAPSHOT_TIMEOUT_MS = 10 * 60_000
1415

15-
/** Copy once on the workbench; metadata and every upload part then refer to these exact bytes. */
16+
const FAILURE_MARKER = 'SIM_SNAPSHOT_FAILURE'
17+
18+
/**
19+
* Copy once on the workbench; metadata and every upload part then refer to these exact bytes.
20+
* A failure prints one marker line naming a reason from a closed set (plus the exception class
21+
* when unclassified) and never raw stderr, which can echo workbench paths and contents. Once
22+
* staging begins, only a full disk is classified: an unwritable staging file says nothing about
23+
* the requested source.
24+
*/
1625
const COPY_FILE = `python3 - <<'SIM_UPLOAD_SNAPSHOT'
17-
import os, stat, json
18-
source = os.path.realpath(os.environ['SIM_UPLOAD_SOURCE'])
19-
roots = json.loads(os.environ.get('SIM_UPLOAD_ALLOWED_ROOTS', '[]'))
20-
if roots and not any(source.startswith(root + '/') for root in roots):
21-
raise ValueError('Scratch file resolves outside the permitted sandbox directories')
22-
target = os.environ['SIM_UPLOAD_SNAPSHOT']
23-
limit = int(os.environ['SIM_UPLOAD_MAX_BYTES'])
24-
fd = os.open(source, os.O_RDONLY | os.O_NONBLOCK)
25-
with os.fdopen(fd, 'rb') as reader:
26-
info = os.fstat(reader.fileno())
26+
import errno, json, os, stat, sys
27+
def fail(reason, detail=''):
28+
print('${FAILURE_MARKER} ' + reason + (' ' + detail if detail else ''))
29+
sys.exit(3)
30+
SOURCE_REASONS = {errno.ENOENT: 'not_found', errno.ENOTDIR: 'not_found',
31+
errno.EISDIR: 'not_regular', errno.ENXIO: 'not_regular',
32+
errno.EACCES: 'permission', errno.EPERM: 'permission'}
33+
STAGING_REASONS = {errno.ENOSPC: 'no_space', errno.EDQUOT: 'no_space'}
34+
reasons = SOURCE_REASONS
35+
try:
36+
source = os.path.realpath(os.environ['SIM_UPLOAD_SOURCE'])
37+
roots = json.loads(os.environ.get('SIM_UPLOAD_ALLOWED_ROOTS', '[]'))
38+
if roots and not any(source.startswith(root + '/') for root in roots):
39+
fail('outside_roots')
40+
target = os.environ['SIM_UPLOAD_SNAPSHOT']
41+
limit = int(os.environ['SIM_UPLOAD_MAX_BYTES'])
42+
fd = os.open(source, os.O_RDONLY | os.O_NONBLOCK)
43+
info = os.fstat(fd)
2744
if not stat.S_ISREG(info.st_mode):
28-
raise ValueError('Upload source must be a regular file')
45+
fail('not_regular')
2946
if info.st_size > limit:
30-
raise ValueError('Upload source exceeds the workspace file size limit')
31-
with os.fdopen(os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as writer:
47+
fail('too_large')
48+
reasons = STAGING_REASONS
49+
with os.fdopen(fd, 'rb') as reader, os.fdopen(
50+
os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb'
51+
) as writer:
3252
size = 0
3353
while True:
3454
chunk = reader.read(min(1024 * 1024, limit - size + 1))
3555
if not chunk:
3656
break
3757
size += len(chunk)
3858
if size > limit:
39-
raise ValueError('Upload source exceeds the workspace file size limit')
59+
fail('too_large')
4060
writer.write(chunk)
61+
except Exception as error:
62+
fail(reasons.get(getattr(error, 'errno', None), 'unknown'), type(error).__name__)
4163
SIM_UPLOAD_SNAPSHOT`
4264

65+
const FAILURE_LINE = new RegExp(`^${FAILURE_MARKER} ([a-z_]+)(?: ([A-Za-z][A-Za-z0-9_]*))?$`, 'm')
66+
67+
/** Maps the copy script's closed failure set to caller-fixable errors the model can act on. */
68+
function snapshotFailure(
69+
copied: { exitCode: number; stdout: string },
70+
path: string,
71+
maxBytes: number
72+
): Error {
73+
const [, reason, exceptionClass] = FAILURE_LINE.exec(copied.stdout) ?? []
74+
switch (reason) {
75+
case 'not_found':
76+
return new OrchestrationError(
77+
'not_found',
78+
`No file exists at ${path} on this chat's workbench. Check the path, or wait for the step that writes it to finish.`
79+
)
80+
case 'not_regular':
81+
return new OrchestrationError(
82+
'validation',
83+
`${path} is not a regular file on this chat's workbench (it may be a directory). Use a file inside it instead.`
84+
)
85+
case 'permission':
86+
return new OrchestrationError(
87+
'forbidden',
88+
`The workbench user does not have permission to read ${path}.`
89+
)
90+
case 'outside_roots':
91+
return new OrchestrationError(
92+
'forbidden',
93+
`${path} resolves outside the permitted workbench directories.`
94+
)
95+
case 'too_large':
96+
return new OrchestrationError(
97+
'payload_too_large',
98+
`${path} exceeds the ${maxBytes}-byte size limit.`
99+
)
100+
case 'no_space':
101+
return new OrchestrationError(
102+
'conflict',
103+
"This chat's workbench is out of disk space. Delete files it no longer needs, then retry."
104+
)
105+
default:
106+
return new Error(
107+
`Could not prepare the workbench upload file (exit ${copied.exitCode}${exceptionClass ? `, ${exceptionClass}` : ''})`
108+
)
109+
}
110+
}
111+
43112
/** Only the existing chat machine may supply an upload; a missing read never allocates one. */
44113
export async function openSessionFileSnapshot(
45114
sessionKey: string,
@@ -66,7 +135,11 @@ export async function openSessionFileSnapshot(
66135
lifetimeMs: SESSION_SANDBOX_IDLE_MS,
67136
})
68137
accessSignal.throwIfAborted()
69-
if (!sandbox) throw new Error(`No workbench exists for this chat; write "${path}" first.`)
138+
if (!sandbox)
139+
throw new OrchestrationError(
140+
'not_found',
141+
`No workbench exists for this chat; write "${path}" first.`
142+
)
70143
const readStream = sandbox.readFileStream?.bind(sandbox)
71144
if (!readStream) throw new Error('This workbench does not support file streaming')
72145
const staged = `/tmp/.sim-upload-${generateShortId(16)}`
@@ -103,14 +176,7 @@ export async function openSessionFileSnapshot(
103176
})
104177
accessSignal.throwIfAborted()
105178
if (copied.timedOut) throw new Error('Workbench upload snapshot timed out')
106-
if (copied.exitCode !== 0) {
107-
const reason = [
108-
'Scratch file resolves outside the permitted sandbox directories',
109-
'Upload source must be a regular file',
110-
'Upload source exceeds the workspace file size limit',
111-
].find((message) => copied.stderr.trim().split('\n').at(-1)?.includes(message))
112-
throw new Error(reason ?? 'Could not prepare the workbench upload file')
113-
}
179+
if (copied.exitCode !== 0) throw snapshotFailure(copied, path, maxBytes)
114180
const size = await sandbox.getFileSize(staged)
115181
accessSignal.throwIfAborted()
116182
if (!Number.isSafeInteger(size) || size < 0 || size > maxBytes) {

‎apps/sim/lib/mothership/chat/application/read-sandbox-file.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99
import { ContentRefusedError, observeContentDelivery } from '@/lib/core/application'
10+
import { OrchestrationError } from '@/lib/core/orchestration/types'
11+
import { fileReadFailure } from '@/lib/mothership/agent-cli/engines/file-view'
1012
import { createCopilotChatFilePrincipal } from '@/lib/mothership/auth/file-delegation'
1113
import { createRegistryContentGate } from '@/lib/mothership/content-delivery'
1214

@@ -131,6 +133,13 @@ describe('owned scratch snapshot reads', () => {
131133
hasRecordedSecrets: true,
132134
})
133135
})
136+
it('passes a classified workbench failure through to the model unchanged', async () => {
137+
const missing = new OrchestrationError('not_found', 'No file exists at /tmp/image.png')
138+
mocks.snapshot.mockRejectedValue(missing)
139+
const failure = await readAsChat().catch((error: unknown) => error)
140+
expect(failure).toBe(missing)
141+
expect(fileReadFailure(failure).stderr).toBe('Error: No file exists at /tmp/image.png')
142+
})
134143
it('enforces size before streaming and still disposes the snapshot', async () => {
135144
await expect(
136145
readChatSandboxFile.execute({ principal, input: { ...input, maxBytes: 2 } })

‎apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
1111
vi.mock('@/lib/uploads/contexts/workspace/file-manager', () => workspaceFileManagerMock)
1212

1313
import { defineWorkspaceOperation } from '@/lib/core/application'
14+
import { fileReadFailure } from '@/lib/mothership/agent-cli/engines/file-view'
15+
import { decodeVfsPathSegments } from '@/lib/vfs/path'
1416
import { fileOperations } from '@/lib/workspace-files/application/operations'
1517
import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference'
1618

@@ -47,6 +49,25 @@ describe('workspace file reference application service', () => {
4749
mocks.fetchBuffer.mockResolvedValue(Buffer.from('source'))
4850
})
4951

52+
it('rejects a reference that is not a VFS path, such as a URL, as a validation error the model sees', async () => {
53+
const reference = 'https://example.com/report.pdf'
54+
mocks.resolveStoredReference.mockImplementation(async (_workspaceId, value: string) => {
55+
decodeVfsPathSegments(value)
56+
return file
57+
})
58+
const failure = await resolveWorkspaceFileReference({
59+
principal,
60+
operation: fileOperations.readContent,
61+
workspaceId: file.workspaceId,
62+
reference,
63+
}).catch((error: unknown) => error)
64+
expect(failure).toMatchObject({ name: 'OrchestrationError', code: 'validation' })
65+
expect(fileReadFailure(failure).stderr).toBe(
66+
`Error: "${reference}" is not a valid workspace file path or ID: VFS path segment cannot be empty or a dot segment`
67+
)
68+
expect(mocks.loadContext).not.toHaveBeenCalled()
69+
})
70+
5071
it('carries trusted chat scope into an authorized upload lookup', async () => {
5172
await resolveWorkspaceFileReference({
5273
principal,

0 commit comments

Comments
 (0)