From cd0c8279b9bd580a375b4bb879c1a961fe68b8d7 Mon Sep 17 00:00:00 2001 From: Jeffrey Holm Date: Thu, 27 Aug 2026 16:24:46 -0400 Subject: [PATCH 1/3] feat: add Modal sandbox provider --- .changeset/modal-sandbox-provider.md | 6 + packages/trueforge-core/package.json | 1 + packages/trueforge-core/src/core/index.ts | 2 + .../core/sandbox/provider/ModalProvider.ts | 250 ++++++++++++++++++ .../tests/core/sandbox/modalProvider.test.ts | 51 ++++ .../trueforge/catalog/sandbox-catalog.yaml | 5 + packages/trueforge/src/apis/capabilities.ts | 6 +- .../trueforge/src/apis/sandboxProviders.ts | 75 ++++-- packages/trueforge/src/apis/turns.ts | 7 +- .../src/routes/sandboxProviderRoutes.ts | 2 +- .../trueforge/src/runtime/sessionResources.ts | 6 +- .../trueforge/src/sandbox/providerUtils.ts | 60 ++++- .../trueforge/src/schemas/sandboxCatalog.ts | 9 +- .../trueforge/src/schemas/sandboxProvider.ts | 62 ++++- .../db/sandboxProviderStoreContractSuite.ts | 8 +- .../tests/unit/apis/capabilities.test.ts | 8 +- .../tests/unit/apis/sandboxProviders.test.ts | 73 ++++- pnpm-lock.yaml | 133 ++++++++++ 18 files changed, 707 insertions(+), 57 deletions(-) create mode 100644 .changeset/modal-sandbox-provider.md create mode 100644 packages/trueforge-core/src/core/sandbox/provider/ModalProvider.ts create mode 100644 packages/trueforge-core/tests/core/sandbox/modalProvider.test.ts diff --git a/.changeset/modal-sandbox-provider.md b/.changeset/modal-sandbox-provider.md new file mode 100644 index 000000000..759661bc0 --- /dev/null +++ b/.changeset/modal-sandbox-provider.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge-core': minor +'@truefoundry/trueforge': minor +--- + +Add Modal as a configurable sandbox provider using Modal's official TypeScript SDK. diff --git a/packages/trueforge-core/package.json b/packages/trueforge-core/package.json index b1f2a1c1a..c73655506 100644 --- a/packages/trueforge-core/package.json +++ b/packages/trueforge-core/package.json @@ -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", diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index 5ebbd311a..947c1731a 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -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, diff --git a/packages/trueforge-core/src/core/sandbox/provider/ModalProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/ModalProvider.ts new file mode 100644 index 000000000..f66ec218b --- /dev/null +++ b/packages/trueforge-core/src/core/sandbox/provider/ModalProvider.ts @@ -0,0 +1,250 @@ +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 { + return this.modal.apps.fromName(this.appName, { + createIfMissing: true, + ...(this.environment ? { environment: this.environment } : {}), + }); + } + + private async image(): Promise { + return this.buildRef ? this.modal.images.fromId(this.buildRef) : this.modal.images.fromRegistry(this.imageUri); + } + + private async sandbox(sandboxId: string): Promise { + 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 { + 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 { + 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 { + 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 { + try { + const bytes = await (await this.sandbox(params.sandboxId)).filesystem.readBytes(sandboxPath(params.path)); + if (bytes.byteLength > this.fileMaxBytesForDownload) { + throw new SandboxFileTooLargeError(params.path, bytes.byteLength, this.fileMaxBytesForDownload); + } + return Buffer.from(bytes); + } 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 { + 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'); + } +} diff --git a/packages/trueforge-core/tests/core/sandbox/modalProvider.test.ts b/packages/trueforge-core/tests/core/sandbox/modalProvider.test.ts new file mode 100644 index 000000000..9bff05165 --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/modalProvider.test.ts @@ -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' }, + }); + }); +}); diff --git a/packages/trueforge/catalog/sandbox-catalog.yaml b/packages/trueforge/catalog/sandbox-catalog.yaml index fc80d83aa..76954c350 100644 --- a/packages/trueforge/catalog/sandbox-catalog.yaml +++ b/packages/trueforge/catalog/sandbox-catalog.yaml @@ -7,3 +7,8 @@ providers: auto_stop_interval_in_minutes: 5 auto_archive_interval_in_minutes: 60 auto_delete_interval_in_minutes: 7200 + - type: modal + app_name: trueforge + exec_timeout_ms: 60000 + sandbox_timeout_ms: 3600000 + idle_timeout_ms: 300000 diff --git a/packages/trueforge/src/apis/capabilities.ts b/packages/trueforge/src/apis/capabilities.ts index 1149a3024..5a24f24f0 100644 --- a/packages/trueforge/src/apis/capabilities.ts +++ b/packages/trueforge/src/apis/capabilities.ts @@ -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'; @@ -29,10 +29,10 @@ export function createCapabilitiesRouter(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, diff --git a/packages/trueforge/src/apis/sandboxProviders.ts b/packages/trueforge/src/apis/sandboxProviders.ts index a4a77aad7..5bce8ce53 100644 --- a/packages/trueforge/src/apis/sandboxProviders.ts +++ b/packages/trueforge/src/apis/sandboxProviders.ts @@ -5,18 +5,19 @@ import type { ISandboxProviderStore, SandboxProviderRecord } from '../db/sandbox import type { WithTransaction } from '../db/transaction'; import { getSandboxProviderRoute, putSandboxProviderRoute } from '../routes/sandboxProviderRoutes'; import { - checkSnapshotStatus, + checkSandboxBuildStatus, isDaytonaAuthError, isDaytonaPermissionError, - toDaytonaSandboxProvider, + isModalAuthError, + toSandboxProvider, toSandboxStatus, } from '../sandbox/providerUtils'; import type { SandboxProviderManifest, UpdateSandboxProviderRequest } from '../schemas/sandboxProvider'; import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretValue } from '../utils/secretRedaction'; import { TENANT_ID } from './sessions'; -/** Cap the Daytona register round-trip so a slow/unreachable provider can't hold the request (or DB txn) open. */ -const BUILD_REQUEST_TIMEOUT_MS = 3_000; +/** Cap provider image preparation so a slow/unreachable provider cannot hold the request indefinitely. */ +const BUILD_REQUEST_TIMEOUT_MS = 120_000; export interface SandboxProvidersRouterDeps { sandboxProviderStore: ISandboxProviderStore; @@ -25,9 +26,48 @@ export interface SandboxProvidersRouterDeps { } function redactSandboxProvider(manifest: SandboxProviderManifest): SandboxProviderManifest { + if (manifest.type === 'daytona') { + return { ...manifest, auth: { api_key: toRedactedSecretValue(manifest.auth.api_key) } }; + } return { ...manifest, - auth: { api_key: toRedactedSecretValue(manifest.auth.api_key) }, + auth: { + token_id: toRedactedSecretValue(manifest.auth.token_id), + token_secret: toRedactedSecretValue(manifest.auth.token_secret), + }, + }; +} + +function resolveManifestSecrets({ + incoming, + existing, +}: { + incoming: SandboxProviderManifest; + existing: SandboxProviderManifest | undefined; +}): SandboxProviderManifest { + if (incoming.type === 'daytona') { + return { + ...incoming, + auth: { + api_key: resolveStoredSecretValue({ + incoming: incoming.auth.api_key, + existing: existing?.type === 'daytona' ? existing.auth.api_key : undefined, + }), + }, + }; + } + return { + ...incoming, + auth: { + token_id: resolveStoredSecretValue({ + incoming: incoming.auth.token_id, + existing: existing?.type === 'modal' ? existing.auth.token_id : undefined, + }), + token_secret: resolveStoredSecretValue({ + incoming: incoming.auth.token_secret, + existing: existing?.type === 'modal' ? existing.auth.token_secret : undefined, + }), + }, }; } @@ -39,7 +79,7 @@ export function createSandboxProvidersRouter(deps: SandboxProvider return c.json({ error: { message: 'No sandbox provider configured' } }, 404); } // Refresh the persisted build status (and re-activate an idle snapshot) on every GET. - const status = await checkSnapshotStatus({ + const status = await checkSandboxBuildStatus({ store: deps.sandboxProviderStore, tenant_id: TENANT_ID, logger: deps.logger, @@ -59,23 +99,16 @@ export function createSandboxProvidersRouter(deps: SandboxProvider const putHandler: RouteHandler = async c => { const body: UpdateSandboxProviderRequest = c.req.valid('json'); const incoming = body.manifest; - const resolveManifest = (existing: SandboxProviderRecord | undefined): SandboxProviderManifest => ({ - ...incoming, - auth: { - api_key: resolveStoredSecretValue({ - incoming: incoming.auth.api_key, - existing: existing?.manifest.auth.api_key, - }), - }, - }); + const resolveManifest = (existing: SandboxProviderRecord | undefined): SandboxProviderManifest => + resolveManifestSecrets({ incoming, existing: existing?.manifest }); try { - // NOTE: build (Daytona network I/O) runs inside the transaction for now; the design is being revisited. + // NOTE: provider network I/O runs inside the transaction for now; the design is being revisited. const { manifest, status } = await deps.withTransaction(async transaction => { const locked = await deps.sandboxProviderStore.getSandboxProviderForUpdate(TENANT_ID, transaction); const resolved = resolveManifest(locked); // Pass persisted build_metadata so a settings re-save does not start a new snapshot for a // bumped SANDBOX_IMAGE_URI (upgrades are unsupported — first configure has no metadata). - const provider = toDaytonaSandboxProvider({ + const provider = toSandboxProvider({ manifest: resolved, tenant_id: TENANT_ID, logger: deps.logger, @@ -102,7 +135,10 @@ export function createSandboxProvidersRouter(deps: SandboxProvider ); } catch (error) { if (error instanceof MissingStoredSecretError) { - return c.json({ error: { message: 'API key is required' } }, 400); + return c.json( + { error: { message: incoming.type === 'daytona' ? 'API key is required' : 'Modal tokens are required' } }, + 400, + ); } if (isDaytonaAuthError(error)) { return c.json({ error: { message: 'Daytona rejected the API key — check the credentials' } }, 422); @@ -118,6 +154,9 @@ export function createSandboxProvidersRouter(deps: SandboxProvider 422, ); } + if (incoming.type === 'modal' && isModalAuthError(error)) { + return c.json({ error: { message: 'Modal rejected the tokens — check the credentials' } }, 422); + } throw error; } }; diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 420069471..956ce15f2 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -56,7 +56,7 @@ import { resolveGitSkills, resolveSandboxProvider, } from '../runtime/sessionResources'; -import { checkSnapshotStatus } from '../sandbox/providerUtils'; +import { checkSandboxBuildStatus } from '../sandbox/providerUtils'; import { TENANT_ID } from './sessions'; export function toWireTurn(record: TurnRecordWithoutSnapshot): Turn { @@ -209,11 +209,10 @@ function createTurnResolver(deps: { existingSandboxId, currentProviderType: provider.type, }); - // A fresh Daytona sandbox is cloned from the release snapshot, so the build must be ready first. - // Restoring an existing sandbox goes through daytona.get and never touches the snapshot. + // A fresh remote sandbox needs the prepared release image; restoring an existing sandbox does not. // Local fallback has no image build. if (carriedSandboxId === undefined && provider.type !== 'local') { - const status = await checkSnapshotStatus({ store: sandboxProviderStore, tenant_id: TENANT_ID, logger }); + const status = await checkSandboxBuildStatus({ store: sandboxProviderStore, tenant_id: TENANT_ID, logger }); if (status?.status !== 'ready') { throw new HTTPException(422, { message: diff --git a/packages/trueforge/src/routes/sandboxProviderRoutes.ts b/packages/trueforge/src/routes/sandboxProviderRoutes.ts index c1be8d052..b829124db 100644 --- a/packages/trueforge/src/routes/sandboxProviderRoutes.ts +++ b/packages/trueforge/src/routes/sandboxProviderRoutes.ts @@ -55,7 +55,7 @@ export const putSandboxProviderRoute = createRoute({ }, 422: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Daytona rejected the provided API key.', + description: 'The sandbox provider rejected the supplied credentials.', }, }, }); diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 0255aa27f..93b4ab5b6 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -25,7 +25,7 @@ import { isMcpAuthRequired, resolveMcpAuth } from '../mcp/auth/mcpDcr'; import type { IOAuthTokenStore } from '../mcp/auth/types'; import { LocalSandboxProvider } from '../sandbox/local/provider/LocalSandboxProvider'; import { getCachedLocalSandboxSupport, isLocalSandboxFallbackEnabled } from '../sandbox/localRuntime'; -import { toDaytonaSandboxProvider } from '../sandbox/providerUtils'; +import { toSandboxProvider } from '../sandbox/providerUtils'; import { resolveConfiguredMcpRequestHeaders } from '../schemas/mcpServer'; import type { ReasoningEffort } from '../schemas/modelProvider'; @@ -217,7 +217,7 @@ export async function resolveGitSkills({ /** * Build a runtime SandboxProvider from the configured store row, or the * in-memory local fallback when standalone + the cached probe is supported. - * Builds a fresh Daytona client per call (no network I/O). + * Builds a fresh provider client per call (no network I/O). */ /** Single path segment under the sandboxes parent (`_` when sessionId is missing or unsafe). */ export function localSandboxSessionSegment(sessionId: string | undefined): string { @@ -242,7 +242,7 @@ export async function resolveSandboxProvider({ if (record !== undefined) { // Clone from the snapshot that was actually built (persisted build_ref), not a name // derived from the current image — otherwise an image bump breaks creation until rebuild. - return toDaytonaSandboxProvider({ + return toSandboxProvider({ manifest: record.manifest, tenant_id, logger, diff --git a/packages/trueforge/src/sandbox/providerUtils.ts b/packages/trueforge/src/sandbox/providerUtils.ts index 0d815dc36..4ca9298cd 100644 --- a/packages/trueforge/src/sandbox/providerUtils.ts +++ b/packages/trueforge/src/sandbox/providerUtils.ts @@ -1,7 +1,8 @@ -/** Daytona provider construction + persisted build-status refresh (see checkSnapshotStatus). */ +/** Provider construction + persisted build-status refresh. */ import { Daytona, DaytonaError } from '@daytona/sdk'; import { DaytonaSandboxProvider, + ModalSandboxProvider, SANDBOX_IMAGE_URI, withTimeout, type SandboxBuild, @@ -11,6 +12,7 @@ import configuration from '../config'; import type { ISandboxProviderStore, SandboxProviderRecord } from '../db/sandboxProviderStore'; import { toDaytonaSandboxProviderInput, + toModalSandboxProviderInput, type SandboxBuildMetadata, type SandboxProviderManifest, type SandboxStatus, @@ -25,6 +27,14 @@ export function isDaytonaPermissionError(error: unknown): boolean { return error instanceof DaytonaError && error.statusCode === 403; } +/** Modal uses ConnectRPC status codes for authentication and authorization failures. */ +export function isModalAuthError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return false; + } + return error.code === 16 || error.code === 7; +} + /** * Builds the runtime provider for a stored manifest. No network I/O until a method is called. * @@ -39,7 +49,7 @@ export function toDaytonaSandboxProvider({ logger, build_metadata, }: { - manifest: SandboxProviderManifest; + manifest: Extract; tenant_id: string; logger: Logger; build_metadata?: SandboxBuildMetadata | null; @@ -57,6 +67,44 @@ export function toDaytonaSandboxProvider({ }); } +export function toModalSandboxProvider({ + manifest, + tenant_id, + logger, + build_metadata, +}: { + manifest: Extract; + tenant_id: string; + logger: Logger; + build_metadata?: SandboxBuildMetadata | null; +}): ModalSandboxProvider { + return new ModalSandboxProvider({ + ...toModalSandboxProviderInput(manifest), + tenantName: tenant_id, + sandboxImage: build_metadata?.['image_uri'] ?? SANDBOX_IMAGE_URI, + buildRef: build_metadata?.['build_ref'], + fileMaxBytesForDownload: configuration.SANDBOX_FILE_MAX_BYTES_FOR_DOWNLOAD, + logger, + }); +} + +export function toSandboxProvider({ + manifest, + tenant_id, + logger, + build_metadata, +}: { + manifest: SandboxProviderManifest; + tenant_id: string; + logger: Logger; + build_metadata?: SandboxBuildMetadata | null; +}): DaytonaSandboxProvider | ModalSandboxProvider { + const input = { tenant_id, logger, ...(build_metadata !== undefined ? { build_metadata } : {}) }; + return manifest.type === 'daytona' + ? toDaytonaSandboxProvider({ manifest, ...input }) + : toModalSandboxProvider({ manifest, ...input }); +} + /** Maps a core `SandboxBuild` onto the persisted/wire status shape (metadata passes through). */ export function toSandboxStatus(build: SandboxBuild): SandboxStatus { return { @@ -74,13 +122,13 @@ function sandboxStatusFromRecord(record: SandboxProviderRecord): SandboxStatus { }; } -// Daytona deactivates idle snapshots after 14 days; revalidate at 13 to stay a day ahead. +// Revalidate ready provider images periodically; Daytona deactivates idle snapshots after 14 days. const READY_REVALIDATE_INTERVAL_MS = 13 * 24 * 60 * 60 * 1000; -/** Cap the Daytona round-trip for the refresh, which runs outside a transaction. */ +/** Cap the provider round-trip for the refresh, which runs outside a transaction. */ const STATUS_REFRESH_TIMEOUT_MS = 60_000; -export async function checkSnapshotStatus({ +export async function checkSandboxBuildStatus({ store, tenant_id, logger, @@ -102,7 +150,7 @@ export async function checkSnapshotStatus({ return persisted; } - const provider = toDaytonaSandboxProvider({ + const provider = toSandboxProvider({ manifest: record.manifest, tenant_id, logger, diff --git a/packages/trueforge/src/schemas/sandboxCatalog.ts b/packages/trueforge/src/schemas/sandboxCatalog.ts index d4e499350..df623913c 100644 --- a/packages/trueforge/src/schemas/sandboxCatalog.ts +++ b/packages/trueforge/src/schemas/sandboxCatalog.ts @@ -3,14 +3,17 @@ * configured provider manifests in sandboxProvider.ts. */ import { z } from '@hono/zod-openapi'; -import { DaytonaSandboxProviderSchema } from './sandboxProvider'; +import { DaytonaSandboxProviderSchema, ModalSandboxProviderSchema } from './sandboxProvider'; /** * Catalog wire type. Single variant today (avoids one-member `oneOf` in OpenAPI). * Widen to a discriminated union when a second provider ships. */ -export const CatalogSandboxProviderSchema = DaytonaSandboxProviderSchema.omit({ auth: true }) - .strict() +export const CatalogSandboxProviderSchema = z + .discriminatedUnion('type', [ + DaytonaSandboxProviderSchema.omit({ auth: true }).strict(), + ModalSandboxProviderSchema.omit({ auth: true }).strict(), + ]) .openapi('CatalogSandboxProvider'); export const SandboxCatalogFileSchema = z diff --git a/packages/trueforge/src/schemas/sandboxProvider.ts b/packages/trueforge/src/schemas/sandboxProvider.ts index a856c9bdc..8bf48fe9c 100644 --- a/packages/trueforge/src/schemas/sandboxProvider.ts +++ b/packages/trueforge/src/schemas/sandboxProvider.ts @@ -5,7 +5,7 @@ * Singleton per tenant — no identity `name` (unlike model providers / skills). */ import { z } from '@hono/zod-openapi'; -import type { DaytonaSandboxProviderOptions } from '@truefoundry/trueforge-core/core'; +import type { DaytonaSandboxProviderOptions, ModalSandboxProviderOptions } from '@truefoundry/trueforge-core/core'; const DaytonaSandboxProviderAuthSchema = z .object({ @@ -47,14 +47,50 @@ export const DaytonaSandboxProviderSchema = z .nonnegative() .describe('Minutes before Daytona auto-deletes the sandbox (0 disables).'), }) - .strict(); + .strict() + .openapi('DaytonaSandboxProvider'); + +const ModalSandboxProviderAuthSchema = z + .object({ + token_id: z.string().min(1).describe('Modal token ID. Responses are redacted; a redacted PUT value keeps it.'), + token_secret: z + .string() + .min(1) + .describe('Modal token secret. Responses are redacted; a redacted PUT value keeps it.'), + }) + .strict() + .describe('Modal authentication credentials.') + .openapi('ModalSandboxProviderAuth'); + +export const ModalSandboxProviderSchema = z + .object({ + type: z.literal('modal').describe('Modal sandbox provider.'), + auth: ModalSandboxProviderAuthSchema, + environment: z + .string() + .min(1) + .optional() + .describe('Modal environment; the workspace default is used when omitted.'), + app_name: z.string().min(1).default('trueforge').describe('Modal App used to own TrueForge sandboxes and images.'), + exec_timeout_ms: z.number().int().positive().describe('Default sandbox command exec timeout in milliseconds.'), + sandbox_timeout_ms: z.number().int().positive().describe('Maximum lifetime of each Modal sandbox in milliseconds.'), + idle_timeout_ms: z + .number() + .int() + .positive() + .describe('Idle time before Modal terminates the sandbox in milliseconds.'), + }) + .strict() + .openapi('ModalSandboxProvider'); /** * Persisted jsonb: the provider config only (no build status). Single variant today — * this alias carries the OpenAPI name so the spec emits one `SandboxProviderManifest` component. * Widen to `z.discriminatedUnion('type', [...])` when a second provider ships. */ -export const SandboxProviderManifestSchema = DaytonaSandboxProviderSchema.openapi('SandboxProviderManifest'); +export const SandboxProviderManifestSchema = z + .discriminatedUnion('type', [DaytonaSandboxProviderSchema, ModalSandboxProviderSchema]) + .openapi('SandboxProviderManifest'); /** Named enum so the generated SDK exposes a reusable `SandboxBuildStatus` type. */ export const SandboxBuildStatusSchema = z @@ -104,6 +140,7 @@ export const GetSandboxProviderResponseSchema = z /** Persisted jsonb — the provider config only (no build status). */ export type SandboxProviderManifest = z.infer; export type DaytonaSandboxProvider = z.infer; +export type ModalSandboxProvider = z.infer; export type SandboxBuildStatus = z.infer; export type SandboxBuildMetadata = z.infer; export type SandboxStatus = z.infer; @@ -111,7 +148,7 @@ export type ConfiguredSandboxProvider = z.infer; /** Wire/persisted snake_case → Daytona client credentials + provider settings. */ -export function toDaytonaSandboxProviderInput(manifest: SandboxProviderManifest): { +export function toDaytonaSandboxProviderInput(manifest: DaytonaSandboxProvider): { apiKey: string; } & Pick< DaytonaSandboxProviderOptions, @@ -125,3 +162,20 @@ export function toDaytonaSandboxProviderInput(manifest: SandboxProviderManifest) autoDeleteIntervalInMinutes: manifest.auto_delete_interval_in_minutes, }; } + +export function toModalSandboxProviderInput( + manifest: ModalSandboxProvider, +): Pick< + ModalSandboxProviderOptions, + 'tokenId' | 'tokenSecret' | 'environment' | 'appName' | 'timeoutMs' | 'sandboxTimeoutMs' | 'idleTimeoutMs' +> { + return { + tokenId: manifest.auth.token_id, + tokenSecret: manifest.auth.token_secret, + environment: manifest.environment, + appName: manifest.app_name, + timeoutMs: manifest.exec_timeout_ms, + sandboxTimeoutMs: manifest.sandbox_timeout_ms, + idleTimeoutMs: manifest.idle_timeout_ms, + }; +} diff --git a/packages/trueforge/tests/db/sandboxProviderStoreContractSuite.ts b/packages/trueforge/tests/db/sandboxProviderStoreContractSuite.ts index a337d1c0c..d632f76e1 100644 --- a/packages/trueforge/tests/db/sandboxProviderStoreContractSuite.ts +++ b/packages/trueforge/tests/db/sandboxProviderStoreContractSuite.ts @@ -3,7 +3,11 @@ * Runs under jest against a fresh store per test (see backend test files). */ import type { ISandboxProviderStore, UpsertSandboxProviderInput } from '../../src/db/sandboxProviderStore'; -import type { SandboxBuildMetadata, SandboxProviderManifest } from '../../src/schemas/sandboxProvider'; +import type { + DaytonaSandboxProvider, + SandboxBuildMetadata, + SandboxProviderManifest, +} from '../../src/schemas/sandboxProvider'; const TENANT = 'default'; @@ -12,7 +16,7 @@ const BUILD_METADATA: SandboxBuildMetadata = { image_uri: 'tfy.jfrog.io/tfy-images/sandbox:029ea5ff', }; -function manifest(overrides: Partial = {}): SandboxProviderManifest { +function manifest(overrides: Partial = {}): SandboxProviderManifest { return { type: 'daytona', auth: { api_key: 'dtn-test' }, diff --git a/packages/trueforge/tests/unit/apis/capabilities.test.ts b/packages/trueforge/tests/unit/apis/capabilities.test.ts index fc9c09047..43a473011 100644 --- a/packages/trueforge/tests/unit/apis/capabilities.test.ts +++ b/packages/trueforge/tests/unit/apis/capabilities.test.ts @@ -1,5 +1,5 @@ -// Sandbox capability is driven by the refreshed image status; stub it so tests never touch Daytona. -jest.mock('../../../src/sandbox/providerUtils', () => ({ checkSnapshotStatus: jest.fn() })); +// Sandbox capability is driven by refreshed image status; stub it so tests never touch a provider. +jest.mock('../../../src/sandbox/providerUtils', () => ({ checkSandboxBuildStatus: jest.fn() })); import { OpenAPIHono } from '@hono/zod-openapi'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; @@ -13,10 +13,10 @@ import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteSandboxProviderStore } from '../../../src/db/sqlite/sandbox-provider-store/SqliteSandboxProviderStore'; import { setCachedLocalSandboxSupport } from '../../../src/sandbox/localRuntime'; -import { checkSnapshotStatus } from '../../../src/sandbox/providerUtils'; +import { checkSandboxBuildStatus } from '../../../src/sandbox/providerUtils'; import type { SandboxBuildStatus, SandboxStatus } from '../../../src/schemas/sandboxProvider'; -const mockStatus = checkSnapshotStatus as jest.Mock; +const mockStatus = checkSandboxBuildStatus as jest.Mock; const silentLogger = createLogger({ silent: true }); const buildWithStatus = (status: SandboxBuildStatus): SandboxStatus => ({ diff --git a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts index 8f86cd883..330328931 100644 --- a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts @@ -1,10 +1,8 @@ -// Stub the Daytona-touching helpers so the router never talks to Daytona: the PUT path builds via -// toDaytonaSandboxProvider, and the GET path refreshes via checkSnapshotStatus. isDaytonaAuthError, -// isDaytonaPermissionError and toSandboxStatus stay real so the error mapping and PUT wire shape are -// exercised. +// Stub provider calls so the router never talks to a sandbox service. The provider-specific error +// predicates and toSandboxStatus stay real so error mappings and PUT wire shapes are exercised. jest.mock('../../../src/sandbox/providerUtils', () => { const actual = jest.requireActual('../../../src/sandbox/providerUtils'); - return { ...actual, toDaytonaSandboxProvider: jest.fn(), checkSnapshotStatus: jest.fn() }; + return { ...actual, toSandboxProvider: jest.fn(), checkSandboxBuildStatus: jest.fn() }; }); import { DaytonaError } from '@daytona/sdk'; @@ -21,11 +19,11 @@ import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import type { ISandboxProviderStore } from '../../../src/db/sandboxProviderStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteSandboxProviderStore } from '../../../src/db/sqlite/sandbox-provider-store/SqliteSandboxProviderStore'; -import { checkSnapshotStatus, toDaytonaSandboxProvider } from '../../../src/sandbox/providerUtils'; +import { checkSandboxBuildStatus, toSandboxProvider } from '../../../src/sandbox/providerUtils'; import { toRedactedSecretValue } from '../../../src/utils/secretRedaction'; -const mockProviderFactory = toDaytonaSandboxProvider as jest.Mock; -const mockCheckStatus = checkSnapshotStatus as jest.Mock; +const mockProviderFactory = toSandboxProvider as jest.Mock; +const mockCheckStatus = checkSandboxBuildStatus as jest.Mock; const silentLogger = createLogger({ silent: true }); const putBody = { @@ -37,6 +35,16 @@ const putBody = { auto_delete_interval_in_minutes: 7200, }; +const modalBody = { + type: 'modal' as const, + auth: { token_id: 'ak-test', token_secret: 'as-test-secret' }, + environment: 'main', + app_name: 'trueforge', + exec_timeout_ms: 60000, + sandbox_timeout_ms: 3600000, + idle_timeout_ms: 300000, +}; + const IMAGE_URI = 'tfy.jfrog.io/tfy-images/truefoundry-utils-core-sandbox:029ea5ff'; const readyBuild: SandboxBuild = { status: 'ready', @@ -149,6 +157,22 @@ describe('sandboxProviders router', () => { expect(stored?.manifest).toEqual(putBody); }); + it('PUT supports Modal and redacts both Modal tokens', async () => { + const { settingsRouter: router, sandboxProviderStore: store } = await createRouters(); + const response = await router.request('/', putInit(modalBody)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: wireResponse({ + ...modalBody, + auth: { + token_id: toRedactedSecretValue(modalBody.auth.token_id), + token_secret: toRedactedSecretValue(modalBody.auth.token_secret), + }, + }), + }); + expect((await store.getSandboxProvider(TENANT_ID))?.manifest).toEqual(modalBody); + }); + it('GET surfaces an error (500) when the status refresh throws', async () => { const { settingsRouter: router } = await createRouters(); expect((await router.request('/', putInit(putBody))).status).toBe(200); @@ -180,6 +204,15 @@ describe('sandboxProviders router', () => { }); }); + it('PUT returns 422 when Modal rejects its tokens', async () => { + mockProviderFactory.mockReturnValue(stubProvider({ buildImage: jest.fn().mockRejectedValue({ code: 16 }) })); + const response = await settingsRouter.request('/', putInit(modalBody)); + expect(response.status).toBe(422); + expect(await response.json()).toEqual({ + error: { message: 'Modal rejected the tokens — check the credentials' }, + }); + }); + it('PUT does not persist config when the build call fails auth', async () => { const { settingsRouter: router } = await createRouters(); mockProviderFactory.mockReturnValue( @@ -203,6 +236,24 @@ describe('sandboxProviders router', () => { }); describe('sandbox-provider secret redaction and strict PUT', () => { + it('PUT with redacted Modal tokens keeps both stored secrets', async () => { + const { settingsRouter, sandboxProviderStore } = await createRouters(); + expect((await settingsRouter.request('/', putInit(modalBody))).status).toBe(200); + const update = { + ...modalBody, + exec_timeout_ms: 90000, + auth: { + token_id: toRedactedSecretValue(modalBody.auth.token_id), + token_secret: toRedactedSecretValue(modalBody.auth.token_secret), + }, + }; + expect((await settingsRouter.request('/', putInit(update))).status).toBe(200); + expect((await sandboxProviderStore.getSandboxProvider(TENANT_ID))?.manifest).toEqual({ + ...modalBody, + exec_timeout_ms: 90000, + }); + }); + it('PUT create with a redacted api_key returns 400', async () => { const { settingsRouter } = await createRouters(); const response = await settingsRouter.request( @@ -264,7 +315,11 @@ describe('sandbox-provider secret redaction and strict PUT', () => { }); const stored = await sandboxProviderStore.getSandboxProvider(TENANT_ID); - expect(stored?.manifest.auth.api_key).toBe(rotatedKey); + expect(stored?.manifest.type).toBe('daytona'); + if (stored?.manifest.type !== 'daytona') { + throw new Error('Expected a Daytona manifest'); + } + expect(stored.manifest.auth.api_key).toBe(rotatedKey); }); it('PUT update reuses persisted build_metadata (no image upgrade on re-save)', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7f051c11..c28c57d96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -265,6 +265,9 @@ importers: dedent: specifier: ^1.7.2 version: 1.7.2(babel-plugin-macros@3.1.0) + modal: + specifier: 0.9.0 + version: 0.9.0 openai: specifier: ^7.5.0 version: 7.5.0(@aws-sdk/credential-provider-node@3.972.81)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.4.3) @@ -925,6 +928,36 @@ packages: '@bundled-es-modules/statuses@1.0.1': resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + '@changesets/apply-release-plan@8.0.0': resolution: {integrity: sha512-kUd2pbf1w5/AYmBMb0Tt+rkIPCjFJdT0SZMrkOjJT/WV/QbtmvkyB5jkV0oaNPheavprZk+SfUiozUti7TIL2w==} engines: {node: ^22.11 || ^24 || >=26} @@ -4072,6 +4105,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller-x@0.5.0: + resolution: {integrity: sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -4340,6 +4376,13 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6144,6 +6187,9 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + modal@0.9.0: + resolution: {integrity: sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -6197,6 +6243,12 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nice-grpc-common@2.0.4: + resolution: {integrity: sha512-gOEXlD6ShXZMZ8k+49wm/bA2j1+3IKbEFV9WYREJcvZV4kM5L1KkILYTX9VYBzZF2OuYCe1wp8c82bQkvR0fHw==} + + nice-grpc@2.1.17: + resolution: {integrity: sha512-pu9xYPlWSeqoYQOCqb2ftQqZi/P2DaI70PSuwnxvoyIRiilaOVtktyPe6LmuXegWB4Ic1sPuDGCnCCASbwzK0w==} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -6215,6 +6267,10 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -6914,6 +6970,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -7174,6 +7234,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-error@1.0.6: + resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -7383,6 +7446,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -8250,6 +8317,24 @@ snapshots: dependencies: statuses: 2.0.2 + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + '@changesets/apply-release-plan@8.0.0': dependencies: '@changesets/config': 4.0.0 @@ -11399,6 +11484,8 @@ snapshots: '@xtuc/long@4.2.2': {} + abort-controller-x@0.5.0: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -11691,6 +11778,22 @@ snapshots: caniuse-lite@1.0.30001810: {} + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.6: + optionalDependencies: + cbor-extract: 2.2.2 + ccount@2.0.1: {} chai@6.2.2: {} @@ -14043,6 +14146,15 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + modal@0.9.0: + dependencies: + cbor-x: 1.6.6 + long: 5.3.2 + nice-grpc: 2.1.17 + protobufjs: 7.6.5 + smol-toml: 1.8.0 + uuid: 11.1.1 + module-details-from-path@1.0.4: {} monaco-editor@0.52.2: {} @@ -14102,6 +14214,16 @@ snapshots: neo-async@2.6.2: {} + nice-grpc-common@2.0.4: + dependencies: + ts-error: 1.0.6 + + nice-grpc@2.1.17: + dependencies: + '@grpc/grpc-js': 1.14.4 + abort-controller-x: 0.5.0 + nice-grpc-common: 2.0.4 + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -14120,6 +14242,11 @@ snapshots: node-forge@1.4.0: {} + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + node-int64@0.4.0: {} node-releases@2.0.53: {} @@ -14929,6 +15056,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smol-toml@1.8.0: {} + snake-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -15177,6 +15306,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-error@1.0.6: {} + ts-interface-checker@0.1.13: {} ts-loader@9.6.2(typescript@5.9.3)(webpack@5.109.2(@swc/core@1.16.1)(esbuild@0.28.2)(lightningcss@1.32.0)(postcss@8.5.26)): @@ -15439,6 +15570,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.1: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 From 659afbcf1e14580f0d6a52b87341b5d01ce3fd44 Mon Sep 17 00:00:00 2001 From: Jeffrey Holm Date: Thu, 27 Aug 2026 18:12:29 -0400 Subject: [PATCH 2/3] fix(build): allow modal native dependency install --- pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e71a1cb60..904123dbe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ allowBuilds: '@parcel/watcher': true '@swc/core': true better-sqlite3: false + cbor-extract: true esbuild: true # Fern-generated SDK tests (packages/trueforge-sdk) run msw's postinstall. msw: true From 965453d7ae884f097e56f816572b3c2079912fe5 Mon Sep 17 00:00:00 2001 From: Jeffrey Holm Date: Wed, 2 Sep 2026 13:12:15 -0400 Subject: [PATCH 3/3] fix: hide Modal from unsupported settings UI --- packages/trueforge/catalog/sandbox-catalog.yaml | 5 ----- packages/trueforge/src/schemas/sandboxCatalog.ts | 15 ++++----------- .../tests/unit/apis/sandboxProviders.test.ts | 4 +++- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/trueforge/catalog/sandbox-catalog.yaml b/packages/trueforge/catalog/sandbox-catalog.yaml index 76954c350..fc80d83aa 100644 --- a/packages/trueforge/catalog/sandbox-catalog.yaml +++ b/packages/trueforge/catalog/sandbox-catalog.yaml @@ -7,8 +7,3 @@ providers: auto_stop_interval_in_minutes: 5 auto_archive_interval_in_minutes: 60 auto_delete_interval_in_minutes: 7200 - - type: modal - app_name: trueforge - exec_timeout_ms: 60000 - sandbox_timeout_ms: 3600000 - idle_timeout_ms: 300000 diff --git a/packages/trueforge/src/schemas/sandboxCatalog.ts b/packages/trueforge/src/schemas/sandboxCatalog.ts index 6c48f6df3..f6f92243f 100644 --- a/packages/trueforge/src/schemas/sandboxCatalog.ts +++ b/packages/trueforge/src/schemas/sandboxCatalog.ts @@ -3,20 +3,13 @@ * configured provider manifests in sandboxProvider.ts. */ import { z } from '@hono/zod-openapi'; -import { DaytonaSandboxProviderSchema, ModalSandboxProviderSchema } from './sandboxProvider'; +import { DaytonaSandboxProviderSchema } from './sandboxProvider'; -const CatalogDaytonaSandboxProviderSchema = z +// The settings UI contract currently models Daytona fields only. Keep Modal out +// of discovery until that canonical contract can represent Modal configuration. +export const CatalogSandboxProviderSchema = z .object(DaytonaSandboxProviderSchema.omit({ auth: true }).shape) .strict() - .openapi('CatalogDaytonaSandboxProvider'); - -const CatalogModalSandboxProviderSchema = z - .object(ModalSandboxProviderSchema.omit({ auth: true }).shape) - .strict() - .openapi('CatalogModalSandboxProvider'); - -export const CatalogSandboxProviderSchema = z - .discriminatedUnion('type', [CatalogDaytonaSandboxProviderSchema, CatalogModalSandboxProviderSchema]) .openapi('CatalogSandboxProvider'); export const SandboxCatalogFileSchema = z diff --git a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts index 584e2a81d..a5d75da75 100644 --- a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts @@ -135,7 +135,9 @@ describe('sandboxProviders router', () => { it('GET /catalogs/sandbox-providers returns the shipped catalog verbatim', async () => { const response = await catalogRouter.request('/sandbox-providers'); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ data: [...SandboxCatalog.load().list()] }); + const providers = [...SandboxCatalog.load().list()]; + expect(await response.json()).toEqual({ data: providers }); + expect(providers.map(provider => provider.type)).toEqual(['daytona']); }); it('GET / returns 404 when none configured', async () => {