Skip to content

backend: simple container backend - #262

Draft
aron-cf wants to merge 4 commits into
mainfrom
simple-container-backend
Draft

aron-cf wants to merge 4 commits into
mainfrom
simple-container-backend

Conversation

@aron-cf

@aron-cf aron-cf commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

SimpleContainerBackend is 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.

  • A path is the same file on both sides: /workspace/src/a.ts in the Workspace is /workspace/src/a.ts in the container.
  • Only changed files are copied, and nothing is ever deleted.
  • If a file changed in both the Workspace and the container, pull refuses and writes nothing, unless you pass force: true.

Host setup

const container = new SimpleContainerBackend({
  container: () => this,
  workspace: { binding: "Agent", id: this.ctx.id.toString() },
});

new Workspace({
  storage: this.ctx.storage,
  backends: [
    new WorkerJavaScriptBackend({
      loader: env.LOADER,
      modules: { "ws:container": createContainerModule({ backend: container }) },
    }),
    container,
  ],
});

Agent code

Build and fetch the output:

import { exec, push, pull } from "ws:container";

export default async function () {
  await push(["/workspace/package.json", "/workspace/package-lock.json", "/workspace/src"]);

  const build = await exec("npm ci && npm run build", { cwd: "/workspace" });
  if (build.exitCode !== 0) return { failed: build.stderr };

  return pull(["/workspace/dist"]); // { pulled, bytes, skipped }
}

Edit, then rerun tests. Only the edited file is sent again:

import { exec, push } from "ws:container";
import fs from "node:fs/promises";

export default async function () {
  await fs.writeFile("/workspace/src/math.js", "export const add = (a, b) => a + b;\n");
  const sent = await push(["/workspace/src"]); // { pushed: 1, unchanged: 41, bytes: 37 }
  const test = await exec("npm test", { cwd: "/workspace" });
  return { sent, passed: test.exitCode === 0, output: test.stdout };
}

Globs and excludes:

import { pull } from "ws:container";

export default async function () {
  return pull(["/workspace/**/*.snap"], { exclude: ["/workspace/node_modules"] });
}

Handling a conflict:

import { exec, push, pull } from "ws:container";
import fs from "node:fs/promises";

export default async function () {
  await push(["/workspace/src"]);
  await exec("npx prettier --write src", { cwd: "/workspace" });     // container edits src
  await fs.writeFile("/workspace/src/index.js", "// my edit\n");     // workspace edits src too

  try {
    return await pull(["/workspace/src"]);
  } catch (error) {
    // "pull refused: 1 file changed in both the workspace and the container:
    //  /workspace/src/index.js. Pass { force: true } to overwrite the workspace copies."
    return { conflict: error.message };
    // or keep the container's version instead:
    // return pull(["/workspace/src"], { force: true });
  }
}

Commands have no default working directory, so always pass cwd.


Devin Review

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-bot

changeset-bot Bot commented Oct 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8e01872

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

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

Comment thread packages/rpc/src/files.ts
else throw new RangeError(`${JSON.stringify(pattern)} is outside ${base}.`);
}
while (path.startsWith("./")) path = path.slice(2);
path = path.replace(/\/+$/, "");
Comment thread packages/rpc/src/files.ts

function trimRoot(root: string): string {
if (!root.startsWith("/")) throw new TypeError(`Root must be absolute, got ${root}.`);
const trimmed = root.replace(/\/+$/, "");

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 potential issues.

Devin Review

Comment on lines +209 to +210
await fs.writeFile(target, incoming.body, { mode: incoming.header.mode });
written.push({ path: incoming.header.path, rev: await fs.revision(target) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +225 to +231
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +303 to +305
async #unchanged(file: LocalFile, recorded: RecordedFile): Promise<boolean> {
if (file.size !== recorded.size) return false;
if (file.mtime === recorded.mtime) return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +142 to +148
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +235 to +238
// 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.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Read-only code can modify container files

With access: "read", push still writes Workspace bytes onto the container's disk. Read-only isolate code can replace container files despite lacking write access.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +148 to +156
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 }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Transfer paths follow symlinked directories

When a transfer path contains a symlinked parent, receive follows it after lexical validation. A push can write outside its intended container directory.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@aron-cf
aron-cf marked this pull request as draft October 26, 2026 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants