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
15 changes: 13 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ jobs:
- name: container
workspace: "@example/computer-container"
path: examples/container
- name: agent
workspace: "@example/computer-agent"
path: examples/agent
# Checks in a hand-written env shape rather than the
# generated file, which also inlines the whole workerd type
# library. `wrangler types` refuses to overwrite a file it
# did not write, so the generate step is skipped below —
# which also means this example's typecheck runs against
# the file the repository actually ships.
worker_types: hand-written
steps:
- uses: actions/checkout@v6
with:
Expand All @@ -127,6 +137,7 @@ jobs:
- run: npm run build --workspaces --if-present

- name: Generate worker types
if: matrix.worker_types != 'hand-written'
run: npx wrangler types
working-directory: ${{ matrix.path }}

Expand All @@ -136,8 +147,8 @@ jobs:
- name: Typecheck
run: npm run typecheck --workspace ${{ matrix.workspace }} --if-present

# Examples don't ship tests today; --if-present makes this a
# no-op until they do.
# Only some examples ship tests; --if-present keeps this a no-op
# for the ones that don't.
- name: Test
run: npm test --workspace ${{ matrix.workspace }} --if-present

Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ loop. Reach for these when you're chasing a behavior the unit tests don't cover.
- `computerd-fuse-flush.mjs` end-to-end checks that the FUSE driver spills
its in-memory write buffer into the backing VFS, so a capnweb-side
`pullOnce` actually sees the bytes.
- `container-mount-probe.sh` asks whether a container can give one command a
read-only view of the mount point, which decides whether `writable: false`
can be enforced preventively there rather than refused on write-back. Run
it inside a deployed container; local Docker is more permissive.
- `fs-tests.sh` / `run-fs-tests.sh` run the filesystem conformance
harness against the FUSE mount.
- `fs-bench.sh` / `run-fs-bench.sh` benchmark common development
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ public surface. Each is a Worker workspace with its own README.
- [`examples/worker-javascript`](examples/worker-javascript) — mirrors
`worker-shell`, but `exec` evaluates an ECMAScript module in a Dynamic
Worker instead of running a shell command.
- [`examples/agent`](examples/agent) — an agent over all three backends
that asks a human before any command whose effect it cannot read off
the command's own text, and runs everything else without write
access.
- [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think)
chat agent that uses the workspace as its working directory, reachable
from a terminal.
Expand Down
10 changes: 10 additions & 0 deletions docs/20_approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ Ask before the command starts. Once it is running there is nowhere to
suspend it that does not risk a partial result, and a write-by-write
prompt would ask hundreds of times for one `rm -rf`.

That puts the question at the tool layer rather than at the gate, since
the tool layer is the only one of the two that runs before the action
exists. [`examples/agent`](../examples/agent) wires it that way and is
worth reading for how the two seams divide the work: the tool layer
asks, and the gate — which cannot ask — narrows.

## The audit hook

Notified after an action has been decided, and after it has run.
Expand Down Expand Up @@ -216,3 +222,7 @@ error, so the agent loop survives it.
which are a fixed property of a path rather than a per-command
decision. Both apply, and neither is a way around the other.
- [09. Tool interface](./09_tool_interface.md) — the agent-facing tools.
- [`examples/agent`](../examples/agent) — all three used together, with
an approval matcher whose only job is to ask fewer questions and a
test that runs every command it allows to check that none of them
write.
3 changes: 3 additions & 0 deletions examples/agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.wrangler/
build/
node_modules/
43 changes: 43 additions & 0 deletions examples/agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Container image for the agent example.
#
# Pulls the computerd 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/computerd; 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.
#
# computerd 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 computerd falls
# back to the userspace shim transparently.


FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd

FROM debian:stable-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fuse3 libfuse2t64 ca-certificates curl gnupg git \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*

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

# computerd'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/computerd"]
228 changes: 228 additions & 0 deletions examples/agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# agent example

> [!IMPORTANT]
> **PREVIEW ONLY** This package is provided as a preview for feedback only.
> APIs are unstable and the design is subject to change.

An agent that runs shell commands in a Workspace and asks before the
ones it cannot vouch for. It is here to show what
[`docs/20_approval.md`](../../docs/20_approval.md) is for: the write
capability, the gate, and the audit hook, with something using all
three.

The property the example is built around:

> a command runs with write access **if and only if** a human approved
> it.

## Why that is one property and not two

There are two obvious ways to keep a model from wrecking a workspace,
and only one of them is a boundary.

The first is to read the command and decide. That is a heuristic. It
was also the only tool available before the write capability existed,
and it was load-bearing, which is why its holes mattered: an early
version of the matcher in `src/approval-policy.ts` waved through `find
/workspace -mindepth 1 -delete`, because the verb was on the allowlist
and its flags were not.

The second is to withhold the capability. A command that was not
approved runs against a filesystem handle that has no write access, so
its writes fail whatever anybody believed about them. That is not a
guess, and it covers every caller rather than the model's path only.

Putting the second one underneath is what makes the first one safe to
keep. The matcher's job shrinks from *stopping* damage to *reducing
interruptions*, and its failure modes stop being symmetrical:

| The matcher is wrong about | What it costs |
|---|---|
| a read, calling it a write | one question nobody needed to answer |
| a write, calling it a read | the command runs read-only and fails visibly |

Neither loses a file. That is the only reason a regex-and-allowlist
matcher belongs anywhere near this decision, and it is why the two
decisions are wired to a single predicate in `src/agent.ts`:

```ts
writable: (input) => decideApproval(input, policy).needsApproval
```

which reads backwards until you notice when it runs. The AI SDK does
not call a tool's `execute` until approval has been granted, so asking
for write access exactly when approval was required means write access
and human attention cannot drift apart.

## The three backends, and why the answer differs

One Workspace, three backends, because a withheld capability is
enforced in a different place in each and that difference is worth
seeing.

| Backend | Runs | Refusal lands | Default rule |
|---|---|---|---|
| `worker-shell` | just-bash in a Dynamic Worker | inside the command, as `EROFS` | matcher |
| `worker-javascript` | an ECMAScript module in a Dynamic Worker | inside the module, the same way | always ask |
| `container-shell` | computerd over real coreutils | on write-back, as skipped entries | always ask |

Only `worker-shell` gets the matcher. The container runs real binaries
with public network access, and it is also where a refused write is
caught late — the command writes to the container's own copy of the
tree and the refusal arrives when those changes are pulled back — so it
is the worst place to be guessing. The JavaScript backend evaluates a
module whose effects are not a function of any verb, so there is
nothing for a matcher to be conservative about. Both are gated
outright.

## Architecture

```
you ──► cli/chat.mjs ──► Worker /c/<name>/agent
▲ (@ai-sdk/tui) │
│ ▼
└── approval prompt ── AgentExample DO
│ streamText + toolApproval
Workspace ├─ gate (narrows write access)
├─ audit (records the outcome)
├─► WorkerShellBackend ──► Dynamic Worker
├─► WorkerJavaScriptBackend ──► Dynamic Worker
└─► CloudflareContainerBackend ──► computerd
```

The gate and the audit hook are installed on the `Workspace`, not
around the agent. That is deliberate: the tool layer only covers the
model's path, while the seams also see the HTTP routes below and
anything added later.

The gate is not a second copy of the approval decision, and it cannot
be — a gate runs once the action exists, and there is nowhere to
suspend a running command that does not risk a partial result. What it
does is check the invariant from the other side. A command holding
write access should be one the matcher would have raised a question
about, since that is the only route to write access through the tool
layer. A recognized read that turns up wanting write access did not
come that way, and it is narrowed back to read-only, which costs it
nothing the matcher says it needed.

## The approval has to survive the trip

The conversation lives in the terminal, not in the Durable Object. An
answer to an approval therefore arrives as a claim the client makes
about something you supposedly did, and on that claim rests the write
access the command is about to get. So the worker signs every approval
it asks for, with a per-object key it keeps in storage, and the AI SDK
checks the signature before it will run the tool call. An approval that
was never issued has nothing to present.

The terminal UI drops the signature. Recording your answer replaces the
approval rather than adding to it, so what goes back is unsigned and
the turn dies with `missing signature`. That is true of every published
`@ai-sdk/tui` through 1.0.52, so the client wraps its transport to
remember the signatures it saw and put them back:
[`cli/approval-signatures.mjs`](cli/approval-signatures.mjs). The
repair belongs in the transport because a signature is not a secret —
it is a MAC only the worker can produce or check — and carrying one
across a turn it was always meant to survive gives the client nothing
it did not already have.

## Running it

```bash
npm install
npm run build # from the repo root
npm run dev --workspace @example/computer-agent # needs Docker for the container backend
```

`wrangler dev` builds the container image before it will start, so a
machine that cannot build it gets none of the example, including the
two backends that never touch a container. There is a second config
without the container for exactly that case:

```bash
npm run dev:local --workspace @example/computer-agent
```

Everything below works the same way under it, except that asking for
the `container-shell` backend fails: it is not there.

Then, in another terminal, put something in the workspace for the agent
to look at and start talking to it:

```bash
curl -X PUT --data-binary 'hello world' \
localhost:8787/c/default/file/workspace/hello.txt

npm run chat --workspace @example/computer-agent
```

That `PUT` is itself a gated action — it goes through `Workspace.fs`,
so it shows up in the audit trail below as `fs.write`.

Two things to try, in this order:

```
cat the file at /workspace/hello.txt
```

Runs unattended. The matcher recognizes it, and it ran without write
access, which cost it nothing.

```
delete everything under /workspace
```

Stops and asks. Say no and nothing happens. Say yes and it runs with
the write access approval bought it.

The approval prompt shows the tool and the command. It does not show
the matcher's reason for asking, because an approval request has
nowhere to carry per-call text; the reason goes to the audit trail
instead:

```bash
curl -s localhost:8787/c/default/audit | jq
```

The HTTP surface, if you would rather drive it without a model:

```
PUT /c/<name>/file/workspace/<path> write a file
GET /c/<name>/file/workspace/<path> read a file
POST /c/<name>/exec run a command {"command":…,"backend":…}
POST /c/<name>/agent one agent turn (UI message stream)
GET /c/<name>/audit what the audit hook recorded
```

`POST /c/<name>/exec` is a caller the tool layer knows nothing about,
which makes it the quickest way to watch the gate work: ask it to run
`cat` and the audit trail shows the command allowed with `writable:
false`, because the gate took away access the command never needed.

## Tests

```bash
npm test --workspace @example/computer-agent
```

Three files, and the third is the interesting one.

`approval-policy.test.ts` pins what the matcher says. `agent.test.ts`
pins the invariant — that write access and approval are the same
decision — and the gate's narrowing.

`approval-policy.effects.test.ts` does something the other two cannot.
Assertions about a matcher are written by whoever wrote the matcher,
from the same blind spot, so they find the cases somebody thought of.
That file instead runs every command the policy would allow through
just-bash itself, against a filesystem that records every mutation,
and fails if any of them wrote — 630 commands generated, 475 allowed
unattended, none of them writing. Both real defects in this policy were
found by running the agent by hand and noticing, not by listing
examples, which is the argument for having it.

Its corpus derives the verbs from the allowlist rather than from a copy,
so a verb added to the policy later comes under test without anybody
remembering to add it.
Loading
Loading