Skip to content

Commit f629c63

Browse files
committed
examples/codemode: Add a standalone example for the codemode CLI
A Worker + Durable Object with one exec route and a container that carries codemode, plus a small notes connector, so the CLI has a home of its own rather than riding inside the Code Mode MCP example. The workers test opens /codemode on the Durable Object the way the container would and drives the real runtime and dynamic worker. examples/mcp goes back to what it was on main.
1 parent 907d349 commit f629c63

22 files changed

Lines changed: 15245 additions & 129 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ public surface. Each is a Worker workspace with its own README.
6363
- [`examples/egress`](examples/egress) — sends one URL through the container,
6464
Worker shell, and Worker JavaScript backends with matching `none`, `all`, or
6565
custom egress policies.
66+
- [`examples/codemode`](examples/codemode) — a container whose commands
67+
can run `codemode < script.js`: the script runs on the Durable Object
68+
in a dynamic worker, with the app's connectors as typed globals.
6669
- [`examples/mcp`](examples/mcp) — a Computer MCP example:
6770
one Code Mode `code` tool backed by a durable workspace, a Worker shell,
6871
and a full Linux container.

examples/codemode/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.wrangler/
2+
node_modules/

examples/codemode/Dockerfile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Container image for the codemode example.
2+
#
3+
# Both binaries come out of the public GHCR image, a single layer over
4+
# `scratch` holding computerd and codemode at /usr/local/bin. The
5+
# :VERSION tag is rewritten by the changesets Version Packages PR
6+
# through .github/changeset-version.mjs.
7+
8+
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.2.1 AS computerd
9+
10+
FROM debian:stable-slim
11+
12+
RUN apt-get update \
13+
&& apt-get install -y --no-install-recommends fuse3 libfuse2t64 ca-certificates \
14+
&& rm -rf /var/lib/apt/lists/*
15+
16+
COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd
17+
COPY --from=computerd /usr/local/bin/codemode /usr/local/bin/codemode
18+
19+
ENV PORT=8080
20+
ENV MOUNT_POINT=/workspace
21+
ENV FUSE_MOUNT=auto
22+
EXPOSE 8080
23+
24+
ENTRYPOINT ["/usr/local/bin/computerd"]

examples/codemode/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# codemode example
2+
3+
> [!IMPORTANT]
4+
> **PREVIEW ONLY** This package is provided as a preview for feedback only.
5+
> APIs are unstable and the design is subject to change.
6+
7+
A Worker + Durable Object that boots a container in which any command
8+
can run `codemode < script.js`. The script does not run in the
9+
container: it travels back to the Durable Object and runs there, in a
10+
dynamic worker, with the connectors the Durable Object configured in
11+
scope as typed globals. Here that is one small `notes` connector over
12+
the Durable Object's storage.
13+
14+
```
15+
client ─► Worker POST /c/<name>/exec ─► DO ─► container: codemode < script.js
16+
17+
ws://computer.internal/codemode
18+
19+
DO: codemode runtime ─► dynamic worker
20+
notes.add(), notes.list()
21+
```
22+
23+
## What is where
24+
25+
`src/index.ts` is the Durable Object and a one-route Worker. The only
26+
codemode-specific lines are the `codemode` option on
27+
`CloudflareContainerBackend`, which names the loader, the Durable
28+
Object state, and the connectors, and the two exports the runtime
29+
needs: `WorkspaceProxy`, which carries the container's requests to the
30+
Durable Object, and `CodemodeRuntime`, the facet the runtime keeps its
31+
executions in. `wrangler.jsonc` lists `CodemodeRuntime` as a Durable
32+
Object binding for the same reason.
33+
34+
`src/notes-connector.ts` is the whole connector. Swap it for connectors
35+
over whatever the container should reach: KV, R2, an MCP server through
36+
`McpConnector`, an OpenAPI service through `OpenApiConnector`.
37+
38+
`Dockerfile` copies both `computerd` and `codemode` out of the public
39+
image.
40+
41+
## Run it
42+
43+
```sh
44+
npm run dev --workspace @example/computer-codemode
45+
./script/run
46+
```
47+
48+
The script runs four commands inside the container through
49+
`POST /c/demo/exec`:
50+
51+
```sh
52+
codemode types # declarations of every global
53+
codemode search "append a note" # find a method
54+
codemode describe notes.add # declarations for one method
55+
echo 'await notes.add({ text: "hello" }); return await notes.list({})' | codemode
56+
```
57+
58+
A script is the body of an async function; `return` sends a value
59+
back and `console.log` lines come back on stderr. Exit code 1 means
60+
the script threw, 2 means `codemode` could not connect or was used
61+
wrongly, and 3 means the run paused for approval on the host. There
62+
is no command to approve it from the container, on purpose: a run
63+
pauses because a connector asked for a human's decision.
64+
65+
## Tests
66+
67+
```sh
68+
npm test --workspace @example/computer-codemode
69+
```
70+
71+
The workers test opens `/codemode` on the Durable Object the way the
72+
container would and runs scripts through the real runtime and dynamic
73+
worker, without a container.

examples/codemode/package.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@example/computer-codemode",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"description": "Example Worker + Durable Object whose container can run scripts on the host with the codemode CLI.",
7+
"scripts": {
8+
"dev": "wrangler dev",
9+
"deploy": "wrangler deploy",
10+
"test": "vitest run --config vitest.config.ts",
11+
"typecheck": "tsc --noEmit",
12+
"cf-typegen": "wrangler types"
13+
},
14+
"dependencies": {
15+
"@cloudflare/codemode": "^0.5.0",
16+
"@cloudflare/computer": "*"
17+
},
18+
"devDependencies": {
19+
"@cloudflare/computer-rpc": "*",
20+
"@cloudflare/vitest-pool-workers": "^0.22.0",
21+
"@cloudflare/workers-types": "^5.20260722.1",
22+
"capnweb": "^0.10.0",
23+
"typescript": "^6.0.3",
24+
"vitest": "^4.1.11",
25+
"wrangler": "^4.107.1"
26+
}
27+
}

examples/codemode/script/run

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
# Smoke test against a running `wrangler dev` of this example. Each
3+
# step runs a command inside the container; the codemode ones travel
4+
# back to the Durable Object and run there.
5+
#
6+
# npm run dev (in another terminal), then:
7+
# ./script/run # against http://127.0.0.1:8787
8+
# NAME=foo ./script/run # use Durable Object instance 'foo'
9+
10+
set -euo pipefail
11+
12+
BASE_URL="${1:-http://127.0.0.1:8787}"
13+
BASE_URL="${BASE_URL%/}"
14+
NAME="${NAME:-demo}"
15+
16+
step() { printf '\n=== %s ===\n' "$*"; }
17+
exec_in_container() {
18+
curl -fsS -X POST "${BASE_URL}/c/${NAME}/exec" \
19+
-H 'content-type: application/json' \
20+
-d "$(printf '{"command":%s}' "$(printf '%s' "$1" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.stringify(s)))')")"
21+
}
22+
23+
step "1. what can a script call?"
24+
exec_in_container 'codemode types'
25+
26+
step "2. find one method"
27+
exec_in_container 'codemode search "append a note" && codemode describe notes.add'
28+
29+
step "3. run a script"
30+
exec_in_container "echo 'await notes.add({ text: \"hello\" }); return await notes.list({})' | codemode"
31+
32+
step "4. a script that throws exits 1"
33+
exec_in_container "codemode -e 'throw new Error(\"nope\")'; echo exit=\$?"
34+
35+
printf '\nOK\n'
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// The public Worker never forwards /codemode; the container reaches it
2+
// through the egress interception, which lands on the Durable Object's
3+
// fetch. These tests take the same door directly and drive the real
4+
// runtime, facet, and dynamic worker against the notes connector.
5+
6+
import { env, SELF } from "cloudflare:test";
7+
import type { CodemodeRPC } from "@cloudflare/computer-rpc";
8+
import { newWebSocketRpcSession } from "capnweb";
9+
import { describe, expect, it } from "vitest";
10+
11+
function durableObject(name: string) {
12+
const { CodemodeExample } = env as unknown as { CodemodeExample: DurableObjectNamespace };
13+
return CodemodeExample.get(CodemodeExample.idFromName(name));
14+
}
15+
16+
async function connect(name: string) {
17+
const response = await durableObject(name).fetch("https://example.test/codemode", {
18+
headers: { upgrade: "websocket" },
19+
});
20+
expect(response.status).toBe(101);
21+
const socket = response.webSocket;
22+
if (!socket) throw new Error("expected a websocket");
23+
socket.accept();
24+
return newWebSocketRpcSession<CodemodeRPC>(socket as unknown as WebSocket);
25+
}
26+
27+
describe("codemode example", () => {
28+
it("keeps /codemode private and insists on a websocket", async () => {
29+
const home = await SELF.fetch("https://example.test/");
30+
expect(home.status).toBe(200);
31+
expect(await home.text()).toContain("codemode types");
32+
const publicRoute = await SELF.fetch("https://example.test/codemode");
33+
expect(publicRoute.status).toBe(404);
34+
const plain = await durableObject("plain").fetch("https://example.test/codemode");
35+
expect(plain.status).toBe(400);
36+
});
37+
38+
it("describes the notes connector and finds one method at a time", async () => {
39+
using api = await connect("discover");
40+
41+
const declared = await api.types();
42+
expect(declared.connectors).toEqual(["notes"]);
43+
expect(declared.types).toContain("declare const notes:");
44+
expect(declared.types).toContain("add: (input: AddInput) => Promise<AddOutput>;");
45+
46+
const found = await api.search("append a note");
47+
expect(found.results[0]?.path).toBe("notes.add");
48+
const described = await api.describe("notes.add");
49+
expect(described.kind).toBe("method");
50+
expect(described.types).toContain("AddInput");
51+
});
52+
53+
it("runs scripts against the connector and reports failures as results", async () => {
54+
using api = await connect("run");
55+
56+
const added = await api.execute({
57+
code: 'await notes.add({ text: "hello" }); console.log("added"); return await notes.list({});',
58+
});
59+
expect(added).toMatchObject({ status: "completed", result: ["hello"], logs: ["added"] });
60+
61+
const failed = await api.execute({ code: 'throw new Error("nope");' });
62+
expect(failed.status).toBe("error");
63+
expect(failed.status === "error" && failed.error).toContain("nope");
64+
65+
const blocked = await api.execute({
66+
code: 'return await fetch("https://example.com").then((r) => r.status);',
67+
});
68+
expect(blocked.status).toBe("error");
69+
});
70+
71+
it("lists pending actions but offers no way to approve them", async () => {
72+
using api = await connect("pending");
73+
expect(await api.pending()).toEqual([]);
74+
// A capnweb stub proxies any name, so the proof is that the host
75+
// refuses the call: approving is not on the surface.
76+
const offSurface = api as unknown as { approve(input: unknown): Promise<unknown> };
77+
await expect(offSurface.approve({ executionId: "none" })).rejects.toThrow();
78+
});
79+
});

examples/codemode/src/index.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Example Worker + Durable Object whose container can run scripts on
2+
// the host.
3+
//
4+
// The Durable Object owns one Workspace backed by one container, the
5+
// same shape as examples/container. The one addition is the `codemode`
6+
// option on the container backend: with it, a command inside the
7+
// container can run `codemode < script.js`, and the script runs here,
8+
// in a dynamic worker, with the notes connector in scope.
9+
//
10+
// client ─► Worker POST /c/<name>/exec ─► DO ─► container
11+
// │ codemode
12+
// ws://computer.internal/codemode
13+
// ▼
14+
// DO: codemode runtime ─► dynamic worker
15+
16+
import { DurableObject } from "cloudflare:workers";
17+
18+
import { CodemodeRuntime } from "@cloudflare/codemode";
19+
import {
20+
type DurableObjectStorageLike,
21+
getWorkspace,
22+
type WorkspaceOptions,
23+
WorkspaceProxy,
24+
withWorkspace,
25+
} from "@cloudflare/computer";
26+
import {
27+
CloudflareContainerBackend,
28+
withWorkspaceContainer,
29+
} from "@cloudflare/computer/backends/container";
30+
31+
import { NotesConnector } from "./notes-connector.js";
32+
33+
interface Env {
34+
LOADER: WorkerLoader;
35+
CodemodeExample: DurableObjectNamespace<CodemodeExample>;
36+
}
37+
38+
// WorkspaceProxy is how the container reaches this Durable Object; the
39+
// codemode runtime keeps its executions in a facet it looks up on
40+
// ctx.exports under the name CodemodeRuntime. Both must be exported
41+
// from the Worker entry.
42+
export { CodemodeRuntime, WorkspaceProxy };
43+
44+
class ContainerBase extends withWorkspaceContainer(class extends DurableObject<Env> {}) {
45+
readonly backend = new CloudflareContainerBackend({
46+
container: () => this,
47+
workspace: { binding: "CodemodeExample", id: this.ctx.id.toString() },
48+
egress: { mode: "direct" },
49+
codemode: {
50+
ctx: this.ctx,
51+
loader: this.env.LOADER,
52+
connectors: () => [new NotesConnector(this.ctx, this.env)],
53+
},
54+
});
55+
}
56+
57+
function workspaceOptions(self: InstanceType<typeof ContainerBase>): WorkspaceOptions {
58+
const { ctx } = self as unknown as { ctx: DurableObjectState };
59+
return {
60+
storage: ctx.storage as unknown as DurableObjectStorageLike,
61+
backends: [self.backend],
62+
};
63+
}
64+
65+
export class CodemodeExample extends withWorkspace(ContainerBase, workspaceOptions) {
66+
// Both computerd's /api upgrade and a codemode session's /codemode
67+
// upgrade arrive here through WorkspaceProxy.
68+
override async fetch(request: Request): Promise<Response> {
69+
return this.backend.handleFetch(request);
70+
}
71+
}
72+
73+
interface ExecRequest {
74+
command?: string;
75+
cwd?: string;
76+
}
77+
78+
export default {
79+
async fetch(request: Request, env: Env): Promise<Response> {
80+
const url = new URL(request.url);
81+
82+
const execMatch = url.pathname.match(/^\/c\/([^/]+)\/exec\/?$/);
83+
if (execMatch) return handleExec(request, env, execMatch[1]);
84+
85+
if (url.pathname === "/") {
86+
return new Response(
87+
[
88+
"codemode example",
89+
"",
90+
" POST /c/<name>/exec run a command in the container (JSON result)",
91+
"",
92+
'Try: {"command":"codemode types"}',
93+
"",
94+
].join("\n"),
95+
{ headers: { "content-type": "text/plain" } },
96+
);
97+
}
98+
99+
return new Response("not found", { status: 404 });
100+
},
101+
} satisfies ExportedHandler<Env>;
102+
103+
async function handleExec(request: Request, env: Env, name: string): Promise<Response> {
104+
if (request.method !== "POST") {
105+
return new Response("method not allowed", { status: 405, headers: { allow: "POST" } });
106+
}
107+
let body: ExecRequest;
108+
try {
109+
body = (await request.json()) as ExecRequest;
110+
} catch {
111+
return errorJSON(new Error("invalid JSON body"), 400);
112+
}
113+
if (typeof body.command !== "string" || body.command.length === 0) {
114+
return errorJSON(new Error("must provide command"), 400);
115+
}
116+
117+
const stub = env.CodemodeExample.get(env.CodemodeExample.idFromName(name));
118+
const ws = await getWorkspace(stub as unknown as Parameters<typeof getWorkspace>[0]);
119+
try {
120+
const handle = await ws.runtime.exec(body.command, { cwd: body.cwd, encoding: "utf8" });
121+
const result = await handle.result();
122+
return new Response(JSON.stringify(result), {
123+
status: 200,
124+
headers: { "content-type": "application/json" },
125+
});
126+
} catch (error) {
127+
return errorJSON(error, 500);
128+
}
129+
}
130+
131+
function errorJSON(error: unknown, status: number): Response {
132+
const message = error instanceof Error ? error.message : String(error);
133+
return new Response(JSON.stringify({ error: message }), {
134+
status,
135+
headers: { "content-type": "application/json" },
136+
});
137+
}

0 commit comments

Comments
 (0)