Guides / JavaScript & TypeScript / Files
Files
kosa8's native API has no file endpoint for sandboxes. There are three ways to move files instead, and which one fits depends on whether the files live on your machine or inside the sandbox.
A mountShare a host folder when you create the sandbox. Files
are on your disk; the sandbox reads and writes them in place, at full speed, with no copying.
Best for a repository an agent edits. See
Share a host folder.
Through execWrite with
base64 -d and read with
cat, inside a running container. Works for any file in the container, and needs
sh and base64 in the image. The helpers below are about 20 lines.The AI SDK adapter
readTextFile, writeBinaryFile, streams and the rest, already written,
doing the same exec-and-base64 work underneath.Through exec, with @kosa8/sdk
import { Kosa8Client } from '@kosa8/sdk';
import { createHash, randomBytes } from 'node:crypto';
const kosa8 = new Kosa8Client();
const sha = (b) => createHash('sha256').update(b).digest('hex').slice(0, 16);
// There is no file endpoint: files go in and out through exec. The image needs sh and base64.
async function writeFile(sandbox, container, path, data) {
const b64 = Buffer.from(data).toString('base64');
const CHUNK = 96 * 1024; // one exec argument stays well under Linux's 128 KiB limit
let r = await kosa8.exec(sandbox, container,
['sh', '-c', 'mkdir -p "$(dirname "$1")" && : > "$1.tmp"', 'sh', path]);
for (let i = 0; i < b64.length && r.exitCode === 0; i += CHUNK) {
r = await kosa8.exec(sandbox, container,
['sh', '-c', 'printf %s "$2" | base64 -d >> "$1.tmp"', 'sh', path, b64.slice(i, i + CHUNK)]);
}
if (r.exitCode === 0) r = await kosa8.exec(sandbox, container, ['mv', '-f', `${path}.tmp`, path]);
if (r.exitCode !== 0) throw new Error(`write ${path}: ${r.stderr.trim()}`);
}
async function readFile(sandbox, container, path) {
const r = await kosa8.exec(sandbox, container, ['cat', '--', path]);
if (r.exitCode !== 0) throw new Error(`read ${path}: ${r.stderr.trim()}`);
return r.stdoutBytes; // the raw bytes; r.stdout is the same decoded as UTF-8
}
const cid = await kosa8.startContainer('runner', 'alpine:latest', ['sh', '-c', 'while :; do sleep 3600; done']);
await writeFile('runner', cid, '/work/hello.txt', 'hi from the host\n');
console.log((await readFile('runner', cid, '/work/hello.txt')).toString());
const blob = randomBytes(1024 * 1024); // 1 MiB of binary
const t0 = Date.now();
await writeFile('runner', cid, '/work/blob.bin', blob);
const back = await readFile('runner', cid, '/work/blob.bin');
console.log(`1 MiB round trip in ${Date.now() - t0}ms: ${sha(blob)} -> ${sha(back)}, ${back.length} bytes`);
await readFile('runner', cid, '/work/missing.txt').catch((e) => console.log(e.message));
$ node files1.mjs
hi from the host
1 MiB round trip in 597ms: 3044a160fdc5bac4 -> 3044a160fdc5bac4, 1048576 bytes
read /work/missing.txt: cat: can't open '/work/missing.txt': No such file or directory
Points that matter, all visible in the code:
- Chunk the base64. The data travels as an exec argument, and Linux caps one argument at 128 KiB. 96 KiB of base64 per call stays well under it.
- Pass data as arguments, not inside the script.
sh -c 'script' sh "$1" "$2"keeps paths and data out of the shell's parser, so a path with spaces or quotes is just a path. - Write to a temporary name, then rename. A reader never sees half a file.
- Read with
stdoutBytesfor binary.stdoutis the same bytes decoded as UTF-8, which would mangle anything that is not text.
The image needs sh and base64. Alpine, Debian, Ubuntu and the official
Node and Python images have both. A distroless image has neither; use a mount for those.
With the AI SDK adapter
The adapter's session does all of this for you. Paths are relative to its working directory,
/workspace by default, and parent directories are created on write.
import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox';
import { createHash, randomBytes } from 'node:crypto';
const sha = (b) => createHash('sha256').update(b).digest('hex').slice(0, 16);
const session = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'deny-all' } });
const sb = session.restricted();
try {
// Binary in and out.
const blob = randomBytes(300 * 1024);
await sb.writeBinaryFile({ path: 'data/blob.bin', content: blob });
const back = await sb.readBinaryFile({ path: 'data/blob.bin' });
console.log('binary:', sha(blob), '->', sha(back), back.length, 'bytes');
// Streams: write from a web ReadableStream, read as one.
const enc = new TextEncoder();
const content = new ReadableStream({
start(c) { for (let i = 1; i <= 3; i++) c.enqueue(enc.encode(`row ${i}\n`)); c.close(); },
});
await sb.writeFile({ path: 'data/rows.csv', content });
let text = '';
for await (const chunk of await sb.readFile({ path: 'data/rows.csv' })) text += new TextDecoder().decode(chunk);
console.log('stream:', JSON.stringify(text));
// Missing files read as null; a directory is an error.
console.log('missing:', await sb.readFile({ path: 'nope' }), await sb.readBinaryFile({ path: 'nope' }));
await sb.readTextFile({ path: 'data' }).catch((e) => console.log('directory:', e.message));
} finally {
await session.destroy();
}
process.exit(0);
$ node adfiles.mjs
binary: dec126367973ae47 -> dec126367973ae47 307200 bytes
stream: "row 1\nrow 2\nrow 3\n"
missing: null null
directory: /workspace/data is a directory
| Method | Notes |
|---|---|
readTextFile({ path, encoding?, startLine?, endLine? }) | Text, or null if missing. startLine/endLine select lines (1-based). |
readBinaryFile({ path }) | Uint8Array, or null. |
readFile({ path }) | A web ReadableStream<Uint8Array>, or null. Streams from cat. |
writeTextFile({ path, content, encoding? }) | Creates parent directories; replaces atomically. |
writeBinaryFile({ path, content }) | Same, from a Uint8Array. |
writeFile({ path, content }) | Same, from a ReadableStream<Uint8Array>, chunked as it reads. |
Each accepts abortSignal. A path that is a directory is an error, not
null.
Which to choose
| Situation | Use |
|---|---|
An agent edits a repository you will review with git diff | A writable mount |
| Inputs the code must not change | A read-only mount |
| Generated code, test fixtures, one file at a time | Through exec, or the adapter |
| Results to collect after a run | A mount if they are large; cat through exec if not |
| Large datasets, gigabytes | A mount. Exec is about 600 ms per MiB round trip in our test, fine for source files and slow for bulk data. |
Next
Checkpoints and rewind: an undo button for the whole machine.