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

Stream sandbox file downloads end to end. `SandboxProvider.downloadFile` now returns `{ size, stream }` so the download route can send bytes incrementally (bounded by chunk size, not file size), keep the exact `Content-Length`, stop provider reads when the client disconnects, and re-enforce the size cap while streaming.
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 @@ -142,6 +142,7 @@ export type { CodeModeLogger } from './sandbox/codeMode/CodeModeDispatcher';
export type { CodeModeClientInstall, CodeModeTransport } from './sandbox/codeMode/CodeModeTransport';
export { CodeModeErrorSourceSchema, CodeModeReplySchema, CodeModeRequestSchema } from './sandbox/codeMode/types';
export type { CodeModeErrorSource, CodeModeReply, CodeModeRequest } from './sandbox/codeMode/types';
export { boundedFileStream } from './sandbox/provider/boundedFileStream';
export { DaytonaSandboxProvider } from './sandbox/provider/DaytonaProvider';
export type { DaytonaSandboxProviderOptions } from './sandbox/provider/DaytonaProvider';
export { absolutizeRelativeExecEnv } from './sandbox/provider/execEnv';
Expand All @@ -154,6 +155,7 @@ export type {
SandboxBuildMetadata,
SandboxBuildStatus,
SandboxExecParams,
SandboxFileDownload,
SandboxInit,
SandboxProvider,
} from './sandbox/provider/Provider';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,25 @@ import { randomUUID } from 'node:crypto';
import { join } from 'node:path/posix';
import type { Logger } from 'winston';
import { extractErrorLogFields } from '../../util/errorLogFields';
import type { CodeModeTransport } from '../codeMode/CodeModeTransport';
import { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';
import { DEFAULT_PREVIEW_URL_EXPIRY_SECONDS, DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants';
import {
SandboxFileNotFoundError,
SandboxFileTooLargeError,
SandboxNotAvailableError,
SandboxPathIsDirectoryError,
validateSandboxOwnedByTenant,
} from '../SandboxErrors';
import type { CodeModeTransport } from '../codeMode/CodeModeTransport';
import { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport';
import { DEFAULT_PREVIEW_URL_EXPIRY_SECONDS, DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants';
import type { ExecResult, SandboxBuild, SandboxExecParams, SandboxFileInfo, SandboxProvider } from './Provider';
import { boundedFileStream } from './boundedFileStream';
import type {
ExecResult,
SandboxBuild,
SandboxExecParams,
SandboxFileDownload,
SandboxFileInfo,
SandboxProvider,
} from './Provider';

const SANDBOX_NOT_FOUND_STATUS = 404;
/** Another replica already registered this build name; its create is the one that counts. */
Expand Down Expand Up @@ -415,9 +423,15 @@ export class DaytonaSandboxProvider implements SandboxProvider {
return { size: details.size, isDir: details.isDir };
}

async downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer> {
async downloadFile(params: {
sandboxId: string;
path: string;
signal?: AbortSignal | undefined;
}): Promise<SandboxFileDownload> {
return context.with(suppressTracing(context.active()), async () => {
try {
// Stat + stream-open happen here so domain errors (404 / dir / too large) reject the
// promise while the route can still answer with a JSON status instead of a broken body.
return await this.executeWithSandboxRecovery(params.sandboxId, async () => {
const { sandbox } = await this.getOrCreateSandbox(params.sandboxId);

Expand All @@ -429,7 +443,33 @@ export class DaytonaSandboxProvider implements SandboxProvider {
throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload);
}

return await sandbox.fs.downloadFile(params.path);
const nodeStream = await sandbox.fs.downloadFileStream(params.path);
if (params.signal !== undefined) {
params.signal.addEventListener(
'abort',
() => {
nodeStream.destroy(new Error(`Download of ${params.path} aborted by the client`));
},
{ once: true },
);
}
return {
size: info.size,
stream: boundedFileStream({
path: params.path,
maxBytes: this.fileMaxBytesForDownload,
chunks: async function* () {
try {
for await (const chunk of nodeStream) {
yield chunk;
}
} finally {
// No-op on natural completion; stops the transfer when cancelled or errored.
nodeStream.destroy();
}
},
}),
};
});
} catch (e: unknown) {
if (e instanceof SandboxPathIsDirectoryError || e instanceof SandboxFileTooLargeError) {
Expand Down
28 changes: 26 additions & 2 deletions packages/trueforge-core/src/core/sandbox/provider/Provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ export interface SandboxBuild {
metadata: SandboxBuildMetadata | null;
}

/** An opened sandbox file ready to be streamed to a client. */
export interface SandboxFileDownload {
/**
* Byte count from the provider's pre-download stat. Drives the response Content-Length;
* providers enforce this same bound again while streaming in case the file changed.
*/
size: number;
/**
* File bytes, single consumption. Domain errors raised before the first byte propagate to the
* caller as a rejected promise (mappable HTTP statuses); anything failing after that surfaces
* as an errored stream — a truncated body the client can detect against Content-Length.
*/
stream: ReadableStream<Uint8Array>;
}

export interface SandboxProvider {
/** Stable provider kind used in fancy sandbox ids and carry-forward (plain string). */
readonly type: string;
Expand Down Expand Up @@ -93,8 +108,17 @@ export interface SandboxProvider {
getSkillsDir(sandboxId: string): string;
/** Path the git skill downloader script is written to before it runs. */
getGitDownloaderPath(sandboxId: string): string;
/** Downloads a file from the sandbox as a Buffer. Throws SandboxFileNotFoundError / SandboxNotAvailableError / SandboxPathIsDirectoryError / SandboxFileTooLargeError. */
downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer>;
/**
* Opens a file for streaming download. Implementations stat first (path checks, is-dir,
* max-size) so domain errors reject before any byte moves, then hand back an incremental
* stream — peak memory stays bounded by chunk size rather than file size.
* `signal` aborts provider reads once the HTTP client goes away mid-stream.
*/
downloadFile(params: {
sandboxId: string;
path: string;
signal?: AbortSignal | undefined;
}): Promise<SandboxFileDownload>;
/** Uploads a file to the sandbox. */
uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,24 @@ import {
SandboxPathIsDirectoryError,
validateSandboxOwnedByTenant,
} from '../SandboxErrors';
import { boundedFileStream } from './boundedFileStream';
import { absolutizeRelativeExecEnv } from './execEnv';
import {
ensureExecSuccess,
shellEscape,
type ExecResult,
type SandboxBuild,
type SandboxExecParams,
type SandboxFileDownload,
type SandboxFileInfo,
type SandboxProvider,
} from './Provider';

const DEFAULT_TIMEOUT_SECONDS = 60;
// Buffer for network latency + response processing on top of the server-side timeout.
const CLIENT_TIMEOUT_BUFFER_SECONDS = 5;
/** Chunk size for streamed downloads — bounds per-exec memory while amortizing round-trips. */
const TFY_DOWNLOAD_CHUNK_BYTES = 2 * 1024 * 1024;

const TFY_MCP_CLIENT_BIN = 'mcp-client/bin';

Expand Down Expand Up @@ -107,6 +111,11 @@ export class TFYSandboxProvider implements SandboxProvider {
* git and `mcp-client` work after `cd` — resolved from `pwd` / `$PATH`, not a hardcoded prefix.
*/
async exec(params: SandboxExecParams): Promise<ExecResult> {
return this.runInSandbox(params);
}

/** Shared exec body: tenant check, layout discovery, env absolutization, then the HTTP exec. */
private async runInSandbox(params: SandboxExecParams & { signal?: AbortSignal | undefined }): Promise<ExecResult> {
validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });
const { root, inheritedPath } = await this.sandboxLayout(params.sandboxId);
const incoming = params.env ?? {};
Expand All @@ -116,7 +125,6 @@ export class TFYSandboxProvider implements SandboxProvider {
});
return this.postExec({ ...params, env });
}

private async sandboxLayout(sandboxId: string): Promise<{ root: string; inheritedPath: string }> {
const cached = this.sandboxLayouts.get(sandboxId);
if (cached !== undefined) {
Expand All @@ -137,7 +145,7 @@ export class TFYSandboxProvider implements SandboxProvider {
return parsed;
}

private async postExec(params: SandboxExecParams): Promise<ExecResult> {
private async postExec(params: SandboxExecParams & { signal?: AbortSignal | undefined }): Promise<ExecResult> {
return context.with(suppressTracing(context.active()), async (): Promise<ExecResult> => {
// Honor the per-call timeout (e.g. the longer skill-download timeout) for both the server-side
// request timeout and the client abort timer; fall back to the provider default otherwise.
Expand All @@ -155,13 +163,16 @@ export class TFYSandboxProvider implements SandboxProvider {
const timer = setTimeout(() => {
controller.abort();
}, clientTimeoutMs);
// One composite signal so either the timeout or a caller abort stops the in-flight fetch.
const requestSignal =
params.signal === undefined ? controller.signal : AbortSignal.any([controller.signal, params.signal]);

try {
const response = await fetch(`${this.serverUrl}/exec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
signal: requestSignal,
});

if (!response.ok) {
Expand All @@ -173,6 +184,10 @@ export class TFYSandboxProvider implements SandboxProvider {
const result = (await response.json()) as ExecResult;
return result;
} catch (e: unknown) {
if (params.signal?.aborted === true) {
this.logger.info('Sandbox exec aborted by the caller');
return { success: false, error: 'Sandbox exec aborted by the caller' };
}
if (e instanceof Error && e.name === 'AbortError') {
this.logger.error(`Sandbox exec timed out after ${String(timeoutSeconds)}s`, extractErrorLogFields(e));
return { success: false, error: `Sandbox exec timed out after ${String(timeoutSeconds)}s` };
Expand Down Expand Up @@ -203,7 +218,11 @@ export class TFYSandboxProvider implements SandboxProvider {
return { size: parsed.size, isDir: parsed.type === 'directory' };
}

async downloadFile(params: { sandboxId: string; path: string }): Promise<Buffer> {
async downloadFile(params: {
sandboxId: string;
path: string;
signal?: AbortSignal | undefined;
}): Promise<SandboxFileDownload> {
const info = await this.getFileInfo(params);
if (info.isDir) {
throw new SandboxPathIsDirectoryError(params.path);
Expand All @@ -212,19 +231,49 @@ export class TFYSandboxProvider implements SandboxProvider {
throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload);
}

const result = await this.exec({
sandboxId: params.sandboxId,
command: `base64 -w0 ${shellEscape(params.path)}`,
});

if (!result.success) {
throw new Error(`Failed to download file: ${result.error}`);
}
if (result.response.exitCode !== 0) {
throw new SandboxFileNotFoundError(params.path);
}

return Buffer.from(result.response.result.trim(), 'base64');
// The sandbox server has no file endpoint, so bytes are pulled as base64 through sequential
// execs over fixed byte windows (`tail -c +N | head -c M`): memory stays bounded per chunk,
// the client receives bytes before the whole file is read, and an abort stops further execs.
const { sandboxId, path, signal } = params;
const execWindow = (offset: number, length: number): Promise<ExecResult> =>
this.runInSandbox({
sandboxId,
command: `tail -c +${String(offset + 1)} ${shellEscape(path)} | head -c ${String(length)} | base64 -w0`,
...(signal === undefined ? {} : { signal }),
});
let offset = 0;
const chunks = async function* (): AsyncGenerator<Uint8Array> {
while (offset < info.size) {
const length = Math.min(TFY_DOWNLOAD_CHUNK_BYTES, info.size - offset);
const result = await execWindow(offset, length);
if (!result.success) {
throw new Error(`Failed to download file: ${result.error}`);
}
if (result.response.exitCode !== 0) {
// First window failing means the stat target is gone (or unreadable); later failures
// mean the file mutated mid-download, which no HTTP status can express.
if (offset === 0) {
throw new SandboxFileNotFoundError(path);
}
throw new Error(`Sandbox file changed during download: ${path}`);
}
const decoded = Buffer.from(result.response.result.trim(), 'base64');
if (decoded.byteLength !== length) {
throw new Error(`Sandbox file changed during download: ${path}`);
}
offset += length;
yield decoded;
}
};

return {
size: info.size,
stream: boundedFileStream({
path,
maxBytes: this.fileMaxBytesForDownload,
chunks,
}),
};
}

async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { SandboxFileTooLargeError } from '../SandboxErrors';

export interface BoundedFileStreamParams {
/** User-visible sandbox path used in cap-violation errors. */
path: string;
/** Hard byte ceiling; also enforced here because the pre-download stat can go stale. */
maxBytes: number;
/**
* Provider-specific incremental source, created fresh per call so the generator's own
* `finally` cleanup runs on completion, source errors, and consumer cancellation alike.
*/
chunks: () => AsyncGenerator<Uint8Array>;
}

/**
* Wraps incremental file chunks into a single-consumption web stream that enforces the
* download cap while streaming. Consumer cancellation (`stream.cancel()`) returns the
* underlying generator early, which runs its cleanup — providers stop their reads there.
*/
export function boundedFileStream(params: BoundedFileStreamParams): ReadableStream<Uint8Array> {
const iterator = params.chunks();
let streamed = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
let next: IteratorResult<Uint8Array>;
try {
next = await iterator.next();
} catch (error) {
controller.error(error instanceof Error ? error : new Error(String(error)));
return;
}
if (next.done) {
controller.close();
return;
}
streamed += next.value.byteLength;
if (streamed > params.maxBytes) {
// Held back rather than enqueued: the client never sees past-the-cap bytes.
await iterator.return(undefined);
controller.error(new SandboxFileTooLargeError(params.path, streamed, params.maxBytes));
return;
}
controller.enqueue(next.value);
},
cancel() {
void iterator.return(undefined);
},
});
}
Loading