diff --git a/.changeset/wild-news-pretend.md b/.changeset/wild-news-pretend.md new file mode 100644 index 00000000..7d751f0d --- /dev/null +++ b/.changeset/wild-news-pretend.md @@ -0,0 +1,12 @@ +--- +'@repo/sandbox-container': patch +'@cloudflare/sandbox': patch +'@repo/shared': patch +--- + +Expose deleteSession API with proper safeguards + +- Add `deleteSession(sessionId)` method to public SDK API +- Prevent deletion of default session (throws error with guidance to use `sandbox.destroy()`) +- Session cleanup kills all running commands in parallel before destroying shell +- Return structured `SessionDeleteResult` with success status, sessionId, and timestamp diff --git a/package.json b/package.json index 6f3457e1..6f7a53c0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.0.0", "description": "an api for computers", "scripts": { - "prepare": "lefthook install", "typecheck": "turbo run typecheck", "format": "prettier --write .", "check": "sherif && biome check && turbo run typecheck", diff --git a/packages/sandbox-container/src/handlers/session-handler.ts b/packages/sandbox-container/src/handlers/session-handler.ts index c903df52..05b075d8 100644 --- a/packages/sandbox-container/src/handlers/session-handler.ts +++ b/packages/sandbox-container/src/handlers/session-handler.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import type { Logger, SessionCreateResult } from '@repo/shared'; +import type { Logger, SessionDeleteRequest } from '@repo/shared'; import { ErrorCode } from '@repo/shared/errors'; import type { RequestContext } from '../core/types'; @@ -30,6 +30,8 @@ export class SessionHandler extends BaseHandler { return await this.handleCreate(request, context); case '/api/session/list': return await this.handleList(request, context); + case '/api/session/delete': + return await this.handleDelete(request, context); default: return this.createErrorResponse( { @@ -102,6 +104,51 @@ export class SessionHandler extends BaseHandler { } } + private async handleDelete( + request: Request, + context: RequestContext + ): Promise { + let body: SessionDeleteRequest; + + try { + body = (await request.json()) as SessionDeleteRequest; + + if (!body.sessionId) { + return this.createErrorResponse( + { + message: 'sessionId is required', + code: ErrorCode.VALIDATION_FAILED + }, + context + ); + } + } catch { + return this.createErrorResponse( + { + message: 'Invalid request body', + code: ErrorCode.VALIDATION_FAILED + }, + context + ); + } + + const sessionId = body.sessionId; + + const result = await this.sessionManager.deleteSession(sessionId); + + if (result.success) { + const response = { + success: true, + sessionId, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } else { + return this.createErrorResponse(result.error, context); + } + } + private generateSessionId(): string { return `session_${Date.now()}_${randomBytes(6).toString('hex')}`; } diff --git a/packages/sandbox-container/src/routes/setup.ts b/packages/sandbox-container/src/routes/setup.ts index 68d3d3c8..ee10a19a 100644 --- a/packages/sandbox-container/src/routes/setup.ts +++ b/packages/sandbox-container/src/routes/setup.ts @@ -21,6 +21,14 @@ export function setupRoutes(router: Router, container: Container): void { middleware: [container.get('loggingMiddleware')] }); + router.register({ + method: 'POST', + path: '/api/session/delete', + handler: async (req, ctx) => + container.get('sessionHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + // Execute routes router.register({ method: 'POST', diff --git a/packages/sandbox-container/src/session.ts b/packages/sandbox-container/src/session.ts index ed9dfe26..b6217d40 100644 --- a/packages/sandbox-container/src/session.ts +++ b/packages/sandbox-container/src/session.ts @@ -554,6 +554,12 @@ export class Session { // Mark as destroying to prevent shell exit monitor from logging errors this.isDestroying = true; + // Kill all running commands first + const runningCommandIds = Array.from(this.runningCommands.keys()); + await Promise.all( + runningCommandIds.map((commandId) => this.killCommand(commandId)) + ); + if (this.shell && !this.shell.killed) { // Close stdin to send EOF to bash (standard way to terminate interactive shells) if (this.shell.stdin && typeof this.shell.stdin !== 'number') { @@ -582,7 +588,7 @@ export class Session { } } - // Clean up session directory + // Clean up session directory (includes pid files, FIFOs, log files) if (this.sessionDir) { await rm(this.sessionDir, { recursive: true, force: true }).catch( () => {} diff --git a/packages/sandbox-container/tests/handlers/session-handler.test.ts b/packages/sandbox-container/tests/handlers/session-handler.test.ts index dfc8e01e..0da0dbab 100644 --- a/packages/sandbox-container/tests/handlers/session-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/session-handler.test.ts @@ -150,6 +150,130 @@ describe('SessionHandler', () => { }); }); + describe('handleDelete - POST /api/session/delete', () => { + it('should delete session successfully', async () => { + (mockSessionManager.deleteSession as any).mockResolvedValue({ + success: true + }); + + const request = new Request('http://localhost:3000/api/session/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: 'test-session-123' }) + }); + + const response = await sessionHandler.handle(request, mockContext); + + expect(response.status).toBe(200); + const responseBody = await response.json(); + expect(responseBody.success).toBe(true); + expect(responseBody.sessionId).toBe('test-session-123'); + expect(responseBody.timestamp).toBeDefined(); + expect(responseBody.timestamp).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + + // Verify service was called correctly + expect(mockSessionManager.deleteSession).toHaveBeenCalledWith( + 'test-session-123' + ); + }); + + it('should handle session deletion failures', async () => { + (mockSessionManager.deleteSession as any).mockResolvedValue({ + success: false, + error: { + message: "Session 'nonexistent' not found", + code: 'INTERNAL_ERROR', + details: { + sessionId: 'nonexistent', + originalError: 'Session not found' + } + } + }); + + const request = new Request('http://localhost:3000/api/session/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: 'nonexistent' }) + }); + + const response = await sessionHandler.handle(request, mockContext); + + expect(response.status).toBe(500); + const responseData = (await response.json()) as ErrorResponse; + expect(responseData.code).toBe('INTERNAL_ERROR'); + expect(responseData.message).toBe("Session 'nonexistent' not found"); + expect(responseData.context).toEqual({ + sessionId: 'nonexistent', + originalError: 'Session not found' + }); + expect(responseData.httpStatus).toBe(500); + expect(responseData.timestamp).toBeDefined(); + }); + + it('should reject requests without sessionId', async () => { + const request = new Request('http://localhost:3000/api/session/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const response = await sessionHandler.handle(request, mockContext); + + expect(response.status).toBe(400); + const responseData = (await response.json()) as ErrorResponse; + expect(responseData.code).toBe('VALIDATION_FAILED'); + expect(responseData.message).toBe('sessionId is required'); + expect(responseData.httpStatus).toBe(400); + + // Should not call service + expect(mockSessionManager.deleteSession).not.toHaveBeenCalled(); + }); + + it('should reject requests with invalid JSON', async () => { + const request = new Request('http://localhost:3000/api/session/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json' + }); + + const response = await sessionHandler.handle(request, mockContext); + + expect(response.status).toBe(400); + const responseData = (await response.json()) as ErrorResponse; + expect(responseData.code).toBe('VALIDATION_FAILED'); + expect(responseData.message).toBe('Invalid request body'); + expect(responseData.httpStatus).toBe(400); + + // Should not call service + expect(mockSessionManager.deleteSession).not.toHaveBeenCalled(); + }); + + it('should include CORS headers in delete responses', async () => { + (mockSessionManager.deleteSession as any).mockResolvedValue({ + success: true + }); + + const request = new Request('http://localhost:3000/api/session/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: 'test-session' }) + }); + + const response = await sessionHandler.handle(request, mockContext); + + expect(response.status).toBe(200); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe( + 'GET, POST, OPTIONS' + ); + expect(response.headers.get('Access-Control-Allow-Headers')).toBe( + 'Content-Type' + ); + }); + }); + describe('handleList - GET /api/session/list', () => { it('should list sessions successfully with active processes', async () => { // SessionManager.listSessions() returns string[] (just session IDs) @@ -298,6 +422,7 @@ describe('SessionHandler', () => { // Should not call any service methods expect(mockSessionManager.createSession).not.toHaveBeenCalled(); expect(mockSessionManager.listSessions).not.toHaveBeenCalled(); + expect(mockSessionManager.deleteSession).not.toHaveBeenCalled(); }); it('should return 500 for root session path', async () => { diff --git a/packages/sandbox/Dockerfile b/packages/sandbox/Dockerfile index 1ece6e81..85c467cd 100644 --- a/packages/sandbox/Dockerfile +++ b/packages/sandbox/Dockerfile @@ -31,7 +31,7 @@ COPY --from=pruner /app/out/package-lock.json ./package-lock.json # Install ALL dependencies with cache mount for npm packages RUN --mount=type=cache,target=/root/.npm \ - npm ci + CI=true npm ci # Copy pruned source code COPY --from=pruner /app/out/full/ . @@ -55,7 +55,7 @@ COPY --from=builder /app/tooling ./tooling # Install ONLY production dependencies (excludes typescript, @types/*, etc.) RUN --mount=type=cache,target=/root/.npm \ - npm ci --production + CI=true npm ci --production # ============================================================================ # Stage 4: Download pre-built Python 3.11.14 diff --git a/packages/sandbox/src/clients/index.ts b/packages/sandbox/src/clients/index.ts index fe5b6756..840ef1ce 100644 --- a/packages/sandbox/src/clients/index.ts +++ b/packages/sandbox/src/clients/index.ts @@ -54,6 +54,10 @@ export type { // Utility client types export type { CommandsResponse, + CreateSessionRequest, + CreateSessionResponse, + DeleteSessionRequest, + DeleteSessionResponse, PingResponse, VersionResponse } from './utility-client'; diff --git a/packages/sandbox/src/clients/utility-client.ts b/packages/sandbox/src/clients/utility-client.ts index 218bd211..a162cabf 100644 --- a/packages/sandbox/src/clients/utility-client.ts +++ b/packages/sandbox/src/clients/utility-client.ts @@ -41,6 +41,20 @@ export interface CreateSessionResponse extends BaseApiResponse { message: string; } +/** + * Request interface for deleting sessions + */ +export interface DeleteSessionRequest { + sessionId: string; +} + +/** + * Response interface for deleting sessions + */ +export interface DeleteSessionResponse extends BaseApiResponse { + sessionId: string; +} + /** * Client for health checks and utility operations */ @@ -100,6 +114,25 @@ export class UtilityClient extends BaseHttpClient { } } + /** + * Delete an execution session + * @param sessionId - Session ID to delete + */ + async deleteSession(sessionId: string): Promise { + try { + const response = await this.post( + '/api/session/delete', + { sessionId } + ); + + this.logSuccess('Session deleted', `ID: ${sessionId}`); + return response; + } catch (error) { + this.logError('deleteSession', error); + throw error; + } + } + /** * Get the container version * Returns the version embedded in the Docker image during build diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 5886d06d..2d7b6c16 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -38,6 +38,12 @@ export type { BaseApiResponse, CommandsResponse, ContainerStub, + + // Utility client types + CreateSessionRequest, + CreateSessionResponse, + DeleteSessionRequest, + DeleteSessionResponse, ErrorResponse, // Command client types @@ -56,8 +62,6 @@ export type { // File client types MkdirRequest, - - // Utility client types PingResponse, PortCloseResult, PortExposeResult, diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 5c5519b7..aeefe895 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -17,7 +17,12 @@ import type { SessionOptions, StreamOptions } from '@repo/shared'; -import { createLogger, runWithLogger, TraceContext } from '@repo/shared'; +import { + createLogger, + runWithLogger, + type SessionDeleteResult, + TraceContext +} from '@repo/shared'; import { type ExecuteResponse, SandboxClient } from './clients'; import type { ErrorResponse } from './errors'; import { CustomDomainRequiredError, ErrorCode } from './errors'; @@ -1110,6 +1115,34 @@ export class Sandbox extends Container implements ISandbox { return this.getSessionWrapper(sessionId); } + /** + * Delete an execution session + * Cleans up session resources and removes it from the container + * Note: Cannot delete the default session. To reset the default session, + * use sandbox.destroy() to terminate the entire sandbox. + * + * @param sessionId - The ID of the session to delete + * @returns Result with success status, sessionId, and timestamp + * @throws Error if attempting to delete the default session + */ + async deleteSession(sessionId: string): Promise { + // Prevent deletion of default session + if (this.defaultSession && sessionId === this.defaultSession) { + throw new Error( + `Cannot delete default session '${sessionId}'. Use sandbox.destroy() to terminate the sandbox.` + ); + } + + const response = await this.client.utils.deleteSession(sessionId); + + // Map HTTP response to result type + return { + success: response.success, + sessionId: response.sessionId, + timestamp: response.timestamp + }; + } + /** * Internal helper to create ExecutionSession wrapper for a given sessionId * Used by both createSession and getSession diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index a4f4112a..35a58948 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -703,4 +703,37 @@ describe('Sandbox - Automatic Session Management', () => { expect(url.searchParams.get('room')).toBe('lobby'); }); }); + + describe('deleteSession', () => { + it('should prevent deletion of default session', async () => { + // Trigger creation of default session + await sandbox.exec('echo "test"'); + + // Verify default session exists + expect((sandbox as any).defaultSession).toBeTruthy(); + const defaultSessionId = (sandbox as any).defaultSession; + + // Attempt to delete default session should throw + await expect(sandbox.deleteSession(defaultSessionId)).rejects.toThrow( + `Cannot delete default session '${defaultSessionId}'. Use sandbox.destroy() to terminate the sandbox.` + ); + }); + + it('should allow deletion of non-default sessions', async () => { + // Mock the deleteSession API response + vi.spyOn(sandbox.client.utils, 'deleteSession').mockResolvedValue({ + success: true, + sessionId: 'custom-session', + timestamp: new Date().toISOString() + }); + + // Create a custom session + await sandbox.createSession({ id: 'custom-session' }); + + // Should successfully delete non-default session + const result = await sandbox.deleteSession('custom-session'); + expect(result.success).toBe(true); + expect(result.sessionId).toBe('custom-session'); + }); + }); }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8c9eb102..c7a49f1c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -724,6 +724,7 @@ export interface ISandbox { // Session management createSession(options?: SessionOptions): Promise; + deleteSession(sessionId: string): Promise; // Code interpreter methods createCodeContext(options?: CreateContextOptions): Promise; diff --git a/packages/shared/tests/git.test.ts b/packages/shared/tests/git.test.ts index ba4e3edd..5c706a47 100644 --- a/packages/shared/tests/git.test.ts +++ b/packages/shared/tests/git.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { redactCredentials, sanitizeGitData, GitLogger } from '../src/git'; +import { GitLogger, redactCredentials, sanitizeGitData } from '../src/git'; import { createNoOpLogger } from '../src/logger'; describe('redactCredentials', () => { diff --git a/tests/e2e/session-state-isolation-workflow.test.ts b/tests/e2e/session-state-isolation-workflow.test.ts index 9132e684..a08bbe19 100644 --- a/tests/e2e/session-state-isolation-workflow.test.ts +++ b/tests/e2e/session-state-isolation-workflow.test.ts @@ -67,7 +67,7 @@ describe('Session State Isolation Workflow', () => { async () => fetchWithStartup(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ env: { NODE_ENV: 'production', @@ -88,7 +88,7 @@ describe('Session State Isolation Workflow', () => { // Create session2 with test environment const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ env: { NODE_ENV: 'test', @@ -179,7 +179,7 @@ describe('Session State Isolation Workflow', () => { async () => fetchWithStartup(`${workerUrl}/api/file/mkdir`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ path: '/workspace/app', recursive: true @@ -190,7 +190,7 @@ describe('Session State Isolation Workflow', () => { await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ path: '/workspace/test', recursive: true @@ -199,7 +199,7 @@ describe('Session State Isolation Workflow', () => { await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ path: '/workspace/app/src', recursive: true @@ -208,7 +208,7 @@ describe('Session State Isolation Workflow', () => { await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ path: '/workspace/test/unit', recursive: true @@ -218,7 +218,7 @@ describe('Session State Isolation Workflow', () => { // Create session1 with cwd: /workspace/app const session1Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ cwd: '/workspace/app' }) @@ -230,7 +230,7 @@ describe('Session State Isolation Workflow', () => { // Create session2 with cwd: /workspace/test const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ cwd: '/workspace/test' }) @@ -314,7 +314,7 @@ describe('Session State Isolation Workflow', () => { async () => fetchWithStartup(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({}) }), { timeout: 90000, interval: 2000 } @@ -325,7 +325,7 @@ describe('Session State Isolation Workflow', () => { const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({}) }); @@ -412,7 +412,7 @@ describe('Session State Isolation Workflow', () => { async () => fetchWithStartup(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({}) }), { timeout: 90000, interval: 2000 } @@ -423,7 +423,7 @@ describe('Session State Isolation Workflow', () => { const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({}) }); @@ -495,7 +495,7 @@ describe('Session State Isolation Workflow', () => { async () => fetchWithStartup(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({}) }), { timeout: 90000, interval: 2000 } @@ -506,7 +506,7 @@ describe('Session State Isolation Workflow', () => { const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({}) }); @@ -580,7 +580,7 @@ describe('Session State Isolation Workflow', () => { async () => fetchWithStartup(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ env: { SESSION_NAME: 'session1' } }) @@ -593,7 +593,7 @@ describe('Session State Isolation Workflow', () => { const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId), + headers: createTestHeaders(currentSandboxId!), body: JSON.stringify({ env: { SESSION_NAME: 'session2' } }) @@ -639,5 +639,91 @@ describe('Session State Isolation Workflow', () => { expect(exec2Data.success).toBe(true); expect(exec2Data.stdout.trim()).toBe('Completed in session2'); }, 90000); + + test('should properly cleanup session resources with deleteSession', async () => { + currentSandboxId = createSandboxId(); + + // Create a session with custom environment variable + const sessionResponse = await vi.waitFor( + async () => + fetchWithStartup(`${workerUrl}/api/session/create`, { + method: 'POST', + headers: createTestHeaders(currentSandboxId!), + body: JSON.stringify({ + env: { SESSION_VAR: 'test-value' } + }) + }), + { timeout: 90000, interval: 2000 } + ); + + expect(sessionResponse.status).toBe(200); + const sessionData = await sessionResponse.json(); + const sessionId = sessionData.sessionId; + + // Verify session works before deletion + const execBeforeResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: createTestHeaders(currentSandboxId, sessionId), + body: JSON.stringify({ + command: 'echo $SESSION_VAR' + }) + }); + + expect(execBeforeResponse.status).toBe(200); + const execBeforeData = await execBeforeResponse.json(); + expect(execBeforeData.stdout.trim()).toBe('test-value'); + + // Delete the session + const deleteResponse = await fetch(`${workerUrl}/api/session/delete`, { + method: 'POST', + headers: createTestHeaders(currentSandboxId!), + body: JSON.stringify({ + sessionId: sessionId + }) + }); + + expect(deleteResponse.status).toBe(200); + const deleteData = await deleteResponse.json(); + expect(deleteData.success).toBe(true); + expect(deleteData.sessionId).toBe(sessionId); + expect(deleteData.timestamp).toBeTruthy(); + + // Verify the deleted session's state is gone + // Note: Container auto-creates sessions on first use, so this succeeds + // but we should verify the custom environment variable is gone + const useDeletedSessionResponse = await fetch( + `${workerUrl}/api/execute`, + { + method: 'POST', + headers: createTestHeaders(currentSandboxId, sessionId), // Use same session ID + body: JSON.stringify({ + command: 'echo $SESSION_VAR' + }) + } + ); + + expect(useDeletedSessionResponse.status).toBe(200); + const recreatedSessionData = await useDeletedSessionResponse.json(); + expect(recreatedSessionData.success).toBe(true); + // Session state should be gone - SESSION_VAR should be empty (fresh session) + expect(recreatedSessionData.stdout.trim()).toBe(''); + + // Verify we can still use the sandbox (it wasn't destroyed) + const sandboxStillAliveResponse = await fetch( + `${workerUrl}/api/execute`, + { + method: 'POST', + headers: createTestHeaders(currentSandboxId!), // Use default session + body: JSON.stringify({ + command: 'echo "sandbox-alive"' + }) + } + ); + + expect(sandboxStillAliveResponse.status).toBe(200); + const sandboxAliveData = await sandboxStillAliveResponse.json(); + expect(sandboxAliveData.success).toBe(true); + expect(sandboxAliveData.stdout.trim()).toBe('sandbox-alive'); + }, 90000); }); }); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index eb070c1e..af2ed5fe 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -253,6 +253,13 @@ console.log('Terminal server on port ' + port); ); } + if (url.pathname === '/api/session/delete' && request.method === 'POST') { + const result = await sandbox.deleteSession(body.sessionId); + return new Response(JSON.stringify(result), { + headers: { 'Content-Type': 'application/json' } + }); + } + // Command execution if (url.pathname === '/api/execute' && request.method === 'POST') { const result = await executor.exec(body.command);