diff --git a/docs/file-system.mdx b/docs/file-system.mdx
index a91fd6b2..27b8fff2 100644
--- a/docs/file-system.mdx
+++ b/docs/file-system.mdx
@@ -122,22 +122,13 @@ Batch upload accepts `application/x-tar` and extracts into the destination direc
```ts TypeScript
import { SandboxAgent } from "sandbox-agent";
-import fs from "node:fs";
-import path from "node:path";
-import tar from "tar";
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
-const archivePath = path.join(process.cwd(), "skills.tar");
-await tar.c({
- cwd: "./skills",
- file: archivePath,
-}, ["."]);
-
-const tarBuffer = await fs.promises.readFile(archivePath);
-const result = await sdk.uploadFsBatch(tarBuffer, {
+// Requires `tar` to be installed (it's an optional peer dependency).
+const result = await sdk.uploadFsBatch({ sourcePath: "./skills" }, {
path: "./skills",
});
@@ -152,3 +143,27 @@ curl -X POST "http://127.0.0.1:2468/v1/fs/upload-batch?path=./skills" \
--data-binary @skills.tar
```
+
+## Batch download (tar)
+
+Batch download returns `application/x-tar` bytes for a file or directory. If the path is a directory,
+the archive contains the directory contents (similar to `tar -C
.`).
+
+
+```ts TypeScript
+import { SandboxAgent } from "sandbox-agent";
+
+const sdk = await SandboxAgent.connect({
+ baseUrl: "http://127.0.0.1:2468",
+});
+
+// Requires `tar` to be installed if you want to extract (it's an optional peer dependency).
+await sdk.downloadFsBatch({ path: "./skills" }, { outPath: "./skills.tar" });
+await sdk.downloadFsBatch({ path: "./skills" }, { extractTo: "./skills-extracted" });
+```
+
+```bash cURL
+curl -X GET "http://127.0.0.1:2468/v1/fs/download-batch?path=./skills" \
+ --output ./skills.tar
+```
+
diff --git a/docs/openapi.json b/docs/openapi.json
index c6e35f4e..274c62da 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -654,6 +654,33 @@
}
}
},
+ "/v1/fs/download-batch": {
+ "get": {
+ "tags": [
+ "v1"
+ ],
+ "summary": "Download a tar archive of a file or directory.",
+ "description": "Returns `application/x-tar` bytes containing the requested path. If the path is a directory,\nthe archive contains its contents (similar to `tar -C .`).",
+ "operationId": "get_v1_fs_download_batch",
+ "parameters": [
+ {
+ "name": "path",
+ "in": "query",
+ "description": "Source path (file or directory)",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "tar archive bytes"
+ }
+ }
+ }
+ },
"/v1/fs/entries": {
"get": {
"tags": [
@@ -1267,6 +1294,15 @@
}
}
},
+ "FsDownloadBatchQuery": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
"FsEntriesQuery": {
"type": "object",
"properties": {
diff --git a/examples/file-system/src/index.ts b/examples/file-system/src/index.ts
index 4595fb86..3e9b3cc0 100644
--- a/examples/file-system/src/index.ts
+++ b/examples/file-system/src/index.ts
@@ -1,7 +1,6 @@
import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
-import * as tar from "tar";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -23,14 +22,9 @@ console.log(" Created 3 files in my-project/");
console.log("Uploading files via batch tar...");
const client = await SandboxAgent.connect({ baseUrl });
-const tarPath = path.join(tmpDir, "upload.tar");
-await tar.create(
- { file: tarPath, cwd: tmpDir },
- ["my-project"],
-);
-const tarBuffer = await fs.promises.readFile(tarPath);
-const uploadResult = await client.uploadFsBatch(tarBuffer, { path: "/opt" });
-console.log(` Uploaded ${uploadResult.paths.length} files: ${uploadResult.paths.join(", ")}`);
+// Requires `tar` to be installed (optional peer dependency of `sandbox-agent`).
+const uploadResult = await client.uploadFsBatch({ sourcePath: projectDir }, { path: "/opt/my-project" });
+console.log(` Uploaded ${uploadResult.paths.length} entries: ${uploadResult.paths.join(", ")}`);
// Cleanup temp files
fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -46,6 +40,22 @@ const readmeBytes = await client.readFsFile({ path: "/opt/my-project/README.md"
const readmeText = new TextDecoder().decode(readmeBytes);
console.log(` README.md content: ${readmeText.trim()}`);
+console.log("Downloading the uploaded project via batch tar...");
+const downloadTmp = path.resolve(__dirname, "../.tmp-download");
+fs.rmSync(downloadTmp, { recursive: true, force: true });
+fs.mkdirSync(downloadTmp, { recursive: true });
+await client.downloadFsBatch(
+ { path: "/opt/my-project" },
+ { outPath: path.join(downloadTmp, "my-project.tar"), extractTo: downloadTmp },
+);
+console.log(` Extracted to: ${downloadTmp}`);
+for (const entry of fs.readdirSync(downloadTmp)) {
+ if (entry.endsWith(".tar")) {
+ continue;
+ }
+ console.log(` ${entry}`);
+}
+
console.log("Creating session...");
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/opt/my-project", mcpServers: [] } });
const sessionId = session.id;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 45e190a5..23a9d441 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -36,7 +36,7 @@ importers:
devDependencies:
'@cloudflare/workers-types':
specifier: latest
- version: 4.20260210.0
+ version: 4.20260213.0
'@types/node':
specifier: latest
version: 25.2.3
@@ -60,7 +60,7 @@ importers:
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
wrangler:
specifier: latest
- version: 4.64.0(@cloudflare/workers-types@4.20260210.0)
+ version: 4.65.0(@cloudflare/workers-types@4.20260213.0)
examples/computesdk:
dependencies:
@@ -91,7 +91,7 @@ importers:
dependencies:
'@daytonaio/sdk':
specifier: latest
- version: 0.141.0(ws@8.19.0)
+ version: 0.142.0(ws@8.19.0)
'@sandbox-agent/example-shared':
specifier: workspace:*
version: link:../shared
@@ -531,7 +531,7 @@ importers:
dependencies:
'@daytonaio/sdk':
specifier: latest
- version: 0.141.0(ws@8.19.0)
+ version: 0.142.0(ws@8.19.0)
'@e2b/code-interpreter':
specifier: latest
version: 2.3.3
@@ -762,6 +762,9 @@ importers:
openapi-typescript:
specifier: ^6.7.0
version: 6.7.6
+ tar:
+ specifier: ^7.0.0
+ version: 7.5.7
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
@@ -1165,38 +1168,38 @@ packages:
workerd:
optional: true
- '@cloudflare/workerd-darwin-64@1.20260210.0':
- resolution: {integrity: sha512-e3vMgzr8ZM6VjpJVFrnMBhjvFhlMIkhT+BLpBk3pKaWsrXao+azDlmzzxB3Zf4CZ8LmCEtaP7n5d2mNGL6Dqww==}
+ '@cloudflare/workerd-darwin-64@1.20260212.0':
+ resolution: {integrity: sha512-kLxuYutk88Wlo7edp8mlkN68TgZZ9237SUnuX9kNaD5jcOdblUqiBctMRZeRcPsuoX/3g2t0vS4ga02NBEVRNg==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
- '@cloudflare/workerd-darwin-arm64@1.20260210.0':
- resolution: {integrity: sha512-ng2uLJVMrI5VrcAS26gDGM+qxCuWD4ZA8VR4i88RdyM8TLn+AqPFisrvn7AMA+QSv0+ck+ZdFtXek7qNp2gNuA==}
+ '@cloudflare/workerd-darwin-arm64@1.20260212.0':
+ resolution: {integrity: sha512-fqoqQWMA1D0ZzDOD8sp0allREM2M8GHdpxMXQ8EdZpZ70z5bJbJ9Vr4qe35++FNIZJspsDHfTw3Xm/M4ELm/dQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
- '@cloudflare/workerd-linux-64@1.20260210.0':
- resolution: {integrity: sha512-frn2/+6DV59h13JbGSk9ATvJw3uORWssFIKZ/G/to+WRrIDQgCpSrjLtGbFSSn5eBEhYOvwxPKc7IrppkmIj/w==}
+ '@cloudflare/workerd-linux-64@1.20260212.0':
+ resolution: {integrity: sha512-bCSQoZzDzV5MSh4ueWo1DgmOn4Hf3QBu4Yo3eQFXA2llYFIu/sZgRtkEehw1X2/SY5Sn6O0EMCqxJYRf82Wdeg==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
- '@cloudflare/workerd-linux-arm64@1.20260210.0':
- resolution: {integrity: sha512-0fmxEHaDcAF+7gcqnBcQdBCOzNvGz3mTMwqxEYJc5xZgFwQf65/dYK5fnV8z56GVNqu88NEnLMG3DD2G7Ey1vw==}
+ '@cloudflare/workerd-linux-arm64@1.20260212.0':
+ resolution: {integrity: sha512-GPvp1iiKQodtbUDi6OmR5I0vD75lawB54tdYGtmypuHC7ZOI2WhBmhb3wCxgnQNOG1z7mhCQrzRCoqrKwYbVWQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
- '@cloudflare/workerd-windows-64@1.20260210.0':
- resolution: {integrity: sha512-G/Apjk/QLNnwbu8B0JO9FuAJKHNr+gl8X3G/7qaUrpwIkPx5JFQElVE6LKk4teSrycvAy5AzLFAL0lOB1xsUIQ==}
+ '@cloudflare/workerd-windows-64@1.20260212.0':
+ resolution: {integrity: sha512-wHRI218Xn4ndgWJCUHH4Zx0YlU5q/o6OmcxXkcw95tJOsQn4lDrhppioPh4eScxJZALf2X+ODeZcyQTCq5exGw==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
- '@cloudflare/workers-types@4.20260210.0':
- resolution: {integrity: sha512-zHaF0RZVYUQwNCJCECnNAJdMur72Lk3FMiD6wU78Dx3Bv7DQRcuXNmPNuJmsGnosVZCcWintHlPTQ/4BEiDG5w==}
+ '@cloudflare/workers-types@4.20260213.0':
+ resolution: {integrity: sha512-dr905ft/1R0mnfdT9aun4vanLgIBN27ZyPxTCENKmhctSz6zNmBOvHbzDWAhGE0RBAKFf3X7ifMRcd0MkmBvgA==}
'@computesdk/cmd@0.4.1':
resolution: {integrity: sha512-hhcYrwMnOpRSwWma3gkUeAVsDFG56nURwSaQx8vCepv0IuUv39bK4mMkgszolnUQrVjBDdW7b3lV+l5B2S8fRA==}
@@ -1216,14 +1219,14 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
- '@daytonaio/api-client@0.141.0':
- resolution: {integrity: sha512-DSPCurIEjfFyXCd07jkDgfsoFppVhTLyIJdvfb0LgG1EgV75BPqqzk2WM4ragBFJUuK2URF5CK7qkaHW0AXKMA==}
+ '@daytonaio/api-client@0.142.0':
+ resolution: {integrity: sha512-WCKaVAN4aM1VqfrIR8soze1KbF5b6F8saJ/fVtSto90F+kW5vpYMHgiW8PaARPz1D/UhJFzWmkqa3HPAPeZ44g==}
- '@daytonaio/sdk@0.141.0':
- resolution: {integrity: sha512-JUopkS9SkO7h4WN8CjparOrP9k954euOF5KG//PeCEFOxUWTPFOME70GrmHXQKa1qkdZiF/4tz9jtZ744B1I2w==}
+ '@daytonaio/sdk@0.142.0':
+ resolution: {integrity: sha512-Wp3wuJFVcWUt0+ExWaDHSE444HE9NC6B+kI6f9JdC6nfrSoSBfRNrLT8Ewl5czRaWnU1kbqO3ZZTNbSrt68BOA==}
- '@daytonaio/toolbox-api-client@0.141.0':
- resolution: {integrity: sha512-KGkCLDLAltd9FCic3PhSJGrTp3RwGsUwWEGp5vyWZFQGWpJV8CVp08CH5SBdo4YhuqFUVlyQcwha1HpzpVH++A==}
+ '@daytonaio/toolbox-api-client@0.142.0':
+ resolution: {integrity: sha512-HtQWxY9EdecJ7ZEXJlQszkdOCQFilPrc5BjSc1GRkYOm7dRj24NydH58va+x0yBCoU3JcDyrhUKn0bp99O0xeg==}
'@e2b/code-interpreter@2.3.3':
resolution: {integrity: sha512-WOpSwc1WpvxyOijf6WMbR76BUuvd2O9ddXgCHHi65lkuy6YgQGq7oyd8PNsT331O9Tqbccjy6uF4xanSdLX1UA==}
@@ -4137,8 +4140,8 @@ packages:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
- miniflare@4.20260210.0:
- resolution: {integrity: sha512-HXR6m53IOqEzq52DuGF1x7I1K6lSIqzhbCbQXv/cTmPnPJmNkr7EBtLDm4nfSkOvlDtnwDCLUjWII5fyGJI5Tw==}
+ miniflare@4.20260212.0:
+ resolution: {integrity: sha512-Lgxq83EuR2q/0/DAVOSGXhXS1V7GDB04HVggoPsenQng8sqEDR3hO4FigIw5ZI2Sv2X7kIc30NCzGHJlCFIYWg==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -5426,17 +5429,17 @@ packages:
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
engines: {node: '>=18'}
- workerd@1.20260210.0:
- resolution: {integrity: sha512-Sb0WXhrvf+XHQigP2trAxQnXo7wxZFC4PWnn6I7LhFxiTvzxvOAqMEiLkIz58wggRCb54T/KAA8hdjkTniR5FA==}
+ workerd@1.20260212.0:
+ resolution: {integrity: sha512-4B9BoZUzKSRv3pVZGEPh7OX+Q817hpUqAUtz5O0TxJVqo4OsYJAUA/sY177Q5ha/twjT9KaJt2DtQzE+oyCOzw==}
engines: {node: '>=16'}
hasBin: true
- wrangler@4.64.0:
- resolution: {integrity: sha512-0PBiVEbshQT4Av/KLHbOAks4ioIKp/eAO7Xr2BgAX5v7cFYYgeOvudBrbtZa/hDDIA6858QuJnTQ8mI+cm8Vqw==}
+ wrangler@4.65.0:
+ resolution: {integrity: sha512-R+n3o3tlGzLK9I4fGocPReOuvcnjhtOL2aCVKkHMeuEwt9pPbOO4FxJtx/ec5cIUG/otRyJnfQGCAr9DplBVng==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
- '@cloudflare/workers-types': ^4.20260210.0
+ '@cloudflare/workers-types': ^4.20260212.0
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
@@ -6348,28 +6351,28 @@ snapshots:
dependencies:
'@cloudflare/containers': 0.0.30
- '@cloudflare/unenv-preset@2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260210.0)':
+ '@cloudflare/unenv-preset@2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260212.0)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
- workerd: 1.20260210.0
+ workerd: 1.20260212.0
- '@cloudflare/workerd-darwin-64@1.20260210.0':
+ '@cloudflare/workerd-darwin-64@1.20260212.0':
optional: true
- '@cloudflare/workerd-darwin-arm64@1.20260210.0':
+ '@cloudflare/workerd-darwin-arm64@1.20260212.0':
optional: true
- '@cloudflare/workerd-linux-64@1.20260210.0':
+ '@cloudflare/workerd-linux-64@1.20260212.0':
optional: true
- '@cloudflare/workerd-linux-arm64@1.20260210.0':
+ '@cloudflare/workerd-linux-arm64@1.20260212.0':
optional: true
- '@cloudflare/workerd-windows-64@1.20260210.0':
+ '@cloudflare/workerd-windows-64@1.20260212.0':
optional: true
- '@cloudflare/workers-types@4.20260210.0': {}
+ '@cloudflare/workers-types@4.20260213.0': {}
'@computesdk/cmd@0.4.1': {}
@@ -6386,18 +6389,18 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
- '@daytonaio/api-client@0.141.0':
+ '@daytonaio/api-client@0.142.0':
dependencies:
axios: 1.13.5
transitivePeerDependencies:
- debug
- '@daytonaio/sdk@0.141.0(ws@8.19.0)':
+ '@daytonaio/sdk@0.142.0(ws@8.19.0)':
dependencies:
'@aws-sdk/client-s3': 3.975.0
'@aws-sdk/lib-storage': 3.975.0(@aws-sdk/client-s3@3.975.0)
- '@daytonaio/api-client': 0.141.0
- '@daytonaio/toolbox-api-client': 0.141.0
+ '@daytonaio/api-client': 0.142.0
+ '@daytonaio/toolbox-api-client': 0.142.0
'@iarna/toml': 2.2.5
'@opentelemetry/api': 1.9.0
'@opentelemetry/exporter-trace-otlp-http': 0.207.0(@opentelemetry/api@1.9.0)
@@ -6423,7 +6426,7 @@ snapshots:
- supports-color
- ws
- '@daytonaio/toolbox-api-client@0.141.0':
+ '@daytonaio/toolbox-api-client@0.142.0':
dependencies:
axios: 1.13.5
transitivePeerDependencies:
@@ -7908,14 +7911,6 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
- '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.7))':
- dependencies:
- '@vitest/spy': 3.2.4
- estree-walker: 3.0.3
- magic-string: 0.30.21
- optionalDependencies:
- vite: 5.4.21(@types/node@22.19.7)
-
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.2.3))':
dependencies:
'@vitest/spy': 3.2.4
@@ -8540,7 +8535,7 @@ snapshots:
glob: 11.1.0
openapi-fetch: 0.14.1
platform: 1.3.6
- tar: 7.5.6
+ tar: 7.5.7
eastasianwidth@0.2.0: {}
@@ -9582,12 +9577,12 @@ snapshots:
mimic-response@3.1.0: {}
- miniflare@4.20260210.0:
+ miniflare@4.20260212.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.18.2
- workerd: 1.20260210.0
+ workerd: 1.20260212.0
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
@@ -10947,7 +10942,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
- '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.7))
+ '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.2.3))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -11048,26 +11043,26 @@ snapshots:
dependencies:
string-width: 7.2.0
- workerd@1.20260210.0:
+ workerd@1.20260212.0:
optionalDependencies:
- '@cloudflare/workerd-darwin-64': 1.20260210.0
- '@cloudflare/workerd-darwin-arm64': 1.20260210.0
- '@cloudflare/workerd-linux-64': 1.20260210.0
- '@cloudflare/workerd-linux-arm64': 1.20260210.0
- '@cloudflare/workerd-windows-64': 1.20260210.0
+ '@cloudflare/workerd-darwin-64': 1.20260212.0
+ '@cloudflare/workerd-darwin-arm64': 1.20260212.0
+ '@cloudflare/workerd-linux-64': 1.20260212.0
+ '@cloudflare/workerd-linux-arm64': 1.20260212.0
+ '@cloudflare/workerd-windows-64': 1.20260212.0
- wrangler@4.64.0(@cloudflare/workers-types@4.20260210.0):
+ wrangler@4.65.0(@cloudflare/workers-types@4.20260213.0):
dependencies:
'@cloudflare/kv-asset-handler': 0.4.2
- '@cloudflare/unenv-preset': 2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260210.0)
+ '@cloudflare/unenv-preset': 2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260212.0)
blake3-wasm: 2.1.5
esbuild: 0.27.3
- miniflare: 4.20260210.0
+ miniflare: 4.20260212.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
- workerd: 1.20260210.0
+ workerd: 1.20260212.0
optionalDependencies:
- '@cloudflare/workers-types': 4.20260210.0
+ '@cloudflare/workers-types': 4.20260213.0
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
diff --git a/sdks/acp-http-client/src/index.ts b/sdks/acp-http-client/src/index.ts
index 976a4644..2c1038e0 100644
--- a/sdks/acp-http-client/src/index.ts
+++ b/sdks/acp-http-client/src/index.ts
@@ -53,6 +53,13 @@ export type QueryValue = string | number | boolean | null | undefined;
export interface AcpHttpTransportOptions {
path?: string;
bootstrapQuery?: Record;
+ /**
+ * Disable the background SSE GET loop. When true, the client operates in
+ * POST-only mode where all responses are read from the POST response body.
+ * Useful for environments where streaming GET requests are not supported
+ * (e.g. Cloudflare Workers `containerFetch`).
+ */
+ disableSse?: boolean;
}
export interface AcpHttpClientOptions {
@@ -271,6 +278,7 @@ class StreamableHttpAcpTransport {
private closed = false;
private closingPromise: Promise | null = null;
private postedOnce = false;
+ private readonly sseDisabled: boolean;
constructor(options: StreamableHttpAcpTransportOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
@@ -279,6 +287,7 @@ class StreamableHttpAcpTransport {
this.token = options.token;
this.defaultHeaders = options.defaultHeaders;
this.onEnvelope = options.onEnvelope;
+ this.sseDisabled = options.transport?.disableSse ?? false;
this.bootstrapQuery = options.transport?.bootstrapQuery
? buildQueryParams(options.transport.bootstrapQuery)
: null;
@@ -405,7 +414,7 @@ class StreamableHttpAcpTransport {
}
private ensureSseLoop(): void {
- if (this.sseLoop || this.closed || !this.postedOnce) {
+ if (this.sseDisabled || this.sseLoop || this.closed || !this.postedOnce) {
return;
}
diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json
index 8997de16..5f81b0d6 100644
--- a/sdks/typescript/package.json
+++ b/sdks/typescript/package.json
@@ -35,10 +35,19 @@
"devDependencies": {
"@types/node": "^22.0.0",
"openapi-typescript": "^6.7.0",
+ "tar": "^7.0.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
+ "peerDependencies": {
+ "tar": "^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "tar": {
+ "optional": true
+ }
+ },
"optionalDependencies": {
"@sandbox-agent/cli": "workspace:*"
}
diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts
index e724e246..4a48d2a2 100644
--- a/sdks/typescript/src/client.ts
+++ b/sdks/typescript/src/client.ts
@@ -28,6 +28,7 @@ import {
type FsMoveResponse,
type FsPathQuery,
type FsStat,
+ type FsDownloadBatchQuery,
type FsUploadBatchQuery,
type FsUploadBatchResponse,
type FsWriteResponse,
@@ -53,6 +54,101 @@ const DEFAULT_REPLAY_MAX_EVENTS = 50;
const DEFAULT_REPLAY_MAX_CHARS = 12_000;
const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500;
+function isNodeRuntime(): boolean {
+ return typeof process !== "undefined" && !!process.versions?.node;
+}
+
+type TarModule = {
+ create: (options: Record, files: string[]) => Promise | unknown;
+ extract: (options: Record) => unknown;
+};
+
+async function importTarOrThrow(): Promise {
+ try {
+ return (await import("tar")) as unknown as TarModule;
+ } catch {
+ throw new Error(
+ "`tar` is required for this operation. Install it (e.g. `npm i tar`) or use the raw byte APIs instead.",
+ );
+ }
+}
+
+async function createTarBytesFromSourcePath(sourcePath: string): Promise {
+ if (!isNodeRuntime()) {
+ throw new Error("Path-based batch upload requires a Node.js runtime.");
+ }
+
+ const tar = await importTarOrThrow();
+ const fs = await import("node:fs/promises");
+ const os = await import("node:os");
+ const path = await import("node:path");
+
+ const stat = await fs.stat(sourcePath);
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sandbox-agent-upload-"));
+ const tarPath = path.join(tmpDir, "upload.tar");
+
+ try {
+ if (stat.isDirectory()) {
+ // Pack directory contents (equivalent to: tar -cf upload.tar -C .)
+ await tar.create(
+ {
+ file: tarPath,
+ cwd: sourcePath,
+ },
+ ["."],
+ );
+ } else if (stat.isFile()) {
+ // Pack a single file as ./
+ await tar.create(
+ {
+ file: tarPath,
+ cwd: path.dirname(sourcePath),
+ },
+ [path.basename(sourcePath)],
+ );
+ } else {
+ throw new Error(`Unsupported path type for batch upload: ${sourcePath}`);
+ }
+
+ const bytes = await fs.readFile(tarPath);
+ // Slice to avoid sharing a larger underlying buffer.
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+ } finally {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ }
+}
+
+async function writeBytesToPath(outPath: string, bytes: Uint8Array): Promise {
+ if (!isNodeRuntime()) {
+ throw new Error("Path-based batch download requires a Node.js runtime.");
+ }
+ const fs = await import("node:fs/promises");
+ const path = await import("node:path");
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
+ await fs.writeFile(outPath, bytes);
+}
+
+async function extractTarBytesToDir(destDir: string, tarBytes: Uint8Array): Promise {
+ if (!isNodeRuntime()) {
+ throw new Error("Extracting batch downloads requires a Node.js runtime.");
+ }
+ const tar = await importTarOrThrow();
+ const fs = await import("node:fs/promises");
+ const stream = await import("node:stream");
+ const streamPromises = await import("node:stream/promises");
+ const buffer = await import("node:buffer");
+
+ await fs.mkdir(destDir, { recursive: true });
+ const readable = new stream.PassThrough();
+ readable.end(buffer.Buffer.from(tarBytes));
+ await streamPromises.pipeline(
+ readable as any,
+ tar.extract({
+ cwd: destDir,
+ }) as any,
+ );
+}
+
export interface SandboxAgentConnectOptions {
baseUrl: string;
token?: string;
@@ -61,6 +157,13 @@ export interface SandboxAgentConnectOptions {
persist?: SessionPersistDriver;
replayMaxEvents?: number;
replayMaxChars?: number;
+ /**
+ * Disable the background SSE GET loop for ACP connections. When true,
+ * all responses are read from POST response bodies. Useful for environments
+ * where streaming GET requests are not supported (e.g. Cloudflare Workers
+ * `containerFetch`).
+ */
+ disableSse?: boolean;
}
export interface SandboxAgentStartOptions extends Omit {
@@ -207,6 +310,7 @@ export class LiveAcpConnection {
headers?: HeadersInit;
agent: string;
serverId: string;
+ disableSse?: boolean;
onObservedEnvelope: (
connection: LiveAcpConnection,
envelope: AnyMessage,
@@ -225,6 +329,7 @@ export class LiveAcpConnection {
transport: {
path: `${API_PREFIX}/acp/${encodeURIComponent(options.serverId)}`,
bootstrapQuery: { agent: options.agent },
+ disableSse: options.disableSse,
},
client: {
sessionUpdate: async (_notification: SessionNotification) => {
@@ -409,6 +514,7 @@ export class SandboxAgent {
private readonly persist: SessionPersistDriver;
private readonly replayMaxEvents: number;
private readonly replayMaxChars: number;
+ private readonly disableSse: boolean;
private spawnHandle?: SandboxAgentSpawnHandle;
@@ -427,6 +533,7 @@ export class SandboxAgent {
this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS);
this.replayMaxChars = normalizePositiveInt(options.replayMaxChars, DEFAULT_REPLAY_MAX_CHARS);
+ this.disableSse = options.disableSse ?? false;
if (!this.fetcher) {
throw new Error("Fetch API is not available; provide a fetch implementation.");
@@ -454,6 +561,7 @@ export class SandboxAgent {
persist: options.persist,
replayMaxEvents: options.replayMaxEvents,
replayMaxChars: options.replayMaxChars,
+ disableSse: options.disableSse,
});
client.spawnHandle = handle;
@@ -685,16 +793,44 @@ export class SandboxAgent {
return this.requestJson("GET", `${FS_PATH}/stat`, { query });
}
- async uploadFsBatch(body: BodyInit, query?: FsUploadBatchQuery): Promise {
+ async uploadFsBatch(
+ body: BodyInit | { sourcePath: string },
+ query?: FsUploadBatchQuery,
+ ): Promise {
+ const resolvedBody =
+ typeof body === "object" && body !== null && "sourcePath" in body
+ ? await createTarBytesFromSourcePath((body as { sourcePath: string }).sourcePath)
+ : body;
const response = await this.requestRaw("POST", `${FS_PATH}/upload-batch`, {
query,
- rawBody: body,
+ rawBody: resolvedBody,
contentType: "application/x-tar",
accept: "application/json",
});
return (await response.json()) as FsUploadBatchResponse;
}
+ async downloadFsBatch(
+ query: FsDownloadBatchQuery = {},
+ options?: { outPath?: string; extractTo?: string },
+ ): Promise {
+ const response = await this.requestRaw("GET", `${FS_PATH}/download-batch`, {
+ query,
+ accept: "application/x-tar",
+ });
+ const buffer = await response.arrayBuffer();
+ const bytes = new Uint8Array(buffer);
+
+ if (options?.outPath) {
+ await writeBytesToPath(options.outPath, bytes);
+ }
+ if (options?.extractTo) {
+ await extractTarBytesToDir(options.extractTo, bytes);
+ }
+
+ return bytes;
+ }
+
async getMcpConfig(query: McpConfigQuery): Promise {
return this.requestJson("GET", `${API_PREFIX}/config/mcp`, { query });
}
@@ -733,6 +869,7 @@ export class SandboxAgent {
headers: this.defaultHeaders,
agent,
serverId,
+ disableSse: this.disableSse,
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
},
diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts
index 91ab56bd..96aec04b 100644
--- a/sdks/typescript/src/generated/openapi.ts
+++ b/sdks/typescript/src/generated/openapi.ts
@@ -32,6 +32,14 @@ export interface paths {
put: operations["put_v1_config_skills"];
delete: operations["delete_v1_config_skills"];
};
+ "/v1/fs/download-batch": {
+ /**
+ * Download a tar archive of a file or directory.
+ * @description Returns `application/x-tar` bytes containing the requested path. If the path is a directory,
+ * the archive contains its contents (similar to `tar -C .`).
+ */
+ get: operations["get_v1_fs_download_batch"];
+ };
"/v1/fs/entries": {
get: operations["get_v1_fs_entries"];
};
@@ -141,6 +149,9 @@ export interface components {
path: string;
recursive?: boolean | null;
};
+ FsDownloadBatchQuery: {
+ path?: string | null;
+ };
FsEntriesQuery: {
path?: string | null;
};
@@ -599,6 +610,25 @@ export interface operations {
};
};
};
+ /**
+ * Download a tar archive of a file or directory.
+ * @description Returns `application/x-tar` bytes containing the requested path. If the path is a directory,
+ * the archive contains its contents (similar to `tar -C .`).
+ */
+ get_v1_fs_download_batch: {
+ parameters: {
+ query?: {
+ /** @description Source path (file or directory) */
+ path?: string | null;
+ };
+ };
+ responses: {
+ /** @description tar archive bytes */
+ 200: {
+ content: never;
+ };
+ };
+ };
get_v1_fs_entries: {
parameters: {
query?: {
diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts
index cb7d8cf0..38272780 100644
--- a/sdks/typescript/src/index.ts
+++ b/sdks/typescript/src/index.ts
@@ -38,6 +38,7 @@ export type {
FsEntry,
FsMoveRequest,
FsMoveResponse,
+ FsDownloadBatchQuery,
FsPathQuery,
FsStat,
FsUploadBatchQuery,
diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts
index 17b321e9..98bf8ff4 100644
--- a/sdks/typescript/src/types.ts
+++ b/sdks/typescript/src/types.ts
@@ -18,6 +18,7 @@ export type FsEntry = components["schemas"]["FsEntry"];
export type FsPathQuery = QueryParams;
export type FsDeleteQuery = QueryParams;
export type FsUploadBatchQuery = QueryParams;
+export type FsDownloadBatchQuery = QueryParams;
export type FsWriteResponse = JsonResponse;
export type FsActionResponse = JsonResponse;
export type FsMoveRequest = JsonRequestBody;
diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts
index 71f91892..1fdde83f 100644
--- a/sdks/typescript/tests/integration.test.ts
+++ b/sdks/typescript/tests/integration.test.ts
@@ -1,6 +1,12 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
-import { existsSync } from "node:fs";
-import { mkdtempSync, rmSync } from "node:fs";
+import {
+ existsSync,
+ mkdtempSync,
+ rmSync,
+ readFileSync,
+ writeFileSync,
+ mkdirSync,
+} from "node:fs";
import { dirname, resolve } from "node:path";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -15,6 +21,77 @@ import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
+function isZeroBlock(block: Uint8Array): boolean {
+ for (const b of block) {
+ if (b !== 0) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function readTarString(block: Uint8Array, offset: number, length: number): string {
+ const slice = block.subarray(offset, offset + length);
+ let end = 0;
+ while (end < slice.length && slice[end] !== 0) {
+ end += 1;
+ }
+ return new TextDecoder().decode(slice.subarray(0, end));
+}
+
+function readTarOctal(block: Uint8Array, offset: number, length: number): number {
+ const raw = readTarString(block, offset, length).trim();
+ if (!raw) {
+ return 0;
+ }
+ return Number.parseInt(raw, 8);
+}
+
+function normalizeTarPath(p: string): string {
+ let out = p.replaceAll("\\", "/");
+ while (out.startsWith("./")) {
+ out = out.slice(2);
+ }
+ while (out.startsWith("/")) {
+ out = out.slice(1);
+ }
+ return out;
+}
+
+function untarFiles(tarBytes: Uint8Array): Map {
+ // Minimal ustar tar reader for tests. Supports regular files and directories.
+ const files = new Map();
+ let offset = 0;
+ while (offset + 512 <= tarBytes.length) {
+ const header = tarBytes.subarray(offset, offset + 512);
+ if (isZeroBlock(header)) {
+ const next = tarBytes.subarray(offset + 512, offset + 1024);
+ if (next.length === 512 && isZeroBlock(next)) {
+ break;
+ }
+ offset += 512;
+ continue;
+ }
+
+ const name = readTarString(header, 0, 100);
+ const prefix = readTarString(header, 345, 155);
+ const fullName = normalizeTarPath(prefix ? `${prefix}/${name}` : name);
+ const size = readTarOctal(header, 124, 12);
+ const typeflag = readTarString(header, 156, 1);
+
+ offset += 512;
+ const content = tarBytes.subarray(offset, offset + size);
+
+ // Regular file type is "0" (or NUL). Directories are "5".
+ if ((typeflag === "" || typeflag === "0") && fullName) {
+ files.set(fullName, content);
+ }
+
+ offset += Math.ceil(size / 512) * 512;
+ }
+ return files;
+}
+
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
@@ -281,4 +358,94 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
rmSync(directory, { recursive: true, force: true });
});
+
+ it("supports filesystem download batch (tar)", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const root = mkdtempSync(join(tmpdir(), "sdk-fs-download-batch-"));
+ const dir = join(root, "docs");
+ const nested = join(dir, "nested");
+ await sdk.mkdirFs({ path: nested });
+ await sdk.writeFsFile({ path: join(dir, "a.txt") }, new TextEncoder().encode("aaa"));
+ await sdk.writeFsFile({ path: join(nested, "b.txt") }, new TextEncoder().encode("bbb"));
+
+ const tarBytes = await sdk.downloadFsBatch({ path: dir });
+ expect(tarBytes.length).toBeGreaterThan(0);
+
+ const files = untarFiles(tarBytes);
+ const a = files.get("a.txt");
+ const b = files.get("nested/b.txt");
+ expect(a).toBeTruthy();
+ expect(b).toBeTruthy();
+ expect(new TextDecoder().decode(a!)).toBe("aaa");
+ expect(new TextDecoder().decode(b!)).toBe("bbb");
+
+ await sdk.dispose();
+ rmSync(root, { recursive: true, force: true });
+ });
+
+ it("supports filesystem upload batch from sourcePath (requires tar)", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const sourceRoot = mkdtempSync(join(tmpdir(), "sdk-upload-source-"));
+ const sourceDir = join(sourceRoot, "project");
+ mkdirSync(join(sourceDir, "nested"), { recursive: true });
+ writeFileSync(join(sourceDir, "a.txt"), "aaa");
+ writeFileSync(join(sourceDir, "nested", "b.txt"), "bbb");
+
+ const destRoot = mkdtempSync(join(tmpdir(), "sdk-upload-dest-"));
+ const destDir = join(destRoot, "uploaded");
+
+ await sdk.uploadFsBatch({ sourcePath: sourceDir }, { path: destDir });
+
+ const a = await sdk.readFsFile({ path: join(destDir, "a.txt") });
+ const b = await sdk.readFsFile({ path: join(destDir, "nested", "b.txt") });
+ expect(new TextDecoder().decode(a)).toBe("aaa");
+ expect(new TextDecoder().decode(b)).toBe("bbb");
+
+ await sdk.dispose();
+ rmSync(sourceRoot, { recursive: true, force: true });
+ rmSync(destRoot, { recursive: true, force: true });
+ });
+
+ it("supports filesystem download batch to outPath and extractTo (requires tar for extract)", async () => {
+ const sdk = await SandboxAgent.connect({
+ baseUrl,
+ token,
+ });
+
+ const serverRoot = mkdtempSync(join(tmpdir(), "sdk-download-server-"));
+ const serverDir = join(serverRoot, "docs");
+ await sdk.mkdirFs({ path: join(serverDir, "nested") });
+ await sdk.writeFsFile({ path: join(serverDir, "a.txt") }, new TextEncoder().encode("aaa"));
+ await sdk.writeFsFile(
+ { path: join(serverDir, "nested", "b.txt") },
+ new TextEncoder().encode("bbb"),
+ );
+
+ const localRoot = mkdtempSync(join(tmpdir(), "sdk-download-local-"));
+ const outTar = join(localRoot, "docs.tar");
+ const extractTo = join(localRoot, "extracted");
+
+ const bytes = await sdk.downloadFsBatch(
+ { path: serverDir },
+ { outPath: outTar, extractTo },
+ );
+ expect(bytes.length).toBeGreaterThan(0);
+
+ const extractedA = readFileSync(join(extractTo, "a.txt"), "utf8");
+ const extractedB = readFileSync(join(extractTo, "nested", "b.txt"), "utf8");
+ expect(extractedA).toBe("aaa");
+ expect(extractedB).toBe("bbb");
+
+ await sdk.dispose();
+ rmSync(serverRoot, { recursive: true, force: true });
+ rmSync(localRoot, { recursive: true, force: true });
+ });
});
diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs
index 99971fff..2ee7b22c 100644
--- a/server/packages/sandbox-agent/src/router.rs
+++ b/server/packages/sandbox-agent/src/router.rs
@@ -26,7 +26,7 @@ use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
-use tar::Archive;
+use tar::{Archive, Builder};
use tower_http::trace::TraceLayer;
use tracing::Span;
use utoipa::{Modify, OpenApi, ToSchema};
@@ -166,6 +166,7 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc)
.route("/fs/move", post(post_v1_fs_move))
.route("/fs/stat", get(get_v1_fs_stat))
.route("/fs/upload-batch", post(post_v1_fs_upload_batch))
+ .route("/fs/download-batch", get(get_v1_fs_download_batch))
.route(
"/config/mcp",
get(get_v1_config_mcp)
@@ -295,6 +296,7 @@ pub async fn shutdown_servers(state: &Arc) {
post_v1_fs_move,
get_v1_fs_stat,
post_v1_fs_upload_batch,
+ get_v1_fs_download_batch,
get_v1_config_mcp,
put_v1_config_mcp,
delete_v1_config_mcp,
@@ -321,6 +323,7 @@ pub async fn shutdown_servers(state: &Arc) {
FsEntriesQuery,
FsDeleteQuery,
FsUploadBatchQuery,
+ FsDownloadBatchQuery,
FsEntryType,
FsEntry,
FsStat,
@@ -1075,6 +1078,129 @@ async fn post_v1_fs_upload_batch(
}))
}
+fn tar_add_path(
+ builder: &mut Builder<&mut Vec>,
+ base: &StdPath,
+ path: &StdPath,
+) -> Result<(), SandboxError> {
+ let metadata = fs::symlink_metadata(path).map_err(|err| map_fs_error(path, err))?;
+ if metadata.file_type().is_symlink() {
+ return Err(SandboxError::InvalidRequest {
+ message: format!(
+ "symlinks are not supported in download-batch: {}",
+ path.display()
+ ),
+ });
+ }
+
+ let rel = path
+ .strip_prefix(base)
+ .map_err(|_| SandboxError::InvalidRequest {
+ message: format!("path is not under base: {}", path.display()),
+ })?;
+ let name = StdPath::new(".").join(rel);
+
+ if metadata.is_dir() {
+ builder
+ .append_dir(&name, path)
+ .map_err(|err| SandboxError::StreamError {
+ message: err.to_string(),
+ })?;
+ for entry in fs::read_dir(path).map_err(|err| map_fs_error(path, err))? {
+ let entry = entry.map_err(|err| SandboxError::StreamError {
+ message: err.to_string(),
+ })?;
+ tar_add_path(builder, base, &entry.path())?;
+ }
+ return Ok(());
+ }
+
+ if metadata.is_file() {
+ builder
+ .append_path_with_name(path, &name)
+ .map_err(|err| SandboxError::StreamError {
+ message: err.to_string(),
+ })?;
+ return Ok(());
+ }
+
+ Err(SandboxError::InvalidRequest {
+ message: format!("unsupported filesystem entry type: {}", path.display()),
+ })
+}
+
+/// Download a tar archive of a file or directory.
+///
+/// Returns `application/x-tar` bytes containing the requested path. If the path is a directory,
+/// the archive contains its contents (similar to `tar -C .`).
+#[utoipa::path(
+ get,
+ path = "/v1/fs/download-batch",
+ tag = "v1",
+ params(
+ ("path" = Option, Query, description = "Source path (file or directory)")
+ ),
+ responses(
+ (status = 200, description = "tar archive bytes")
+ )
+)]
+async fn get_v1_fs_download_batch(
+ Query(query): Query,
+) -> Result {
+ let raw = query.path.unwrap_or_else(|| ".".to_string());
+ let target = resolve_fs_path(&raw)?;
+ let metadata = fs::symlink_metadata(&target).map_err(|err| map_fs_error(&target, err))?;
+ if metadata.file_type().is_symlink() {
+ return Err(SandboxError::InvalidRequest {
+ message: format!(
+ "symlinks are not supported in download-batch: {}",
+ target.display()
+ ),
+ }
+ .into());
+ }
+
+ let mut out = Vec::::new();
+ {
+ let mut builder = Builder::new(&mut out);
+ if metadata.is_dir() {
+ // Pack directory contents, not an extra top-level folder wrapper.
+ for entry in fs::read_dir(&target).map_err(|err| map_fs_error(&target, err))? {
+ let entry = entry.map_err(|err| SandboxError::StreamError {
+ message: err.to_string(),
+ })?;
+ tar_add_path(&mut builder, &target, &entry.path())?;
+ }
+ } else if metadata.is_file() {
+ let name = StdPath::new(".").join(target.file_name().ok_or_else(|| {
+ SandboxError::InvalidRequest {
+ message: format!("invalid file path: {}", target.display()),
+ }
+ })?);
+ builder
+ .append_path_with_name(&target, name)
+ .map_err(|err| SandboxError::StreamError {
+ message: err.to_string(),
+ })?;
+ } else {
+ return Err(SandboxError::InvalidRequest {
+ message: format!("unsupported filesystem entry type: {}", target.display()),
+ }
+ .into());
+ }
+
+ builder.finish().map_err(|err| SandboxError::StreamError {
+ message: err.to_string(),
+ })?;
+ }
+
+ Ok((
+ [(header::CONTENT_TYPE, "application/x-tar")],
+ Bytes::from(out),
+ )
+ .into_response())
+}
+
#[utoipa::path(
get,
path = "/v1/config/mcp",
diff --git a/server/packages/sandbox-agent/src/router/types.rs b/server/packages/sandbox-agent/src/router/types.rs
index 481850b6..2ccb9a0b 100644
--- a/server/packages/sandbox-agent/src/router/types.rs
+++ b/server/packages/sandbox-agent/src/router/types.rs
@@ -128,6 +128,13 @@ pub struct FsUploadBatchQuery {
pub path: Option,
}
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FsDownloadBatchQuery {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub path: Option,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum FsEntryType {
diff --git a/server/packages/sandbox-agent/tests/v1_api/control_plane.rs b/server/packages/sandbox-agent/tests/v1_api/control_plane.rs
index 1728f8b2..924ece40 100644
--- a/server/packages/sandbox-agent/tests/v1_api/control_plane.rs
+++ b/server/packages/sandbox-agent/tests/v1_api/control_plane.rs
@@ -1,4 +1,5 @@
use super::*;
+use std::io::Cursor;
#[tokio::test]
async fn v1_health_removed_legacy_and_opencode_unmounted() {
@@ -134,6 +135,73 @@ async fn v1_filesystem_endpoints_round_trip() {
assert_eq!(status, StatusCode::OK);
}
+#[tokio::test]
+async fn v1_filesystem_download_batch_returns_tar() {
+ let test_app = TestApp::new(AuthConfig::disabled());
+
+ let (status, _, _) = send_request_raw(
+ &test_app.app,
+ Method::PUT,
+ "/v1/fs/file?path=docs/a.txt",
+ Some(b"aaa".to_vec()),
+ &[],
+ Some("application/octet-stream"),
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK);
+
+ let (status, _, _) = send_request_raw(
+ &test_app.app,
+ Method::PUT,
+ "/v1/fs/file?path=docs/nested/b.txt",
+ Some(b"bbb".to_vec()),
+ &[],
+ Some("application/octet-stream"),
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK);
+
+ let (status, headers, body) = send_request_raw(
+ &test_app.app,
+ Method::GET,
+ "/v1/fs/download-batch?path=docs",
+ None,
+ &[],
+ None,
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK);
+ assert_eq!(
+ headers
+ .get(header::CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or(""),
+ "application/x-tar"
+ );
+
+ let mut archive = tar::Archive::new(Cursor::new(body));
+ let mut paths: Vec = archive
+ .entries()
+ .expect("tar entries")
+ .map(|entry| {
+ entry
+ .expect("tar entry")
+ .path()
+ .expect("tar path")
+ .to_string_lossy()
+ .to_string()
+ })
+ .collect();
+ paths.sort();
+
+ let has_a = paths.iter().any(|p| p == "a.txt" || p == "./a.txt");
+ let has_b = paths
+ .iter()
+ .any(|p| p == "nested/b.txt" || p == "./nested/b.txt");
+ assert!(has_a, "expected a.txt in tar, got: {paths:?}");
+ assert!(has_b, "expected nested/b.txt in tar, got: {paths:?}");
+}
+
#[tokio::test]
#[serial]
async fn require_preinstall_blocks_missing_agent() {