Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 0711ac8

Browse files
committed
feat: base Sandbox interface
1 parent 61db428 commit 0711ac8

11 files changed

Lines changed: 828 additions & 0 deletions

File tree

strands-ts/eslint.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ function sdkRules(options) {
5555
process: 'readonly',
5656
setTimeout: 'readonly',
5757
clearTimeout: 'readonly',
58+
setInterval: 'readonly',
59+
clearInterval: 'readonly',
60+
atob: 'readonly',
61+
btoa: 'readonly',
62+
crypto: 'readonly',
5863
},
5964
},
6065
plugins: {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { spawn } from 'child_process'
2+
import { ShellSandbox } from '../sandbox/shell.js'
3+
import { shellQuote } from '../utils/shell-quote.js'
4+
import { streamProcess } from '../sandbox/stream-process.js'
5+
import type { ExecuteOptions } from '../sandbox/base.js'
6+
import type { ExecutionResult, StreamChunk } from '../sandbox/types.js'
7+
8+
/**
9+
* Test sandbox that executes commands within a specific working directory.
10+
*
11+
* Extends ShellSandbox (same base as DockerSandbox and RemoteSandbox) so it
12+
* exercises the same code path real sandboxes use: base64 file encoding,
13+
* shell quoting, ls parsing, etc. The only difference is commands run on
14+
* the host rather than in a container or over SSH.
15+
*/
16+
export class TestSandbox extends ShellSandbox {
17+
readonly workingDir: string
18+
19+
constructor(workingDir: string) {
20+
super()
21+
this.workingDir = workingDir
22+
}
23+
24+
async *executeStreaming(
25+
command: string,
26+
options?: ExecuteOptions
27+
): AsyncGenerator<StreamChunk | ExecutionResult, void, undefined> {
28+
const cwd = options?.cwd ?? this.workingDir
29+
const fullCommand = `cd ${shellQuote(cwd)} && ${command}`
30+
const proc = spawn('sh', ['-c', fullCommand])
31+
yield* streamProcess(proc, { timeout: options?.timeout, signal: options?.signal })
32+
}
33+
}

strands-ts/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ export { AgentTrace } from './telemetry/tracer.js'
296296
// Local Metrics
297297
export { AgentMetrics } from './telemetry/meter.js'
298298

299+
// Sandbox
300+
export { Sandbox, type ExecuteOptions } from './sandbox/base.js'
301+
export { ShellSandbox } from './sandbox/shell.js'
302+
export type { StreamType, StreamChunk, FileInfo, OutputFile, ExecutionResult } from './sandbox/types.js'
303+
299304
// Multi-agent orchestration
300305
export { Graph } from './multiagent/index.js'
301306
export { Swarm } from './multiagent/index.js'
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { execSync } from 'child_process'
3+
import { TestSandbox } from '../../__fixtures__/test-sandbox.node.js'
4+
5+
const TEST_DIR = '/tmp/strands-test-shell-sandbox'
6+
7+
describe.skipIf(process.platform === 'win32')('ShellSandbox', () => {
8+
let sandbox: TestSandbox
9+
10+
beforeEach(() => {
11+
execSync(`rm -rf ${TEST_DIR} && mkdir -p ${TEST_DIR}`)
12+
sandbox = new TestSandbox(TEST_DIR)
13+
})
14+
15+
afterEach(() => {
16+
execSync(`rm -rf ${TEST_DIR}`)
17+
})
18+
19+
describe('execute (via shell commands)', () => {
20+
it('runs a command', async () => {
21+
const result = await sandbox.execute('echo hello')
22+
expect(result.exitCode).toBe(0)
23+
expect(result.stdout).toBe('hello\n')
24+
})
25+
26+
it('runs in workingDir', async () => {
27+
const result = await sandbox.execute('pwd')
28+
expect(result.stdout.trim()).toContain('strands-test-shell-sandbox')
29+
})
30+
31+
it('respects cwd option', async () => {
32+
const result = await sandbox.execute('pwd', { cwd: '/tmp' })
33+
expect(result.stdout.trim()).toMatch(/\/tmp$/)
34+
})
35+
})
36+
37+
describe('executeCode (via shell quoting)', () => {
38+
it('runs python code through shell', async () => {
39+
const result = await sandbox.executeCode('print(2 + 2)', 'python3')
40+
expect(result.exitCode).toBe(0)
41+
expect(result.stdout).toBe('4\n')
42+
})
43+
44+
it('handles code with special characters', async () => {
45+
const result = await sandbox.executeCode('print(\'hello "world"\')', 'python3')
46+
expect(result.stdout).toBe('hello "world"\n')
47+
})
48+
49+
it('handles code with single quotes', async () => {
50+
const result = await sandbox.executeCode('print("it\'s working")', 'python3')
51+
expect(result.stdout).toBe("it's working\n")
52+
})
53+
})
54+
55+
describe('language validation', () => {
56+
it('rejects path traversal', async () => {
57+
await expect(sandbox.executeCode('x', '../../../bin/sh')).rejects.toThrow('unsafe characters')
58+
})
59+
60+
it('rejects shell metacharacters', async () => {
61+
await expect(sandbox.executeCode('x', 'python;rm -rf /')).rejects.toThrow('unsafe characters')
62+
})
63+
64+
it('rejects spaces', async () => {
65+
await expect(sandbox.executeCode('x', 'python -c')).rejects.toThrow('unsafe characters')
66+
})
67+
68+
it('allows valid interpreters', async () => {
69+
const result = await sandbox.executeCode('print("safe")', 'python3')
70+
expect(result.exitCode).toBe(0)
71+
})
72+
73+
it('allows dots and hyphens', async () => {
74+
const result = await sandbox.executeCode('x', 'fake-lang.99')
75+
expect(result.exitCode).toBe(127)
76+
})
77+
})
78+
79+
describe('read/write (via base64 encoding over shell)', () => {
80+
it('text file roundtrip', async () => {
81+
await sandbox.writeText('test.txt', 'hello shell')
82+
const text = await sandbox.readText('test.txt')
83+
expect(text).toBe('hello shell')
84+
})
85+
86+
it('binary file roundtrip', async () => {
87+
const bytes = new Uint8Array([0, 1, 2, 127, 128, 254, 255])
88+
await sandbox.writeFile('binary.bin', bytes)
89+
const read = await sandbox.readFile('binary.bin')
90+
expect(Array.from(read)).toStrictEqual(Array.from(bytes))
91+
})
92+
93+
it('all 256 byte values roundtrip', async () => {
94+
const bytes = new Uint8Array(256)
95+
for (let i = 0; i < 256; i++) bytes[i] = i
96+
await sandbox.writeFile('all-bytes.bin', bytes)
97+
const read = await sandbox.readFile('all-bytes.bin')
98+
expect(Array.from(read)).toStrictEqual(Array.from(bytes))
99+
})
100+
101+
it('creates parent directories', async () => {
102+
await sandbox.writeText('deep/nested/file.txt', 'deep')
103+
const text = await sandbox.readText('deep/nested/file.txt')
104+
expect(text).toBe('deep')
105+
})
106+
107+
it('handles unicode content', async () => {
108+
const content = '日本語 🚀 émojis'
109+
await sandbox.writeText('unicode.txt', content)
110+
const text = await sandbox.readText('unicode.txt')
111+
expect(text).toBe(content)
112+
})
113+
114+
it('handles shell metacharacters in content', async () => {
115+
const content = '$(rm -rf /) `whoami` && || $HOME'
116+
await sandbox.writeText('meta.txt', content)
117+
const text = await sandbox.readText('meta.txt')
118+
expect(text).toBe(content)
119+
})
120+
121+
it('throws on nonexistent file', async () => {
122+
await expect(sandbox.readFile('nope.txt')).rejects.toThrow()
123+
})
124+
})
125+
126+
describe('remove', () => {
127+
it('removes a file', async () => {
128+
await sandbox.writeText('delete-me.txt', 'bye')
129+
await sandbox.removeFile('delete-me.txt')
130+
await expect(sandbox.readFile('delete-me.txt')).rejects.toThrow()
131+
})
132+
133+
it('throws on nonexistent file', async () => {
134+
await expect(sandbox.removeFile('nope.txt')).rejects.toThrow()
135+
})
136+
})
137+
138+
describe('list (via ls -1aF parsing)', () => {
139+
it('lists directory contents', async () => {
140+
await sandbox.writeText('a.txt', 'a')
141+
await sandbox.writeText('b.txt', 'b')
142+
const files = await sandbox.listFiles('.')
143+
const names = files.map((f) => f.name)
144+
expect(names).toContain('a.txt')
145+
expect(names).toContain('b.txt')
146+
})
147+
148+
it('identifies directories', async () => {
149+
await sandbox.execute('mkdir -p subdir')
150+
const files = await sandbox.listFiles('.')
151+
const subdir = files.find((f) => f.name === 'subdir')
152+
expect(subdir?.isDir).toBe(true)
153+
})
154+
155+
it('excludes . and .. entries', async () => {
156+
await sandbox.writeText('file.txt', '')
157+
const files = await sandbox.listFiles('.')
158+
const names = files.map((f) => f.name)
159+
expect(names).not.toContain('.')
160+
expect(names).not.toContain('..')
161+
})
162+
163+
it('throws on nonexistent directory', async () => {
164+
await expect(sandbox.listFiles('/tmp/nonexistent-dir-xyz')).rejects.toThrow()
165+
})
166+
167+
it('throws when path is a file, not a directory', async () => {
168+
await sandbox.writeText('not-a-dir.txt', 'hello')
169+
await expect(sandbox.listFiles('not-a-dir.txt')).rejects.toThrow()
170+
})
171+
})
172+
173+
describe('shellQuote', () => {
174+
it('handles paths with spaces', async () => {
175+
await sandbox.execute('mkdir -p "with spaces"')
176+
await sandbox.writeText('with spaces/file.txt', 'spaced')
177+
const text = await sandbox.readText('with spaces/file.txt')
178+
expect(text).toBe('spaced')
179+
})
180+
181+
it('handles paths with single quotes', async () => {
182+
await sandbox.execute('mkdir -p "it\'s"')
183+
await sandbox.writeText("it's/file.txt", 'quoted')
184+
const text = await sandbox.readText("it's/file.txt")
185+
expect(text).toBe('quoted')
186+
})
187+
})
188+
189+
describe('timeout', () => {
190+
it('kills process on timeout', async () => {
191+
const start = Date.now()
192+
await expect(sandbox.execute('sleep 60', { timeout: 0.2 })).rejects.toThrow('timed out')
193+
const elapsed = Date.now() - start
194+
expect(elapsed).toBeLessThan(2000)
195+
})
196+
197+
it('does not timeout fast commands', async () => {
198+
const result = await sandbox.execute('echo fast', { timeout: 5 })
199+
expect(result.exitCode).toBe(0)
200+
expect(result.stdout).toBe('fast\n')
201+
})
202+
})
203+
204+
describe('abort signal', () => {
205+
it('kills process when signal is aborted', async () => {
206+
const controller = new AbortController()
207+
const promise = sandbox.execute('sleep 60', { signal: controller.signal })
208+
setTimeout(() => controller.abort(), 100)
209+
await expect(promise).rejects.toThrow('aborted')
210+
})
211+
212+
it('rejects immediately if signal is already aborted', async () => {
213+
const controller = new AbortController()
214+
controller.abort()
215+
await expect(sandbox.execute('sleep 60', { signal: controller.signal })).rejects.toThrow('aborted')
216+
})
217+
})
218+
219+
describe('concurrent execution', () => {
220+
it('handles multiple concurrent commands', async () => {
221+
const results = await Promise.all([
222+
sandbox.execute('echo one'),
223+
sandbox.execute('echo two'),
224+
sandbox.execute('echo three'),
225+
])
226+
expect(results.map((r) => r.stdout.trim()).sort()).toStrictEqual(['one', 'three', 'two'])
227+
})
228+
229+
it('handles concurrent file writes to different files', async () => {
230+
await Promise.all([
231+
sandbox.writeText('a.txt', 'aaa'),
232+
sandbox.writeText('b.txt', 'bbb'),
233+
sandbox.writeText('c.txt', 'ccc'),
234+
])
235+
const [a, b, c] = await Promise.all([
236+
sandbox.readText('a.txt'),
237+
sandbox.readText('b.txt'),
238+
sandbox.readText('c.txt'),
239+
])
240+
expect(a).toBe('aaa')
241+
expect(b).toBe('bbb')
242+
expect(c).toBe('ccc')
243+
})
244+
})
245+
246+
describe('streaming', () => {
247+
it('yields StreamChunks then ExecutionResult', async () => {
248+
const chunks: Array<{ type: string }> = []
249+
for await (const chunk of sandbox.executeStreaming('echo hello')) {
250+
chunks.push(chunk)
251+
}
252+
const streamChunks = chunks.filter((c) => c.type === 'streamChunk')
253+
const results = chunks.filter((c) => c.type === 'executionResult')
254+
expect(streamChunks.length).toBeGreaterThan(0)
255+
expect(results).toHaveLength(1)
256+
})
257+
})
258+
})

0 commit comments

Comments
 (0)