Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/tfy-sandbox-upload-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge-core": patch
---

Upload TFY sandbox files via POST /files/upload so large files are not stuffed onto exec argv.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
} from '../SandboxErrors';
import { absolutizeRelativeExecEnv } from './execEnv';
import {
ensureExecSuccess,
shellEscape,
type ExecResult,
type SandboxBuild,
Expand All @@ -29,6 +28,9 @@ const DEFAULT_TIMEOUT_SECONDS = 60;
// Buffer for network latency + response processing on top of the server-side timeout.
const CLIENT_TIMEOUT_BUFFER_SECONDS = 5;

// TFY sandbox file upload timeout (same as Daytona SDK uploadFile default timeout).
const FILE_UPLOAD_TIMEOUT_MS = 30 * 60 * 1000;

const TFY_MCP_CLIENT_BIN = 'mcp-client/bin';

/** Put the layout CLI first on PATH (cwd-relative; {@link absolutizeRelativeExecEnv} makes it absolute). */
Expand Down Expand Up @@ -228,14 +230,23 @@ export class TFYSandboxProvider implements SandboxProvider {
}

async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise<void> {
const encoded = params.content.toString('base64');
const escapedPath = shellEscape(params.remotePath);
validateSandboxOwnedByTenant({ sandboxId: params.sandboxId, tenantName: this.tenantName });

const result = await this.exec({
sandboxId: params.sandboxId,
command: `echo ${shellEscape(encoded)} | base64 -d > ${escapedPath}`,
const query = new URLSearchParams({ sandbox_id: params.sandboxId, path: params.remotePath });
const response = await fetch(`${this.serverUrl}/files/upload?${query.toString()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: params.content,
signal: AbortSignal.timeout(FILE_UPLOAD_TIMEOUT_MS),
});
ensureExecSuccess(result);
if (!response.ok) {
throw new Error(`Sandbox server returned ${String(response.status)}: ${await response.text()}`);
}

const result = (await response.json()) as { success: true } | { success: false; error: string };
if (!result.success) {
throw new Error(result.error);
}
}

// The TFY sandbox exposes a static, cluster-internal NATS WebSocket URL (no signed URLs).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { TFYSandboxProvider } from '../../../../src/core/sandbox/provider/TFYSandboxProvider';
import { makeSilentLogger } from '../../harnessMocks';

const SERVER_URL = 'http://sandbox.example';
const SANDBOX_ID = 'acme.00000000-0000-0000-0000-000000000001';

function makeProvider(): TFYSandboxProvider {
return new TFYSandboxProvider({
serverUrl: SERVER_URL,
natsBridgeUrl: 'ws://nats.example',
tenantName: 'acme',
fileMaxBytesForDownload: 1024,
logger: makeSilentLogger(),
});
}

function mockFetch({ status, body }: { status: number; body: unknown }): jest.SpiedFunction<typeof globalThis.fetch> {
return jest
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }));
}

afterEach(() => {
jest.restoreAllMocks();
});

describe('TFYSandboxProvider.uploadFile', () => {
it('POSTs the raw bytes to /files/upload', async () => {
const timeout = jest.spyOn(AbortSignal, 'timeout');
const fetchMock = mockFetch({ status: 200, body: { success: true } });
const content = Buffer.from([0x00, 0xff, 0x0a]);

await makeProvider().uploadFile({
sandboxId: SANDBOX_ID,
remotePath: 'uploads/report.docx',
content,
});

expect(timeout).toHaveBeenCalledWith(30 * 60 * 1000);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] ?? [];
expect(String(url)).toBe(`${SERVER_URL}/files/upload?sandbox_id=${SANDBOX_ID}&path=uploads%2Freport.docx`);
expect(init?.method).toBe('POST');
expect(init?.headers).toEqual({ 'Content-Type': 'application/octet-stream' });
expect(init?.body).toEqual(content);
expect(init?.signal).toBe(timeout.mock.results[0]?.value);
});

it('throws the server error body when success is false', async () => {
mockFetch({ status: 200, body: { success: false, error: 'File exceeds 20971520 bytes' } });

await expect(
makeProvider().uploadFile({
sandboxId: SANDBOX_ID,
remotePath: 'uploads/big.bin',
content: Buffer.from('ok'),
}),
).rejects.toThrow('File exceeds 20971520 bytes');
});

it('rejects a sandbox that is not owned by the tenant', async () => {
const fetchMock = mockFetch({ status: 200, body: { success: true } });

await expect(
makeProvider().uploadFile({
sandboxId: 'other.00000000-0000-0000-0000-000000000001',
remotePath: 'uploads/a.txt',
content: Buffer.from('x'),
}),
).rejects.toMatchObject({ name: 'SandboxTenantMismatchError' });
expect(fetchMock).not.toHaveBeenCalled();
});
});
Loading