diff --git a/.changeset/runtime-identity-api.md b/.changeset/runtime-identity-api.md new file mode 100644 index 000000000..32ddff624 --- /dev/null +++ b/.changeset/runtime-identity-api.md @@ -0,0 +1,8 @@ +--- +'@cloudflare/sandbox': patch +--- + +Add `getRuntimeIdentity()` to detect when a sandbox starts a new +container runtime. Use the returned `runtimeId` to compare the current +runtime with the last one you observed and decide when reconciliation +is needed. diff --git a/packages/sandbox-container/src/handlers/misc-handler.ts b/packages/sandbox-container/src/handlers/misc-handler.ts index aa9a72383..26f4930f5 100644 --- a/packages/sandbox-container/src/handlers/misc-handler.ts +++ b/packages/sandbox-container/src/handlers/misc-handler.ts @@ -11,6 +11,16 @@ export interface VersionResult { timestamp: string; } +export interface RuntimeIdentityResult { + success: boolean; + runtimeId: string; + startedAt: string; + timestamp: string; +} + +const CONTAINER_RUNTIME_ID = crypto.randomUUID(); +const CONTAINER_STARTED_AT = new Date().toISOString(); + export class MiscHandler extends BaseHandler { async handle(request: Request, context: RequestContext): Promise { const url = new URL(request.url); @@ -27,6 +37,8 @@ export class MiscHandler extends BaseHandler { return await this.handleShutdown(request, context); case '/api/version': return await this.handleVersion(request, context); + case '/api/runtime': + return await this.handleRuntime(request, context); default: return this.createErrorResponse( { @@ -90,4 +102,18 @@ export class MiscHandler extends BaseHandler { return this.createTypedResponse(response, context); } + + private async handleRuntime( + request: Request, + context: RequestContext + ): Promise { + const response: RuntimeIdentityResult = { + success: true, + runtimeId: CONTAINER_RUNTIME_ID, + startedAt: CONTAINER_STARTED_AT, + timestamp: new Date().toISOString() + }; + + return this.createTypedResponse(response, context); + } } diff --git a/packages/sandbox-container/src/routes/setup.ts b/packages/sandbox-container/src/routes/setup.ts index c8bfc5c6d..206663ce4 100644 --- a/packages/sandbox-container/src/routes/setup.ts +++ b/packages/sandbox-container/src/routes/setup.ts @@ -478,4 +478,11 @@ export function setupRoutes(router: Router, container: Container): void { handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx), middleware: [container.get('loggingMiddleware')] }); + + router.register({ + method: 'GET', + path: '/api/runtime', + handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); } diff --git a/packages/sandbox-container/tests/handlers/misc-handler.test.ts b/packages/sandbox-container/tests/handlers/misc-handler.test.ts index e7d923b05..53a2d8233 100644 --- a/packages/sandbox-container/tests/handlers/misc-handler.test.ts +++ b/packages/sandbox-container/tests/handlers/misc-handler.test.ts @@ -4,6 +4,7 @@ import type { ErrorResponse } from '@repo/shared/errors'; import type { RequestContext } from '@sandbox-container/core/types'; import { MiscHandler, + type RuntimeIdentityResult, type VersionResult } from '@sandbox-container/handlers/misc-handler'; @@ -224,6 +225,48 @@ describe('MiscHandler', () => { }); }); + describe('handleRuntime - GET /api/runtime', () => { + it('should return runtime identity', async () => { + const request = new Request('http://localhost:3000/api/runtime', { + method: 'GET' + }); + + const response = await miscHandler.handle(request, mockContext); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('application/json'); + + const responseData = (await response.json()) as RuntimeIdentityResult; + expect(responseData.success).toBe(true); + expect(responseData.runtimeId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + expect(responseData.startedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + expect(responseData.timestamp).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + }); + + it('should keep runtime identity stable across requests', async () => { + const request = new Request('http://localhost:3000/api/runtime', { + method: 'GET' + }); + + const response1 = await miscHandler.handle(request, mockContext); + await new Promise((resolve) => setTimeout(resolve, 5)); + const response2 = await miscHandler.handle(request, mockContext); + + const responseData1 = (await response1.json()) as RuntimeIdentityResult; + const responseData2 = (await response2.json()) as RuntimeIdentityResult; + + expect(responseData1.runtimeId).toBe(responseData2.runtimeId); + expect(responseData1.startedAt).toBe(responseData2.startedAt); + expect(responseData1.timestamp).not.toBe(responseData2.timestamp); + }); + }); + describe('handleShutdown - POST /api/shutdown', () => { it('should return shutdown response with JSON content type', async () => { const request = new Request('http://localhost:3000/api/shutdown', { diff --git a/packages/sandbox/src/clients/utility-client.ts b/packages/sandbox/src/clients/utility-client.ts index dfe4df238..c4843efe3 100644 --- a/packages/sandbox/src/clients/utility-client.ts +++ b/packages/sandbox/src/clients/utility-client.ts @@ -1,3 +1,5 @@ +import type { RuntimeIdentity } from '@repo/shared'; +import { createErrorFromResponse, ErrorCode, SandboxError } from '../errors'; import { BaseHttpClient } from './base-client'; import type { BaseApiResponse, HttpClientOptions } from './types'; @@ -24,6 +26,11 @@ export interface VersionResponse extends BaseApiResponse { version: string; } +/** + * Response interface for getting container runtime identity + */ +interface RuntimeIdentityResponse extends BaseApiResponse, RuntimeIdentity {} + /** * Request interface for creating sessions */ @@ -141,4 +148,36 @@ export class UtilityClient extends BaseHttpClient { return 'unknown'; } } + + /** + * Get the current container runtime identity. + * `runtimeId` changes when the underlying container boots again. + */ + async getRuntimeIdentity(): Promise { + try { + const response = await this.get('/api/runtime'); + + return { + runtimeId: response.runtimeId, + startedAt: response.startedAt + }; + } catch (error) { + if (error instanceof SandboxError && error.httpStatus === 404) { + throw createErrorFromResponse({ + code: ErrorCode.UNKNOWN_ERROR, + message: + 'Runtime identity is not supported by this sandbox container. Recreate the sandbox or upgrade to a newer container image.', + context: { + endpoint: '/api/runtime' + }, + httpStatus: 404, + timestamp: new Date().toISOString(), + suggestion: + 'Recreate the sandbox or upgrade to a newer container image before calling getRuntimeIdentity().' + }); + } + + throw error; + } + } } diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 0e818df66..e42ab28fc 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -52,6 +52,7 @@ export type { RemoteMountBucketOptions, RestoreBackupResult, RunCodeOptions, + RuntimeIdentity, SandboxOptions, SessionOptions, StreamOptions, diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index ab91f93b9..3e54e2cee 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -25,6 +25,7 @@ import type { RemoteMountBucketOptions, RestoreBackupResult, RunCodeOptions, + RuntimeIdentity, SandboxOptions, SessionOptions, StreamOptions, @@ -2986,6 +2987,10 @@ export class Sandbox extends Container implements ISandbox { }); } + async getRuntimeIdentity(): Promise { + return await this.client.utils.getRuntimeIdentity(); + } + /** * Expose a port and get a preview URL for accessing services running in the sandbox * diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index bfd861103..05678a209 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -218,6 +218,20 @@ describe('Sandbox - Automatic Session Management', () => { }); }); + it('should return runtime identity from the utility client', async () => { + vi.spyOn(sandbox.client.utils, 'getRuntimeIdentity').mockResolvedValue({ + runtimeId: 'rt-1', + startedAt: '2026-04-07T00:00:00.000Z' + }); + + const result = await sandbox.getRuntimeIdentity(); + + expect(result).toEqual({ + runtimeId: 'rt-1', + startedAt: '2026-04-07T00:00:00.000Z' + }); + }); + it('should reuse default session across multiple operations', async () => { await sandbox.exec('echo test1'); await sandbox.writeFile('/test.txt', 'content'); diff --git a/packages/sandbox/tests/utility-client.test.ts b/packages/sandbox/tests/utility-client.test.ts index 20f6196f1..3d3ae2d14 100644 --- a/packages/sandbox/tests/utility-client.test.ts +++ b/packages/sandbox/tests/utility-client.test.ts @@ -39,6 +39,21 @@ const mockVersionResponse = ( ...overrides }); +const mockRuntimeIdentityResponse = ( + overrides: Partial<{ + success: boolean; + runtimeId: string; + startedAt: string; + timestamp: string; + }> = {} +) => ({ + success: true, + runtimeId: 'rt-123', + startedAt: '2023-01-01T00:00:00Z', + timestamp: '2023-01-01T00:00:01Z', + ...overrides +}); + describe('UtilityClient', () => { let client: UtilityClient; let mockFetch: ReturnType; @@ -324,6 +339,54 @@ describe('UtilityClient', () => { }); }); + describe('runtime identity', () => { + it('should get runtime identity successfully', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockRuntimeIdentityResponse()), { + status: 200 + }) + ); + + const result = await client.getRuntimeIdentity(); + + expect(result).toEqual({ + runtimeId: 'rt-123', + startedAt: '2023-01-01T00:00:00Z' + }); + }); + + it('should request the runtime endpoint', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(mockRuntimeIdentityResponse()), { + status: 200 + }) + ); + + await client.getRuntimeIdentity(); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.com/api/runtime', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('should surface runtime endpoint failures', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ error: 'Not Found' }), { status: 404 }) + ); + + await expect(client.getRuntimeIdentity()).rejects.toThrow(); + }); + + it('should throw an explicit compatibility error for missing endpoint', async () => { + mockFetch.mockResolvedValue(new Response('Not Found', { status: 404 })); + + await expect(client.getRuntimeIdentity()).rejects.toThrow( + 'Runtime identity is not supported by this sandbox container. Recreate the sandbox or upgrade to a newer container image.' + ); + }); + }); + describe('constructor options', () => { it('should initialize with minimal options', () => { const minimalClient = new UtilityClient(); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 103f75bb8..2c5d9f315 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -172,6 +172,7 @@ export type { RemoteMountBucketOptions, RenameFileResult, RestoreBackupResult, + RuntimeIdentity, // Sandbox configuration options SandboxOptions, // Session management result types diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2e3be370e..e5055d6c3 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1071,6 +1071,18 @@ export interface ExecutionSession { terminal(request: Request, options?: PtyOptions): Promise; } +/** + * Identity for a single container runtime. + * + * `runtimeId` changes whenever the underlying container boots again. + */ +export interface RuntimeIdentity { + /** Unique identifier for the current container boot. */ + runtimeId: string; + /** When the current container boot started. */ + startedAt: string; +} + // Backup types /** * Options for creating a directory backup @@ -1333,6 +1345,9 @@ export interface ISandbox { createBackup(options: BackupOptions): Promise; restoreBackup(backup: DirectoryBackup): Promise; + // Runtime lifecycle metadata + getRuntimeIdentity(): Promise; + // WebSocket connection wsConnect(request: Request, port: number): Promise; } diff --git a/tests/e2e/runtime-identity-workflow.test.ts b/tests/e2e/runtime-identity-workflow.test.ts new file mode 100644 index 000000000..ab98d0968 --- /dev/null +++ b/tests/e2e/runtime-identity-workflow.test.ts @@ -0,0 +1,63 @@ +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { + cleanupTestSandbox, + createTestSandbox, + type TestSandbox +} from './helpers/global-sandbox'; +import type { RuntimeIdentityResponse } from './test-worker/types'; + +describe('Runtime Identity E2E', () => { + let sandbox: TestSandbox | null = null; + let workerUrl: string; + + beforeAll(async () => { + sandbox = await createTestSandbox(); + workerUrl = sandbox.workerUrl; + }, 120000); + + afterAll(async () => { + await cleanupTestSandbox(sandbox); + sandbox = null; + }, 120000); + + test('should keep identity stable until the sandbox is recreated', async () => { + if (!sandbox) { + throw new Error('Sandbox was not initialized'); + } + + const response1 = await fetch(`${workerUrl}/api/runtime/identity`, { + method: 'GET', + headers: sandbox.headers() + }); + expect(response1.ok).toBe(true); + + const runtime1 = (await response1.json()) as RuntimeIdentityResponse; + + const response2 = await fetch(`${workerUrl}/api/runtime/identity`, { + method: 'GET', + headers: sandbox.headers() + }); + expect(response2.ok).toBe(true); + + const runtime2 = (await response2.json()) as RuntimeIdentityResponse; + expect(runtime2).toEqual(runtime1); + + await cleanupTestSandbox(sandbox); + + const recreateResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: sandbox.headers(), + body: JSON.stringify({ command: 'echo ready' }) + }); + expect(recreateResponse.ok).toBe(true); + + const response3 = await fetch(`${workerUrl}/api/runtime/identity`, { + method: 'GET', + headers: sandbox.headers() + }); + expect(response3.ok).toBe(true); + + const runtime3 = (await response3.json()) as RuntimeIdentityResponse; + expect(runtime3.runtimeId).not.toBe(runtime1.runtimeId); + }, 120000); +}); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index 2ba86d591..a637c8f5d 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -28,6 +28,7 @@ import type { ErrorResponse, HealthResponse, PortUnexposeResponse, + RuntimeIdentityResponse, SessionCreateResponse, SuccessResponse, SuccessWithMessageResponse, @@ -494,6 +495,18 @@ console.log('Terminal server on port ' + port); }); } + if ( + url.pathname === '/api/runtime/identity' && + request.method === 'GET' + ) { + const runtimeIdentity = await sandbox.getRuntimeIdentity(); + const response: RuntimeIdentityResponse = runtimeIdentity; + + return new Response(JSON.stringify(response), { + headers: { 'Content-Type': 'application/json' } + }); + } + // Command execution if (url.pathname === '/api/execute' && request.method === 'POST') { const result = await executor.exec(body.command, { diff --git a/tests/e2e/test-worker/types.ts b/tests/e2e/test-worker/types.ts index 8a0bce2a6..f504be7fd 100644 --- a/tests/e2e/test-worker/types.ts +++ b/tests/e2e/test-worker/types.ts @@ -31,6 +31,11 @@ export interface SuccessWithMessageResponse { message: string; } +export interface RuntimeIdentityResponse { + runtimeId: string; + startedAt: string; +} + // R2 bucket operations export interface BucketPutResponse { success: boolean;