From 0c4add21d6ba387781d1cd369aa763b6e7b1e62b Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Thu, 17 Sep 2026 14:23:05 +0530 Subject: [PATCH 1/2] feat: upload TFY sandbox files without stuffing them onto exec argv --- .changeset/tfy-sandbox-upload-api.md | 5 ++ .../sandbox/provider/TFYSandboxProvider.ts | 25 +++++-- .../core/sandbox/provider/tfyUpload.test.ts | 73 +++++++++++++++++++ 3 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 .changeset/tfy-sandbox-upload-api.md create mode 100644 packages/trueforge-core/tests/core/sandbox/provider/tfyUpload.test.ts 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(); + }); +}); From ef8d44acb3b92f65edaaabb3246ae02afde47e44 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Thu, 17 Sep 2026 19:43:04 +0000 Subject: [PATCH 2/2] Regenerate OpenAPI document and SDKs --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 214ea2acb..7a06b406e 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -5816,7 +5816,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0-rc.13" + "version": "0.2.0-rc.14" }, "openapi": "3.1.0", "paths": { diff --git a/docs/openapi.json b/docs/openapi.json index 214ea2acb..7a06b406e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5816,7 +5816,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0-rc.13" + "version": "0.2.0-rc.14" }, "openapi": "3.1.0", "paths": {