Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/10_project_layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ packages/workspace/
│ ├── workspace.ts # Workspace facade
│ ├── shell.ts # WorkspaceShell
│ ├── backend.ts # Backend interface
│ ├── backends/
│ │ ├── container/ # Cloudflare Container + wsd backend
│ │ ├── worker/ # Dynamic Worker + just-bash backend
│ │ └── test.ts # In-process test backend
│ ├── backends/
│ │ ├── container/ # Cloudflare Container + wsd backend
│ │ ├── worker/ # Dynamic Worker + just-bash backend
│ │ ├── codemode/ # Dynamic Worker + JavaScript sandbox backend
│ │ └── test.ts # In-process test backend
│ ├── proxy.ts # WorkspaceProxy
│ ├── proxy-stub.ts # Client-side stub plumbing
│ ├── stub.ts # DO stub helpers
Expand Down
161 changes: 161 additions & 0 deletions docs/16_codemode_backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# 16. Codemode backend

> [!NOTE]
> This doc reflects shipped code in
> `packages/workspace/src/backends/codemode/`. The example deployment
> lives at `examples/codemode/`.

The codemode backend is the third `WorkspaceBackend` shape the
package ships. Like the worker backend it runs in a Dynamic Worker
minted through `env.LOADER`, but the command it runs is a JavaScript
snippet rather than a shell line. The snippet executes in a fresh,
network-isolated sandbox and reaches the host workspace through a
`state.*` namespace. There is no second store and no sync round
trip; the host Durable Object's SQLite is the only authoritative
state.

Import via the sub-path so the codemode payload tree-shakes out of
consumers that don't use it:

```ts
import { CodemodeBackend } from "@cloudflare/workspace/backends/codemode";
```

## When to reach for it

All three backends act on the same store, and the caller (or agent)
picks which one runs a given command. The choice is about the shape
of the task, not access:

- The **worker** backend takes a shell line and is the natural fit
for text tooling and `git` expressed as a pipeline.
- The **codemode** backend takes JavaScript and is the natural fit
for logic over the files — loops, conditionals, shaping data, and
returning a structured value rather than parsing text out of
stdout.
- The **container** backend takes a shell line against a real Linux
userland; reach for it when the command needs real binaries, a
language runtime, or the network.

Reach for the codemode backend when:

- The task reads more clearly as code than as a shell line, or wants
to compute and return a value.
- You want the tightest sandbox for model-authored code: the isolate
has no network at all, and nothing carries over between runs.

## The `state.*` surface

The snippet reaches the filesystem through an async `state.*`
namespace. It mirrors the surface the worker backend already exposes
to just-bash, because keeping it smaller would not contain anything —
the agent picks the backend and every backend acts on the same store.

- Reads: `readFile(path)` (utf8), `readFileBytes(path)` (returns a
`Uint8Array`), `stat(path)`, `lstat(path)`, `exists(path)`,
`readlink(path)`, `readdir(path)` (names), `find(dir, glob?)`,
`ls(prefix)`, `grep(pattern, path, { ignoreCase? })`.
- Mutations: `writeFile(path, data)` (string or `Uint8Array`),
`mkdir(path, { recursive? })`, `rm(path, { recursive?, force? })`,
`chmod(path, mode)`, `symlink(target, path)`.

```js
await state.mkdir("/workspace", { recursive: true });
await state.writeFile("/workspace/hello.txt", "hello world");
return await state.readFile("/workspace/hello.txt"); // → stdout
```

Every call forwards straight to the live `WorkspaceFilesystem`, so
the storage shape, mount rules, and read-only enforcement are the
same as the other backends. The functions take positional arguments:
codemode serializes a call's argument list and spreads it back into
the host function, so `state.writeFile("/a", "hi")` arrives as
`writeFile("/a", "hi")`.

The only filesystem operation left out is the streaming `readFile`
variant. Its `ReadableStream` cannot cross the host-to-sandbox call
boundary, so `readFileBytes` drains it into bytes host-side instead.
Binary survives the trip in both directions because codemode's
transport codec tags `Uint8Array` / `ArrayBuffer` values as base64;
the same tagging lets `writeFile` accept a `Uint8Array` body.
Convenience operations the shell adapter composes on top of the
primitives — `appendFile`, `cp`, `mv`, `readdirWithFileTypes` — are
not separate calls, because a snippet composes them in a line or two
of JavaScript.

## Wire shape

```
agent code
│ Workers RPC
host DO ─── Workspace ─── CodemodeBackend
│ env.LOADER (DynamicWorkerExecutor)
sandbox isolate (one per exec)
runs the JavaScript snippet
│ state.* tool call over the executor's
│ own host↔sandbox channel
state provider closures (host side)
│ forward to Workspace.fs
host DO's SQLite
```

Unlike the worker backend, the codemode backend needs no
`WorkspaceServiceProxy` loopback binding. The `state.*` functions are
plain closures that capture the live `Workspace.fs`; the codemode
executor carries the tool calls between the sandbox and those
closures, and the closures run in the host Durable Object's own
request context where its storage handles are valid.

## Isolation and lifecycle

`globalOutbound` stays at its default of `null`, so a snippet cannot
`fetch()` or `connect()` anywhere. Its only door to the outside is
the `state.*` namespace. Each `exec` runs in a throwaway isolate, so
there are no shared globals between runs and nothing to reattach to:
`getExec` rejects with `ENOENT`, and `killExec` / `disposeExec` are
no-ops.

## Output and exit mapping

The snippet's return value and any `console.log` output become
stdout; the return value is rendered after the logged lines. A
thrown error becomes stderr with exit code 1. A snippet that returns
nothing produces empty stdout and exit 0. The value `0` and other
falsy returns are rendered rather than dropped.

## Push and pull

`BackendHandle.sync` is `"none"`. With a single authoritative store
there is nothing to ship or fetch; `Workspace.push` and
`Workspace.pull` short-circuit and the reconcile pass on connect is
skipped. The exec bracket still calls them so the surface stays
uniform — every `ExecResult.pushed`, `pulled`, and `skipped` is
empty.

## The `/workspace` mount root is not created for you

A fresh workspace has an empty tree, and the filesystem does not
create parent directories on write, so a snippet that writes under
`/workspace` before the directory exists fails with `ENOENT`. Create
it first with `await state.mkdir("/workspace", { recursive: true })`,
or write under a path whose parent already exists. Deployments that
mount something under `/workspace` get the root for free, because
registering a mount runs the same recursive `mkdir`.

## Example

`examples/codemode/` is a single wrangler project that runs one
Workspace with all three backends (`shell`, `codemode`, `container`)
and an optional agent layer that drives them through a model. It
exposes the same `/c/<name>/file/...` and `/c/<name>/exec` routes as
the container and worker examples, plus a `/c/<name>/agent` route.
`script/run` is a smoke test that round-trips one file through every
backend.

Run with `npm run dev --workspace @example/workspace-codemode`.
4 changes: 3 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ It provides:
- A fs API for working with files and directories compatible with Worker bindings.
- R2-backed mounts for pre-filling read-only data into the workspace tree.
- Durability over DO restarts for all file operations.
- A pluggable shell backend: a Cloudflare Container running the `wsd` FUSE daemon (full Linux userland) or a Dynamic Worker running [just-bash](https://github.com/vercel-labs/just-bash) (no container, broad textual tooling).
- Pluggable backends for running commands against the workspace: a Cloudflare Container running the `wsd` FUSE daemon (full Linux userland), a Dynamic Worker running [just-bash](https://github.com/vercel-labs/just-bash) (no container, broad textual tooling), or a Dynamic Worker running a JavaScript snippet in a network-isolated sandbox (codemode).
- Workspace constructable without a backend, for filesystem-only use cases.
- Out-of-the-box tools for `@cloudflare/agents`. **(not yet implemented)**

Expand All @@ -44,6 +44,7 @@ The package ships several entrypoints:
| `@cloudflare/workspace` | The Workspace facade, stub types, the R2 mount, and proxy classes. |
| `@cloudflare/workspace/backends/container` | `CloudflareContainerBackend` and `withWorkspaceContainer`. Pulls in the wsd / capnweb sync plumbing. |
| `@cloudflare/workspace/backends/worker` | `WorkerBackend` and the bundled just-bash shell. The shell ships as a record of code-split modules the Dynamic Worker loads on demand: a ~290 KB entry parsed on cold start, plus ~2.5 MB of chunks that stay cold until a script reaches for them. |
| `@cloudflare/workspace/backends/codemode` | `CodemodeBackend`. Runs a JavaScript snippet in a network-isolated Dynamic Worker that reaches the workspace through a `state.*` namespace. |
| `@cloudflare/workspace/git` | Isomorphic-git glue for working with checkouts inside the workspace. |
| `@cloudflare/workspace/artifacts` | `createArtifact`, a session-scoped facade over the Cloudflare Artifacts Workers binding, plus its argv CLI. |

Expand Down Expand Up @@ -235,6 +236,7 @@ above, then dive into the area you're working on.
| [13. Git interface](./13_git_interface.md) | `workspace.git` and the `git` CLI inside the shell, backed by isomorphic-git. |
| [14. Assets interface](./14_assets_interface.md) | `share` a workspace file to R2 and get back a presigned URL. |
| [15. Artifacts interface](./15_artifacts_interface.md) | `createArtifact` and the `artifacts` CLI, a session-scoped facade over the Cloudflare Artifacts binding. |
| [16. Codemode backend](./16_codemode_backend.md) | Running a JavaScript snippet in a network-isolated Dynamic Worker that reaches the workspace through a `state.*` namespace. |

## High-level API

Expand Down
3 changes: 3 additions & 0 deletions examples/codemode/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.wrangler/
build/
node_modules/
45 changes: 45 additions & 0 deletions examples/codemode/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Container image for the container example.
#
# Pulls the wsd binary out of the public GHCR image. That image is
# a single layer over `scratch` whose only contents are the SEA
# binary at /usr/local/bin/wsd; we COPY it into a slim debian
# runtime below. The :VERSION tag is rewritten in lockstep with
# the rest of the monorepo by script/set-versions.mjs.
#
# wsd mounts a FUSE filesystem at MOUNT_POINT so exec'd commands
# see the same VFS the RPC surface reads and writes. With
# FUSE_MOUNT=auto (below) the same image works in both directions:
# Cloudflare Containers expose /dev/fuse to the workload, so the
# real FUSE backend mounts; `wrangler dev` doesn't, so wsd falls
# back to the userspace shim transparently.
#
# Node comes from Debian's own package repository, not from the
# NodeSource repository. The examples/container image installs
# NodeSource's build of Node 22 over HTTPS, but that fetch fails
# behind a network that intercepts TLS with its own certificate
# authority (the curl to deb.nodesource.com aborts with a
# self-signed-certificate error). The Debian mirror is served over
# plain HTTP, so installing Debian's `nodejs` and `npm` builds in
# those environments too. The tradeoff is an older Node than
# NodeSource ships; fine for exercising the container backend.

FROM ghcr.io/cloudflare/workspace-wsd-linux-x64:0.0.0-alpha.11 AS wsd

FROM debian:stable-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fuse3 libfuse2t64 ca-certificates git nodejs npm \
&& rm -rf /var/lib/apt/lists/*

COPY --from=wsd /usr/local/bin/wsd /usr/local/bin/wsd

# wsd's defaults: HTTP+WS on :8080, FUSE mount on MOUNT_POINT.
# FUSE_MOUNT=auto picks real FUSE on Cloudflare Containers (where
# /dev/fuse is exposed) and the userspace shim under wrangler dev.
ENV PORT=8080
ENV MOUNT_POINT=/workspace
ENV FUSE_MOUNT=auto
EXPOSE 8080

ENTRYPOINT ["/usr/local/bin/wsd"]
Loading
Loading