Skip to content

Commit f9d52ea

Browse files
committed
feat: integrate Sandbox with Agent
1 parent 6dd249d commit f9d52ea

14 files changed

Lines changed: 360 additions & 14 deletions

File tree

strands-ts/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
"module": "dist/src/index.js",
77
"types": "dist/src/index.d.ts",
88
"type": "module",
9+
"sideEffects": [
10+
"./dist/src/index.node.js",
11+
"./src/index.node.ts"
12+
],
913
"files": [
1014
"dist",
1115
"README.md",
@@ -14,6 +18,7 @@
1418
"exports": {
1519
".": {
1620
"types": "./dist/src/index.d.ts",
21+
"node": "./dist/src/index.node.js",
1722
"default": "./dist/src/index.js"
1823
},
1924
"./models/anthropic": {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defaultSandbox } from '../sandbox/default.js'
2+
import { NotASandboxLocalEnvironment } from '../sandbox/not-a-sandbox-local-environment.js'
3+
4+
// In production, index.node.ts registers this on import. Tests don't go through that entry
5+
// point, so this setup file does it instead.
6+
defaultSandbox.set(new NotASandboxLocalEnvironment())

strands-ts/src/agent/agent.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ import { isInterruptResponseContent, type InterruptResponseContent } from '../ty
8989
import { takeSnapshot as takeSnapshotInternal, loadSnapshot as loadSnapshotInternal } from './snapshot.js'
9090
import type { TakeSnapshotOptions } from './snapshot.js'
9191
import type { Snapshot } from '../types/snapshot.js'
92+
import type { Sandbox } from '../sandbox/base.js'
93+
import { defaultSandbox } from '../sandbox/default.js'
9294

9395
/**
9496
* Recursive type definition for nested tool arrays.
@@ -222,6 +224,20 @@ export type AgentConfig = {
222224
* Defaults to `'concurrent'`. See {@link ToolExecutorStrategy} for details.
223225
*/
224226
toolExecutor?: ToolExecutorStrategy
227+
/**
228+
* Execution environment for running commands, code, and file operations.
229+
* When provided, sandbox-aware tools route operations through it.
230+
*
231+
* Two distinct intents, even though they resolve to the same host execution
232+
* in Node today:
233+
* - Omitted: use the environment's default sandbox (host execution in Node).
234+
* This default is the slot reserved for richer behavior later.
235+
* - `false`: explicitly opt out of a managed sandbox and run on the host.
236+
*
237+
* Keep `false` distinct from omitting so the opt-out stays stable even if the
238+
* default changes.
239+
*/
240+
sandbox?: Sandbox | false
225241
}
226242

227243
/** Default name assigned to agents when none is provided. */
@@ -289,6 +305,18 @@ export class Agent implements LocalAgent, InvokableAgent {
289305
*/
290306
public readonly sessionManager?: SessionManager | undefined
291307

308+
private readonly _sandbox: Sandbox | false | undefined
309+
310+
/**
311+
* Execution environment for running commands, code, and file operations.
312+
*
313+
* @throws DefaultNotConfiguredError if no sandbox is configured for this
314+
* environment (e.g. browsers, where no host default is registered).
315+
*/
316+
get sandbox(): Sandbox {
317+
return this._sandbox || defaultSandbox.get()
318+
}
319+
292320
private readonly _hooksRegistry: HookRegistryImplementation
293321
private readonly _pluginRegistry: PluginRegistry
294322
private readonly _interventionRegistry: InterventionRegistry
@@ -324,6 +352,7 @@ export class Agent implements LocalAgent, InvokableAgent {
324352
this.id = config?.id ?? DEFAULT_AGENT_ID
325353
if (config?.description !== undefined) this.description = config.description
326354
this.sessionManager = config?.sessionManager
355+
this._sandbox = config?.sandbox
327356

328357
if (typeof config?.model === 'string') {
329358
this.model = new BedrockModel({ modelId: config.model })

strands-ts/src/default-slot.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export class DefaultNotConfiguredError extends Error {
2+
constructor(message: string) {
3+
super(message)
4+
this.name = 'DefaultNotConfiguredError'
5+
}
6+
}
7+
8+
export interface DefaultSlot<T> {
9+
set(value: T): void
10+
get(): T
11+
}
12+
13+
export function createDefaultSlot<T>(errorMessage: string): DefaultSlot<T> {
14+
let value: T | undefined
15+
return {
16+
set(v): void {
17+
value = v
18+
},
19+
get(): T {
20+
if (value !== undefined) return value
21+
throw new DefaultNotConfiguredError(errorMessage)
22+
},
23+
}
24+
}

strands-ts/src/index.node.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Node entry point (selected by the "node" export condition in package.json).
2+
// Registers Node-specific defaults, then re-exports the full public API.
3+
// This is a load-bearing side effect -- do NOT mark this module side-effect-free
4+
// or bundlers will tree-shake the registrations.
5+
import { defaultSandbox } from './sandbox/default.js'
6+
import { NotASandboxLocalEnvironment } from './sandbox/not-a-sandbox-local-environment.js'
7+
8+
defaultSandbox.set(new NotASandboxLocalEnvironment())
9+
10+
export * from './index.js'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { Agent } from '../../agent/agent.js'
3+
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js'
4+
5+
// The unit-browser project has no setupFiles registering a default sandbox,
6+
// mirroring a real browser where index.node.ts never loads.
7+
describe('Agent.sandbox getter (browser)', () => {
8+
it('throws when unconfigured because no default sandbox is registered', () => {
9+
expect(() => new Agent({ model: new MockMessageModel() }).sandbox).toThrow('No Sandbox configured')
10+
})
11+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { Agent } from '../../agent/agent.js'
3+
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js'
4+
import { NotASandboxLocalEnvironment } from '../not-a-sandbox-local-environment.js'
5+
import { defaultSandbox } from '../default.js'
6+
7+
describe('default sandbox registry', () => {
8+
it('returns the instance registered by the setup file', () => {
9+
expect(defaultSandbox.get()).toBeInstanceOf(NotASandboxLocalEnvironment)
10+
})
11+
})
12+
13+
describe('Agent.sandbox getter', () => {
14+
it('returns the configured sandbox when one is provided', () => {
15+
const sandbox = new NotASandboxLocalEnvironment()
16+
expect(new Agent({ model: new MockMessageModel(), sandbox }).sandbox).toBe(sandbox)
17+
})
18+
19+
it('falls back to the registered default when unconfigured', () => {
20+
expect(new Agent({ model: new MockMessageModel() }).sandbox).toBeInstanceOf(NotASandboxLocalEnvironment)
21+
})
22+
23+
it('treats sandbox: false the same as unconfigured', () => {
24+
expect(new Agent({ model: new MockMessageModel(), sandbox: false }).sandbox).toBeInstanceOf(
25+
NotASandboxLocalEnvironment
26+
)
27+
})
28+
})
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import fs from 'fs'
3+
import path from 'path'
4+
import { NotASandboxLocalEnvironment } from '../not-a-sandbox-local-environment.js'
5+
6+
const TEST_DIR = '/tmp/strands-test-not-a-sandbox'
7+
// Written relative (resolved against process.cwd()) to exercise _resolvePath; cleaned up below.
8+
const REL_NAME = 'strands-not-a-sandbox-rel-probe.txt'
9+
const REL_ABS = path.join(process.cwd(), REL_NAME)
10+
11+
describe.skipIf(process.platform === 'win32')('NotASandboxLocalEnvironment', () => {
12+
let sandbox: NotASandboxLocalEnvironment
13+
14+
beforeEach(() => {
15+
fs.rmSync(TEST_DIR, { recursive: true, force: true })
16+
fs.mkdirSync(TEST_DIR, { recursive: true })
17+
sandbox = new NotASandboxLocalEnvironment()
18+
})
19+
20+
afterEach(() => {
21+
fs.rmSync(TEST_DIR, { recursive: true, force: true })
22+
fs.rmSync(REL_ABS, { force: true })
23+
})
24+
25+
describe('execute', () => {
26+
it('runs a command on the host', async () => {
27+
const result = await sandbox.execute('echo hello')
28+
expect(result.exitCode).toBe(0)
29+
expect(result.stdout).toBe('hello\n')
30+
})
31+
32+
it('respects the cwd option', async () => {
33+
const result = await sandbox.execute('pwd', { cwd: TEST_DIR })
34+
expect(result.stdout.trim()).toBe(TEST_DIR)
35+
})
36+
37+
it('reports a non-zero exit code and stderr from a failing command', async () => {
38+
const result = await sandbox.execute('echo oops >&2; exit 3')
39+
expect(result.exitCode).toBe(3)
40+
expect(result.stderr).toBe('oops\n')
41+
})
42+
})
43+
44+
describe('executeCode', () => {
45+
it('runs code through the named interpreter', async () => {
46+
const result = await sandbox.executeCode('print(2 + 2)', 'python3', { cwd: TEST_DIR })
47+
expect(result.exitCode).toBe(0)
48+
expect(result.stdout).toBe('4\n')
49+
})
50+
51+
it('rejects a language with invalid characters', async () => {
52+
await expect(sandbox.executeCode('x', '../../bin/sh')).rejects.toThrow('invalid characters')
53+
})
54+
})
55+
56+
describe('read/write (native fs)', () => {
57+
it('text file roundtrip via absolute path', async () => {
58+
const file = path.join(TEST_DIR, 'note.txt')
59+
await sandbox.writeText(file, 'hello host')
60+
expect(await sandbox.readText(file)).toBe('hello host')
61+
})
62+
63+
it('binary roundtrip preserves all byte values', async () => {
64+
const file = path.join(TEST_DIR, 'all-bytes.bin')
65+
const bytes = new Uint8Array(256)
66+
for (let i = 0; i < 256; i++) bytes[i] = i
67+
await sandbox.writeFile(file, bytes)
68+
expect(Array.from(await sandbox.readFile(file))).toStrictEqual(Array.from(bytes))
69+
})
70+
71+
it('creates missing parent directories on write', async () => {
72+
const file = path.join(TEST_DIR, 'deep/nested/file.txt')
73+
await sandbox.writeText(file, 'deep')
74+
expect(await sandbox.readText(file)).toBe('deep')
75+
})
76+
77+
it('throws when reading a nonexistent file', async () => {
78+
await expect(sandbox.readFile(path.join(TEST_DIR, 'nope.txt'))).rejects.toThrow()
79+
})
80+
})
81+
82+
describe('removeFile', () => {
83+
it('removes a file', async () => {
84+
const file = path.join(TEST_DIR, 'delete-me.txt')
85+
await sandbox.writeText(file, 'bye')
86+
await sandbox.removeFile(file)
87+
await expect(sandbox.readFile(file)).rejects.toThrow()
88+
})
89+
90+
it('throws on a nonexistent file', async () => {
91+
await expect(sandbox.removeFile(path.join(TEST_DIR, 'nope.txt'))).rejects.toThrow()
92+
})
93+
})
94+
95+
describe('listFiles', () => {
96+
it('returns entries sorted by name with isDir and size metadata', async () => {
97+
await sandbox.writeText(path.join(TEST_DIR, 'c.txt'), 'cc')
98+
await sandbox.writeText(path.join(TEST_DIR, 'a.txt'), 'a')
99+
await sandbox.writeText(path.join(TEST_DIR, 'b.txt'), 'bbb')
100+
fs.mkdirSync(path.join(TEST_DIR, 'sub'))
101+
102+
const files = await sandbox.listFiles(TEST_DIR)
103+
expect(files.map((f) => f.name)).toStrictEqual(['a.txt', 'b.txt', 'c.txt', 'sub'])
104+
105+
const a = files.find((f) => f.name === 'a.txt')
106+
expect(a?.isDir).toBe(false)
107+
expect(a?.size).toBe(1)
108+
expect(files.find((f) => f.name === 'sub')?.isDir).toBe(true)
109+
})
110+
111+
it('throws on a nonexistent directory', async () => {
112+
await expect(sandbox.listFiles(path.join(TEST_DIR, 'no-such-dir'))).rejects.toThrow()
113+
})
114+
})
115+
116+
describe('_resolvePath (relative vs absolute)', () => {
117+
it('writes absolute paths as-is', async () => {
118+
const file = path.join(TEST_DIR, 'abs.txt')
119+
await sandbox.writeText(file, 'absolute')
120+
expect(fs.readFileSync(file, 'utf8')).toBe('absolute')
121+
})
122+
123+
it('resolves relative paths against process.cwd()', async () => {
124+
await sandbox.writeText(REL_NAME, 'relative')
125+
expect(fs.readFileSync(REL_ABS, 'utf8')).toBe('relative')
126+
})
127+
})
128+
})

strands-ts/src/sandbox/default.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { Sandbox } from './base.js'
2+
import { createDefaultSlot } from '../default-slot.js'
3+
4+
export const defaultSandbox = createDefaultSlot<Sandbox>(
5+
'No Sandbox configured. Pass a `sandbox` to the Agent to use sandbox features in this environment.'
6+
)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { readFile, writeFile, unlink, mkdir, readdir, stat } from 'fs/promises'
2+
import { dirname, isAbsolute, join } from 'path'
3+
import { Sandbox } from './base.js'
4+
import type { ExecuteOptions } from './base.js'
5+
import { LANGUAGE_PATTERN } from './constants.js'
6+
import { streamProcess } from './stream-process.js'
7+
import type { ExecutionResult, FileInfo, StreamChunk } from './types.js'
8+
import { shellQuote } from './posix-shell.js'
9+
10+
/**
11+
* Runs on the host with no isolation. Used as the default when no sandbox is configured.
12+
*/
13+
export class NotASandboxLocalEnvironment extends Sandbox {
14+
private _resolvePath(path: string): string {
15+
return isAbsolute(path) ? path : join(process.cwd(), path)
16+
}
17+
18+
async *executeStreaming(
19+
command: string,
20+
options?: ExecuteOptions
21+
): AsyncGenerator<StreamChunk | ExecutionResult, void, undefined> {
22+
const cwd = options?.cwd ?? process.cwd()
23+
yield* streamProcess('sh', ['-c', `cd ${shellQuote(cwd)} && ${command}`], {
24+
timeout: options?.timeout,
25+
signal: options?.signal,
26+
})
27+
}
28+
29+
async *executeCodeStreaming(
30+
code: string,
31+
language: string,
32+
options?: ExecuteOptions
33+
): AsyncGenerator<StreamChunk | ExecutionResult, void, undefined> {
34+
if (!LANGUAGE_PATTERN.test(language)) {
35+
throw new Error(`language parameter contains invalid characters: ${language}`)
36+
}
37+
const cwd = options?.cwd ?? process.cwd()
38+
const encoded = btoa(Array.from(new TextEncoder().encode(code), (b) => String.fromCharCode(b)).join(''))
39+
const eof = `STRANDS_EOF_${crypto.randomUUID().slice(0, 16)}`
40+
yield* streamProcess(
41+
'sh',
42+
['-c', `cd ${shellQuote(cwd)} && base64 -d << '${eof}' | ${language}\n${encoded}\n${eof}`],
43+
{
44+
timeout: options?.timeout,
45+
signal: options?.signal,
46+
enoentMessage: `Language interpreter not found: ${language}`,
47+
}
48+
)
49+
}
50+
51+
async readFile(path: string): Promise<Uint8Array> {
52+
return readFile(this._resolvePath(path))
53+
}
54+
55+
async writeFile(path: string, content: Uint8Array): Promise<void> {
56+
const fullPath = this._resolvePath(path)
57+
await mkdir(dirname(fullPath), { recursive: true })
58+
await writeFile(fullPath, content)
59+
}
60+
61+
async removeFile(path: string): Promise<void> {
62+
await unlink(this._resolvePath(path))
63+
}
64+
65+
async listFiles(path: string): Promise<FileInfo[]> {
66+
const fullPath = this._resolvePath(path)
67+
const entries = await readdir(fullPath, { withFileTypes: true })
68+
const results: FileInfo[] = []
69+
70+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
71+
try {
72+
const entryStat = await stat(join(fullPath, entry.name))
73+
results.push({ name: entry.name, isDir: entryStat.isDirectory(), size: entryStat.size })
74+
} catch {
75+
results.push({ name: entry.name })
76+
}
77+
}
78+
79+
return results
80+
}
81+
}

0 commit comments

Comments
 (0)