Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/neat-shirts-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/sandbox': patch
---

add environment variables and working directory support to command exec
12 changes: 9 additions & 3 deletions packages/sandbox-container/src/handlers/execute-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export class ExecuteHandler extends BaseHandler<Request, Response> {
body.command,
{
sessionId,
timeoutMs: body.timeoutMs
timeoutMs: body.timeoutMs,
env: body.env,
cwd: body.cwd
}
);

Expand All @@ -72,7 +74,9 @@ export class ExecuteHandler extends BaseHandler<Request, Response> {
// For non-background commands, execute and return result
const result = await this.processService.executeCommand(body.command, {
sessionId,
timeoutMs: body.timeoutMs
timeoutMs: body.timeoutMs,
env: body.env,
cwd: body.cwd
});

if (!result.success) {
Expand Down Expand Up @@ -105,7 +109,9 @@ export class ExecuteHandler extends BaseHandler<Request, Response> {

// Start the process for streaming
const processResult = await this.processService.startProcess(body.command, {
sessionId
sessionId,
env: body.env,
cwd: body.cwd
});

if (!processResult.success) {
Expand Down
8 changes: 6 additions & 2 deletions packages/sandbox-container/src/services/process-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ export class ProcessService {
sessionId,
command,
options.cwd,
options.timeoutMs
options.timeoutMs,
options.env
);

if (!result.success) {
Expand Down Expand Up @@ -240,7 +241,10 @@ export class ProcessService {
);
}
},
options.cwd,
{
cwd: options.cwd,
env: options.env
},
processRecordData.id // Pass process ID as commandId for tracking and killing
);

Expand Down
14 changes: 10 additions & 4 deletions packages/sandbox-container/src/services/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ export class SessionManager {
sessionId: string,
command: string,
cwd?: string,
timeoutMs?: number
timeoutMs?: number,
env?: Record<string, string>
): Promise<ServiceResult<RawExecResult>> {
try {
// Get or create session on demand
Expand All @@ -141,7 +142,10 @@ export class SessionManager {

const session = sessionResult.data;

const result = await session.exec(command, cwd ? { cwd } : undefined);
const result = await session.exec(
command,
cwd || env ? { cwd, env } : undefined
);

return {
success: true,
Expand Down Expand Up @@ -187,10 +191,12 @@ export class SessionManager {
sessionId: string,
command: string,
onEvent: (event: ExecEvent) => void,
cwd: string | undefined,
options: { cwd?: string; env?: Record<string, string> } = {},
commandId: string
): Promise<ServiceResult<{ continueStreaming: Promise<void> }>> {
try {
const { cwd, env } = options;

// Get or create session on demand
let sessionResult = await this.getSession(sessionId);

Expand All @@ -215,7 +221,7 @@ export class SessionManager {
const session = sessionResult.data;

// Get async generator
const generator = session.execStream(command, { commandId, cwd });
const generator = session.execStream(command, { commandId, cwd, env });

// CRITICAL: Await first event to ensure command is tracked before returning
// This prevents race condition where killCommand() is called before trackCommand()
Expand Down
98 changes: 87 additions & 11 deletions packages/sandbox-container/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export interface RawExecResult {
interface ExecOptions {
/** Override working directory for this command only */
cwd?: string;
/** Environment variables for this command only (does not persist in session) */
env?: Record<string, string>;
}

/** Command handle for tracking and killing running commands */
Expand Down Expand Up @@ -233,7 +235,8 @@ export class Session {
logFile,
exitCodeFile,
options?.cwd,
false
false,
options?.env
);

// Write script to shell's stdin
Expand Down Expand Up @@ -333,7 +336,8 @@ export class Session {
logFile,
exitCodeFile,
options?.cwd,
true
true,
options?.env
);

if (this.shell!.stdin && typeof this.shell!.stdin !== 'number') {
Expand Down Expand Up @@ -624,7 +628,8 @@ export class Session {
logFile: string,
exitCodeFile: string,
cwd?: string,
isBackground = false
isBackground = false,
env?: Record<string, string>
): string {
// Create unique FIFO names to prevent collisions
const stdoutPipe = join(this.sessionDir!, `${cmdId}.stdout.pipe`);
Expand All @@ -639,6 +644,77 @@ export class Session {
const safeSessionDir = this.escapeShellPath(this.sessionDir!);
const safePidFile = this.escapeShellPath(pidFile);

const indentLines = (input: string, spaces: number) => {
const prefix = ' '.repeat(spaces);
return input
.split('\n')
.map((line) => (line.length > 0 ? `${prefix}${line}` : ''))
.join('\n');
};

const sanitizeIdentifier = (value: string) =>
value.replace(/[^A-Za-z0-9_]/g, '_');
Copy link
Contributor

Choose a reason for hiding this comment

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

Collision risk: sanitizeIdentifier('abc-123') and sanitizeIdentifier('abc_123') both return 'abc_123'. If two commands with similar IDs run concurrently (background mode), their state variables will collide.

Since cmdId values are UUIDs (already shell-safe), consider using them directly without sanitization, or add a hash suffix to guarantee uniqueness.


let envSetupBlock = '';
let envCleanupBlock = '';

if (env && Object.keys(env).length > 0) {
const setupLines: string[] = [];
const cleanupLines: string[] = [];
const cmdSuffix = sanitizeIdentifier(cmdId);

Object.entries(env).forEach(([key, value], index) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
throw new Error(`Invalid environment variable name: ${key}`);
}

const escapedValue = value.replace(/'/g, "'\\''");
const stateSuffix = `${cmdSuffix}_${index}`;
const hasVar = `__SANDBOX_HAS_${stateSuffix}`;
const prevVar = `__SANDBOX_PREV_${stateSuffix}`;

setupLines.push(` ${hasVar}=0`);
setupLines.push(` if [ "\${${key}+x}" = "x" ]; then`);
setupLines.push(` ${hasVar}=1`);
setupLines.push(` ${prevVar}=$(printf '%q' "\${${key}}")`);
setupLines.push(' fi');
setupLines.push(` export ${key}='${escapedValue}'`);

cleanupLines.push(` if [ "$${hasVar}" = "1" ]; then`);
cleanupLines.push(` eval "export ${key}=$${prevVar}"`);
Copy link
Contributor

Choose a reason for hiding this comment

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

CRITICAL BUG: $$ in bash is the process ID, not variable expansion.

This generates:

eval "export KEY=12345__SANDBOX_PREV_suffix_0"

Where 12345 is the PID.

Fix:

cleanupLines.push(`    eval "export ${key}=${prevVar}"`);

Since line 679 uses printf '%q' which already shell-quotes the value, no extra escaping is needed.

cleanupLines.push(' else');
cleanupLines.push(` unset ${key}`);
cleanupLines.push(' fi');
cleanupLines.push(` unset ${hasVar} ${prevVar}`);
});

envSetupBlock = setupLines.join('\n');
envCleanupBlock = cleanupLines.join('\n');
}

const hasScopedEnv = env && Object.keys(env).length > 0;

const buildCommandBlock = (exitVar: string): string => {
const lines: string[] = [];
if (hasScopedEnv && envSetupBlock) {
lines.push(envSetupBlock);
}
lines.push(` ${command}`);
lines.push(` ${exitVar}=$?`);
if (hasScopedEnv && envCleanupBlock) {
lines.push(envCleanupBlock);
}
return lines.join('\n');
};

const buildCommandSection = (exitVar: string, indent: number): string => {
if (hasScopedEnv) {
return `${indentLines(buildCommandBlock(exitVar), indent)}\n`;
}
const padding = ' '.repeat(indent);
return `${padding}${command}\n${padding}${exitVar}=$?\n`;
};

// Build the FIFO script
// For background: monitor handles cleanup (no trap needed)
// For foreground: trap handles cleanup (standard pattern)
Expand Down Expand Up @@ -684,8 +760,7 @@ export class Session {
script += ` if cd ${safeCwd}; then\n`;
script += ` # Execute command in BACKGROUND (runs in subshell, enables concurrency)\n`;
script += ` {\n`;
script += ` ${command}\n`;
script += ` CMD_EXIT=$?\n`;
script += `${indentLines(buildCommandBlock('CMD_EXIT'), 6)}\n`;
script += ` # Write exit code\n`;
script += ` echo "$CMD_EXIT" > ${safeExitCodeFile}.tmp\n`;
script += ` mv ${safeExitCodeFile}.tmp ${safeExitCodeFile}\n`;
Expand All @@ -708,8 +783,7 @@ export class Session {
} else {
script += ` # Execute command in BACKGROUND (runs in subshell, enables concurrency)\n`;
script += ` {\n`;
script += ` ${command}\n`;
script += ` CMD_EXIT=$?\n`;
script += `${indentLines(buildCommandBlock('CMD_EXIT'), 4)}\n`;
script += ` # Write exit code\n`;
script += ` echo "$CMD_EXIT" > ${safeExitCodeFile}.tmp\n`;
script += ` mv ${safeExitCodeFile}.tmp ${safeExitCodeFile}\n`;
Expand Down Expand Up @@ -738,8 +812,9 @@ export class Session {
script += ` PREV_DIR=$(pwd)\n`;
script += ` if cd ${safeCwd}; then\n`;
script += ` # Execute command, redirect to temp files\n`;
script += ` { ${command}; } < /dev/null > "$log.stdout" 2> "$log.stderr"\n`;
script += ` EXIT_CODE=$?\n`;
script += ` {\n`;
script += `${indentLines(buildCommandBlock('EXIT_CODE'), 6)}\n`;
script += ` } < /dev/null > "$log.stdout" 2> "$log.stderr"\n`;
script += ` # Restore directory\n`;
script += ` cd "$PREV_DIR"\n`;
script += ` else\n`;
Expand All @@ -748,8 +823,9 @@ export class Session {
script += ` fi\n`;
} else {
script += ` # Execute command, redirect to temp files\n`;
script += ` { ${command}; } < /dev/null > "$log.stdout" 2> "$log.stderr"\n`;
script += ` EXIT_CODE=$?\n`;
script += ` {\n`;
script += `${indentLines(buildCommandBlock('EXIT_CODE'), 4)}\n`;
script += ` } < /dev/null > "$log.stdout" 2> "$log.stderr"\n`;
}

script += ` \n`;
Expand Down
4 changes: 3 additions & 1 deletion packages/sandbox-container/src/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export const ExecuteRequestSchema = z.object({
command: z.string().min(1, 'Command cannot be empty'),
sessionId: z.string().optional(),
background: z.boolean().optional(),
timeoutMs: z.number().positive().optional()
timeoutMs: z.number().positive().optional(),
env: z.record(z.string()).optional(),
cwd: z.string().optional()
});

// File operation schemas
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ describe('ProcessService', () => {
'default', // sessionId
'echo "hello world"',
'/tmp', // cwd
undefined // timeoutMs (not provided in options)
undefined, // timeoutMs (not provided in options)
undefined // env (not provided in options)
);
});

Expand Down Expand Up @@ -177,7 +178,7 @@ describe('ProcessService', () => {
'session-123',
'sleep 10',
expect.any(Function), // event handler callback
'/tmp',
expect.objectContaining({ cwd: '/tmp' }),
expect.any(String) // commandId (generated dynamically)
);

Expand Down
76 changes: 76 additions & 0 deletions packages/sandbox-container/tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,82 @@ describe('Session', () => {
expect(result2.stdout.trim()).toContain('subdir');
});

it('should scope per-command environment variables', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Test gap: This verifies NEW variables disappear, but doesn't test the critical case of overriding EXISTING variables and restoring original values.

Add:

it('should restore overridden environment variables', async () => {
  await session.exec('export EXISTING="original"');
  await session.exec('echo $EXISTING', { env: { EXISTING: 'temp' }});
  const result = await session.exec('echo $EXISTING');
  expect(result.stdout.trim()).toBe('original');
});

This test will expose the bug at line 684.

const result = await session.exec('printenv TEMP_CMD_VAR', {
env: { TEMP_CMD_VAR: 'scoped-value' }
});

expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('scoped-value');

const verify = await session.exec('printenv TEMP_CMD_VAR');
expect(verify.exitCode).not.toBe(0);
});

it('should reject invalid per-command environment variable names', async () => {
await expect(
session.exec('pwd', {
env: { 'INVALID-NAME': 'value' }
})
).rejects.toThrow(/Invalid environment variable name/);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Add tests for env var values containing shell metacharacters:

it('should safely handle env values with shell special chars', async () => {
  const result = await session.exec('echo "$SPECIAL"', {
    env: { SPECIAL: '$(whoami) `date` $PATH' }
  });
  expect(result.stdout.trim()).toBe('$(whoami) `date` $PATH');  // Literal
});

it('should handle env values with quotes', async () => {
  const result = await session.exec('echo "$QUOTED"', {
    env: { QUOTED: "it's got 'quotes'" }
  });
  expect(result.stdout.trim()).toBe("it's got 'quotes'");
});

This ensures the escaping at line 671 works correctly for all edge cases.


it('should safely handle env values with shell special chars', async () => {
const result = await session.exec('echo "$SPECIAL"', {
env: { SPECIAL: '$(whoami) `date` $PATH' }
});

expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('$(whoami) `date` $PATH');
});

it('should handle env values with quotes', async () => {
const result = await session.exec('echo "$QUOTED"', {
env: { QUOTED: "it's got 'quotes'" }
});

expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("it's got 'quotes'");
});

it('should restore existing env vars with special characters', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

This test verifies restoration of env vars with special chars. It should fail with the current bug at session.ts:684 where $$ expands to PID instead of the variable.

Can you confirm this test actually passes in CI? If it does pass, there may be a subtle bash behavior I'm missing.

await session.destroy();
session = new Session({
id: 'test-exec',
cwd: testDir,
env: { RESTORE_VAR: '$(whoami) $PATH' }
});
await session.initialize();

const initial = await session.exec('echo "$RESTORE_VAR"');
expect(initial.exitCode).toBe(0);
expect(initial.stdout.trim()).toBe('$(whoami) $PATH');

const overrideResult = await session.exec('echo "$RESTORE_VAR"', {
env: { RESTORE_VAR: 'temporary-value' }
});
expect(overrideResult.exitCode).toBe(0);
expect(overrideResult.stdout.trim()).toBe('temporary-value');

const restoredResult = await session.exec('echo "$RESTORE_VAR"');
expect(restoredResult.exitCode).toBe(0);
expect(restoredResult.stdout.trim()).toBe('$(whoami) $PATH');
});

it('should restore overridden environment variables', async () => {
await session.exec('export EXISTING="original"');

const overrideResult = await session.exec('echo "$EXISTING"', {
env: { EXISTING: 'temp' }
});
expect(overrideResult.exitCode).toBe(0);
expect(overrideResult.stdout.trim()).toBe('temp');

const restoredResult = await session.exec('echo "$EXISTING"');
expect(restoredResult.exitCode).toBe(0);
expect(restoredResult.stdout.trim()).toBe('original');
});

it('should override cwd temporarily when option provided', async () => {
// Create a subdirectory
await session.exec('mkdir -p tempdir');
Expand Down
Loading
Loading