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/docker-sandbox-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@truefoundry/trueforge-core': patch
'@truefoundry/trueforge': patch
---

Add a Docker sandbox provider with optional GPU passthrough, widen the sandbox provider manifest to a discriminated union, and let a provider declare whether it supports Code Mode so sessions degrade instead of failing.
8 changes: 8 additions & 0 deletions packages/trueforge-core/src/core/sandbox/Sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,14 @@ export class Sandbox extends LocalToolMCP {
if (!servers.length) {
return;
}
if (this.provider.supportsCodeMode === false) {
// Degrade rather than fail: the agent keeps its tools and its sandbox, and
// simply routes calls individually instead of batching them in a script.
this.logger.info('Code Mode unavailable for this sandbox provider; continuing without it', {
provider: this.provider.type,
});
return;
}
if (this.codeModeDispatcher !== undefined || this.codeModeTransport !== undefined) {
throw new Error('Code Mode is already configured for this Sandbox');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ export interface SandboxBuild {
export interface SandboxProvider {
/** Stable provider kind used in fancy sandbox ids and carry-forward (plain string). */
readonly type: string;
/**
* Whether this provider can carry Code Mode's bidirectional transport.
*
* Optional for backwards compatibility: absent means yes, which is correct for
* every provider that predates the flag. A provider that sets this to false is
* skipped rather than asked and allowed to throw -- Code Mode is an
* optimisation, and losing it must not fail the session.
*/
readonly supportsCodeMode?: boolean;
/**
* Ensures the release image is being built into the provider's backing store and
* returns its current status. Idempotent: an already-built image reports `ready`;
Expand Down
7 changes: 7 additions & 0 deletions packages/trueforge/catalog/sandbox-catalog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@ providers:
auto_stop_interval_in_minutes: 5
auto_archive_interval_in_minutes: 60
auto_delete_interval_in_minutes: 7200
- type: docker
# The image must provide python3 and pydantic: the sandbox bootstrap runs a
# Python script to materialise git-backed skills, and without them
# initialisation fails with "python3: not found" while the session still
# starts, which makes the failure easy to miss.
image: python:3.12-slim
exec_timeout_ms: 600000

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catalog image cannot run skill init

Medium Severity

The shipped docker preset uses python:3.12-slim, which has neither pydantic nor git. The comment says both are required because sandbox init runs git_downloader.py with python3, not a venv bootstrap. Copying this preset into settings leaves git-backed skills failing while the session still starts.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 02f66d2. Configure here.

43 changes: 43 additions & 0 deletions packages/trueforge/jest.docker-sandbox.contract.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', decorators: true, dynamicImport: true },
target: 'es2022',
},
module: { type: 'commonjs' },
},
],
'^.+\\.m?js$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'ecmascript', dynamicImport: true },
target: 'es2022',
},
module: { type: 'commonjs' },
},
],
},
transformIgnorePatterns: [],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@truefoundry/trueforge-core/agent-session$': '<rootDir>/../trueforge-core/src/agent-session/index.ts',
'^@truefoundry/trueforge-core/agent-session/(.*)$': '<rootDir>/../trueforge-core/src/agent-session/$1',
'^@truefoundry/trueforge-core/request-reply$': '<rootDir>/../trueforge-core/src/request-reply/index.ts',
'^@truefoundry/trueforge-core/request-reply/(.*)$': '<rootDir>/../trueforge-core/src/request-reply/$1',
'^@truefoundry/trueforge-core/core$': '<rootDir>/../trueforge-core/src/core/index.ts',
'^@truefoundry/trueforge-core/core/(.*)$': '<rootDir>/../trueforge-core/src/core/$1',
},
testTimeout: 120_000,
maxWorkers: 1,
roots: ['<rootDir>/tests/unit'],
testMatch: ['<rootDir>/tests/unit/sandbox/docker/**/*.contract.test.ts'],
// The GPU suite is separate: it pulls a multi-gigabyte CUDA image, which is not
// something the plain contract run should do on a machine that has no GPU to use it.
testPathIgnorePatterns: ['/node_modules/', 'gpu\\.contract\\.test\\.ts$'],
};
40 changes: 40 additions & 0 deletions packages/trueforge/jest.docker-sandbox.gpu.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', decorators: true, dynamicImport: true },
target: 'es2022',
},
module: { type: 'commonjs' },
},
],
'^.+\\.m?js$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'ecmascript', dynamicImport: true },
target: 'es2022',
},
module: { type: 'commonjs' },
},
],
},
transformIgnorePatterns: [],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@truefoundry/trueforge-core/agent-session$': '<rootDir>/../trueforge-core/src/agent-session/index.ts',
'^@truefoundry/trueforge-core/agent-session/(.*)$': '<rootDir>/../trueforge-core/src/agent-session/$1',
'^@truefoundry/trueforge-core/request-reply$': '<rootDir>/../trueforge-core/src/request-reply/index.ts',
'^@truefoundry/trueforge-core/request-reply/(.*)$': '<rootDir>/../trueforge-core/src/request-reply/$1',
'^@truefoundry/trueforge-core/core$': '<rootDir>/../trueforge-core/src/core/index.ts',
'^@truefoundry/trueforge-core/core/(.*)$': '<rootDir>/../trueforge-core/src/core/$1',
},
testTimeout: 120_000,
maxWorkers: 1,
roots: ['<rootDir>/tests/unit'],
testMatch: ['<rootDir>/tests/unit/sandbox/docker/**/gpu.contract.test.ts'],
};
2 changes: 2 additions & 0 deletions packages/trueforge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"test:store:sqlite": "jest --config jest.store.sqlite.config.cjs",
"test": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.unit.config.cjs",
"test:local-sandbox:contract": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.local-sandbox.contract.config.cjs",
"test:docker-sandbox:contract": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.docker-sandbox.contract.config.cjs",
"test:docker-sandbox:gpu": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.docker-sandbox.gpu.config.cjs",
"smoke:local-sandbox": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.local-sandbox.smoke.config.cjs --runInBand --forceExit tests/sandbox/local/smoke.test.ts",
"smoke:local-sandbox:lima": "bash scripts/local-sandbox/smoke-lima.sh",
"probe:loopback": "pnpm exec tsx scripts/local-sandbox/probe-loopback.ts",
Expand Down
52 changes: 32 additions & 20 deletions packages/trueforge/src/apis/sandboxProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ import type { Logger } from 'winston';
import type { ISandboxProviderStore, SandboxProviderRecord } from '../db/sandboxProviderStore';
import type { WithTransaction } from '../db/transaction';
import { getSandboxProviderRoute, putSandboxProviderRoute } from '../routes/sandboxProviderRoutes';
import {
checkSnapshotStatus,
isDaytonaAuthError,
toDaytonaSandboxProvider,
toSandboxStatus,
} from '../sandbox/providerUtils';
import { checkSnapshotStatus, isDaytonaAuthError, toSandboxProvider, toSandboxStatus } from '../sandbox/providerUtils';
import type { SandboxProviderManifest, UpdateSandboxProviderRequest } from '../schemas/sandboxProvider';
import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretValue } from '../utils/secretRedaction';
import { TENANT_ID } from './sessions';
Expand All @@ -24,10 +19,18 @@ export interface SandboxProvidersRouterDeps<TTransaction> {
}

function redactSandboxProvider(manifest: SandboxProviderManifest): SandboxProviderManifest {
return {
...manifest,
auth: { api_key: toRedactedSecretValue(manifest.auth.api_key) },
};
// Switch rather than an optional-chain on `auth`: a new variant that does carry
// credentials should fail to compile here instead of silently returning them.
switch (manifest.type) {
case 'daytona':
return {
...manifest,
auth: { api_key: toRedactedSecretValue(manifest.auth.api_key) },
};
case 'docker':
// No credentials: the container runtime is a local socket.
return manifest;
}
}

/** Admin/settings sandbox provider surface (mounted at /api/v1/settings/sandbox-providers). */
Expand Down Expand Up @@ -58,23 +61,32 @@ export function createSandboxProvidersRouter<TTransaction>(deps: SandboxProvider
const putHandler: RouteHandler<typeof putSandboxProviderRoute> = 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 => {
if (incoming.type !== 'daytona') {
// No stored secret to carry forward: the container backend has no auth.
return incoming;
}
const existingManifest = existing?.manifest;
return {
...incoming,
auth: {
api_key: resolveStoredSecretValue({
incoming: incoming.auth.api_key,
// Only a stored daytona manifest can supply the previous key. If the
// tenant is switching backends, there is nothing to carry forward.
existing: existingManifest?.type === 'daytona' ? existingManifest.auth.api_key : undefined,
}),
},
};
};
try {
// NOTE: build (Daytona 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,
Expand Down
4 changes: 2 additions & 2 deletions packages/trueforge/src/runtime/sessionResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

export interface McpConnection {
Expand Down Expand Up @@ -238,7 +238,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,
Expand Down
Loading