From 3c29926bd30a02c6e56d149d29f73b279455c3cb Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 10 Dec 2025 16:51:03 +0000 Subject: [PATCH 1/7] fix workspace bug --- packages/sandbox-container/src/session.ts | 23 +- .../sandbox-container/tests/session.test.ts | 132 +++++ tests/e2e/workspace-deletion-workflow.test.ts | 475 ++++++++++++++++++ 3 files changed, 628 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/workspace-deletion-workflow.test.ts diff --git a/packages/sandbox-container/src/session.ts b/packages/sandbox-container/src/session.ts index b5c58f03..91e073af 100644 --- a/packages/sandbox-container/src/session.ts +++ b/packages/sandbox-container/src/session.ts @@ -25,7 +25,7 @@ import { randomUUID } from 'node:crypto'; import { watch } from 'node:fs'; -import { mkdir, rm } from 'node:fs/promises'; +import { mkdir, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import type { ExecEvent, Logger } from '@repo/shared'; @@ -137,10 +137,29 @@ export class Session { this.sessionDir = join(tmpdir(), `session-${this.id}-${Date.now()}`); await mkdir(this.sessionDir, { recursive: true }); + // Determine working directory and verify it exists to avoid spawn failures + // If /workspace or specified cwd doesn't exist, fall back to / + // This handles the case where /workspace is deleted + let cwd = this.options.cwd || CONFIG.DEFAULT_CWD; + try { + await stat(cwd); + } catch { + // Directory doesn't exist, fall back to root + this.logger.warn( + `Working directory '${cwd}' does not exist, falling back to '/'`, + { + sessionId: this.id, + requestedCwd: cwd, + fallbackCwd: '/' + } + ); + cwd = '/'; + } + // Spawn persistent bash with stdin pipe - no IPC or wrapper needed! this.shell = Bun.spawn({ cmd: ['bash', '--norc'], - cwd: this.options.cwd || CONFIG.DEFAULT_CWD, + cwd, env: { ...process.env, ...this.options.env, diff --git a/packages/sandbox-container/tests/session.test.ts b/packages/sandbox-container/tests/session.test.ts index ed9247fa..e083800f 100644 --- a/packages/sandbox-container/tests/session.test.ts +++ b/packages/sandbox-container/tests/session.test.ts @@ -75,6 +75,52 @@ describe('Session', () => { // Session directory should be created (we can't easily check without accessing private fields) expect(session.isReady()).toBe(true); }); + + it('should initialize with non-existent cwd by falling back to / (issue #288)', async () => { + // This tests the fix for issue #288 where session creation failed + // if /workspace was deleted before a new session was created + session = new Session({ + id: 'test-session-nonexistent-cwd', + cwd: '/nonexistent/path/that/does/not/exist' + }); + + // This should NOT throw - instead it should fall back to / + await session.initialize(); + + expect(session.isReady()).toBe(true); + + // Verify we can execute commands + const result = await session.exec('pwd'); + expect(result.exitCode).toBe(0); + // The shell should have started in / since the requested cwd doesn't exist + expect(result.stdout.trim()).toBe('/'); + }); + + it('should initialize with missing /workspace by falling back to / (issue #288)', async () => { + // Simulate the exact scenario from issue #288 + // Create a workspace, then delete it, then try to create a session with it + const workspaceDir = join(testDir, 'workspace'); + await mkdir(workspaceDir, { recursive: true }); + + // Delete the workspace + await rm(workspaceDir, { recursive: true, force: true }); + + // Now try to create a session with the deleted workspace as cwd + session = new Session({ + id: 'test-session-deleted-workspace', + cwd: workspaceDir + }); + + // This should NOT throw - instead it should fall back to / + await session.initialize(); + + expect(session.isReady()).toBe(true); + + // Verify we can execute commands + const result = await session.exec('echo "session works"'); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('session works'); + }); }); describe('exec', () => { @@ -496,6 +542,92 @@ describe('Session', () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Failed to change directory'); }); + + it('should continue working after session cwd is deleted (issue #288)', async () => { + // Create a working directory for the session + const workspaceDir = join(testDir, 'workspace'); + await mkdir(workspaceDir, { recursive: true }); + + session = new Session({ + id: 'test-cwd-deletion', + cwd: workspaceDir + }); + + await session.initialize(); + + // Verify baseline works + const baseline = await session.exec('echo "baseline"'); + expect(baseline.exitCode).toBe(0); + expect(baseline.stdout.trim()).toBe('baseline'); + + // Delete the workspace directory (this is the bug scenario) + await session.exec(`rm -rf ${workspaceDir}`); + + // Try a subsequent command - this should NOT fail with an obscure error + // It should either work (falling back to /) or give a clear error message + const afterRemoval = await session.exec('echo "after removal"'); + + // The command should succeed - bash can still run commands even if cwd is deleted + // It will use the deleted directory's inode until a cd happens + expect(afterRemoval.exitCode).toBe(0); + expect(afterRemoval.stdout.trim()).toBe('after removal'); + }); + + it('should handle cwd being replaced with symlink (issue #288)', async () => { + // Create directories for the test + const workspaceDir = join(testDir, 'workspace'); + const backupDir = join(testDir, 'backup'); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(backupDir, { recursive: true }); + + session = new Session({ + id: 'test-cwd-symlink', + cwd: workspaceDir + }); + + await session.initialize(); + + // Verify baseline works + const baseline = await session.exec('echo "baseline"'); + expect(baseline.exitCode).toBe(0); + expect(baseline.stdout.trim()).toBe('baseline'); + + // Replace workspace with a symlink to backup directory + await session.exec( + `rm -rf ${workspaceDir} && ln -sf ${backupDir} ${workspaceDir}` + ); + + // Try a subsequent command - should continue working + const afterSymlink = await session.exec('echo "after symlink"'); + expect(afterSymlink.exitCode).toBe(0); + expect(afterSymlink.stdout.trim()).toBe('after symlink'); + }); + + it('should be recoverable by cd-ing to valid directory after cwd deletion', async () => { + // Create a working directory for the session + const workspaceDir = join(testDir, 'workspace'); + await mkdir(workspaceDir, { recursive: true }); + + session = new Session({ + id: 'test-cwd-recovery', + cwd: workspaceDir + }); + + await session.initialize(); + + // Delete the workspace directory + await session.exec(`rm -rf ${workspaceDir}`); + + // Change to a valid directory - this should work and recover the session + const cdResult = await session.exec('cd /tmp && pwd'); + expect(cdResult.exitCode).toBe(0); + expect(cdResult.stdout.trim()).toBe('/tmp'); + + // Subsequent commands should work + const afterCd = await session.exec('echo "recovered"'); + expect(afterCd.exitCode).toBe(0); + expect(afterCd.stdout.trim()).toBe('recovered'); + }); }); describe('FIFO cleanup', () => { diff --git a/tests/e2e/workspace-deletion-workflow.test.ts b/tests/e2e/workspace-deletion-workflow.test.ts new file mode 100644 index 00000000..bad93c65 --- /dev/null +++ b/tests/e2e/workspace-deletion-workflow.test.ts @@ -0,0 +1,475 @@ +/** + * Workspace Deletion Workflow Tests (Issue #288) + * + * Tests the fix for GitHub issue #288 where `sandbox.exec()` fails with + * "Unknown Error, TODO" after `/workspace` is removed or replaced with symlink. + * + * These tests verify: + * 1. Symlinks work correctly in non-/workspace directories + * 2. Session continues working after /workspace is deleted + * 3. Session continues working after /workspace is replaced with symlink + * 4. Session can recreate /workspace after deletion + * 5. New sessions can be created even when /workspace doesn't exist + * + * @see https://github.com/cloudflare/sandbox-sdk/issues/288 + */ + +import { describe, test, expect, beforeAll } from 'vitest'; +import { + getSharedSandbox, + createUniqueSession, + uniqueTestPath +} from './helpers/global-sandbox'; +import type { ExecResult } from '@repo/shared'; + +describe('Workspace Deletion Workflow (Issue #288)', () => { + let workerUrl: string; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + }, 120000); + + /** + * Test 1: Symlinks in /tmp (non-/workspace directories) + * + * Verifies that symlinks work correctly when /workspace is not involved. + * This establishes a baseline that symlink functionality itself is working. + */ + test('should handle symlinks in /tmp directories correctly', async () => { + // Use a unique session for this test + const sandbox = await getSharedSandbox(); + const headers = sandbox.createHeaders(createUniqueSession()); + const testDir = `/tmp/symlink-test-${Date.now()}`; + + // Create source directory and file + const mkdirResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `mkdir -p ${testDir}/source` + }) + }); + expect(mkdirResponse.status).toBe(200); + + // Write a file in the source directory + const writeResponse = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/source/test.txt`, + content: 'symlink content' + }) + }); + expect(writeResponse.status).toBe(200); + + // Create a symlink to the source directory + const symlinkResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `ln -sf ${testDir}/source ${testDir}/target` + }) + }); + expect(symlinkResponse.status).toBe(200); + + // Read the file through the symlink + const catResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `cat ${testDir}/target/test.txt` + }) + }); + expect(catResponse.status).toBe(200); + const catData = (await catResponse.json()) as ExecResult; + expect(catData.success).toBe(true); + expect(catData.stdout?.trim()).toBe('symlink content'); + + // Cleanup + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: `rm -rf ${testDir}` }) + }); + }, 90000); + + /** + * Test 2: Session continues working after deleting its working directory + * + * This tests that an existing session can continue executing commands + * even after its current working directory is deleted. The shell maintains + * the directory inode until it changes directory. + */ + test('should continue working after session cwd is deleted', async () => { + // Use a unique session for this test + const sandbox = await getSharedSandbox(); + const headers = sandbox.createHeaders(createUniqueSession()); + const testWorkspace = uniqueTestPath('workspace-deletion'); + + // Create a working directory for this test + const mkdirResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `mkdir -p ${testWorkspace}` + }) + }); + expect(mkdirResponse.status).toBe(200); + + // Verify baseline works + const baselineResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "baseline works"' + }) + }); + expect(baselineResponse.status).toBe(200); + const baselineData = (await baselineResponse.json()) as ExecResult; + expect(baselineData.success).toBe(true); + expect(baselineData.stdout?.trim()).toBe('baseline works'); + + // Delete the workspace directory + const deleteResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `rm -rf ${testWorkspace}` + }) + }); + expect(deleteResponse.status).toBe(200); + + // Try a subsequent command - this should NOT fail + const afterDeleteResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "after deletion"' + }) + }); + expect(afterDeleteResponse.status).toBe(200); + const afterDeleteData = (await afterDeleteResponse.json()) as ExecResult; + expect(afterDeleteData.success).toBe(true); + expect(afterDeleteData.stdout?.trim()).toBe('after deletion'); + }, 90000); + + /** + * Test 3: Session continues after /workspace is replaced with symlink + * + * Tests that replacing /workspace with a symlink to a valid directory + * doesn't break subsequent exec() calls. + */ + test('should continue working after workspace is replaced with symlink', async () => { + // Use a unique session for this test + const sandbox = await getSharedSandbox(); + const headers = sandbox.createHeaders(createUniqueSession()); + const testWorkspace = uniqueTestPath('workspace-symlink'); + const backupDir = `/tmp/backup-${Date.now()}`; + + // Create directories for the test + const setupResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `mkdir -p ${testWorkspace} ${backupDir}` + }) + }); + expect(setupResponse.status).toBe(200); + + // Verify baseline works + const baselineResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "baseline"' + }) + }); + expect(baselineResponse.status).toBe(200); + const baselineData = (await baselineResponse.json()) as ExecResult; + expect(baselineData.success).toBe(true); + expect(baselineData.stdout?.trim()).toBe('baseline'); + + // Replace workspace with a symlink to backup directory + const symlinkResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `rm -rf ${testWorkspace} && ln -sf ${backupDir} ${testWorkspace}` + }) + }); + expect(symlinkResponse.status).toBe(200); + + // Try a subsequent command - should continue working + const afterSymlinkResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "after symlink"' + }) + }); + expect(afterSymlinkResponse.status).toBe(200); + const afterSymlinkData = (await afterSymlinkResponse.json()) as ExecResult; + expect(afterSymlinkData.success).toBe(true); + expect(afterSymlinkData.stdout?.trim()).toBe('after symlink'); + + // Cleanup + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: `rm -rf ${testWorkspace} ${backupDir}` }) + }); + }, 90000); + + /** + * Test 4: Can recreate directory after deletion + * + * Tests that after deleting a directory, we can recreate it with mkdir. + * This was one of the failure cases in issue #288. + */ + test('should be able to recreate directory after deletion', async () => { + // Use a unique session for this test + const sandbox = await getSharedSandbox(); + const headers = sandbox.createHeaders(createUniqueSession()); + const testWorkspace = uniqueTestPath('workspace-recreate'); + + // Create initial workspace + const createResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `mkdir -p ${testWorkspace}` + }) + }); + expect(createResponse.status).toBe(200); + + // Delete it + const deleteResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `rm -rf ${testWorkspace}` + }) + }); + expect(deleteResponse.status).toBe(200); + + // Recreate it - this should work + const recreateResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `mkdir -p ${testWorkspace}` + }) + }); + expect(recreateResponse.status).toBe(200); + const recreateData = (await recreateResponse.json()) as ExecResult; + expect(recreateData.success).toBe(true); + + // Verify it was recreated + const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `ls -d ${testWorkspace}` + }) + }); + expect(verifyResponse.status).toBe(200); + const verifyData = (await verifyResponse.json()) as ExecResult; + expect(verifyData.success).toBe(true); + expect(verifyData.stdout?.trim()).toBe(testWorkspace); + + // Cleanup + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: `rm -rf ${testWorkspace}` }) + }); + }, 90000); + + /** + * Test 5: Recovery by changing to a valid directory + * + * Tests that after cwd is deleted, we can recover by cd-ing to a valid directory. + */ + test('should be recoverable by cd-ing to valid directory after cwd deletion', async () => { + // Use a unique session for this test + const sandbox = await getSharedSandbox(); + const headers = sandbox.createHeaders(createUniqueSession()); + const testWorkspace = uniqueTestPath('workspace-recovery'); + + // Create workspace + const createResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `mkdir -p ${testWorkspace}` + }) + }); + expect(createResponse.status).toBe(200); + + // Delete workspace + const deleteResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `rm -rf ${testWorkspace}` + }) + }); + expect(deleteResponse.status).toBe(200); + + // Change to a valid directory and verify + const cdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'cd /tmp && pwd' + }) + }); + expect(cdResponse.status).toBe(200); + const cdData = (await cdResponse.json()) as ExecResult; + expect(cdData.success).toBe(true); + expect(cdData.stdout?.trim()).toBe('/tmp'); + + // Subsequent commands should work + const afterCdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "recovered"' + }) + }); + expect(afterCdResponse.status).toBe(200); + const afterCdData = (await afterCdResponse.json()) as ExecResult; + expect(afterCdData.success).toBe(true); + expect(afterCdData.stdout?.trim()).toBe('recovered'); + }, 90000); + + /** + * Test 6: New session can be created when /workspace doesn't exist + * + * This tests the core fix for issue #288: when a new session is created + * and /workspace doesn't exist, it should fall back to / instead of failing. + * + * Note: This test uses a different sandbox ID to ensure a completely new + * session is created (not reusing an existing one). + */ + test('should create new session successfully even when default cwd does not exist', async () => { + // Use a completely new session to force session creation + const sandbox = await getSharedSandbox(); + const uniqueSession = `new-session-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const headers = sandbox.createHeaders(uniqueSession); + + // Execute a simple command in the new session + // The session will be created on-demand, and should fall back to / + // if /workspace doesn't exist + const execResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "new session works"' + }) + }); + + expect(execResponse.status).toBe(200); + const execData = (await execResponse.json()) as ExecResult; + expect(execData.success).toBe(true); + expect(execData.stdout?.trim()).toBe('new session works'); + + // The command should have executed without errors + expect(execData.stderr?.toLowerCase() || '').not.toContain('unknown error'); + expect(execData.stderr?.toLowerCase() || '').not.toContain('todo'); + }, 90000); + + /** + * Test 7: Multiple operations after workspace manipulation + * + * Tests a realistic workflow where the workspace is manipulated + * and then multiple subsequent operations are performed. + */ + test('should handle multiple operations after workspace manipulation', async () => { + const sandbox = await getSharedSandbox(); + const headers = sandbox.createHeaders(createUniqueSession()); + const testWorkspace = uniqueTestPath('workspace-multi-ops'); + const backupDir = `/tmp/multi-ops-backup-${Date.now()}`; + + // Setup: create workspace and backup directories + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `mkdir -p ${testWorkspace} ${backupDir}` + }) + }); + + // Write a file + const writeResponse = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testWorkspace}/test.txt`, + content: 'original content' + }) + }); + expect(writeResponse.status).toBe(200); + + // Replace workspace with symlink + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `rm -rf ${testWorkspace} && ln -sf ${backupDir} ${testWorkspace}` + }) + }); + + // Multiple subsequent operations should all work: + + // 1. Echo command + const echo1Response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "operation 1"' }) + }); + expect(echo1Response.status).toBe(200); + const echo1Data = (await echo1Response.json()) as ExecResult; + expect(echo1Data.success).toBe(true); + + // 2. Write file to backup (through symlink) + const write2Response = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${backupDir}/new-file.txt`, + content: 'new content' + }) + }); + expect(write2Response.status).toBe(200); + + // 3. Read the file back + const readResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${backupDir}/new-file.txt` + }) + }); + expect(readResponse.status).toBe(200); + + // 4. Another exec command + const echo2Response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "operation 4"' }) + }); + expect(echo2Response.status).toBe(200); + const echo2Data = (await echo2Response.json()) as ExecResult; + expect(echo2Data.success).toBe(true); + expect(echo2Data.stdout?.trim()).toBe('operation 4'); + + // Cleanup + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: `rm -rf ${testWorkspace} ${backupDir}` }) + }); + }, 90000); +}); From 3f745690af01736253da07efcd1d854c5343c88f Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 10 Dec 2025 17:47:20 +0000 Subject: [PATCH 2/7] fallback to tmp --- packages/sandbox-container/src/session.ts | 23 +- .../sandbox-container/tests/session.test.ts | 17 +- tests/e2e/workspace-deletion-workflow.test.ts | 279 +++++++++++++++++- 3 files changed, 291 insertions(+), 28 deletions(-) diff --git a/packages/sandbox-container/src/session.ts b/packages/sandbox-container/src/session.ts index 91e073af..93486861 100644 --- a/packages/sandbox-container/src/session.ts +++ b/packages/sandbox-container/src/session.ts @@ -46,7 +46,13 @@ export interface SessionOptions { /** Session identifier (generated if not provided) */ id: string; - /** Working directory for the session */ + /** + * Initial working directory for the shell. + * + * Note: This only affects where the shell starts. Individual commands can + * specify their own cwd via exec options, and the shell can cd anywhere. + * If the directory doesn't exist, the session falls back to /tmp. + */ cwd?: string; /** Environment variables for the session */ @@ -137,23 +143,22 @@ export class Session { this.sessionDir = join(tmpdir(), `session-${this.id}-${Date.now()}`); await mkdir(this.sessionDir, { recursive: true }); - // Determine working directory and verify it exists to avoid spawn failures - // If /workspace or specified cwd doesn't exist, fall back to / - // This handles the case where /workspace is deleted + // Determine working directory. If the requested + // cwd doesn't exist, we fall back to /tmp since it's always available and + // safer than / for accidental operations. let cwd = this.options.cwd || CONFIG.DEFAULT_CWD; try { await stat(cwd); } catch { - // Directory doesn't exist, fall back to root - this.logger.warn( - `Working directory '${cwd}' does not exist, falling back to '/'`, + this.logger.debug( + `Shell startup directory '${cwd}' does not exist, using '/tmp'`, { sessionId: this.id, requestedCwd: cwd, - fallbackCwd: '/' + actualCwd: '/tmp' } ); - cwd = '/'; + cwd = '/tmp'; } // Spawn persistent bash with stdin pipe - no IPC or wrapper needed! diff --git a/packages/sandbox-container/tests/session.test.ts b/packages/sandbox-container/tests/session.test.ts index e083800f..c9d2a80b 100644 --- a/packages/sandbox-container/tests/session.test.ts +++ b/packages/sandbox-container/tests/session.test.ts @@ -76,15 +76,15 @@ describe('Session', () => { expect(session.isReady()).toBe(true); }); - it('should initialize with non-existent cwd by falling back to / (issue #288)', async () => { - // This tests the fix for issue #288 where session creation failed - // if /workspace was deleted before a new session was created + it('should fall back to /tmp when cwd does not exist (issue #288)', async () => { + // Session cwd only affects shell startup directory - it's not critical. + // If cwd doesn't exist, we fall back to /tmp since individual commands + // can specify their own cwd anyway. session = new Session({ id: 'test-session-nonexistent-cwd', cwd: '/nonexistent/path/that/does/not/exist' }); - // This should NOT throw - instead it should fall back to / await session.initialize(); expect(session.isReady()).toBe(true); @@ -92,11 +92,12 @@ describe('Session', () => { // Verify we can execute commands const result = await session.exec('pwd'); expect(result.exitCode).toBe(0); - // The shell should have started in / since the requested cwd doesn't exist - expect(result.stdout.trim()).toBe('/'); + // The shell should have started in /tmp since the requested cwd doesn't exist + // On macOS, /tmp is a symlink to /private/tmp + expect(result.stdout.trim()).toMatch(/^(\/tmp|\/private\/tmp)$/); }); - it('should initialize with missing /workspace by falling back to / (issue #288)', async () => { + it('should fall back to /tmp when workspace is deleted before session creation (issue #288)', async () => { // Simulate the exact scenario from issue #288 // Create a workspace, then delete it, then try to create a session with it const workspaceDir = join(testDir, 'workspace'); @@ -111,7 +112,7 @@ describe('Session', () => { cwd: workspaceDir }); - // This should NOT throw - instead it should fall back to / + // Should succeed - falls back to /tmp await session.initialize(); expect(session.isReady()).toBe(true); diff --git a/tests/e2e/workspace-deletion-workflow.test.ts b/tests/e2e/workspace-deletion-workflow.test.ts index bad93c65..acd94a28 100644 --- a/tests/e2e/workspace-deletion-workflow.test.ts +++ b/tests/e2e/workspace-deletion-workflow.test.ts @@ -1,15 +1,6 @@ /** * Workspace Deletion Workflow Tests (Issue #288) - * - * Tests the fix for GitHub issue #288 where `sandbox.exec()` fails with - * "Unknown Error, TODO" after `/workspace` is removed or replaced with symlink. - * - * These tests verify: - * 1. Symlinks work correctly in non-/workspace directories - * 2. Session continues working after /workspace is deleted - * 3. Session continues working after /workspace is replaced with symlink - * 4. Session can recreate /workspace after deletion - * 5. New sessions can be created even when /workspace doesn't exist + * * @see https://github.com/cloudflare/sandbox-sdk/issues/288 */ @@ -381,7 +372,212 @@ describe('Workspace Deletion Workflow (Issue #288)', () => { }, 90000); /** - * Test 7: Multiple operations after workspace manipulation + * Test 7: New session creation after /workspace is deleted (Core bug from Issue #288) + * + * This is the PRIMARY test for issue #288. The bug was that creating a NEW session + * after /workspace was deleted would fail with "Unknown Error, TODO". + * + * The fix makes session initialization fall back to "/" if /workspace doesn't exist. + * + * IMPORTANT: This test actually deletes /workspace and creates a new session. + * It restores /workspace at the end to avoid breaking other tests. + */ + test('should create new session successfully after /workspace is deleted (issue #288 core bug)', async () => { + const sandbox = await getSharedSandbox(); + + // Use a dedicated session for the setup/cleanup operations + const setupSession = createUniqueSession(); + const setupHeaders = sandbox.createHeaders(setupSession); + + // Step 1: Verify /workspace exists (or create it if needed) + const ensureWorkspaceResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: setupHeaders, + body: JSON.stringify({ + command: 'mkdir -p /workspace && ls -la /workspace' + }) + }); + expect(ensureWorkspaceResponse.status).toBe(200); + + // Step 2: Delete /workspace entirely + const deleteResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: setupHeaders, + body: JSON.stringify({ + command: 'rm -rf /workspace' + }) + }); + expect(deleteResponse.status).toBe(200); + const deleteData = (await deleteResponse.json()) as ExecResult; + expect(deleteData.success).toBe(true); + + // Step 3: Verify /workspace is gone + const verifyGoneResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: setupHeaders, + body: JSON.stringify({ + command: + '[ ! -d /workspace ] && echo "workspace deleted" || echo "workspace exists"' + }) + }); + expect(verifyGoneResponse.status).toBe(200); + const verifyGoneData = (await verifyGoneResponse.json()) as ExecResult; + expect(verifyGoneData.stdout?.trim()).toBe('workspace deleted'); + + // Step 4: Create a BRAND NEW session - this is where the bug occurred + // Before the fix, this would fail with "Unknown Error, TODO" + const newSession = `new-after-delete-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const newHeaders = sandbox.createHeaders(newSession); + + const newSessionResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: newHeaders, + body: JSON.stringify({ + command: 'echo "new session after workspace deletion works"' + }) + }); + + expect(newSessionResponse.status).toBe(200); + const newSessionData = (await newSessionResponse.json()) as ExecResult; + expect(newSessionData.success).toBe(true); + expect(newSessionData.stdout?.trim()).toBe( + 'new session after workspace deletion works' + ); + + // Verify NO "Unknown Error" or "TODO" in stderr + expect(newSessionData.stderr?.toLowerCase() || '').not.toContain( + 'unknown error' + ); + expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo'); + + // Step 5: Verify the new session fell back to "/" as working directory + const pwdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: newHeaders, + body: JSON.stringify({ + command: 'pwd' + }) + }); + expect(pwdResponse.status).toBe(200); + const pwdData = (await pwdResponse.json()) as ExecResult; + expect(pwdData.success).toBe(true); + // Session should have fallen back to / since /workspace didn't exist + expect(pwdData.stdout?.trim()).toBe('/'); + + // Step 6: Restore /workspace for other tests + const restoreResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: setupHeaders, + body: JSON.stringify({ + command: 'mkdir -p /workspace && echo "workspace restored"' + }) + }); + expect(restoreResponse.status).toBe(200); + const restoreData = (await restoreResponse.json()) as ExecResult; + expect(restoreData.success).toBe(true); + }, 120000); + + /** + * Test 8: New session creation after /workspace is replaced with symlink (Issue #288 variant) + * + * Tests the symlink variant of issue #288 where /workspace is replaced with + * a symlink to another directory. + */ + test('should create new session successfully after /workspace is replaced with symlink', async () => { + const sandbox = await getSharedSandbox(); + + // Use a dedicated session for setup/cleanup + const setupSession = createUniqueSession(); + const setupHeaders = sandbox.createHeaders(setupSession); + const backupDir = `/tmp/workspace-backup-${Date.now()}`; + + // Step 1: Create backup directory and ensure /workspace exists + const setupResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: setupHeaders, + body: JSON.stringify({ + command: `mkdir -p ${backupDir} /workspace` + }) + }); + expect(setupResponse.status).toBe(200); + + // Step 2: Replace /workspace with a symlink + const symlinkResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: setupHeaders, + body: JSON.stringify({ + command: `rm -rf /workspace && ln -sf ${backupDir} /workspace` + }) + }); + expect(symlinkResponse.status).toBe(200); + const symlinkData = (await symlinkResponse.json()) as ExecResult; + expect(symlinkData.success).toBe(true); + + // Step 3: Verify /workspace is now a symlink + const verifySymlinkResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: setupHeaders, + body: JSON.stringify({ + command: '[ -L /workspace ] && echo "is symlink" || echo "not symlink"' + }) + }); + expect(verifySymlinkResponse.status).toBe(200); + const verifySymlinkData = + (await verifySymlinkResponse.json()) as ExecResult; + expect(verifySymlinkData.stdout?.trim()).toBe('is symlink'); + + // Step 4: Create a NEW session - should work with the symlink + const newSession = `new-after-symlink-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const newHeaders = sandbox.createHeaders(newSession); + + const newSessionResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: newHeaders, + body: JSON.stringify({ + command: 'echo "new session with symlink workspace works"' + }) + }); + + expect(newSessionResponse.status).toBe(200); + const newSessionData = (await newSessionResponse.json()) as ExecResult; + expect(newSessionData.success).toBe(true); + expect(newSessionData.stdout?.trim()).toBe( + 'new session with symlink workspace works' + ); + + // Verify NO "Unknown Error" or "TODO" in stderr + expect(newSessionData.stderr?.toLowerCase() || '').not.toContain( + 'unknown error' + ); + expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo'); + + // Step 5: Verify the session's cwd is through the symlink (resolves to backup dir) + const pwdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: newHeaders, + body: JSON.stringify({ + command: 'pwd -P' // -P shows physical path, resolving symlinks + }) + }); + expect(pwdResponse.status).toBe(200); + const pwdData = (await pwdResponse.json()) as ExecResult; + expect(pwdData.success).toBe(true); + // The physical path should be the backup directory + expect(pwdData.stdout?.trim()).toBe(backupDir); + + // Step 6: Cleanup - restore /workspace as a real directory + const cleanupResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: setupHeaders, + body: JSON.stringify({ + command: `rm -f /workspace && mkdir -p /workspace && rm -rf ${backupDir}` + }) + }); + expect(cleanupResponse.status).toBe(200); + }, 120000); + + /** + * Test 9: Multiple operations after workspace manipulation * * Tests a realistic workflow where the workspace is manipulated * and then multiple subsequent operations are performed. @@ -472,4 +668,65 @@ describe('Workspace Deletion Workflow (Issue #288)', () => { body: JSON.stringify({ command: `rm -rf ${testWorkspace} ${backupDir}` }) }); }, 90000); + + /** + * Exact reproduction from Issue #288 + * + * @see https://github.com/cloudflare/sandbox-sdk/issues/288 + */ + test('issue #288 exact minimal reproduction', async () => { + const sandbox = await getSharedSandbox(); + + // Use a single session for the entire test (matches the bug report scenario) + const sessionId = `issue-288-repro-${Date.now()}`; + const headers = sandbox.createHeaders(sessionId); + + // 1. Get a sandbox instance (done via shared sandbox) + + // 2. Verify baseline works + const baselineResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "baseline works"' }) + }); + expect(baselineResponse.status).toBe(200); + const baselineData = (await baselineResponse.json()) as ExecResult; + expect(baselineData.success).toBe(true); + expect(baselineData.stdout?.trim()).toBe('baseline works'); + + // 3. Remove /workspace + const removeResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'rm -rf /workspace' }) + }); + expect(removeResponse.status).toBe(200); + const removeData = (await removeResponse.json()) as ExecResult; + expect(removeData.success).toBe(true); + + // 4. Try ANY subsequent exec() call + const afterRemovalResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "after removal"' }) + }); + expect(afterRemovalResponse.status).toBe(200); + const afterRemovalData = (await afterRemovalResponse.json()) as ExecResult; + + expect(afterRemovalData.success).toBe(true); + expect(afterRemovalData.stdout?.trim()).toBe('after removal'); + + // Verify no "Unknown Error, TODO" anywhere + expect(afterRemovalData.stderr?.toLowerCase() || '').not.toContain( + 'unknown error' + ); + expect(afterRemovalData.stderr?.toLowerCase() || '').not.toContain('todo'); + + // Cleanup: restore /workspace for other tests + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'mkdir -p /workspace' }) + }); + }, 90000); }); From b515c007ca25bbb5133144c467fd57fc826032d5 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 10 Dec 2025 17:55:36 +0000 Subject: [PATCH 3/7] fallbac to homedir instead of /tmp --- packages/sandbox-container/src/session.ts | 15 ++++++++------- tests/e2e/workspace-deletion-workflow.test.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/sandbox-container/src/session.ts b/packages/sandbox-container/src/session.ts index 93486861..dc385ae1 100644 --- a/packages/sandbox-container/src/session.ts +++ b/packages/sandbox-container/src/session.ts @@ -51,7 +51,8 @@ export interface SessionOptions { * * Note: This only affects where the shell starts. Individual commands can * specify their own cwd via exec options, and the shell can cd anywhere. - * If the directory doesn't exist, the session falls back to /tmp. + * If the specified directory doesn't exist when the session initializes, + * the session will fall back to the home directory. */ cwd?: string; @@ -143,22 +144,22 @@ export class Session { this.sessionDir = join(tmpdir(), `session-${this.id}-${Date.now()}`); await mkdir(this.sessionDir, { recursive: true }); - // Determine working directory. If the requested - // cwd doesn't exist, we fall back to /tmp since it's always available and - // safer than / for accidental operations. + // Determine working directory. If the requested cwd doesn't exist, we fall + // back to the home directory since it's a natural default for shell sessions. + const homeDir = process.env.HOME || '/root'; let cwd = this.options.cwd || CONFIG.DEFAULT_CWD; try { await stat(cwd); } catch { this.logger.debug( - `Shell startup directory '${cwd}' does not exist, using '/tmp'`, + `Shell startup directory '${cwd}' does not exist, using '${homeDir}'`, { sessionId: this.id, requestedCwd: cwd, - actualCwd: '/tmp' + actualCwd: homeDir } ); - cwd = '/tmp'; + cwd = homeDir; } // Spawn persistent bash with stdin pipe - no IPC or wrapper needed! diff --git a/tests/e2e/workspace-deletion-workflow.test.ts b/tests/e2e/workspace-deletion-workflow.test.ts index acd94a28..c3c4284a 100644 --- a/tests/e2e/workspace-deletion-workflow.test.ts +++ b/tests/e2e/workspace-deletion-workflow.test.ts @@ -339,7 +339,8 @@ describe('Workspace Deletion Workflow (Issue #288)', () => { * Test 6: New session can be created when /workspace doesn't exist * * This tests the core fix for issue #288: when a new session is created - * and /workspace doesn't exist, it should fall back to / instead of failing. + * and /workspace doesn't exist, it should fall back to the home directory + * instead of failing. * * Note: This test uses a different sandbox ID to ensure a completely new * session is created (not reusing an existing one). @@ -377,7 +378,8 @@ describe('Workspace Deletion Workflow (Issue #288)', () => { * This is the PRIMARY test for issue #288. The bug was that creating a NEW session * after /workspace was deleted would fail with "Unknown Error, TODO". * - * The fix makes session initialization fall back to "/" if /workspace doesn't exist. + * The fix makes session initialization fall back to the home directory if + * /workspace doesn't exist. * * IMPORTANT: This test actually deletes /workspace and creates a new session. * It restores /workspace at the end to avoid breaking other tests. @@ -450,7 +452,7 @@ describe('Workspace Deletion Workflow (Issue #288)', () => { ); expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo'); - // Step 5: Verify the new session fell back to "/" as working directory + // Step 5: Verify the new session fell back to home directory const pwdResponse = await fetch(`${workerUrl}/api/execute`, { method: 'POST', headers: newHeaders, @@ -461,8 +463,8 @@ describe('Workspace Deletion Workflow (Issue #288)', () => { expect(pwdResponse.status).toBe(200); const pwdData = (await pwdResponse.json()) as ExecResult; expect(pwdData.success).toBe(true); - // Session should have fallen back to / since /workspace didn't exist - expect(pwdData.stdout?.trim()).toBe('/'); + // Session should have fallen back to /root since /workspace didn't exist + expect(pwdData.stdout?.trim()).toBe('/root'); // Step 6: Restore /workspace for other tests const restoreResponse = await fetch(`${workerUrl}/api/execute`, { From 09f238e68bf5a94cb54a45e7edf56325a8334f7b Mon Sep 17 00:00:00 2001 From: whoiskatrin Date: Wed, 10 Dec 2025 17:56:29 +0000 Subject: [PATCH 4/7] Fix workspace bug in sandbox container Fixes a bug in the workspace related to sandbox functionality. --- .changeset/sixty-walls-serve.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sixty-walls-serve.md diff --git a/.changeset/sixty-walls-serve.md b/.changeset/sixty-walls-serve.md new file mode 100644 index 00000000..2331d634 --- /dev/null +++ b/.changeset/sixty-walls-serve.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/sandbox": patch +--- + +fix workspace bug From da8fb503ee753ebdb5f27e8ee3b6fde87eb48e68 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 10 Dec 2025 18:02:34 +0000 Subject: [PATCH 5/7] clear tests --- .../sandbox-container/tests/session.test.ts | 16 +- tests/e2e/workspace-deletion-workflow.test.ts | 709 +----------------- 2 files changed, 48 insertions(+), 677 deletions(-) diff --git a/packages/sandbox-container/tests/session.test.ts b/packages/sandbox-container/tests/session.test.ts index c9d2a80b..d4f2a500 100644 --- a/packages/sandbox-container/tests/session.test.ts +++ b/packages/sandbox-container/tests/session.test.ts @@ -76,10 +76,10 @@ describe('Session', () => { expect(session.isReady()).toBe(true); }); - it('should fall back to /tmp when cwd does not exist (issue #288)', async () => { + it('should fall back to home directory when cwd does not exist (issue #288)', async () => { // Session cwd only affects shell startup directory - it's not critical. - // If cwd doesn't exist, we fall back to /tmp since individual commands - // can specify their own cwd anyway. + // If cwd doesn't exist, we fall back to the home directory since individual + // commands can specify their own cwd anyway. session = new Session({ id: 'test-session-nonexistent-cwd', cwd: '/nonexistent/path/that/does/not/exist' @@ -92,12 +92,12 @@ describe('Session', () => { // Verify we can execute commands const result = await session.exec('pwd'); expect(result.exitCode).toBe(0); - // The shell should have started in /tmp since the requested cwd doesn't exist - // On macOS, /tmp is a symlink to /private/tmp - expect(result.stdout.trim()).toMatch(/^(\/tmp|\/private\/tmp)$/); + // The shell should have started in the home directory since the requested cwd doesn't exist + const homeDir = process.env.HOME || '/root'; + expect(result.stdout.trim()).toBe(homeDir); }); - it('should fall back to /tmp when workspace is deleted before session creation (issue #288)', async () => { + it('should fall back to home directory when workspace is deleted before session creation (issue #288)', async () => { // Simulate the exact scenario from issue #288 // Create a workspace, then delete it, then try to create a session with it const workspaceDir = join(testDir, 'workspace'); @@ -112,7 +112,7 @@ describe('Session', () => { cwd: workspaceDir }); - // Should succeed - falls back to /tmp + // Should succeed - falls back to home directory await session.initialize(); expect(session.isReady()).toBe(true); diff --git a/tests/e2e/workspace-deletion-workflow.test.ts b/tests/e2e/workspace-deletion-workflow.test.ts index c3c4284a..200d1f37 100644 --- a/tests/e2e/workspace-deletion-workflow.test.ts +++ b/tests/e2e/workspace-deletion-workflow.test.ts @@ -1,6 +1,8 @@ /** * Workspace Deletion Workflow Tests (Issue #288) - + * + * Tests that new sessions fall back to the home directory when /workspace + * doesn't exist, preventing "Unknown Error, TODO" failures. * * @see https://github.com/cloudflare/sandbox-sdk/issues/288 */ @@ -8,727 +10,96 @@ import { describe, test, expect, beforeAll } from 'vitest'; import { getSharedSandbox, - createUniqueSession, - uniqueTestPath + createUniqueSession } from './helpers/global-sandbox'; import type { ExecResult } from '@repo/shared'; describe('Workspace Deletion Workflow (Issue #288)', () => { let workerUrl: string; + let sandbox: Awaited>; beforeAll(async () => { - const sandbox = await getSharedSandbox(); + sandbox = await getSharedSandbox(); workerUrl = sandbox.workerUrl; }, 120000); /** - * Test 1: Symlinks in /tmp (non-/workspace directories) - * - * Verifies that symlinks work correctly when /workspace is not involved. - * This establishes a baseline that symlink functionality itself is working. - */ - test('should handle symlinks in /tmp directories correctly', async () => { - // Use a unique session for this test - const sandbox = await getSharedSandbox(); - const headers = sandbox.createHeaders(createUniqueSession()); - const testDir = `/tmp/symlink-test-${Date.now()}`; - - // Create source directory and file - const mkdirResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `mkdir -p ${testDir}/source` - }) - }); - expect(mkdirResponse.status).toBe(200); - - // Write a file in the source directory - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testDir}/source/test.txt`, - content: 'symlink content' - }) - }); - expect(writeResponse.status).toBe(200); - - // Create a symlink to the source directory - const symlinkResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `ln -sf ${testDir}/source ${testDir}/target` - }) - }); - expect(symlinkResponse.status).toBe(200); - - // Read the file through the symlink - const catResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `cat ${testDir}/target/test.txt` - }) - }); - expect(catResponse.status).toBe(200); - const catData = (await catResponse.json()) as ExecResult; - expect(catData.success).toBe(true); - expect(catData.stdout?.trim()).toBe('symlink content'); - - // Cleanup - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: `rm -rf ${testDir}` }) - }); - }, 90000); - - /** - * Test 2: Session continues working after deleting its working directory - * - * This tests that an existing session can continue executing commands - * even after its current working directory is deleted. The shell maintains - * the directory inode until it changes directory. - */ - test('should continue working after session cwd is deleted', async () => { - // Use a unique session for this test - const sandbox = await getSharedSandbox(); - const headers = sandbox.createHeaders(createUniqueSession()); - const testWorkspace = uniqueTestPath('workspace-deletion'); - - // Create a working directory for this test - const mkdirResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `mkdir -p ${testWorkspace}` - }) - }); - expect(mkdirResponse.status).toBe(200); - - // Verify baseline works - const baselineResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "baseline works"' - }) - }); - expect(baselineResponse.status).toBe(200); - const baselineData = (await baselineResponse.json()) as ExecResult; - expect(baselineData.success).toBe(true); - expect(baselineData.stdout?.trim()).toBe('baseline works'); - - // Delete the workspace directory - const deleteResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `rm -rf ${testWorkspace}` - }) - }); - expect(deleteResponse.status).toBe(200); - - // Try a subsequent command - this should NOT fail - const afterDeleteResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "after deletion"' - }) - }); - expect(afterDeleteResponse.status).toBe(200); - const afterDeleteData = (await afterDeleteResponse.json()) as ExecResult; - expect(afterDeleteData.success).toBe(true); - expect(afterDeleteData.stdout?.trim()).toBe('after deletion'); - }, 90000); - - /** - * Test 3: Session continues after /workspace is replaced with symlink - * - * Tests that replacing /workspace with a symlink to a valid directory - * doesn't break subsequent exec() calls. - */ - test('should continue working after workspace is replaced with symlink', async () => { - // Use a unique session for this test - const sandbox = await getSharedSandbox(); - const headers = sandbox.createHeaders(createUniqueSession()); - const testWorkspace = uniqueTestPath('workspace-symlink'); - const backupDir = `/tmp/backup-${Date.now()}`; - - // Create directories for the test - const setupResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `mkdir -p ${testWorkspace} ${backupDir}` - }) - }); - expect(setupResponse.status).toBe(200); - - // Verify baseline works - const baselineResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "baseline"' - }) - }); - expect(baselineResponse.status).toBe(200); - const baselineData = (await baselineResponse.json()) as ExecResult; - expect(baselineData.success).toBe(true); - expect(baselineData.stdout?.trim()).toBe('baseline'); - - // Replace workspace with a symlink to backup directory - const symlinkResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `rm -rf ${testWorkspace} && ln -sf ${backupDir} ${testWorkspace}` - }) - }); - expect(symlinkResponse.status).toBe(200); - - // Try a subsequent command - should continue working - const afterSymlinkResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "after symlink"' - }) - }); - expect(afterSymlinkResponse.status).toBe(200); - const afterSymlinkData = (await afterSymlinkResponse.json()) as ExecResult; - expect(afterSymlinkData.success).toBe(true); - expect(afterSymlinkData.stdout?.trim()).toBe('after symlink'); - - // Cleanup - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: `rm -rf ${testWorkspace} ${backupDir}` }) - }); - }, 90000); - - /** - * Test 4: Can recreate directory after deletion + * Core test for issue #288: new session creation after /workspace is deleted. * - * Tests that after deleting a directory, we can recreate it with mkdir. - * This was one of the failure cases in issue #288. + * The bug was that creating a NEW session after /workspace was deleted would + * fail with "Unknown Error, TODO". The fix makes session initialization fall + * back to the home directory if /workspace doesn't exist. */ - test('should be able to recreate directory after deletion', async () => { - // Use a unique session for this test - const sandbox = await getSharedSandbox(); - const headers = sandbox.createHeaders(createUniqueSession()); - const testWorkspace = uniqueTestPath('workspace-recreate'); - - // Create initial workspace - const createResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `mkdir -p ${testWorkspace}` - }) - }); - expect(createResponse.status).toBe(200); + test('new session falls back to home directory when /workspace is deleted', async () => { + const setupHeaders = sandbox.createHeaders(createUniqueSession()); - // Delete it + // Delete /workspace const deleteResponse = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers, - body: JSON.stringify({ - command: `rm -rf ${testWorkspace}` - }) - }); - expect(deleteResponse.status).toBe(200); - - // Recreate it - this should work - const recreateResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `mkdir -p ${testWorkspace}` - }) - }); - expect(recreateResponse.status).toBe(200); - const recreateData = (await recreateResponse.json()) as ExecResult; - expect(recreateData.success).toBe(true); - - // Verify it was recreated - const verifyResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `ls -d ${testWorkspace}` - }) - }); - expect(verifyResponse.status).toBe(200); - const verifyData = (await verifyResponse.json()) as ExecResult; - expect(verifyData.success).toBe(true); - expect(verifyData.stdout?.trim()).toBe(testWorkspace); - - // Cleanup - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: `rm -rf ${testWorkspace}` }) - }); - }, 90000); - - /** - * Test 5: Recovery by changing to a valid directory - * - * Tests that after cwd is deleted, we can recover by cd-ing to a valid directory. - */ - test('should be recoverable by cd-ing to valid directory after cwd deletion', async () => { - // Use a unique session for this test - const sandbox = await getSharedSandbox(); - const headers = sandbox.createHeaders(createUniqueSession()); - const testWorkspace = uniqueTestPath('workspace-recovery'); - - // Create workspace - const createResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `mkdir -p ${testWorkspace}` - }) - }); - expect(createResponse.status).toBe(200); - - // Delete workspace - const deleteResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `rm -rf ${testWorkspace}` - }) + headers: setupHeaders, + body: JSON.stringify({ command: 'rm -rf /workspace' }) }); expect(deleteResponse.status).toBe(200); - // Change to a valid directory and verify - const cdResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'cd /tmp && pwd' - }) - }); - expect(cdResponse.status).toBe(200); - const cdData = (await cdResponse.json()) as ExecResult; - expect(cdData.success).toBe(true); - expect(cdData.stdout?.trim()).toBe('/tmp'); - - // Subsequent commands should work - const afterCdResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "recovered"' - }) - }); - expect(afterCdResponse.status).toBe(200); - const afterCdData = (await afterCdResponse.json()) as ExecResult; - expect(afterCdData.success).toBe(true); - expect(afterCdData.stdout?.trim()).toBe('recovered'); - }, 90000); - - /** - * Test 6: New session can be created when /workspace doesn't exist - * - * This tests the core fix for issue #288: when a new session is created - * and /workspace doesn't exist, it should fall back to the home directory - * instead of failing. - * - * Note: This test uses a different sandbox ID to ensure a completely new - * session is created (not reusing an existing one). - */ - test('should create new session successfully even when default cwd does not exist', async () => { - // Use a completely new session to force session creation - const sandbox = await getSharedSandbox(); - const uniqueSession = `new-session-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const headers = sandbox.createHeaders(uniqueSession); + // Create a NEW session - this is where the bug occurred + const newHeaders = sandbox.createHeaders(createUniqueSession()); - // Execute a simple command in the new session - // The session will be created on-demand, and should fall back to / - // if /workspace doesn't exist const execResponse = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "new session works"' - }) + headers: newHeaders, + body: JSON.stringify({ command: 'pwd' }) }); expect(execResponse.status).toBe(200); const execData = (await execResponse.json()) as ExecResult; expect(execData.success).toBe(true); - expect(execData.stdout?.trim()).toBe('new session works'); + expect(execData.stdout?.trim()).toBe('/root'); - // The command should have executed without errors - expect(execData.stderr?.toLowerCase() || '').not.toContain('unknown error'); - expect(execData.stderr?.toLowerCase() || '').not.toContain('todo'); - }, 90000); - - /** - * Test 7: New session creation after /workspace is deleted (Core bug from Issue #288) - * - * This is the PRIMARY test for issue #288. The bug was that creating a NEW session - * after /workspace was deleted would fail with "Unknown Error, TODO". - * - * The fix makes session initialization fall back to the home directory if - * /workspace doesn't exist. - * - * IMPORTANT: This test actually deletes /workspace and creates a new session. - * It restores /workspace at the end to avoid breaking other tests. - */ - test('should create new session successfully after /workspace is deleted (issue #288 core bug)', async () => { - const sandbox = await getSharedSandbox(); - - // Use a dedicated session for the setup/cleanup operations - const setupSession = createUniqueSession(); - const setupHeaders = sandbox.createHeaders(setupSession); - - // Step 1: Verify /workspace exists (or create it if needed) - const ensureWorkspaceResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ - command: 'mkdir -p /workspace && ls -la /workspace' - }) - }); - expect(ensureWorkspaceResponse.status).toBe(200); - - // Step 2: Delete /workspace entirely - const deleteResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ - command: 'rm -rf /workspace' - }) - }); - expect(deleteResponse.status).toBe(200); - const deleteData = (await deleteResponse.json()) as ExecResult; - expect(deleteData.success).toBe(true); - - // Step 3: Verify /workspace is gone - const verifyGoneResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ - command: - '[ ! -d /workspace ] && echo "workspace deleted" || echo "workspace exists"' - }) - }); - expect(verifyGoneResponse.status).toBe(200); - const verifyGoneData = (await verifyGoneResponse.json()) as ExecResult; - expect(verifyGoneData.stdout?.trim()).toBe('workspace deleted'); - - // Step 4: Create a BRAND NEW session - this is where the bug occurred - // Before the fix, this would fail with "Unknown Error, TODO" - const newSession = `new-after-delete-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const newHeaders = sandbox.createHeaders(newSession); - - const newSessionResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: newHeaders, - body: JSON.stringify({ - command: 'echo "new session after workspace deletion works"' - }) - }); - - expect(newSessionResponse.status).toBe(200); - const newSessionData = (await newSessionResponse.json()) as ExecResult; - expect(newSessionData.success).toBe(true); - expect(newSessionData.stdout?.trim()).toBe( - 'new session after workspace deletion works' - ); - - // Verify NO "Unknown Error" or "TODO" in stderr - expect(newSessionData.stderr?.toLowerCase() || '').not.toContain( - 'unknown error' - ); - expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo'); - - // Step 5: Verify the new session fell back to home directory - const pwdResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: newHeaders, - body: JSON.stringify({ - command: 'pwd' - }) - }); - expect(pwdResponse.status).toBe(200); - const pwdData = (await pwdResponse.json()) as ExecResult; - expect(pwdData.success).toBe(true); - // Session should have fallen back to /root since /workspace didn't exist - expect(pwdData.stdout?.trim()).toBe('/root'); - - // Step 6: Restore /workspace for other tests - const restoreResponse = await fetch(`${workerUrl}/api/execute`, { + // Restore /workspace for other tests + await fetch(`${workerUrl}/api/execute`, { method: 'POST', headers: setupHeaders, - body: JSON.stringify({ - command: 'mkdir -p /workspace && echo "workspace restored"' - }) + body: JSON.stringify({ command: 'mkdir -p /workspace' }) }); - expect(restoreResponse.status).toBe(200); - const restoreData = (await restoreResponse.json()) as ExecResult; - expect(restoreData.success).toBe(true); - }, 120000); + }, 90000); /** - * Test 8: New session creation after /workspace is replaced with symlink (Issue #288 variant) - * - * Tests the symlink variant of issue #288 where /workspace is replaced with - * a symlink to another directory. + * Tests that new sessions work when /workspace is a symlink to a valid directory. */ - test('should create new session successfully after /workspace is replaced with symlink', async () => { - const sandbox = await getSharedSandbox(); - - // Use a dedicated session for setup/cleanup - const setupSession = createUniqueSession(); - const setupHeaders = sandbox.createHeaders(setupSession); + test('new session works when /workspace is a symlink', async () => { + const setupHeaders = sandbox.createHeaders(createUniqueSession()); const backupDir = `/tmp/workspace-backup-${Date.now()}`; - // Step 1: Create backup directory and ensure /workspace exists - const setupResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ - command: `mkdir -p ${backupDir} /workspace` - }) - }); - expect(setupResponse.status).toBe(200); - - // Step 2: Replace /workspace with a symlink - const symlinkResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ - command: `rm -rf /workspace && ln -sf ${backupDir} /workspace` - }) - }); - expect(symlinkResponse.status).toBe(200); - const symlinkData = (await symlinkResponse.json()) as ExecResult; - expect(symlinkData.success).toBe(true); - - // Step 3: Verify /workspace is now a symlink - const verifySymlinkResponse = await fetch(`${workerUrl}/api/execute`, { + // Replace /workspace with a symlink + await fetch(`${workerUrl}/api/execute`, { method: 'POST', headers: setupHeaders, body: JSON.stringify({ - command: '[ -L /workspace ] && echo "is symlink" || echo "not symlink"' + command: `mkdir -p ${backupDir} && rm -rf /workspace && ln -sf ${backupDir} /workspace` }) }); - expect(verifySymlinkResponse.status).toBe(200); - const verifySymlinkData = - (await verifySymlinkResponse.json()) as ExecResult; - expect(verifySymlinkData.stdout?.trim()).toBe('is symlink'); - // Step 4: Create a NEW session - should work with the symlink - const newSession = `new-after-symlink-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const newHeaders = sandbox.createHeaders(newSession); + // Create a NEW session - should work with the symlink + const newHeaders = sandbox.createHeaders(createUniqueSession()); - const newSessionResponse = await fetch(`${workerUrl}/api/execute`, { + const execResponse = await fetch(`${workerUrl}/api/execute`, { method: 'POST', headers: newHeaders, - body: JSON.stringify({ - command: 'echo "new session with symlink workspace works"' - }) + body: JSON.stringify({ command: 'pwd -P' }) // -P resolves symlinks }); - expect(newSessionResponse.status).toBe(200); - const newSessionData = (await newSessionResponse.json()) as ExecResult; - expect(newSessionData.success).toBe(true); - expect(newSessionData.stdout?.trim()).toBe( - 'new session with symlink workspace works' - ); - - // Verify NO "Unknown Error" or "TODO" in stderr - expect(newSessionData.stderr?.toLowerCase() || '').not.toContain( - 'unknown error' - ); - expect(newSessionData.stderr?.toLowerCase() || '').not.toContain('todo'); - - // Step 5: Verify the session's cwd is through the symlink (resolves to backup dir) - const pwdResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: newHeaders, - body: JSON.stringify({ - command: 'pwd -P' // -P shows physical path, resolving symlinks - }) - }); - expect(pwdResponse.status).toBe(200); - const pwdData = (await pwdResponse.json()) as ExecResult; - expect(pwdData.success).toBe(true); - // The physical path should be the backup directory - expect(pwdData.stdout?.trim()).toBe(backupDir); + expect(execResponse.status).toBe(200); + const execData = (await execResponse.json()) as ExecResult; + expect(execData.success).toBe(true); + expect(execData.stdout?.trim()).toBe(backupDir); - // Step 6: Cleanup - restore /workspace as a real directory - const cleanupResponse = await fetch(`${workerUrl}/api/execute`, { + // Restore /workspace as a real directory + await fetch(`${workerUrl}/api/execute`, { method: 'POST', headers: setupHeaders, body: JSON.stringify({ command: `rm -f /workspace && mkdir -p /workspace && rm -rf ${backupDir}` }) }); - expect(cleanupResponse.status).toBe(200); - }, 120000); - - /** - * Test 9: Multiple operations after workspace manipulation - * - * Tests a realistic workflow where the workspace is manipulated - * and then multiple subsequent operations are performed. - */ - test('should handle multiple operations after workspace manipulation', async () => { - const sandbox = await getSharedSandbox(); - const headers = sandbox.createHeaders(createUniqueSession()); - const testWorkspace = uniqueTestPath('workspace-multi-ops'); - const backupDir = `/tmp/multi-ops-backup-${Date.now()}`; - - // Setup: create workspace and backup directories - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `mkdir -p ${testWorkspace} ${backupDir}` - }) - }); - - // Write a file - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testWorkspace}/test.txt`, - content: 'original content' - }) - }); - expect(writeResponse.status).toBe(200); - - // Replace workspace with symlink - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `rm -rf ${testWorkspace} && ln -sf ${backupDir} ${testWorkspace}` - }) - }); - - // Multiple subsequent operations should all work: - - // 1. Echo command - const echo1Response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'echo "operation 1"' }) - }); - expect(echo1Response.status).toBe(200); - const echo1Data = (await echo1Response.json()) as ExecResult; - expect(echo1Data.success).toBe(true); - - // 2. Write file to backup (through symlink) - const write2Response = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${backupDir}/new-file.txt`, - content: 'new content' - }) - }); - expect(write2Response.status).toBe(200); - - // 3. Read the file back - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${backupDir}/new-file.txt` - }) - }); - expect(readResponse.status).toBe(200); - - // 4. Another exec command - const echo2Response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'echo "operation 4"' }) - }); - expect(echo2Response.status).toBe(200); - const echo2Data = (await echo2Response.json()) as ExecResult; - expect(echo2Data.success).toBe(true); - expect(echo2Data.stdout?.trim()).toBe('operation 4'); - - // Cleanup - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: `rm -rf ${testWorkspace} ${backupDir}` }) - }); - }, 90000); - - /** - * Exact reproduction from Issue #288 - * - * @see https://github.com/cloudflare/sandbox-sdk/issues/288 - */ - test('issue #288 exact minimal reproduction', async () => { - const sandbox = await getSharedSandbox(); - - // Use a single session for the entire test (matches the bug report scenario) - const sessionId = `issue-288-repro-${Date.now()}`; - const headers = sandbox.createHeaders(sessionId); - - // 1. Get a sandbox instance (done via shared sandbox) - - // 2. Verify baseline works - const baselineResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'echo "baseline works"' }) - }); - expect(baselineResponse.status).toBe(200); - const baselineData = (await baselineResponse.json()) as ExecResult; - expect(baselineData.success).toBe(true); - expect(baselineData.stdout?.trim()).toBe('baseline works'); - - // 3. Remove /workspace - const removeResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'rm -rf /workspace' }) - }); - expect(removeResponse.status).toBe(200); - const removeData = (await removeResponse.json()) as ExecResult; - expect(removeData.success).toBe(true); - - // 4. Try ANY subsequent exec() call - const afterRemovalResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'echo "after removal"' }) - }); - expect(afterRemovalResponse.status).toBe(200); - const afterRemovalData = (await afterRemovalResponse.json()) as ExecResult; - - expect(afterRemovalData.success).toBe(true); - expect(afterRemovalData.stdout?.trim()).toBe('after removal'); - - // Verify no "Unknown Error, TODO" anywhere - expect(afterRemovalData.stderr?.toLowerCase() || '').not.toContain( - 'unknown error' - ); - expect(afterRemovalData.stderr?.toLowerCase() || '').not.toContain('todo'); - - // Cleanup: restore /workspace for other tests - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'mkdir -p /workspace' }) - }); }, 90000); }); From 732b2b8ca5b94b0151af3cdb932abecd38dd6f50 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Wed, 10 Dec 2025 18:14:38 +0000 Subject: [PATCH 6/7] minor nits --- .../sandbox-container/tests/session.test.ts | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/packages/sandbox-container/tests/session.test.ts b/packages/sandbox-container/tests/session.test.ts index d4f2a500..74556d15 100644 --- a/packages/sandbox-container/tests/session.test.ts +++ b/packages/sandbox-container/tests/session.test.ts @@ -76,7 +76,7 @@ describe('Session', () => { expect(session.isReady()).toBe(true); }); - it('should fall back to home directory when cwd does not exist (issue #288)', async () => { + it('should fall back to home directory when cwd does not exist', async () => { // Session cwd only affects shell startup directory - it's not critical. // If cwd doesn't exist, we fall back to the home directory since individual // commands can specify their own cwd anyway. @@ -97,8 +97,8 @@ describe('Session', () => { expect(result.stdout.trim()).toBe(homeDir); }); - it('should fall back to home directory when workspace is deleted before session creation (issue #288)', async () => { - // Simulate the exact scenario from issue #288 + it('should fall back to home directory when workspace is deleted before session creation', async () => { + // Simulate the scenario where workspace is deleted before session creation // Create a workspace, then delete it, then try to create a session with it const workspaceDir = join(testDir, 'workspace'); await mkdir(workspaceDir, { recursive: true }); @@ -544,7 +544,7 @@ describe('Session', () => { expect(result.stderr).toContain('Failed to change directory'); }); - it('should continue working after session cwd is deleted (issue #288)', async () => { + it('should continue working after session cwd is deleted', async () => { // Create a working directory for the session const workspaceDir = join(testDir, 'workspace'); await mkdir(workspaceDir, { recursive: true }); @@ -574,7 +574,7 @@ describe('Session', () => { expect(afterRemoval.stdout.trim()).toBe('after removal'); }); - it('should handle cwd being replaced with symlink (issue #288)', async () => { + it('should handle cwd being replaced with symlink', async () => { // Create directories for the test const workspaceDir = join(testDir, 'workspace'); const backupDir = join(testDir, 'backup'); @@ -603,32 +603,6 @@ describe('Session', () => { expect(afterSymlink.exitCode).toBe(0); expect(afterSymlink.stdout.trim()).toBe('after symlink'); }); - - it('should be recoverable by cd-ing to valid directory after cwd deletion', async () => { - // Create a working directory for the session - const workspaceDir = join(testDir, 'workspace'); - await mkdir(workspaceDir, { recursive: true }); - - session = new Session({ - id: 'test-cwd-recovery', - cwd: workspaceDir - }); - - await session.initialize(); - - // Delete the workspace directory - await session.exec(`rm -rf ${workspaceDir}`); - - // Change to a valid directory - this should work and recover the session - const cdResult = await session.exec('cd /tmp && pwd'); - expect(cdResult.exitCode).toBe(0); - expect(cdResult.stdout.trim()).toBe('/tmp'); - - // Subsequent commands should work - const afterCd = await session.exec('echo "recovered"'); - expect(afterCd.exitCode).toBe(0); - expect(afterCd.stdout.trim()).toBe('recovered'); - }); }); describe('FIFO cleanup', () => { From d6391838b3680c4b1d04fe1d7bfeb8c18e014484 Mon Sep 17 00:00:00 2001 From: whoiskatrin Date: Wed, 10 Dec 2025 18:38:12 +0000 Subject: [PATCH 7/7] Delete tests/e2e/workspace-deletion-workflow.test.ts --- tests/e2e/workspace-deletion-workflow.test.ts | 105 ------------------ 1 file changed, 105 deletions(-) delete mode 100644 tests/e2e/workspace-deletion-workflow.test.ts diff --git a/tests/e2e/workspace-deletion-workflow.test.ts b/tests/e2e/workspace-deletion-workflow.test.ts deleted file mode 100644 index 200d1f37..00000000 --- a/tests/e2e/workspace-deletion-workflow.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Workspace Deletion Workflow Tests (Issue #288) - * - * Tests that new sessions fall back to the home directory when /workspace - * doesn't exist, preventing "Unknown Error, TODO" failures. - * - * @see https://github.com/cloudflare/sandbox-sdk/issues/288 - */ - -import { describe, test, expect, beforeAll } from 'vitest'; -import { - getSharedSandbox, - createUniqueSession -} from './helpers/global-sandbox'; -import type { ExecResult } from '@repo/shared'; - -describe('Workspace Deletion Workflow (Issue #288)', () => { - let workerUrl: string; - let sandbox: Awaited>; - - beforeAll(async () => { - sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - }, 120000); - - /** - * Core test for issue #288: new session creation after /workspace is deleted. - * - * The bug was that creating a NEW session after /workspace was deleted would - * fail with "Unknown Error, TODO". The fix makes session initialization fall - * back to the home directory if /workspace doesn't exist. - */ - test('new session falls back to home directory when /workspace is deleted', async () => { - const setupHeaders = sandbox.createHeaders(createUniqueSession()); - - // Delete /workspace - const deleteResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ command: 'rm -rf /workspace' }) - }); - expect(deleteResponse.status).toBe(200); - - // Create a NEW session - this is where the bug occurred - const newHeaders = sandbox.createHeaders(createUniqueSession()); - - const execResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: newHeaders, - body: JSON.stringify({ command: 'pwd' }) - }); - - expect(execResponse.status).toBe(200); - const execData = (await execResponse.json()) as ExecResult; - expect(execData.success).toBe(true); - expect(execData.stdout?.trim()).toBe('/root'); - - // Restore /workspace for other tests - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ command: 'mkdir -p /workspace' }) - }); - }, 90000); - - /** - * Tests that new sessions work when /workspace is a symlink to a valid directory. - */ - test('new session works when /workspace is a symlink', async () => { - const setupHeaders = sandbox.createHeaders(createUniqueSession()); - const backupDir = `/tmp/workspace-backup-${Date.now()}`; - - // Replace /workspace with a symlink - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ - command: `mkdir -p ${backupDir} && rm -rf /workspace && ln -sf ${backupDir} /workspace` - }) - }); - - // Create a NEW session - should work with the symlink - const newHeaders = sandbox.createHeaders(createUniqueSession()); - - const execResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: newHeaders, - body: JSON.stringify({ command: 'pwd -P' }) // -P resolves symlinks - }); - - expect(execResponse.status).toBe(200); - const execData = (await execResponse.json()) as ExecResult; - expect(execData.success).toBe(true); - expect(execData.stdout?.trim()).toBe(backupDir); - - // Restore /workspace as a real directory - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: setupHeaders, - body: JSON.stringify({ - command: `rm -f /workspace && mkdir -p /workspace && rm -rf ${backupDir}` - }) - }); - }, 90000); -});