Skip to content

Commit 3f74569

Browse files
committed
fallback to tmp
1 parent 3c29926 commit 3f74569

File tree

3 files changed

+291
-28
lines changed

3 files changed

+291
-28
lines changed

packages/sandbox-container/src/session.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ export interface SessionOptions {
4646
/** Session identifier (generated if not provided) */
4747
id: string;
4848

49-
/** Working directory for the session */
49+
/**
50+
* Initial working directory for the shell.
51+
*
52+
* Note: This only affects where the shell starts. Individual commands can
53+
* specify their own cwd via exec options, and the shell can cd anywhere.
54+
* If the directory doesn't exist, the session falls back to /tmp.
55+
*/
5056
cwd?: string;
5157

5258
/** Environment variables for the session */
@@ -137,23 +143,22 @@ export class Session {
137143
this.sessionDir = join(tmpdir(), `session-${this.id}-${Date.now()}`);
138144
await mkdir(this.sessionDir, { recursive: true });
139145

140-
// Determine working directory and verify it exists to avoid spawn failures
141-
// If /workspace or specified cwd doesn't exist, fall back to /
142-
// This handles the case where /workspace is deleted
146+
// Determine working directory. If the requested
147+
// cwd doesn't exist, we fall back to /tmp since it's always available and
148+
// safer than / for accidental operations.
143149
let cwd = this.options.cwd || CONFIG.DEFAULT_CWD;
144150
try {
145151
await stat(cwd);
146152
} catch {
147-
// Directory doesn't exist, fall back to root
148-
this.logger.warn(
149-
`Working directory '${cwd}' does not exist, falling back to '/'`,
153+
this.logger.debug(
154+
`Shell startup directory '${cwd}' does not exist, using '/tmp'`,
150155
{
151156
sessionId: this.id,
152157
requestedCwd: cwd,
153-
fallbackCwd: '/'
158+
actualCwd: '/tmp'
154159
}
155160
);
156-
cwd = '/';
161+
cwd = '/tmp';
157162
}
158163

159164
// Spawn persistent bash with stdin pipe - no IPC or wrapper needed!

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,27 +76,28 @@ describe('Session', () => {
7676
expect(session.isReady()).toBe(true);
7777
});
7878

79-
it('should initialize with non-existent cwd by falling back to / (issue #288)', async () => {
80-
// This tests the fix for issue #288 where session creation failed
81-
// if /workspace was deleted before a new session was created
79+
it('should fall back to /tmp when cwd does not exist (issue #288)', async () => {
80+
// Session cwd only affects shell startup directory - it's not critical.
81+
// If cwd doesn't exist, we fall back to /tmp since individual commands
82+
// can specify their own cwd anyway.
8283
session = new Session({
8384
id: 'test-session-nonexistent-cwd',
8485
cwd: '/nonexistent/path/that/does/not/exist'
8586
});
8687

87-
// This should NOT throw - instead it should fall back to /
8888
await session.initialize();
8989

9090
expect(session.isReady()).toBe(true);
9191

9292
// Verify we can execute commands
9393
const result = await session.exec('pwd');
9494
expect(result.exitCode).toBe(0);
95-
// The shell should have started in / since the requested cwd doesn't exist
96-
expect(result.stdout.trim()).toBe('/');
95+
// The shell should have started in /tmp since the requested cwd doesn't exist
96+
// On macOS, /tmp is a symlink to /private/tmp
97+
expect(result.stdout.trim()).toMatch(/^(\/tmp|\/private\/tmp)$/);
9798
});
9899

99-
it('should initialize with missing /workspace by falling back to / (issue #288)', async () => {
100+
it('should fall back to /tmp when workspace is deleted before session creation (issue #288)', async () => {
100101
// Simulate the exact scenario from issue #288
101102
// Create a workspace, then delete it, then try to create a session with it
102103
const workspaceDir = join(testDir, 'workspace');
@@ -111,7 +112,7 @@ describe('Session', () => {
111112
cwd: workspaceDir
112113
});
113114

114-
// This should NOT throw - instead it should fall back to /
115+
// Should succeed - falls back to /tmp
115116
await session.initialize();
116117

117118
expect(session.isReady()).toBe(true);

tests/e2e/workspace-deletion-workflow.test.ts

Lines changed: 268 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
/**
22
* Workspace Deletion Workflow Tests (Issue #288)
3-
*
4-
* Tests the fix for GitHub issue #288 where `sandbox.exec()` fails with
5-
* "Unknown Error, TODO" after `/workspace` is removed or replaced with symlink.
6-
*
7-
* These tests verify:
8-
* 1. Symlinks work correctly in non-/workspace directories
9-
* 2. Session continues working after /workspace is deleted
10-
* 3. Session continues working after /workspace is replaced with symlink
11-
* 4. Session can recreate /workspace after deletion
12-
* 5. New sessions can be created even when /workspace doesn't exist
3+
134
*
145
* @see https://github.com/cloudflare/sandbox-sdk/issues/288
156
*/
@@ -381,7 +372,212 @@ describe('Workspace Deletion Workflow (Issue #288)', () => {
381372
}, 90000);
382373

383374
/**
384-
* Test 7: Multiple operations after workspace manipulation
375+
* Test 7: New session creation after /workspace is deleted (Core bug from Issue #288)
376+
*
377+
* This is the PRIMARY test for issue #288. The bug was that creating a NEW session
378+
* after /workspace was deleted would fail with "Unknown Error, TODO".
379+
*
380+
* The fix makes session initialization fall back to "/" if /workspace doesn't exist.
381+
*
382+
* IMPORTANT: This test actually deletes /workspace and creates a new session.
383+
* It restores /workspace at the end to avoid breaking other tests.
384+
*/
385+
test('should create new session successfully after /workspace is deleted (issue #288 core bug)', async () => {
386+
const sandbox = await getSharedSandbox();
387+
388+
// Use a dedicated session for the setup/cleanup operations
389+
const setupSession = createUniqueSession();
390+
const setupHeaders = sandbox.createHeaders(setupSession);
391+
392+
// Step 1: Verify /workspace exists (or create it if needed)
393+
const ensureWorkspaceResponse = await fetch(`${workerUrl}/api/execute`, {
394+
method: 'POST',
395+
headers: setupHeaders,
396+
body: JSON.stringify({
397+
command: 'mkdir -p /workspace && ls -la /workspace'
398+
})
399+
});
400+
expect(ensureWorkspaceResponse.status).toBe(200);
401+
402+
// Step 2: Delete /workspace entirely
403+
const deleteResponse = await fetch(`${workerUrl}/api/execute`, {
404+
method: 'POST',
405+
headers: setupHeaders,
406+
body: JSON.stringify({
407+
command: 'rm -rf /workspace'
408+
})
409+
});
410+
expect(deleteResponse.status).toBe(200);
411+
const deleteData = (await deleteResponse.json()) as ExecResult;
412+
expect(deleteData.success).toBe(true);
413+
414+
// Step 3: Verify /workspace is gone
415+
const verifyGoneResponse = await fetch(`${workerUrl}/api/execute`, {
416+
method: 'POST',
417+
headers: setupHeaders,
418+
body: JSON.stringify({
419+
command:
420+
'[ ! -d /workspace ] && echo "workspace deleted" || echo "workspace exists"'
421+
})
422+
});
423+
expect(verifyGoneResponse.status).toBe(200);
424+
const verifyGoneData = (await verifyGoneResponse.json()) as ExecResult;
425+
expect(verifyGoneData.stdout?.trim()).toBe('workspace deleted');
426+
427+
// Step 4: Create a BRAND NEW session - this is where the bug occurred
428+
// Before the fix, this would fail with "Unknown Error, TODO"
429+
const newSession = `new-after-delete-${Date.now()}-${Math.random().toString(36).slice(2)}`;
430+
const newHeaders = sandbox.createHeaders(newSession);
431+
432+
const newSessionResponse = await fetch(`${workerUrl}/api/execute`, {
433+
method: 'POST',
434+
headers: newHeaders,
435+
body: JSON.stringify({
436+
command: 'echo "new session after workspace deletion works"'
437+
})
438+
});
439+
440+
expect(newSessionResponse.status).toBe(200);
441+
const newSessionData = (await newSessionResponse.json()) as ExecResult;
442+
expect(newSessionData.success).toBe(true);
443+
expect(newSessionData.stdout?.trim()).toBe(
444+
'new session after workspace deletion works'
445+
);
446+
447+
// Verify NO "Unknown Error" or "TODO" in stderr
448+
expect(newSessionData.stderr?.toLowerCase() || '').not.toContain(
449+
'unknown error'
450+
);
451+
expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo');
452+
453+
// Step 5: Verify the new session fell back to "/" as working directory
454+
const pwdResponse = await fetch(`${workerUrl}/api/execute`, {
455+
method: 'POST',
456+
headers: newHeaders,
457+
body: JSON.stringify({
458+
command: 'pwd'
459+
})
460+
});
461+
expect(pwdResponse.status).toBe(200);
462+
const pwdData = (await pwdResponse.json()) as ExecResult;
463+
expect(pwdData.success).toBe(true);
464+
// Session should have fallen back to / since /workspace didn't exist
465+
expect(pwdData.stdout?.trim()).toBe('/');
466+
467+
// Step 6: Restore /workspace for other tests
468+
const restoreResponse = await fetch(`${workerUrl}/api/execute`, {
469+
method: 'POST',
470+
headers: setupHeaders,
471+
body: JSON.stringify({
472+
command: 'mkdir -p /workspace && echo "workspace restored"'
473+
})
474+
});
475+
expect(restoreResponse.status).toBe(200);
476+
const restoreData = (await restoreResponse.json()) as ExecResult;
477+
expect(restoreData.success).toBe(true);
478+
}, 120000);
479+
480+
/**
481+
* Test 8: New session creation after /workspace is replaced with symlink (Issue #288 variant)
482+
*
483+
* Tests the symlink variant of issue #288 where /workspace is replaced with
484+
* a symlink to another directory.
485+
*/
486+
test('should create new session successfully after /workspace is replaced with symlink', async () => {
487+
const sandbox = await getSharedSandbox();
488+
489+
// Use a dedicated session for setup/cleanup
490+
const setupSession = createUniqueSession();
491+
const setupHeaders = sandbox.createHeaders(setupSession);
492+
const backupDir = `/tmp/workspace-backup-${Date.now()}`;
493+
494+
// Step 1: Create backup directory and ensure /workspace exists
495+
const setupResponse = await fetch(`${workerUrl}/api/execute`, {
496+
method: 'POST',
497+
headers: setupHeaders,
498+
body: JSON.stringify({
499+
command: `mkdir -p ${backupDir} /workspace`
500+
})
501+
});
502+
expect(setupResponse.status).toBe(200);
503+
504+
// Step 2: Replace /workspace with a symlink
505+
const symlinkResponse = await fetch(`${workerUrl}/api/execute`, {
506+
method: 'POST',
507+
headers: setupHeaders,
508+
body: JSON.stringify({
509+
command: `rm -rf /workspace && ln -sf ${backupDir} /workspace`
510+
})
511+
});
512+
expect(symlinkResponse.status).toBe(200);
513+
const symlinkData = (await symlinkResponse.json()) as ExecResult;
514+
expect(symlinkData.success).toBe(true);
515+
516+
// Step 3: Verify /workspace is now a symlink
517+
const verifySymlinkResponse = await fetch(`${workerUrl}/api/execute`, {
518+
method: 'POST',
519+
headers: setupHeaders,
520+
body: JSON.stringify({
521+
command: '[ -L /workspace ] && echo "is symlink" || echo "not symlink"'
522+
})
523+
});
524+
expect(verifySymlinkResponse.status).toBe(200);
525+
const verifySymlinkData =
526+
(await verifySymlinkResponse.json()) as ExecResult;
527+
expect(verifySymlinkData.stdout?.trim()).toBe('is symlink');
528+
529+
// Step 4: Create a NEW session - should work with the symlink
530+
const newSession = `new-after-symlink-${Date.now()}-${Math.random().toString(36).slice(2)}`;
531+
const newHeaders = sandbox.createHeaders(newSession);
532+
533+
const newSessionResponse = await fetch(`${workerUrl}/api/execute`, {
534+
method: 'POST',
535+
headers: newHeaders,
536+
body: JSON.stringify({
537+
command: 'echo "new session with symlink workspace works"'
538+
})
539+
});
540+
541+
expect(newSessionResponse.status).toBe(200);
542+
const newSessionData = (await newSessionResponse.json()) as ExecResult;
543+
expect(newSessionData.success).toBe(true);
544+
expect(newSessionData.stdout?.trim()).toBe(
545+
'new session with symlink workspace works'
546+
);
547+
548+
// Verify NO "Unknown Error" or "TODO" in stderr
549+
expect(newSessionData.stderr?.toLowerCase() || '').not.toContain(
550+
'unknown error'
551+
);
552+
expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo');
553+
554+
// Step 5: Verify the session's cwd is through the symlink (resolves to backup dir)
555+
const pwdResponse = await fetch(`${workerUrl}/api/execute`, {
556+
method: 'POST',
557+
headers: newHeaders,
558+
body: JSON.stringify({
559+
command: 'pwd -P' // -P shows physical path, resolving symlinks
560+
})
561+
});
562+
expect(pwdResponse.status).toBe(200);
563+
const pwdData = (await pwdResponse.json()) as ExecResult;
564+
expect(pwdData.success).toBe(true);
565+
// The physical path should be the backup directory
566+
expect(pwdData.stdout?.trim()).toBe(backupDir);
567+
568+
// Step 6: Cleanup - restore /workspace as a real directory
569+
const cleanupResponse = await fetch(`${workerUrl}/api/execute`, {
570+
method: 'POST',
571+
headers: setupHeaders,
572+
body: JSON.stringify({
573+
command: `rm -f /workspace && mkdir -p /workspace && rm -rf ${backupDir}`
574+
})
575+
});
576+
expect(cleanupResponse.status).toBe(200);
577+
}, 120000);
578+
579+
/**
580+
* Test 9: Multiple operations after workspace manipulation
385581
*
386582
* Tests a realistic workflow where the workspace is manipulated
387583
* and then multiple subsequent operations are performed.
@@ -472,4 +668,65 @@ describe('Workspace Deletion Workflow (Issue #288)', () => {
472668
body: JSON.stringify({ command: `rm -rf ${testWorkspace} ${backupDir}` })
473669
});
474670
}, 90000);
671+
672+
/**
673+
* Exact reproduction from Issue #288
674+
*
675+
* @see https://github.com/cloudflare/sandbox-sdk/issues/288
676+
*/
677+
test('issue #288 exact minimal reproduction', async () => {
678+
const sandbox = await getSharedSandbox();
679+
680+
// Use a single session for the entire test (matches the bug report scenario)
681+
const sessionId = `issue-288-repro-${Date.now()}`;
682+
const headers = sandbox.createHeaders(sessionId);
683+
684+
// 1. Get a sandbox instance (done via shared sandbox)
685+
686+
// 2. Verify baseline works
687+
const baselineResponse = await fetch(`${workerUrl}/api/execute`, {
688+
method: 'POST',
689+
headers,
690+
body: JSON.stringify({ command: 'echo "baseline works"' })
691+
});
692+
expect(baselineResponse.status).toBe(200);
693+
const baselineData = (await baselineResponse.json()) as ExecResult;
694+
expect(baselineData.success).toBe(true);
695+
expect(baselineData.stdout?.trim()).toBe('baseline works');
696+
697+
// 3. Remove /workspace
698+
const removeResponse = await fetch(`${workerUrl}/api/execute`, {
699+
method: 'POST',
700+
headers,
701+
body: JSON.stringify({ command: 'rm -rf /workspace' })
702+
});
703+
expect(removeResponse.status).toBe(200);
704+
const removeData = (await removeResponse.json()) as ExecResult;
705+
expect(removeData.success).toBe(true);
706+
707+
// 4. Try ANY subsequent exec() call
708+
const afterRemovalResponse = await fetch(`${workerUrl}/api/execute`, {
709+
method: 'POST',
710+
headers,
711+
body: JSON.stringify({ command: 'echo "after removal"' })
712+
});
713+
expect(afterRemovalResponse.status).toBe(200);
714+
const afterRemovalData = (await afterRemovalResponse.json()) as ExecResult;
715+
716+
expect(afterRemovalData.success).toBe(true);
717+
expect(afterRemovalData.stdout?.trim()).toBe('after removal');
718+
719+
// Verify no "Unknown Error, TODO" anywhere
720+
expect(afterRemovalData.stderr?.toLowerCase() || '').not.toContain(
721+
'unknown error'
722+
);
723+
expect(afterRemovalData.stderr?.toLowerCase() || '').not.toContain('todo');
724+
725+
// Cleanup: restore /workspace for other tests
726+
await fetch(`${workerUrl}/api/execute`, {
727+
method: 'POST',
728+
headers,
729+
body: JSON.stringify({ command: 'mkdir -p /workspace' })
730+
});
731+
}, 90000);
475732
});

0 commit comments

Comments
 (0)