Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/wild-news-pretend.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 48 additions & 1 deletion packages/sandbox-container/src/handlers/session-handler.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,6 +30,8 @@ export class SessionHandler extends BaseHandler<Request, Response> {
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(
{
Expand Down Expand Up @@ -102,6 +104,51 @@ export class SessionHandler extends BaseHandler<Request, Response> {
}
}

private async handleDelete(
request: Request,
context: RequestContext
): Promise<Response> {
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')}`;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/sandbox-container/src/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 7 additions & 1 deletion packages/sandbox-container/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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(
() => {}
Expand Down
125 changes: 125 additions & 0 deletions packages/sandbox-container/tests/handlers/session-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/ .
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/sandbox/src/clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export type {
// Utility client types
export type {
CommandsResponse,
CreateSessionRequest,
CreateSessionResponse,
DeleteSessionRequest,
DeleteSessionResponse,
PingResponse,
VersionResponse
} from './utility-client';
Expand Down
33 changes: 33 additions & 0 deletions packages/sandbox/src/clients/utility-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -100,6 +114,25 @@ export class UtilityClient extends BaseHttpClient {
}
}

/**
* Delete an execution session
* @param sessionId - Session ID to delete
*/
async deleteSession(sessionId: string): Promise<DeleteSessionResponse> {
try {
const response = await this.post<DeleteSessionResponse>(
'/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
Expand Down
8 changes: 6 additions & 2 deletions packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export type {
BaseApiResponse,
CommandsResponse,
ContainerStub,

// Utility client types
CreateSessionRequest,
CreateSessionResponse,
DeleteSessionRequest,
DeleteSessionResponse,
ErrorResponse,

// Command client types
Expand All @@ -56,8 +62,6 @@ export type {

// File client types
MkdirRequest,

// Utility client types
PingResponse,
PortCloseResult,
PortExposeResult,
Expand Down
Loading
Loading