diff --git a/.changeset/tfy-sandbox-upload-api.md b/.changeset/tfy-sandbox-upload-api.md new file mode 100644 index 000000000..be742d5d3 --- /dev/null +++ b/.changeset/tfy-sandbox-upload-api.md @@ -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. diff --git a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts index 7544eba18..950a96d71 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts @@ -16,7 +16,6 @@ import { } from '../SandboxErrors'; import { absolutizeRelativeExecEnv } from './execEnv'; import { - ensureExecSuccess, shellEscape, type ExecResult, type SandboxBuild, @@ -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). */ @@ -228,14 +230,23 @@ export class TFYSandboxProvider implements SandboxProvider { } async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise { - 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). diff --git a/packages/trueforge-core/tests/core/sandbox/provider/tfyUpload.test.ts b/packages/trueforge-core/tests/core/sandbox/provider/tfyUpload.test.ts new file mode 100644 index 000000000..26844f495 --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/provider/tfyUpload.test.ts @@ -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 { + 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(); + }); +});