Stream computer files instead of reading them through the shell
import_file sent a computer's bytes back with base64 over /v1/shell/exec, whose stdout is capped at 30,047 characters. Every import was therefore cut at 22,528 bytes and saved anyway, with the tool reporting success: a 1.12 MiB archive became a 22 KB file that would not open, and a crawler's crawl.json lost half its pages. The bytes now come from the sandbox's streaming GET /v1/file/download. The size is read first so nothing over BotFileImportMaxBytes is transferred, the download asks for change_policy=abort so a file that changes mid-send is refused rather than half-saved, and the received length is checked against the size read, so a cut-short transfer fails loudly. Folders go the same way, as a gzipped tar unpacked into one row per file. Bots also name a path as [path:volume:owner:path] now, so what a bot leaves on a computer is a link into the files browser instead of text.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The sandbox's REST address (no `/mcp`).
|
||||
*/
|
||||
base_url: string;
|
||||
/**
|
||||
* The file on the computer, absolute.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* Refuse it above this many bytes.
|
||||
*/
|
||||
max_bytes: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type ComputerDownload =
|
||||
| { ok: true; bytes: Buffer; size: number }
|
||||
| { ok: false; reason: "missing" | "too_big" | "cut_short"; size: number };
|
||||
|
||||
/**
|
||||
* A computer's file, byte for byte. It goes through the sandbox's own
|
||||
* download endpoint (`/v1/file/download`), which streams the file as it
|
||||
* is, rather than its shell: a shell's output is capped (30,000
|
||||
* characters) and would silently cut a big file short, which is exactly
|
||||
* how a 1.12 MiB archive once arrived as 22 KB.
|
||||
*
|
||||
* The size is read first so nothing over the limit is transferred, and the
|
||||
* download asks the sandbox to abort if the file changes while it is sent
|
||||
* (`change_policy=abort`); the length is also checked, so a cut-short
|
||||
* transfer is reported instead of being saved.
|
||||
*/
|
||||
export default async function downloadComputerFile({
|
||||
base_url,
|
||||
path,
|
||||
max_bytes,
|
||||
signal,
|
||||
}: Params): Promise<ComputerDownload> {
|
||||
const size = await fileSize({ base_url, path, signal });
|
||||
|
||||
if (size == null) return { ok: false, reason: "missing", size: 0 };
|
||||
if (size > max_bytes) return { ok: false, reason: "too_big", size };
|
||||
|
||||
const res = await fetch(
|
||||
`${base_url}/v1/file/download?path=${encodeURIComponent(path)}&change_policy=abort`,
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
AbortSignal.timeout(size > 5 * 1024 * 1024 ? 120_000 : 60_000),
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
if (res.status == 409) return { ok: false, reason: "cut_short", size };
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`The computer refused to send ${path}: ${res.status}`);
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
if (bytes.byteLength != size) {
|
||||
return { ok: false, reason: "cut_short", size: bytes.byteLength };
|
||||
}
|
||||
|
||||
return { ok: true, bytes, size };
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's size on the computer, or null when it isn't there.
|
||||
*/
|
||||
async function fileSize({
|
||||
base_url,
|
||||
path,
|
||||
signal,
|
||||
}: {
|
||||
base_url: string;
|
||||
path: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { output } = await import("./run-computer-command").then((mod) =>
|
||||
mod.default({
|
||||
base_url,
|
||||
command: `f=${shellQuote({ text: path })}; if [ -f "$f" ]; then wc -c < "$f"; fi`,
|
||||
timeout_ms: 30_000,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
const digits = output.trim().match(/^\d+/)?.[0];
|
||||
|
||||
return digits ? Number(digits) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A path as one shell word, so a name with spaces or quotes is still one
|
||||
* argument.
|
||||
*/
|
||||
export function shellQuote({ text }: { text: string }) {
|
||||
return `'${text.split("'").join(`'\\''`)}'`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user