Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/modal-sandbox-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@truefoundry/trueforge-core': minor
'@truefoundry/trueforge': minor
---

Add Modal as a configurable sandbox provider using Modal's official TypeScript SDK.
1 change: 1 addition & 0 deletions packages/trueforge-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"@opentelemetry/core": "^2.10.0",
"ai": "^7.0.82",
"dedent": "^1.7.2",
"modal": "0.9.0",
"openai": "^7.5.0",
"ulid": "^3.0.2",
"winston": "^3.19.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/trueforge-core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export type { CodeModeErrorSource, CodeModeReply, CodeModeRequest } from './sand
export { DaytonaSandboxProvider } from './sandbox/provider/DaytonaProvider';
export type { DaytonaSandboxProviderOptions } from './sandbox/provider/DaytonaProvider';
export { absolutizeRelativeExecEnv } from './sandbox/provider/execEnv';
export { ModalSandboxProvider } from './sandbox/provider/ModalProvider';
export type { ModalSandboxProviderOptions } from './sandbox/provider/ModalProvider';
export { ensureExecSuccess, shellEscape } from './sandbox/provider/Provider';
export type {
ExecErrorResult,
Expand Down
259 changes: 259 additions & 0 deletions packages/trueforge-core/src/core/sandbox/provider/ModalProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import {
ModalClient,
NotFoundError,
SandboxFilesystemFileTooLargeError,
SandboxFilesystemIsADirectoryError,
SandboxFilesystemNotFoundError,
type App,
type Image,
type Sandbox as ModalSandbox,
} from 'modal';
import { randomUUID } from 'node:crypto';
import { isAbsolute, join } from 'node:path/posix';
import type { Logger } from 'winston';
import { extractErrorLogFields } from '../../util/errorLogFields';
import {
SandboxFileNotFoundError,
SandboxFileTooLargeError,
SandboxNotAvailableError,
SandboxPathIsDirectoryError,
validateSandboxOwnedByTenant,
} from '../SandboxErrors';
import type { CodeModeTransport } from '../codeMode/CodeModeTransport';
import { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';
import { DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants';
import type { ExecResult, SandboxBuild, SandboxExecParams, SandboxProvider } from './Provider';

const SANDBOX_ROOT = '/opt/tf';
const DEFAULT_APP_NAME = 'trueforge';

function httpUrlToWsUrl(url: string): string {
const parsed = new URL(url);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
return parsed.toString();
}

function sandboxPath(path: string): string {
return isAbsolute(path) ? path : join(SANDBOX_ROOT, path);
}

export interface ModalSandboxProviderOptions {
tokenId: string;
tokenSecret: string;
tenantName: string;
sandboxImage: string;
buildRef?: string | undefined;
environment?: string | undefined;
appName?: string | undefined;
timeoutMs: number;
sandboxTimeoutMs: number;
idleTimeoutMs: number;
fileMaxBytesForDownload: number;
natsBridgePort?: number | undefined;
logger: Logger;
/** Injectable for tests; production callers use the official Modal client. */
client?: ModalClient | undefined;
}

/** Modal-backed implementation of the TrueForge sandbox contract. */
export class ModalSandboxProvider implements SandboxProvider {
readonly type = 'modal';
private readonly modal: ModalClient;
private readonly tenantName: string;
private readonly imageUri: string;
private readonly buildRef: string | undefined;
private readonly environment: string | undefined;
private readonly appName: string;
private readonly timeoutMs: number;
private readonly sandboxTimeoutMs: number;
private readonly idleTimeoutMs: number;
private readonly fileMaxBytesForDownload: number;
private readonly natsBridgePort: number;
private readonly logger: Logger;

constructor(options: ModalSandboxProviderOptions) {
this.modal =
options.client ??
new ModalClient({
tokenId: options.tokenId,
tokenSecret: options.tokenSecret,
...(options.environment ? { environment: options.environment } : {}),
});
this.tenantName = options.tenantName;
this.imageUri = options.sandboxImage;
this.buildRef = options.buildRef;
this.environment = options.environment;
this.appName = options.appName ?? DEFAULT_APP_NAME;
this.timeoutMs = options.timeoutMs;
this.sandboxTimeoutMs = options.sandboxTimeoutMs;
this.idleTimeoutMs = options.idleTimeoutMs;
this.fileMaxBytesForDownload = options.fileMaxBytesForDownload;
this.natsBridgePort = options.natsBridgePort ?? DEFAULT_SANDBOX_NATS_WS_PORT;
this.logger = options.logger.child({ module: 'ModalProvider' });
}

private async app(): Promise<App> {
return this.modal.apps.fromName(this.appName, {
createIfMissing: true,
...(this.environment ? { environment: this.environment } : {}),
});
}

private async image(): Promise<Image> {
return this.buildRef ? this.modal.images.fromId(this.buildRef) : this.modal.images.fromRegistry(this.imageUri);
}

private async sandbox(sandboxId: string): Promise<ModalSandbox> {
validateSandboxOwnedByTenant({ sandboxId, tenantName: this.tenantName });
try {
return await this.modal.sandboxes.fromName(this.appName, sandboxId, {
...(this.environment ? { environment: this.environment } : {}),
});
} catch (error) {
if (error instanceof NotFoundError) {
throw new SandboxNotAvailableError(sandboxId);
}
throw error;
}
}

async buildImage(): Promise<SandboxBuild> {
if (this.buildRef) {
return this.getImageBuildStatus();
}
try {
const image = await this.modal.images.fromRegistry(this.imageUri).build(await this.app());
return { status: 'ready', reason: null, metadata: { build_ref: image.imageId, image_uri: this.imageUri } };
} catch (error) {
this.logger.error('Modal image build failed', extractErrorLogFields(error));
throw error;
}
}

async getImageBuildStatus(): Promise<SandboxBuild> {
if (!this.buildRef) {
return { status: 'pending', reason: 'Sandbox image build not started.', metadata: { image_uri: this.imageUri } };
}
try {
await this.modal.images.fromId(this.buildRef);
return { status: 'ready', reason: null, metadata: { build_ref: this.buildRef, image_uri: this.imageUri } };
} catch (error) {
if (error instanceof NotFoundError) {
return {
status: 'failed',
reason: 'The built Modal image no longer exists.',
metadata: { build_ref: this.buildRef, image_uri: this.imageUri },
};
}
throw error;
}
}

async createSandbox(): Promise<{ sandboxId: string }> {
const sandboxId = `${this.tenantName}.${randomUUID()}`;
const sandbox = await this.modal.sandboxes.create(await this.app(), await this.image(), {
name: sandboxId,
timeoutMs: this.sandboxTimeoutMs,
idleTimeoutMs: this.idleTimeoutMs,
workdir: SANDBOX_ROOT,
encryptedPorts: [this.natsBridgePort],
});
this.logger.debug(`Modal sandbox created: id=${sandbox.sandboxId} name=${sandboxId}`);
return { sandboxId };
}

async exec(params: SandboxExecParams): Promise<ExecResult> {
try {
const sandbox = await this.sandbox(params.sandboxId);
const process = await sandbox.exec(['/bin/sh', '-lc', params.command], {
workdir: params.cwd ?? SANDBOX_ROOT,
timeoutMs: (params.timeoutSeconds ?? this.timeoutMs / 1000) * 1000,
...(params.env ? { env: params.env } : {}),
});
const [stdout, stderr, exitCode] = await Promise.all([
process.stdout.readText(),
process.stderr.readText(),
process.wait(),
]);
return { success: true, response: { exitCode, result: `${stdout}${stderr}` } };
} catch (error) {
if (error instanceof SandboxNotAvailableError) {
throw error;
}
this.logger.error('Modal sandbox execution error', extractErrorLogFields(error));
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}

async downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer> {
try {
const filesystem = (await this.sandbox(params.sandboxId)).filesystem;
const remotePath = sandboxPath(params.path);
const parentPath = remotePath.slice(0, remotePath.lastIndexOf('/')) || '/';
const fileInfo = (await filesystem.listFiles(parentPath)).find(entry => entry.path === remotePath);
if (fileInfo === undefined) {
throw new SandboxFileNotFoundError(params.path);
}
if (fileInfo.type === 'directory') {
throw new SandboxPathIsDirectoryError(params.path);
}
if (fileInfo.size > this.fileMaxBytesForDownload) {
throw new SandboxFileTooLargeError(params.path, fileInfo.size, this.fileMaxBytesForDownload);
}
return Buffer.from(await filesystem.readBytes(remotePath));
} catch (error) {
if (error instanceof SandboxFilesystemNotFoundError) {
throw new SandboxFileNotFoundError(params.path);
}
if (error instanceof SandboxFilesystemIsADirectoryError) {
throw new SandboxPathIsDirectoryError(params.path);
}
if (error instanceof SandboxFilesystemFileTooLargeError) {
throw new SandboxFileTooLargeError(params.path, this.fileMaxBytesForDownload + 1, this.fileMaxBytesForDownload);
}
throw error;
}
}

async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void> {
await (await this.sandbox(params.sandboxId)).filesystem.writeBytes(params.content, sandboxPath(params.remotePath));
}

createCodeModeTransport(): CodeModeTransport {
return new CodeModeNatsTransport({
resolveHostUrl: async sandboxId => {
const tunnels = await (await this.sandbox(sandboxId)).tunnels();
const tunnel = tunnels[this.natsBridgePort];
if (!tunnel) {
throw new Error(`Modal did not expose sandbox port ${String(this.natsBridgePort)}.`);
}
return httpUrlToWsUrl(tunnel.url);
},
sandboxClientNatsUrl: `ws://localhost:${String(this.natsBridgePort)}`,
logger: this.logger,
mcpClientInstall: {
remotePath: join('/opt', 'tf', 'mcp-client', 'mcp_client.py'),
pathBinSymlink: join('/usr', 'local', 'bin', 'mcp-client'),
},
});
}

getAdditionalInstructions(): string | undefined {
return undefined;
}
getToolResultDumpDir(): string {
return join(SANDBOX_ROOT, 'tool-results');
}
getGitCredentialsPath(): string {
return join(SANDBOX_ROOT, '.git-credentials');
}
getFileUploadsDir(): string {
return join(SANDBOX_ROOT, 'uploads');
}
getSkillsDir(): string {
return join(SANDBOX_ROOT, 'skills');
}
getGitDownloaderPath(): string {
return join(SANDBOX_ROOT, 'git_downloader.py');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { App, Image, ModalClient } from 'modal';
import { createLogger } from 'winston';
import { ModalSandboxProvider } from '../../../src/core/sandbox/provider/ModalProvider';

const logger = createLogger({ silent: true });

function makeProvider(client: ModalClient, buildRef?: string): ModalSandboxProvider {
return new ModalSandboxProvider({
client,
tokenId: 'ak-test',
tokenSecret: 'as-test',
tenantName: 'tenant',
sandboxImage: 'registry.example.com/trueforge:sha',
buildRef,
environment: 'main',
appName: 'trueforge',
timeoutMs: 60_000,
sandboxTimeoutMs: 3_600_000,
idleTimeoutMs: 300_000,
fileMaxBytesForDownload: 1024,
logger,
});
}

describe('ModalSandboxProvider image lifecycle', () => {
it('eagerly builds the release image and persists its Modal image ID', async () => {
const client = new ModalClient({ tokenId: 'ak-test', tokenSecret: 'as-test' });
const app = new App('ap-test', 'trueforge', 'main');
const source = new Image(client, '', 'registry.example.com/trueforge:sha');
const built = new Image(client, 'im-built', 'registry.example.com/trueforge:sha');
jest.spyOn(client.apps, 'fromName').mockResolvedValue(app);
jest.spyOn(client.images, 'fromRegistry').mockReturnValue(source);
jest.spyOn(source, 'build').mockResolvedValue(built);

await expect(makeProvider(client).buildImage()).resolves.toEqual({
status: 'ready',
reason: null,
metadata: { build_ref: 'im-built', image_uri: 'registry.example.com/trueforge:sha' },
});
});

it('checks a persisted image without rebuilding it', async () => {
const client = new ModalClient({ tokenId: 'ak-test', tokenSecret: 'as-test' });
jest.spyOn(client.images, 'fromId').mockResolvedValue(new Image(client, 'im-built', ''));
await expect(makeProvider(client, 'im-built').getImageBuildStatus()).resolves.toEqual({
status: 'ready',
reason: null,
metadata: { build_ref: 'im-built', image_uri: 'registry.example.com/trueforge:sha' },
});
});
});
6 changes: 3 additions & 3 deletions packages/trueforge/src/apis/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ISandboxProviderStore } from '../db/sandboxProviderStore';
import type { WithTransaction } from '../db/transaction';
import { getCapabilitiesRoute } from '../routes/capabilityRoutes';
import { isLocalSandboxFallbackEnabled } from '../sandbox/localRuntime';
import { checkSnapshotStatus } from '../sandbox/providerUtils';
import { checkSandboxBuildStatus } from '../sandbox/providerUtils';
import type { SandboxBuildStatus } from '../schemas/sandboxProvider';
import { TENANT_ID } from './sessions';

Expand All @@ -29,10 +29,10 @@ export function createCapabilitiesRouter<TTransaction>(deps: {
const router = new OpenAPIHono();
router.openapi(getCapabilitiesRoute, async c => {
// Sandbox is usable only when a provider is configured AND its image build reports ready.
// Refresh the persisted status (and re-activate an idle snapshot); fail closed (disabled) if it throws.
// Refresh stale provider build state; fail closed (disabled) if the provider cannot be reached.
let status: SandboxBuildStatus | undefined;
try {
const refreshed = await checkSnapshotStatus({
const refreshed = await checkSandboxBuildStatus({
store: deps.sandboxProviderStore,
tenant_id: TENANT_ID,
logger: deps.logger,
Expand Down
Loading