Container files
Read and write a running workload's own filesystem — the whole container, not the workspace mounted into it.
FilesystemService is a file API over a running workload's own container filesystem. Where Workspaces give you durable storage that outlives every pod, this gives you the container as it exists right now: what the image actually shipped, what the workload wrote outside its mounts, a log under /var/log after a job failed.
const sandbox = await cp.sandboxes.get(id);
await sandbox.fs.readText("/etc/os-release"); // what the image shipped
await sandbox.fs.list("/var/log"); // what the workload left behind
await sandbox.fs.write("/tmp/config.json", body); // writable, because /tmp is a mount.fs and .files are not the same thing
They share verbs and sit next to each other, so the distinction is worth stating plainly:
sandbox.files | sandbox.fs | |
|---|---|---|
| What it addresses | The durable workspace mounted into the sandbox | The whole container filesystem |
| Lifetime | Outlives every pod; snapshottable | Gone when the pod is |
copy / move | Metadata-only — no bytes move | Real bytes; a move across filesystems is not atomic |
| Backed by | Content-addressed object storage | The container's actual filesystem |
sha256 on stat | Populated — it's the addressing key | Empty; hashing would put a full read in every listing |
Reach for .fs when you want something that is not in a workspace. Reach for .files for anything you want to keep.
An absolute path does not mean the container's root
Both APIs take absolute-looking paths, and neither rejects the other's. What differs is what the leading / is relative to:
await sandbox.files.write("/tmp/x.txt", body); // -> /workspace/tmp/x.txt
await sandbox.fs.write("/tmp/x.txt", body); // -> /tmp/x.txtA workspace is its own tree. / means the root of the workspace, which the container sees at its mount path — so files.write("/tmp/x.txt") quietly creates a tmp/ directory inside the workspace rather than writing to the container's /tmp. It does not error, and the matching files.read("/tmp/x.txt") succeeds, so nothing looks wrong until something else goes looking in the real /tmp and finds nothing.
The same applies to reading a file a command produced. A bash or python step runs in the container, so a script writing to /tmp/chart.png has written to the container's /tmp. Reading it back needs fs.read("/tmp/chart.png"); files.read("/tmp/chart.png") looks for <workspace>/tmp/chart.png and reports not-found.
Rule of thumb: if a command produced it, .fs reads it; if you want it to survive the pod, put it in .files. Paths under the mount point (/workspace/...) are the one place both agree, because there they genuinely are the same bytes.
Writes usually fail, and that is deliberate
The platform's hardened default is a read-only root filesystem. That means:
- Reads cover the whole tree.
- Writes land only in mounted volumes —
/tmp, and any workspace mounts — and fail everywhere else withFAILED_PRECONDITIONand a message namingEROFS.
This is the isolation posture working, not a fault. If a workload genuinely needs a writable root, register its environment with writable_rootfs — it is a per-environment decision, made once, rather than a per-call flag.
EROFS is deliberately kept distinguishable from a permission failure. "Nothing may write here" and "you may not write here" have different fixes, and an API that collapsed them would send you looking at your role when the answer is the environment.
Permissions
Two layers, and the second is why there is no third:
- Namespace RBAC decides whether you may address the workload at all —
VIEWERto read,OPERATORto write. - POSIX, as the workload's own user. The handler runs inside the container as the workload's user, so the container's own file permissions bound everything past that. You can never read or write anything the workload itself could not.
No path allowlist is needed to make that true, and none is offered. One exception: /.cp is not visible — it holds the platform's own staged binaries, not part of your container, and it reports as absent rather than forbidden.
A workspace-scoped credential (the key a workload's own agent holds) is refused outright. It is granted for specific trees, and a whole container filesystem is more than that.
Sandboxes and jobs
A sandbox answers whenever it is running. Nothing to opt into.
A job must ask for it, and only answers while it runs:
const outcome = await cp.jobs.run({
environment: envId,
argv: ["./build.sh"],
filesystemApi: true, // forces the sidecar + supervisor this needs
});
// while it is running, from another task:
await cp.jobs.fs(jobId).readText("/work/build.log");Two things follow from a job's pod being torn down at completion:
- The API is reachable only in
RUNNING. Calling it on a finished job returnsFAILED_PRECONDITION, not an empty result. - It does not replace
outputs. Files that must survive the run belong in an output spec, which is collected before the pod goes away.
filesystemApi is opt-in because serving it forces the agent sidecar onto a job that may need no egress, no workspace and no outputs, and runs the workload under the supervisor. Most jobs need neither and should not pay for both.
Apps are not addressable. An app's replicas are interchangeable and individually disposable, so a file API aimed at one would write to a pod that may vanish mid-call and read state that differs between replicas.
The calls
| Call | Notes |
|---|---|
Stat | One FileInfo. Symlinks are reported as symlinks, never followed |
ListFiles | Directory listing, optionally recursive. Unreadable subtrees are skipped, not fatal |
ReadFile | Server stream; offset + length for partial reads |
WriteFile | Client stream: a header then data frames. OVERWRITE, APPEND, PATCH_AT_OFFSET |
MakeDir | parents: true is mkdir -p |
RemoveFile | File or directory (recursive). Removing / is refused |
CopyFile | Real bytes. Directories recurse; symlinks stay symlinks |
MoveFile | A rename where it can be; a copy+unlink across filesystems, which is not atomic |
Truncate | Set size |
SetAttr | Mode, mtime, symlink target. Only the fields you pass are applied |
ImportZip | Unpack into the container. Entries are clamped to the destination — ../ cannot escape it |
ExportZip | A subtree as a zip, behind a byte ceiling |
ExportZip refuses rather than truncates: a subtree over the ceiling raises RESOURCE_EXHAUSTED before a byte is written, because a half archive that looks whole is the worse failure. exportZip("/") on a real rootfs will refuse — pass a subtree.
There is no fresh option here. sandbox.files has one because /workspace is a materialised copy reconciled on a tick; this talks to the live container synchronously, so there is nothing to flush first.
From the console
The sandbox Files tab browses every filesystem the sandbox has — each workspace mount, and the container itself — one tree at a time. Selecting a text file opens it in an editor; anything binary, or over 2 MiB, offers a download instead. Files can be uploaded into the directory you are looking at.
Whether you can save depends on where you are:
- A workspace mount — yes, it's durable storage.
- A snapshot mount — no, and the Save control is absent rather than disabled: a snapshot is immutable by nature, not by permission.
- The container — wherever the container itself allows it.
/tmpand the mounts work; most of the rest returns the read-only-filesystem error above. The console lets the attempt happen and shows what the kernel said, rather than guessing which paths are writable — that depends on the mounts and onwritable_rootfs, and guessing wrong would hide an upload that would have worked.
Errors
| Code | Means |
|---|---|
NOT_FOUND | No such path — or a hidden one |
ALREADY_EXISTS | Destination exists and overwrite was not set |
PERMISSION_DENIED | The workload's user may not do this |
FAILED_PRECONDITION | Read-only filesystem (EROFS), not a directory, directory not empty |
RESOURCE_EXHAUSTED | Out of space, or an export over the ceiling |
UNAVAILABLE | The handler is not up — a job that did not set filesystemApi |