Skip to content

Commit 55981f8

Browse files
add environment variables and working directory support to command exec (#204)
* add environment variables and working directory support to command execution * add execution options support to test worker API endpoints * improve environment variable handling in command execution * updates based on review * Fix openai agents example typecheck * Add support for env vars and working directory in exec Add support for environment variables and working directory in command execution. * minor fixes and nits * one more nit * handling of environment variables in the session execution * fix tests * update tests * additional security and tests * nits * fix handling of environment variable exports in session management * Move env setting + cleanup method out of buildFIFOScript * Fix type errors --------- Co-authored-by: Naresh <[email protected]>
1 parent 9d05666 commit 55981f8

File tree

17 files changed

+457
-76
lines changed

17 files changed

+457
-76
lines changed

.changeset/neat-shirts-bathe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cloudflare/sandbox': patch
3+
---
4+
5+
add environment variables and working directory support to command exec

packages/sandbox-container/src/handlers/execute-handler.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ export class ExecuteHandler extends BaseHandler<Request, Response> {
4848
body.command,
4949
{
5050
sessionId,
51-
timeoutMs: body.timeoutMs
51+
timeoutMs: body.timeoutMs,
52+
env: body.env,
53+
cwd: body.cwd
5254
}
5355
);
5456

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

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

106110
// Start the process for streaming
107111
const processResult = await this.processService.startProcess(body.command, {
108-
sessionId
112+
sessionId,
113+
env: body.env,
114+
cwd: body.cwd
109115
});
110116

111117
if (!processResult.success) {

packages/sandbox-container/src/services/process-service.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ export class ProcessService {
5858
sessionId,
5959
command,
6060
options.cwd,
61-
options.timeoutMs
61+
options.timeoutMs,
62+
options.env
6263
);
6364

6465
if (!result.success) {
@@ -202,7 +203,10 @@ export class ProcessService {
202203
);
203204
}
204205
},
205-
options.cwd,
206+
{
207+
cwd: options.cwd,
208+
env: options.env
209+
},
206210
processRecordData.id // Pass process ID as commandId for tracking and killing
207211
);
208212

packages/sandbox-container/src/services/session-manager.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ export class SessionManager {
116116
sessionId: string,
117117
command: string,
118118
cwd?: string,
119-
timeoutMs?: number
119+
timeoutMs?: number,
120+
env?: Record<string, string>
120121
): Promise<ServiceResult<RawExecResult>> {
121122
try {
122123
// Get or create session on demand
@@ -141,7 +142,10 @@ export class SessionManager {
141142

142143
const session = sessionResult.data;
143144

144-
const result = await session.exec(command, cwd ? { cwd } : undefined);
145+
const result = await session.exec(
146+
command,
147+
cwd || env ? { cwd, env } : undefined
148+
);
145149

146150
return {
147151
success: true,
@@ -187,10 +191,12 @@ export class SessionManager {
187191
sessionId: string,
188192
command: string,
189193
onEvent: (event: ExecEvent) => Promise<void>,
190-
cwd: string | undefined,
194+
options: { cwd?: string; env?: Record<string, string> } = {},
191195
commandId: string
192196
): Promise<ServiceResult<{ continueStreaming: Promise<void> }>> {
193197
try {
198+
const { cwd, env } = options;
199+
194200
// Get or create session on demand
195201
let sessionResult = await this.getSession(sessionId);
196202

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

217223
// Get async generator
218-
const generator = session.execStream(command, { commandId, cwd });
224+
const generator = session.execStream(command, { commandId, cwd, env });
219225

220226
// CRITICAL: Await first event to ensure command is tracked before returning
221227
// This prevents race condition where killCommand() is called before trackCommand()

packages/sandbox-container/src/session.ts

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export interface RawExecResult {
8888
interface ExecOptions {
8989
/** Override working directory for this command only */
9090
cwd?: string;
91+
/** Environment variables for this command only (does not persist in session) */
92+
env?: Record<string, string>;
9193
}
9294

9395
/** Command handle for tracking and killing running commands */
@@ -233,7 +235,8 @@ export class Session {
233235
logFile,
234236
exitCodeFile,
235237
options?.cwd,
236-
false
238+
false,
239+
options?.env
237240
);
238241

239242
// Write script to shell's stdin
@@ -333,7 +336,8 @@ export class Session {
333336
logFile,
334337
exitCodeFile,
335338
options?.cwd,
336-
true
339+
true,
340+
options?.env
337341
);
338342

339343
if (this.shell!.stdin && typeof this.shell!.stdin !== 'number') {
@@ -624,7 +628,8 @@ export class Session {
624628
logFile: string,
625629
exitCodeFile: string,
626630
cwd?: string,
627-
isBackground = false
631+
isBackground = false,
632+
env?: Record<string, string>
628633
): string {
629634
// Create unique FIFO names to prevent collisions
630635
const stdoutPipe = join(this.sessionDir!, `${cmdId}.stdout.pipe`);
@@ -639,6 +644,32 @@ export class Session {
639644
const safeSessionDir = this.escapeShellPath(this.sessionDir!);
640645
const safePidFile = this.escapeShellPath(pidFile);
641646

647+
const indentLines = (input: string, spaces: number) => {
648+
const prefix = ' '.repeat(spaces);
649+
return input
650+
.split('\n')
651+
.map((line) => (line.length > 0 ? `${prefix}${line}` : ''))
652+
.join('\n');
653+
};
654+
655+
const { setup: envSetupBlock, cleanup: envCleanupBlock } =
656+
this.buildScopedEnvBlocks(env, cmdId, { restore: !isBackground });
657+
658+
const hasScopedEnv = envSetupBlock.length > 0;
659+
660+
const buildCommandBlock = (exitVar: string, indent: number): string => {
661+
const lines: string[] = [];
662+
if (hasScopedEnv) {
663+
lines.push(envSetupBlock);
664+
}
665+
lines.push(` ${command}`);
666+
lines.push(` ${exitVar}=$?`);
667+
if (envCleanupBlock) {
668+
lines.push(envCleanupBlock);
669+
}
670+
return indentLines(lines.join('\n'), indent);
671+
};
672+
642673
// Build the FIFO script
643674
// For background: monitor handles cleanup (no trap needed)
644675
// For foreground: trap handles cleanup (standard pattern)
@@ -684,8 +715,7 @@ export class Session {
684715
script += ` if cd ${safeCwd}; then\n`;
685716
script += ` # Execute command in BACKGROUND (runs in subshell, enables concurrency)\n`;
686717
script += ` {\n`;
687-
script += ` ${command}\n`;
688-
script += ` CMD_EXIT=$?\n`;
718+
script += `${buildCommandBlock('CMD_EXIT', 6)}\n`;
689719
script += ` # Write exit code\n`;
690720
script += ` echo "$CMD_EXIT" > ${safeExitCodeFile}.tmp\n`;
691721
script += ` mv ${safeExitCodeFile}.tmp ${safeExitCodeFile}\n`;
@@ -708,8 +738,7 @@ export class Session {
708738
} else {
709739
script += ` # Execute command in BACKGROUND (runs in subshell, enables concurrency)\n`;
710740
script += ` {\n`;
711-
script += ` ${command}\n`;
712-
script += ` CMD_EXIT=$?\n`;
741+
script += `${buildCommandBlock('CMD_EXIT', 4)}\n`;
713742
script += ` # Write exit code\n`;
714743
script += ` echo "$CMD_EXIT" > ${safeExitCodeFile}.tmp\n`;
715744
script += ` mv ${safeExitCodeFile}.tmp ${safeExitCodeFile}\n`;
@@ -738,8 +767,9 @@ export class Session {
738767
script += ` PREV_DIR=$(pwd)\n`;
739768
script += ` if cd ${safeCwd}; then\n`;
740769
script += ` # Execute command, redirect to temp files\n`;
741-
script += ` { ${command}; } < /dev/null > "$log.stdout" 2> "$log.stderr"\n`;
742-
script += ` EXIT_CODE=$?\n`;
770+
script += ` {\n`;
771+
script += `${buildCommandBlock('EXIT_CODE', 6)}\n`;
772+
script += ` } < /dev/null > "$log.stdout" 2> "$log.stderr"\n`;
743773
script += ` # Restore directory\n`;
744774
script += ` cd "$PREV_DIR"\n`;
745775
script += ` else\n`;
@@ -748,8 +778,9 @@ export class Session {
748778
script += ` fi\n`;
749779
} else {
750780
script += ` # Execute command, redirect to temp files\n`;
751-
script += ` { ${command}; } < /dev/null > "$log.stdout" 2> "$log.stderr"\n`;
752-
script += ` EXIT_CODE=$?\n`;
781+
script += ` {\n`;
782+
script += `${buildCommandBlock('EXIT_CODE', 4)}\n`;
783+
script += ` } < /dev/null > "$log.stdout" 2> "$log.stderr"\n`;
753784
}
754785

755786
script += ` \n`;
@@ -775,6 +806,58 @@ export class Session {
775806
return script;
776807
}
777808

809+
private buildScopedEnvBlocks(
810+
env: Record<string, string> | undefined,
811+
cmdId: string,
812+
options: { restore: boolean }
813+
): { setup: string; cleanup: string } {
814+
if (!env || Object.keys(env).length === 0) {
815+
return { setup: '', cleanup: '' };
816+
}
817+
818+
const sanitizeIdentifier = (value: string) =>
819+
value.replace(/[^A-Za-z0-9_]/g, '_');
820+
821+
const setupLines: string[] = [];
822+
const cleanupLines: string[] = [];
823+
const cmdSuffix = sanitizeIdentifier(cmdId);
824+
825+
Object.entries(env).forEach(([key, value], index) => {
826+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
827+
throw new Error(`Invalid environment variable name: ${key}`);
828+
}
829+
830+
const escapedValue = value.replace(/'/g, "'\\''");
831+
832+
if (options.restore) {
833+
const stateSuffix = `${cmdSuffix}_${index}`;
834+
const hasVar = `__SANDBOX_HAS_${stateSuffix}`;
835+
const prevVar = `__SANDBOX_PREV_${stateSuffix}`;
836+
837+
setupLines.push(` ${hasVar}=0`);
838+
setupLines.push(` if [ "\${${key}+x}" = "x" ]; then`);
839+
setupLines.push(` ${hasVar}=1`);
840+
setupLines.push(` ${prevVar}=$(printf '%q' "\${${key}}")`);
841+
setupLines.push(' fi');
842+
setupLines.push(` export ${key}='${escapedValue}'`);
843+
844+
cleanupLines.push(` if [ "$${hasVar}" = "1" ]; then`);
845+
cleanupLines.push(` eval "export ${key}=$${prevVar}"`);
846+
cleanupLines.push(' else');
847+
cleanupLines.push(` unset ${key}`);
848+
cleanupLines.push(' fi');
849+
cleanupLines.push(` unset ${hasVar} ${prevVar}`);
850+
} else {
851+
setupLines.push(` export ${key}='${escapedValue}'`);
852+
}
853+
});
854+
855+
return {
856+
setup: setupLines.join('\n'),
857+
cleanup: options.restore ? cleanupLines.join('\n') : ''
858+
};
859+
}
860+
778861
/**
779862
* Wait for exit code file to appear using hybrid fs.watch + polling
780863
*

packages/sandbox-container/src/validation/schemas.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ export const ExecuteRequestSchema = z.object({
1717
command: z.string().min(1, 'Command cannot be empty'),
1818
sessionId: z.string().optional(),
1919
background: z.boolean().optional(),
20-
timeoutMs: z.number().positive().optional()
20+
timeoutMs: z.number().positive().optional(),
21+
env: z.record(z.string()).optional(),
22+
cwd: z.string().optional()
2123
});
2224

2325
// File operation schemas

packages/sandbox-container/tests/services/process-service.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ describe('ProcessService', () => {
108108
'default', // sessionId
109109
'echo "hello world"',
110110
'/tmp', // cwd
111-
undefined // timeoutMs (not provided in options)
111+
undefined, // timeoutMs (not provided in options)
112+
undefined // env (not provided in options)
112113
);
113114
});
114115

@@ -180,7 +181,7 @@ describe('ProcessService', () => {
180181
'session-123',
181182
'sleep 10',
182183
expect.any(Function), // event handler callback
183-
'/tmp',
184+
expect.objectContaining({ cwd: '/tmp' }),
184185
expect.any(String) // commandId (generated dynamically)
185186
);
186187

packages/sandbox-container/tests/session.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,82 @@ describe('Session', () => {
151151
expect(result2.stdout.trim()).toContain('subdir');
152152
});
153153

154+
it('should scope per-command environment variables', async () => {
155+
const result = await session.exec('printenv TEMP_CMD_VAR', {
156+
env: { TEMP_CMD_VAR: 'scoped-value' }
157+
});
158+
159+
expect(result.exitCode).toBe(0);
160+
expect(result.stdout.trim()).toBe('scoped-value');
161+
162+
const verify = await session.exec('printenv TEMP_CMD_VAR');
163+
expect(verify.exitCode).not.toBe(0);
164+
});
165+
166+
it('should reject invalid per-command environment variable names', async () => {
167+
await expect(
168+
session.exec('pwd', {
169+
env: { 'INVALID-NAME': 'value' }
170+
})
171+
).rejects.toThrow(/Invalid environment variable name/);
172+
});
173+
174+
it('should safely handle env values with shell special chars', async () => {
175+
const result = await session.exec('echo "$SPECIAL"', {
176+
env: { SPECIAL: '$(whoami) `date` $PATH' }
177+
});
178+
179+
expect(result.exitCode).toBe(0);
180+
expect(result.stdout.trim()).toBe('$(whoami) `date` $PATH');
181+
});
182+
183+
it('should handle env values with quotes', async () => {
184+
const result = await session.exec('echo "$QUOTED"', {
185+
env: { QUOTED: "it's got 'quotes'" }
186+
});
187+
188+
expect(result.exitCode).toBe(0);
189+
expect(result.stdout.trim()).toBe("it's got 'quotes'");
190+
});
191+
192+
it('should restore existing env vars with special characters', async () => {
193+
await session.destroy();
194+
session = new Session({
195+
id: 'test-exec',
196+
cwd: testDir,
197+
env: { RESTORE_VAR: '$(whoami) $PATH' }
198+
});
199+
await session.initialize();
200+
201+
const initial = await session.exec('echo "$RESTORE_VAR"');
202+
expect(initial.exitCode).toBe(0);
203+
expect(initial.stdout.trim()).toBe('$(whoami) $PATH');
204+
205+
const overrideResult = await session.exec('echo "$RESTORE_VAR"', {
206+
env: { RESTORE_VAR: 'temporary-value' }
207+
});
208+
expect(overrideResult.exitCode).toBe(0);
209+
expect(overrideResult.stdout.trim()).toBe('temporary-value');
210+
211+
const restoredResult = await session.exec('echo "$RESTORE_VAR"');
212+
expect(restoredResult.exitCode).toBe(0);
213+
expect(restoredResult.stdout.trim()).toBe('$(whoami) $PATH');
214+
});
215+
216+
it('should restore overridden environment variables', async () => {
217+
await session.exec('export EXISTING="original"');
218+
219+
const overrideResult = await session.exec('echo "$EXISTING"', {
220+
env: { EXISTING: 'temp' }
221+
});
222+
expect(overrideResult.exitCode).toBe(0);
223+
expect(overrideResult.stdout.trim()).toBe('temp');
224+
225+
const restoredResult = await session.exec('echo "$EXISTING"');
226+
expect(restoredResult.exitCode).toBe(0);
227+
expect(restoredResult.stdout.trim()).toBe('original');
228+
});
229+
154230
it('should override cwd temporarily when option provided', async () => {
155231
// Create a subdirectory
156232
await session.exec('mkdir -p tempdir');

0 commit comments

Comments
 (0)