Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions .changeset/runtime-identity-api.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions packages/sandbox-container/src/handlers/misc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Request, Response> {
async handle(request: Request, context: RequestContext): Promise<Response> {
const url = new URL(request.url);
Expand All @@ -27,6 +37,8 @@ export class MiscHandler extends BaseHandler<Request, Response> {
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(
{
Expand Down Expand Up @@ -90,4 +102,18 @@ export class MiscHandler extends BaseHandler<Request, Response> {

return this.createTypedResponse(response, context);
}

private async handleRuntime(
request: Request,
context: RequestContext
): Promise<Response> {
const response: RuntimeIdentityResult = {
success: true,
runtimeId: CONTAINER_RUNTIME_ID,
startedAt: CONTAINER_STARTED_AT,
timestamp: new Date().toISOString()
};

return this.createTypedResponse(response, context);
}
}
7 changes: 7 additions & 0 deletions packages/sandbox-container/src/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')]
});
}
43 changes: 43 additions & 0 deletions packages/sandbox-container/tests/handlers/misc-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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', {
Expand Down
19 changes: 19 additions & 0 deletions packages/sandbox/src/clients/utility-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RuntimeIdentity } from '@repo/shared';
import { BaseHttpClient } from './base-client';
import type { BaseApiResponse, HttpClientOptions } from './types';

Expand All @@ -24,6 +25,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
*/
Expand Down Expand Up @@ -141,4 +147,17 @@ export class UtilityClient extends BaseHttpClient {
return 'unknown';
}
}

/**
* Get the current container runtime identity.
* `runtimeId` changes when the underlying container boots again.
*/
async getRuntimeIdentity(): Promise<RuntimeIdentity> {
const response = await this.get<RuntimeIdentityResponse>('/api/runtime');

return {
runtimeId: response.runtimeId,
startedAt: response.startedAt
};
}
}
1 change: 1 addition & 0 deletions packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type {
RemoteMountBucketOptions,
RestoreBackupResult,
RunCodeOptions,
RuntimeIdentity,
SandboxOptions,
SessionOptions,
StreamOptions,
Expand Down
5 changes: 5 additions & 0 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
RemoteMountBucketOptions,
RestoreBackupResult,
RunCodeOptions,
RuntimeIdentity,
SandboxOptions,
SessionOptions,
StreamOptions,
Expand Down Expand Up @@ -2986,6 +2987,10 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
});
}

async getRuntimeIdentity(): Promise<RuntimeIdentity> {
return await this.client.utils.getRuntimeIdentity();
}

/**
* Expose a port and get a preview URL for accessing services running in the sandbox
*
Expand Down
14 changes: 14 additions & 0 deletions packages/sandbox/tests/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
55 changes: 55 additions & 0 deletions packages/sandbox/tests/utility-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;
Expand Down Expand Up @@ -324,6 +339,46 @@ 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();
});
});

describe('constructor options', () => {
it('should initialize with minimal options', () => {
const minimalClient = new UtilityClient();
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export type {
RemoteMountBucketOptions,
RenameFileResult,
RestoreBackupResult,
RuntimeIdentity,
// Sandbox configuration options
SandboxOptions,
// Session management result types
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,18 @@ export interface ExecutionSession {
terminal(request: Request, options?: PtyOptions): Promise<Response>;
}

/**
* 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
Expand Down Expand Up @@ -1333,6 +1345,9 @@ export interface ISandbox {
createBackup(options: BackupOptions): Promise<DirectoryBackup>;
restoreBackup(backup: DirectoryBackup): Promise<RestoreBackupResult>;

// Runtime lifecycle metadata
getRuntimeIdentity(): Promise<RuntimeIdentity>;

// WebSocket connection
wsConnect(request: Request, port: number): Promise<Response>;
}
Expand Down
63 changes: 63 additions & 0 deletions tests/e2e/runtime-identity-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading