Repository navigation
Conversation
File transfer outside the VFS needs two things dofs already has but does not expose. computerd walks its own disk and has to match patterns exactly as find does, so compileGlob is exported rather than copied. The host has to tell which of two versions of a file is later. vfs_nodes already stamps every mutation with the database's rev counter, so revision(path) reads it, and WorkspaceFilesystem gains the same method. It is a separate lookup rather than a field on stat, because stat sits under every FUSE getattr and only callers comparing versions need it.
A container that works on its own disk needs a much smaller protocol than sync: copy these files in, run a command, copy those out. The new files entry point is the half both ends share. A transfer body is a sequence of frames, each a length-prefixed JSON header and exactly the bytes it names, ending in a zero length. The terminator is what tells a finished transfer from one cut short, and a body whose length disagrees with its header errors the stream rather than misframing what follows. Files are encoded and decoded one at a time, so neither end holds a transfer in memory. Headers can carry the file's Workspace revision, which the container records so a later pull can detect a file changed on both sides. The module also defines the routes computerd serves, the conflict shape a refused pull reports, and the rules that turn a caller's pattern into globs.
With FUSE_MOUNT=none there is now no mount point at all. MOUNT_POINT is ignored and never created, and /__computerd/info reports it as null. The host copies files in and out of the container's own filesystem over /files, /files/diff, /files/pull and /files/record, with paths relative to the root, so /workspace/a.ts in the Workspace is /workspace/a.ts here. The routes sit behind the client secret and answer 409 when a mount is active. A walk never enters /proc, /sys or /dev, whose files report a size of zero and then produce bytes. computerd keeps an in-memory manifest of each file that last matched the Workspace: its revision, and the hash, size and mtime of the bytes on disk. A pull skips files the container has not changed and refuses, before sending anything, when a file it changed was also changed in the Workspace since, unless forced. Commands have no default working directory in this mode, and the Runner checks a cwd on disk instead of in the database, where no directory exists without a mount. MOUNT_IGNORE is refused, since there is no mount to keep paths out of, and the store is no longer checked against a mount point that does not exist.
ContainerBackend mounts the Workspace over FUSE and syncs before and
after every command. That suits a shell the model drives. It costs a
build step dearly: every read crosses FUSE and every command waits on
a sync.
SimpleContainerBackend runs the same container with mount: false, a
new ContainerBackend option that launches computerd with
FUSE_MOUNT=none and no MOUNT_POINT. Its handle reports sync: "none",
so the runtime skips the sync bracket. Passed to createContainerModule,
it makes ws:container export push and pull beside exec:
await push(["/workspace/src"]);
await exec("npm run build", { cwd: "/workspace" });
await pull(["/workspace/dist"]);
A path is the same file on both sides. Both directions send a manifest
with each file's revision first, so only changed files cross the wire.
A pull that would overwrite a Workspace change throws
FileTransferConflictError and writes nothing, unless forced, and
reports the revisions it wrote so the next pull compares against them.
ContainerBackend gains fetchComputerd for authenticated requests to the
daemon, and host modules receive the Workspace filesystem.
🦋 Changeset detectedLatest commit: 8e01872 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| else throw new RangeError(`${JSON.stringify(pattern)} is outside ${base}.`); | ||
| } | ||
| while (path.startsWith("./")) path = path.slice(2); | ||
| path = path.replace(/\/+$/, ""); |
|
|
||
| function trimRoot(root: string): string { | ||
| if (!root.startsWith("/")) throw new TypeError(`Root must be absolute, got ${root}.`); | ||
| const trimmed = root.replace(/\/+$/, ""); |
| await fs.writeFile(target, incoming.body, { mode: incoming.header.mode }); | ||
| written.push({ path: incoming.header.path, rev: await fs.revision(target) }); |
There was a problem hiding this comment.
🔴 Concurrent edits lost during pull
When the Workspace edits a file during pullFiles, its earlier have snapshot misses the edit. The pull then overwrites that edit without reporting a conflict.
Learn more
A pull first collects the Workspace file's revision and sends it to send. The container decides conflicts against that revision before streaming bytes. Another Workspace operation can change the file between that snapshot and this write, so the container approves an overwrite that would now be a conflict. The write replaces the newer Workspace content.
Example: A file has revision 5 on both sides. Pull snapshots revision 5; a second operation writes revision 6 while the container prepares its changed copy. The container sees revision 5, sends its copy, and pull overwrites revision 6 instead of refusing it.
Recommended fix: Recheck the target revision immediately before committing each incoming file against the revision collected for have, with a compare-and-write transaction or equivalent serialization around validation and write. Ensure a conflict does not leave earlier files committed if all-or-nothing conflict semantics are required.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (written.length > 0) { | ||
| const recorded = await fetch(FILE_TRANSFER_ROUTES.record, { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ files: written }), | ||
| }); | ||
| await readJSON<{ recorded: number }>(recorded, "pull"); |
There was a problem hiding this comment.
🔴 Interrupted pulls strand copied files
If a pull ends early after a complete file, pullFiles leaves that file written but never records its revision. A retry can skip it, leaving later container changes blocked as untracked conflicts.
Learn more
The decoder rejects an incomplete transfer, including one missing its final terminator, through decodeFiles. Earlier complete files have already been written to the Workspace, but this record request runs only after the whole decoder completes. On retry, send sees a Workspace copy without a recorded base. It can skip that copy by size and mtime, permanently losing the chance to record it; later edits on the container produce an untracked conflict.
Example: A pull streams dist/a.js completely, then disconnects halfway through dist/b.js. The Workspace now has a.js, but the record request never runs. A retry skips a.js as same-size and older on the container; after rebuilding a.js, a further pull reports untracked.
Recommended fix: Record successfully committed files even if a later file or terminator fails, or stage all files until the full transfer validates and commit them afterward. Preserve the expected revision on retries so partial pulls cannot silently establish an unrecorded copy.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async #unchanged(file: LocalFile, recorded: RecordedFile): Promise<boolean> { | ||
| if (file.size !== recorded.size) return false; | ||
| if (file.mtime === recorded.mtime) return true; |
There was a problem hiding this comment.
🟡 Container permission changes never reach Workspace
When a container command only changes file permissions, #unchanged treats the file as unchanged. A later pull omits the new mode, so executables remain non-executable in the Workspace.
Learn more
The container captures a file's content hash, size, and modification time in receive and after a pull in record. POSIX chmod changes the mode without changing file content or modification time. This test therefore skips the file, even though the transfer format carries its mode and the Workspace write applies it.
Example: Push bin/run.sh at mode 0644, then execute chmod +x bin/run.sh in the container. A subsequent pull reports zero files and the Workspace copy remains 0644.
Recommended fix: Include mode in the recorded disk version and in the unchanged check. Treat a mode difference as a change, then send and apply the header's mode.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const response = await fetch(FILE_TRANSFER_ROUTES.put, { | ||
| method: "PUT", | ||
| headers: { "content-type": FILE_TRANSFER_CONTENT_TYPE }, | ||
| body, | ||
| // Node's fetch needs this to send a stream; workerd ignores it. | ||
| duplex: "half", | ||
| } as RequestInit); |
There was a problem hiding this comment.
🟡 Cancelled transfers continue writing files
When a host call expires during pushFiles, the active PUT lacks options.signal and keeps sending files. The caller stops waiting while the container continues changing.
Learn more
The host module passes its abort signal to file transfer. Abort checks in this function happen before starting the PUT and before reading each new file, not while an active file streams to computerd. The pull, diff, and record HTTP calls likewise omit the signal. A cancelled JavaScript execution can therefore finish with the caller gone while its file transfer continues to change a filesystem.
Example: An isolate starts pushing a large archive and reaches its host-call deadline halfway through the PUT. The request remains active and writes the rest of the archive into the container after the isolate has received a timeout.
Recommended fix: Pass options.signal through every transfer fetch's RequestInit, and ensure streaming bodies and the response reader are cancelled when the signal aborts.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // Pushing only reads the Workspace; pulling writes it. | ||
| if (direction === "pull" && context.access !== "read-write") { | ||
| throw new Error("ws:container pull requires Workspace write access."); | ||
| } |
There was a problem hiding this comment.
| const target = this.#resolve(header.path); | ||
| await mkdir(path.dirname(target), { recursive: true }); | ||
| const temporary = `${target}.computerd-${randomUUID()}`; | ||
| const hash = createHash("sha256"); | ||
| try { | ||
| await pipeline( | ||
| Readable.fromWeb(incoming.body as import("node:stream/web").ReadableStream<Uint8Array>), | ||
| hashing(hash), | ||
| createWriteStream(temporary, { mode: header.mode }), |
Summary
SimpleContainerBackendis a container that doesn't share files with the Workspace automatically. There's no FUSE mount and no sync around each command. The agent copies files in, runs commands at full disk speed, and copies the results back./workspace/src/a.tsin the Workspace is/workspace/src/a.tsin the container.pullrefuses and writes nothing, unless you passforce: true.Host setup
Agent code
Build and fetch the output:
Edit, then rerun tests. Only the edited file is sent again:
Globs and excludes:
Handling a conflict:
Commands have no default working directory, so always pass
cwd.