Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions strands-ts/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ function sdkRules(options) {
process: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
atob: 'readonly',
btoa: 'readonly',
crypto: 'readonly',
},
},
plugins: {
Expand Down Expand Up @@ -90,6 +95,7 @@ function unitTestRules(options) {
navigator: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
crypto: 'readonly',
},
},
plugins: {
Expand Down
12 changes: 12 additions & 0 deletions strands-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@
"types": "./dist/src/vended-tools/bash/index.d.ts",
"default": "./dist/src/vended-tools/bash/index.js"
},
"./vended-tools/exec": {
"types": "./dist/src/vended-tools/exec/index.d.ts",
"default": "./dist/src/vended-tools/exec/index.js"
},
"./vended-tools/code-interpreter": {
"types": "./dist/src/vended-tools/code-interpreter/index.d.ts",
"default": "./dist/src/vended-tools/code-interpreter/index.js"
},
"./sandbox": {
"types": "./dist/src/sandbox/index.d.ts",
"default": "./dist/src/sandbox/index.js"
},
"./a2a": {
"types": "./dist/src/a2a/index.d.ts",
"default": "./dist/src/a2a/index.js"
Expand Down
6 changes: 6 additions & 0 deletions strands-ts/src/__fixtures__/agent-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { Role } from '../types/messages.js'
import { StateStore } from '../state-store.js'
import type { JSONValue } from '../types/json.js'
import { ToolRegistry } from '../registry/tool-registry.js'
import type { Sandbox } from '../sandbox/base.js'
import type { HookableEvent, StreamEvent } from '../hooks/events.js'
import type { HookableEventConstructor, HookCallback } from '../hooks/types.js'
import { expectLoopMetrics, type LoopMetricsMatcher } from './metrics-helpers.js'
Expand Down Expand Up @@ -40,6 +41,10 @@ export interface MockAgentData {
* Optional tool registry for the agent.
*/
toolRegistry?: ToolRegistry
/**
* Sandbox instance for the agent.
*/
sandbox?: Sandbox
/**
* Additional properties to spread onto the mock agent.
*/
Expand All @@ -66,6 +71,7 @@ export function createMockAgent(data?: MockAgentData): MockAgent {
appState: new StateStore(data?.appState ?? {}),
modelState: new StateStore(),
toolRegistry: data?.toolRegistry ?? new ToolRegistry(),
sandbox: data?.sandbox,
cancelSignal: new AbortController().signal,
addHook: <T extends HookableEvent>(eventType: HookableEventConstructor<T>, callback: HookCallback<T>) => {
trackedHooks.push({
Expand Down
33 changes: 33 additions & 0 deletions strands-ts/src/__fixtures__/test-sandbox.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { spawn } from 'child_process'
import { ShellSandbox } from '../sandbox/shell.js'
import { shellQuote } from '../utils/shell-quote.js'
import { streamProcess } from '../sandbox/stream-process.js'
import type { ExecuteOptions } from '../sandbox/base.js'
import type { ExecutionResult, StreamChunk } from '../sandbox/types.js'

/**
* Test sandbox that executes commands within a specific working directory.
*
* Extends ShellSandbox (same base as DockerSandbox and RemoteSandbox) so it
* exercises the same code path real sandboxes use: base64 file encoding,
* shell quoting, ls parsing, etc. The only difference is commands run on
* the host rather than in a container or over SSH.
*/
export class TestSandbox extends ShellSandbox {
readonly workingDir: string

constructor(workingDir: string) {
super()
this.workingDir = workingDir
}

async *executeStreaming(
command: string,
options?: ExecuteOptions
): AsyncGenerator<StreamChunk | ExecutionResult, void, undefined> {
const cwd = options?.cwd ?? this.workingDir
const fullCommand = `cd ${shellQuote(cwd)} && ${command}`
const proc = spawn('sh', ['-c', fullCommand])
yield* streamProcess(proc, { timeout: options?.timeout, signal: options?.signal })
}
}
4 changes: 3 additions & 1 deletion strands-ts/src/agent/__tests__/agent.model-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { ConstantBackoff } from '../../retry/backoff-strategy.js'
import { ModelThrottledError } from '../../errors.js'
import { AfterModelCallEvent } from '../../hooks/events.js'
import { logger } from '../../logging/logger.js'
import '../../sandbox/not-a-sandbox-local-environment.js'
// eslint-disable-next-line no-restricted-imports
import '../../vended-tools/sandbox-default-tools.js'

describe('Agent retryStrategy wiring', () => {
beforeEach(() => {
Expand All @@ -34,7 +37,6 @@ describe('Agent retryStrategy wiring', () => {
})

const invokePromise = agent.invoke('hi')
// Flush any pending timers the retry scheduled.
await vi.runAllTimersAsync()
const result = await invokePromise

Expand Down
2 changes: 1 addition & 1 deletion strands-ts/src/agent/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ describe('Agent', () => {
await agent.invoke('First prompt')
expect(agent.systemPrompt).toEqual([new TextBlock('You are a helpful assistant')])

// Should have been called with the given promp
// Should have been called with the given prompt and no tools (no sandbox configured)
expect(streamSpy).toHaveBeenCalledWith(
expect.any(Array),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Test assertions weakened unnecessarily — toolSpecs: []toolSpecs: expect.any(Array)

These test agents are created with new Agent({ model }) (no sandbox in config), so userProvidedSandbox = false and SANDBOX_DEFAULT_TOOLS are not registered. The toolSpecs should still be [] for these cases. The original strict assertions should be restored:

toolSpecs: [],

Weakening to expect.any(Array) hides regressions where tools are unexpectedly added to the registry.

expect.objectContaining({
Expand Down
44 changes: 44 additions & 0 deletions strands-ts/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type LocalAgent,
type localAgentSymbol,
} from '../types/agent.js'
import type { Sandbox } from '../sandbox/base.js'
import { BedrockModel } from '../models/bedrock.js'
import {
contentBlockFromData,
Expand Down Expand Up @@ -220,6 +221,14 @@ export type AgentConfig = {
* Defaults to `'concurrent'`. See {@link ToolExecutorStrategy} for details.
*/
toolExecutor?: ToolExecutorStrategy
/**
* Sandbox for tool code execution and filesystem access.
* When provided, sandbox default tools (fileEditor, exec, codeInterpreter) are
* auto-registered and execute within the sandbox.
* When omitted, no sandbox tools are auto-registered.
* Pass `false` to explicitly disable sandbox and sandbox tool auto-registration.
*/
sandbox?: Sandbox | false
}

/** Default name assigned to agents when none is provided. */
Expand Down Expand Up @@ -262,6 +271,19 @@ export class Agent implements LocalAgent, InvokableAgent {
*/
public model: Model

/**
* Sandbox for tool code execution and filesystem access.
* Set immediately if passed via config, otherwise defaults to NotASandboxLocalEnvironment during initialize().
*/
Comment thread
gautamsirdeshmukh marked this conversation as resolved.
private _sandbox: Sandbox | false | undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we allow undefined?


get sandbox(): Sandbox {
if (!this._sandbox) {
throw new Error('Sandbox is not available. Pass a Sandbox instance to the agent config to enable it.')
}
return this._sandbox
}

/**
* The system prompt to pass to the model provider.
*/
Expand Down Expand Up @@ -407,6 +429,8 @@ export class Agent implements LocalAgent, InvokableAgent {
this._appendMessageAndFireHooks(message, invocationState)
)

this._sandbox = config?.sandbox

this._initialized = false
}

Expand Down Expand Up @@ -443,6 +467,17 @@ export class Agent implements LocalAgent, InvokableAgent {
return
}

const userProvidedSandbox = this._sandbox !== undefined && this._sandbox !== false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: sandbox: false is silently overwritten by NotASandboxLocalEnvironment

When a user passes sandbox: false:

  1. this._sandbox = false (from constructor)
  2. userProvidedSandbox = false !== undefined && false !== falsefalse
  3. !userProvidedSandbox is true, so the condition enters and overwrites this._sandbox with new NotASandboxLocalEnvironment()

The AgentConfig docs say "Pass false to explicitly disable sandbox and sandbox tools" but the sandbox isn't actually disabled — context.agent.sandbox will return a working NotASandboxLocalEnvironment with full host execution capabilities. Only tool auto-registration is prevented.

Suggestion: Guard the default assignment to respect false:

if (this._sandbox === undefined && typeof process !== 'undefined' && process.versions?.node) {
  const { NotASandboxLocalEnvironment } = await import('../sandbox/not-a-sandbox-local-environment.js')
  this._sandbox = new NotASandboxLocalEnvironment()
}

This way:

  • undefined → gets default NotASandboxLocalEnvironment (current behavior)
  • false → stays false, getter throws "Sandbox is not available" (matches documented intent)
  • Sandbox instance → used as-is, tools auto-registered

if (!userProvidedSandbox && typeof process !== 'undefined' && process.versions?.node) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: sandbox: false is silently overwritten — the documented "disable" behavior doesn't work

The initialize() logic:

const userProvidedSandbox = this._sandbox !== undefined && this._sandbox !== false  // → false when sandbox: false
if (!userProvidedSandbox && typeof process !== 'undefined' && process.versions?.node) {
  this._sandbox = new NotASandboxLocalEnvironment()  // overwrites false!
}

When a user passes sandbox: false:

  1. userProvidedSandbox = false !== undefined && false !== false = true && false = false
  2. The !userProvidedSandbox branch fires and overwrites this._sandbox with NotASandboxLocalEnvironment
  3. context.agent.sandbox returns a working sandbox (doesn't throw)

The AgentConfig TSDoc says: "Pass false to explicitly disable sandbox and sandbox tools" — but the sandbox isn't actually disabled.

Suggestion: Change the default-assignment guard from !userProvidedSandbox to this._sandbox === undefined:

if (this._sandbox === undefined && typeof process !== 'undefined' && process.versions?.node) {
  const { NotASandboxLocalEnvironment } = await import('../sandbox/not-a-sandbox-local-environment.js')
  this._sandbox = new NotASandboxLocalEnvironment()
}

This way:

  • undefined (not specified) → gets default NotASandboxLocalEnvironment
  • false (explicitly disabled) → stays false, getter throws ✅
  • Sandbox instance → used as-is, tools auto-registered ✅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: sandbox: false is silently overwritten — documented behavior broken

This has been flagged in 3+ prior reviews and remains unfixed. When a user passes sandbox: false:

  1. userProvidedSandbox = false !== undefined && false !== false = false
  2. !userProvidedSandbox fires → overwrites this._sandbox with NotASandboxLocalEnvironment

The AgentConfig TSDoc says "Pass false to explicitly disable sandbox" but it doesn't actually disable anything.

Fix (1-line change):

if (this._sandbox === undefined && typeof process !== 'undefined' && process.versions?.node) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: sandbox: false is silently overwritten — documented behavior broken

This bug has been flagged repeatedly but remains unfixed. When a user passes sandbox: false:

  1. userProvidedSandbox = false !== undefined && false !== false = true && false = false
  2. !userProvidedSandbox fires → overwrites this._sandbox with NotASandboxLocalEnvironment

The AgentConfig TSDoc says "Pass false to explicitly disable sandbox" but agent.sandbox will happily return a working NotASandboxLocalEnvironment.

Fix (1-line change):

if (this._sandbox === undefined && typeof process !== 'undefined' && process.versions?.node) {

This ensures:

  • undefined (no config) → gets default ✅
  • false (explicitly disabled) → stays false, getter throws ✅
  • Sandbox instance → used as-is ✅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: sandbox: false is still silently overwritten — documented behavior doesn't work

This has been flagged across multiple review passes and remains the only blocking issue. When a user passes sandbox: false:

const userProvidedSandbox = this._sandbox !== undefined && this._sandbox !== false
// ↑ false !== undefined (true) && false !== false (false) → false

if (!userProvidedSandbox && ...) {
  this._sandbox = new NotASandboxLocalEnvironment()  // overwrites false!
}

The AgentConfig TSDoc says "Pass false to explicitly disable sandbox" but it doesn't actually disable anything.

Fix (1-line change):

if (this._sandbox === undefined && typeof process !== 'undefined' && process.versions?.node) {

This ensures:

  • undefined (no config) → gets default ✅
  • false (explicitly disabled) → stays false, getter throws ✅
  • Sandbox instance → used as-is, tools auto-registered ✅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional, not a bug. NotASandboxLocalEnvironment is not an actual sandbox, but rather a simple interface adapter that lets vended tools use fs/promises and child_process directly on the host, which is exactly what a non-sandboxed agent does today.

Setting it as the default ensures the vended tools work uniformly regardless of whether a real sandbox is configured. Without this default, every vended tool would need separate code paths for "sandbox available" vs "no sandbox".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification — the design rationale makes sense. Having NotASandboxLocalEnvironment as a universal adapter so vended tools never need "sandbox available vs not" branching is a clean pattern.

The remaining issue is that the TSDoc is inaccurate for the false case:

* Pass `false` to explicitly disable sandbox and sandbox tool auto-registration.

Since false actually results in the same behavior as undefined (both get NotASandboxLocalEnvironment, both skip tool registration), the documented distinction is misleading. Suggestions:

  1. If false has no distinct purpose from undefined: remove it from the type and simplify to sandbox?: Sandbox
  2. If false exists for future-proofing (e.g., when a default sandbox with tools is planned): update the TSDoc to clarify what it actually controls today:
    * Pass `false` to explicitly opt out of sandbox tool auto-registration
    * (equivalent to omitting sandbox for now).

This is non-blocking — just a documentation accuracy concern.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, what happens if I don't provide a sandbox, and I am in browser? what's the devx?

const { NotASandboxLocalEnvironment } = await import('../sandbox/not-a-sandbox-local-environment.js')
this._sandbox = new NotASandboxLocalEnvironment()
}

if (this._sandbox) {
await this._sandbox.start()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is the agent "starting" the sandbox? should the agent own sandboxes' lifecycle? I wouldn't want that as sandbox can live outside the agent too

}

// Initialize MCP clients and register their tools
await Promise.all(
this._mcpClients.map(async (client) => {
Expand All @@ -457,6 +492,15 @@ export class Agent implements LocalAgent, InvokableAgent {

await this._pluginRegistry.initialize(this)

if (userProvidedSandbox) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do want this behavior, but it also makes me think if DevX is good enough 🤔

const { SANDBOX_DEFAULT_TOOLS } = await import('../vended-tools/sandbox-default-tools.js')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Dynamic import() bypasses the no-restricted-imports lint rule for vended-tools

The eslint rule prevents static imports of vended-tools from core SDK files:

"Core SDK files should not import from vended-tools. Vended tools are optional and independently importable."

This dynamic import creates a hidden runtime coupling between agent.ts and sandbox-default-tools.ts. While the gating behind if (userProvidedSandbox) means it only loads when explicitly opted in, it still means the vended tools code must be shipped with the SDK and cannot be tree-shaken independently.

Suggestion: Consider adding an explicit eslint-disable comment here with justification to make the architectural exception visible, or add the dynamic import pattern to the rule. This makes the intentional violation discoverable for future maintainers:

// eslint-disable-next-line -- Intentional: sandbox tools are auto-registered when user opts into sandbox
const { SANDBOX_DEFAULT_TOOLS } = await import('../vended-tools/sandbox-default-tools.js')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add an eslint-disable comment documenting the intentional architectural exception

The no-restricted-imports rule exists to keep vended tools decoupled from core SDK. This dynamic import() bypasses it (the rule only catches static import statements). An explicit comment makes the exception visible to future maintainers:

// eslint-disable-next-line -- Intentional: auto-register sandbox tools when user opts into sandbox
const { SANDBOX_DEFAULT_TOOLS } = await import('../vended-tools/sandbox-default-tools.js')

(Note: the test file agent.model-retry.test.ts already uses // eslint-disable-next-line no-restricted-imports for its preload import, confirming the rule exists and applies.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add an eslint-disable comment documenting the intentional architectural exception

The no-restricted-imports rule keeps vended tools decoupled from core SDK. This dynamic import() bypasses the static rule. An explicit comment makes the exception visible:

// eslint-disable-next-line -- Intentional: auto-register sandbox tools when user opts into sandbox
const { SANDBOX_DEFAULT_TOOLS } = await import('../vended-tools/sandbox-default-tools.js')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add an eslint-disable comment documenting the intentional architectural bypass

The no-restricted-imports lint rule prevents static imports of vended-tools from core SDK files. This dynamic import() bypasses the static analysis rule. An explicit comment makes the exception visible and prevents future maintainers from unknowingly replicating the pattern:

// eslint-disable-next-line -- Intentional: auto-register sandbox tools when user opts into sandbox
const { SANDBOX_DEFAULT_TOOLS } = await import('../vended-tools/sandbox-default-tools.js')

(The test file agent.model-retry.test.ts already uses // eslint-disable-next-line no-restricted-imports for its preload import, confirming the convention.)

for (const tool of SANDBOX_DEFAULT_TOOLS) {
if (!this._toolRegistry.get(tool.name)) {
this._toolRegistry.add(tool)
}
}
}

await this._hooksRegistry.invokeCallbacks(new InitializedEvent({ agent: this }))

this._initialized = true
Expand Down
11 changes: 11 additions & 0 deletions strands-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,17 @@ export { AgentTrace } from './telemetry/tracer.js'
// Local Metrics
export { AgentMetrics } from './telemetry/meter.js'

// Sandbox (base class and types only — Node-specific implementations available via './sandbox' sub-export)
export { Sandbox, type ExecuteOptions } from './sandbox/base.js'
export type {
StreamType,
StreamChunk,
FileInfo,
OutputFile,
ExecutionResult,
SandboxSnapshot,
} from './sandbox/types.js'

// Multi-agent orchestration
export { Graph } from './multiagent/index.js'
export { Swarm } from './multiagent/index.js'
158 changes: 158 additions & 0 deletions strands-ts/src/sandbox/__tests__/remote.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { RemoteSandbox } from '../remote.js'
import * as childProcess from 'child_process'

vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof childProcess>('child_process')
return { ...actual, spawn: vi.fn() }
})

function createMockProcess() {
const proc = {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
kill: vi.fn(),
}

// Simulate immediate close with exit code 0
proc.on.mockImplementation((event: string, cb: (code: number | null) => void) => {
if (event === 'close') {
// Schedule the close callback
Promise.resolve().then(() => cb(0))
}
})

return proc
}

describe('RemoteSandbox (unit)', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('constructor', () => {
it('stores host and workingDir', () => {
const sandbox = new RemoteSandbox({ host: 'myhost', workingDir: '/workspace' })
expect(sandbox.host).toBe('myhost')
expect(sandbox.workingDir).toBe('/workspace')
})

it('defaults port to 22', () => {
const sandbox = new RemoteSandbox({ host: 'myhost', workingDir: '/ws' })
// Port is private but we can verify via the SSH args in stream()
expect(sandbox).toBeDefined()
})
})

describe('stream() SSH argument construction', () => {
it('builds correct SSH args with defaults', async () => {
const mockProc = createMockProcess()
vi.mocked(childProcess.spawn).mockReturnValue(mockProc as never)

const sandbox = new RemoteSandbox({ host: 'user@server.com', workingDir: '/remote/path' })

// Start consuming the generator to trigger spawn
const gen = sandbox.executeStreaming('echo hi')
const iter = gen[Symbol.asyncIterator]()
await iter.next()

expect(childProcess.spawn).toHaveBeenCalledWith('ssh', [
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'BatchMode=yes',
'-p',
'22',
'user@server.com',
"cd '/remote/path' && echo hi",
])
})

it('includes identity file when provided', async () => {
const mockProc = createMockProcess()
vi.mocked(childProcess.spawn).mockReturnValue(mockProc as never)

const sandbox = new RemoteSandbox({
host: 'server',
workingDir: '/ws',
identityFile: '/home/user/.ssh/key',
})

const gen = sandbox.executeStreaming('ls')
const iter = gen[Symbol.asyncIterator]()
await iter.next()

const args = vi.mocked(childProcess.spawn).mock.calls[0]![1] as string[]
expect(args).toContain('-i')
expect(args).toContain('/home/user/.ssh/key')
})

it('uses custom port', async () => {
const mockProc = createMockProcess()
vi.mocked(childProcess.spawn).mockReturnValue(mockProc as never)

const sandbox = new RemoteSandbox({
host: 'server',
workingDir: '/ws',
port: 2222,
})

const gen = sandbox.executeStreaming('ls')
const iter = gen[Symbol.asyncIterator]()
await iter.next()

const args = vi.mocked(childProcess.spawn).mock.calls[0]![1] as string[]
expect(args).toContain('-p')
expect(args).toContain('2222')
})

it('quotes cwd with single quotes', async () => {
const mockProc = createMockProcess()
vi.mocked(childProcess.spawn).mockReturnValue(mockProc as never)

const sandbox = new RemoteSandbox({
host: 'server',
workingDir: "/path/with spaces/and'quotes",
})

const gen = sandbox.executeStreaming('ls')
const iter = gen[Symbol.asyncIterator]()
await iter.next()

const args = vi.mocked(childProcess.spawn).mock.calls[0]![1] as string[]
const remoteCommand = args[args.length - 1]
expect(remoteCommand).toContain("cd '/path/with spaces/and'\\''quotes'")
})

it('uses cwd option when provided', async () => {
const mockProc = createMockProcess()
vi.mocked(childProcess.spawn).mockReturnValue(mockProc as never)

const sandbox = new RemoteSandbox({ host: 'server', workingDir: '/default' })

const gen = sandbox.executeStreaming('ls', { cwd: '/override' })
const iter = gen[Symbol.asyncIterator]()
await iter.next()

const args = vi.mocked(childProcess.spawn).mock.calls[0]![1] as string[]
const remoteCommand = args[args.length - 1]
expect(remoteCommand).toContain("cd '/override'")
})
})

describe('start()', () => {
it('creates working directory with cwd: /', async () => {
const mockProc = createMockProcess()
vi.mocked(childProcess.spawn).mockReturnValue(mockProc as never)

const sandbox = new RemoteSandbox({ host: 'server', workingDir: '/my/workspace' })
await sandbox.start()

const args = vi.mocked(childProcess.spawn).mock.calls[0]![1] as string[]
const remoteCommand = args[args.length - 1]
expect(remoteCommand).toContain("cd '/'")
expect(remoteCommand).toContain("mkdir -p '/my/workspace'")
})
})
})
Loading
Loading