Skip to content
Closed
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
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
39 changes: 39 additions & 0 deletions packages/sandbox/src/clients/utility-client.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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
*/
Expand Down Expand Up @@ -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<RuntimeIdentity> {
try {
const response = await this.get<RuntimeIdentityResponse>('/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;
}
}
}
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
63 changes: 63 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,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();
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
Loading
Loading