Move files into and out of a Box
Copy files to and from a Cubicle Box with scp, sftp or rsync through the cubicle ssh-config alias, write and read files from code with exec and base64 within the 64 KiB request body and 1 MiB output limits, and move large files through presigned storage URLs that the Box downloads or uploads.
Cubicle has no file upload or download API yet. Move files with the tools you already use:
| You are | Files | Use |
|---|---|---|
| A person with the CLI | Any size | scp, sftp or rsync over SSH |
| A program with an API key | Small: a request carries under 64 KiB, and output at most 1 MiB | Exec, with the content in the command and in its output |
| A program with an API key | Large | A URL that the Box downloads from or uploads to |
API keys cannot use SSH, so programs use exec or a URL.
Before you begin
- Over SSH: the
cubicleCLI with the tunnel installed, an SSH key on your account, a grant on the Box and a running Box. The SSH quick start sets these up. - With exec: the SDK or the CLI, set up as in the Quickstart, with a
writekey or a CLI login, a grant on the Box (its creator gets one) and a running Box. The examples useacme/job-1. - Files belong to the guest account that writes them, and each person and service account has its own. Relative paths start in that account's home directory.
Copy files over SSH
-
Write the SSH alias for the Box and connect the tunnel:
cubicle ssh-config acme/job-1 # configured: ssh cubicle-acme-job-1 cubicle connectThe alias is
cubicle-<org>-<box>.cubicle ssh-configlists its options. -
Copy files with
scporsftp:scp ./data.csv cubicle-acme-job-1: # into your home directory in the Box scp -r ./src cubicle-acme-job-1: # a directory, to ~/src scp cubicle-acme-job-1:results/report.pdf . # from the Box to this computer sftp cubicle-acme-job-1 # an interactive sessionBoth use the Box's SFTP server.
scpuses SFTP from OpenSSH 9.0 on; with an older client, usesftp. -
Or sync a directory with
rsync:rsync -av ./src/ cubicle-acme-job-1:src/rsyncmust be installed in the Box as well as on your computer. Check withssh cubicle-acme-job-1 command -v rsync. If it prints nothing, installrsyncwith the Box's package manager over SSH, where your account hassudo, or usescp -r.
Write a file with exec
Exec has no standard input, so a file's content travels inside the command's argv, within exec's
limits:
- The whole request body is at most 64 KiB (65,536 bytes) after JSON encoding. Escaped characters, such as quotes, backslashes and newlines, take 2 bytes or more, and non-ASCII characters 2 to 4.
argvis at most 1024 strings of up to 65,536 characters each.argvstrings are text. They cannot carry NUL bytes or bytes that are not valid UTF-8, so encode anything other than plain text with base64. Base64 is a third larger than its data: 48 KiB of base64 carries 36 KiB.
Pass the content and the path as separate arguments after an sh -c script, which reads them as
$1 and $2, so neither needs quoting:
const config = JSON.stringify({ retries: 3 }, null, 2)
await box.exec(["sh", "-c", 'printf %s "$1" > "$2"', "sh", config, "config.json"])For binary data or larger files, use the helper below.
Read a file with exec
The file comes back in the command's output:
stdoutis decoded as UTF-8, and invalid bytes become U+FFFD (�). Read text withcat, and anything else withbase64 -w 0; Read the result says why.stdoutandstderrshare onemaxOutputBytesbudget: 64 KiB by default, at most 1 MiB. Output past it is discarded andtruncatedistrue, so always check it.- Base64 is a third larger than its data, so one exec with a 1 MiB budget returns at most 768 KiB of a file.
const summary = await box.exec(["cat", "results/summary.txt"])
if (summary.exitCode !== 0 || summary.truncated) throw new Error(summary.stderr || "too long")
console.log(summary.stdout)
const plot = await box.exec(["base64", "-w", "0", "results/plot.png"], {
maxOutputBytes: 1_048_576, // 1 MiB of base64, so up to 768 KiB of data
})
if (plot.exitCode !== 0 || plot.truncated) throw new Error(plot.stderr || "over 768 KiB")
const png = Buffer.from(plot.stdout, "base64")Move larger files in parts
This helper writes and reads files of any size through exec. It sends 36 KiB parts, each written at
its own offset so that sending one again is harmless, and reads 768 KiB parts. It compares SHA-256
digests at the end, so a file that arrives incomplete or changes while it is read raises an error.
It uses sh, base64, dd, truncate, stat and sha256sum in the Box, which both base images
include.
import { createHash } from "node:crypto"
import { readFile, writeFile } from "node:fs/promises"
import { Cubicle, type BoxRef } from "@cubicle/sdk"
// 36 KiB of data is 48 KiB of base64, which leaves room for the path in the 64 KiB request body.
const WRITE_PART = 36 * 1024
// 768 KiB of data is exactly 1 MiB of base64, the most output that one exec returns.
const READ_PART = 768 * 1024
/** Runs a script with `sh -c`, and returns its stdout unless it failed or was cut. */
async function sh(box: BoxRef, script: string, args: string[], maxOutputBytes?: number) {
const result = await box.exec(["sh", "-c", script, "sh", ...args], { maxOutputBytes })
if (result.exitCode !== 0 || result.timedOut || result.truncated) {
throw new Error(`exec ${result.operationId} exited ${result.exitCode}: ${result.stderr}`)
}
return result.stdout
}
const sha256 = (data: Uint8Array) => createHash("sha256").update(data).digest("hex")
/** Writes `data` to `path` in the Box, and checks the Box's copy. */
async function writeBoxFile(box: BoxRef, path: string, data: Uint8Array) {
for (let offset = 0; offset < data.length; offset += WRITE_PART) {
const part = Buffer.from(data.subarray(offset, offset + WRITE_PART)).toString("base64")
await sh(
box,
'printf %s "$1" | base64 -d | dd of="$2" seek="$3" oflag=seek_bytes conv=notrunc status=none',
[part, path, String(offset)],
)
}
// Set the exact length, which also creates an empty file, then compare digests.
const stdout = await sh(box, 'truncate -s "$2" "$1" && sha256sum < "$1"', [
path,
String(data.length),
])
if (stdout.split(" ")[0] !== sha256(data)) throw new Error(`${path} in the Box does not match`)
}
/** Reads `path` from the Box. */
async function readBoxFile(box: BoxRef, path: string): Promise<Buffer> {
const [size, digest] = (await sh(box, 'stat -c %s "$1" && sha256sum < "$1"', [path])).split(/\s+/)
const parts: Buffer[] = []
for (let offset = 0; offset < Number(size); offset += READ_PART) {
const part = await sh(
box,
'dd if="$1" skip="$2" count="$3" iflag=skip_bytes,count_bytes status=none | base64 -w 0',
[path, String(offset), String(READ_PART)],
1_048_576,
)
parts.push(Buffer.from(part, "base64"))
}
const data = Buffer.concat(parts)
if (sha256(data) !== digest) throw new Error(`${path} changed while it was read`)
return data
}
const cubicle = new Cubicle()
const box = cubicle.boxes.ref("acme/job-1")
await writeBoxFile(box, "input.tar.gz", await readFile("input.tar.gz"))
await writeFile("report.pdf", await readBoxFile(box, "results/report.pdf"))- The Box must be running. Pass
wake: trueinshif it may have hibernated. - Writing a 1 MiB file takes 30 execs, and reading it takes 3. Each exec is one non-
GETrequest, and a service account may send 120 of those a minute (Rate limits): a 4 MiB write takes 115 execs. Move larger files through a URL. - The directory must already exist. Create it first with
mkdir -p.
Move large files through a URL
Boxes can open connections to the public IPv4 internet (outbound traffic), so a command in the Box can download its input from a URL and upload its results to one. With object storage, create short-lived presigned URLs in your program and pass them to the Box. Your storage credentials and your API key stay out of the Box.
The examples use Python 3's standard library: both base images include /usr/bin/python3. One exec
runs for at most 10 minutes, so each transfer must finish within its timeoutMs.
// Presigned URLs from your storage service: a GET URL for the input, a PUT URL for the output.
const { INPUT_URL, OUTPUT_URL } = process.env
if (!INPUT_URL || !OUTPUT_URL) throw new Error("set INPUT_URL and OUTPUT_URL")
const download =
'import shutil, sys, urllib.request; shutil.copyfileobj(urllib.request.urlopen(sys.argv[1], timeout=60), open(sys.argv[2], "wb"))'
const upload =
'import os, sys, urllib.request; f = open(sys.argv[2], "rb"); urllib.request.urlopen(urllib.request.Request(sys.argv[1], data=f, method="PUT", headers={"Content-Length": str(os.path.getsize(sys.argv[2])), "Content-Type": "application/octet-stream"}), timeout=60)'
const fetched = await box.exec(["python3", "-c", download, INPUT_URL, "input.tar.gz"], {
timeoutMs: 600_000,
})
if (fetched.exitCode !== 0) throw new Error(fetched.stderr)
// … run the job, which writes results.tar.gz …
const sent = await box.exec(["python3", "-c", upload, OUTPUT_URL, "results.tar.gz"], {
timeoutMs: 600_000,
})
if (sent.exitCode !== 0) throw new Error(sent.stderr)- When the server refuses a request, the command exits
1with Python's error instderr, such asurllib.error.HTTPError: HTTP Error 403: Forbidden, and a download creates no file. A server that refuses an upload before reading it can instead end it withurllib.error.URLError, such as[Errno 32] Broken pipe. - The upload sets
Content-Lengthfrom the file's size. If you signed the URL for a particular content type, send that type instead ofapplication/octet-stream. - Anyone who has a presigned URL can use it until it expires, so give it a short expiry time.
- Any other HTTP client in the Box works the same way.
Verify
Compare SHA-256 digests on both sides. The helper above does this itself. Over SSH:
ssh cubicle-acme-job-1 sha256sum data.csv
# 3b5d… data.csv
shasum -a 256 data.csv # on Linux: sha256sum data.csv
# 3b5d… data.csvWith exec, run ["sha256sum", "data.csv"] and compare its stdout with the digest of your copy.
Troubleshooting
413 payload_too_large"request body too large": the exec request is over 64 KiB. Send the content in smaller parts, as the helper does.truncated: true, orcubicle: output truncated: the output went pastmaxOutputBytes. Read the file in parts, or raisemaxOutputBytesto at most 1 MiB from the SDK or the API.- Replacement characters (
�) instdout: the file is not UTF-8 text. Read it withbase64 -w 0and decode it. dd: failed to open '…': No such file or directory: the directory does not exist in the Box. Create it withmkdir -pfirst.Permission denied: the guest account cannot write there. Write under its home directory or/tmp. Commands sent with an API key cannot usesudo; see Root and sudo.HTTP Error 403from a URL, orBroken pipeon an upload: the presigned URL has expired, or was signed for another method or content type. Create a new one.- A download or upload times out or cannot connect: the URL's host must have a public IPv4 address. Boxes cannot reach IPv6 or private address ranges.
rsyncfails because the remote command is not found:rsyncis not installed in the Box. Install it over SSH, or usescp -r.ssh: Could not resolve hostname …: runcubicle ssh-configfor the Box andcubicle connect. SSH troubleshooting lists the other causes.
Related
- SSH into a Box: the alias, keys, grants and host keys.
- Run commands: exec's options, results and errors.
- Limits and defaults: every exec limit in one table.
- Networking: what a Box can reach.
- Give an AI agent a Box: run an agent's commands with exec.
Run commands
Run commands in a Cubicle Box with exec from the TypeScript SDK, the CLI or the HTTP API, covering argv and sh -c, cwd, environment variables and secrets, timeouts, output limits, exit codes, work longer than 10 minutes, waking hibernated Boxes, idempotency keys and polling operations.
SSH into a Box
Open an SSH session to a Box with cubicle ssh, or write an OpenSSH alias with cubicle ssh-config for ssh, file copies and VS Code or Cursor Remote-SSH, using ed25519 keys, Box grants and pinned host keys.

