Skip to content

Commit 907d349

Browse files
committed
rpc, computer, computerd, examples/mcp, docs: Add discovery commands to codemode
codemode gains subcommands: types prints every declaration, search and describe reach the runtime's own discovery helpers so a script author can find one method without reading everything, and pending lists what a paused run is waiting on. CodemodeRPC carries the same five methods. Approving or rejecting a paused run is deliberately not on the surface: a run pauses because a connector asked for a human's decision, and the process that wrote the script is not that human. Two review findings are folded in. The proxy only ever forwarded the literal /codemode, so a configured path could not be reached; the path option is gone and one shared constant serves both sides. The CLI sets process.exitCode instead of calling process.exit, so a large result piped to another process drains before it ends.
1 parent ec9e84e commit 907d349

17 files changed

Lines changed: 446 additions & 183 deletions

.changeset/codemode-cli-binary.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@cloudflare/computerd": minor
33
---
44

5-
Ships a second binary, codemode, next to computerd in the release artifacts and the computer-computerd-linux-x64 image. Run inside the container, it sends a script to the workspace's host and prints the result, or prints the host's TypeScript declarations with --types. It dials ws://computer.internal/codemode by default and needs no credentials.
5+
Ships a second binary, codemode, next to computerd in the release artifacts and the computer-computerd-linux-x64 image. Run inside the container, it sends a script to the workspace's host and prints the result, with subcommands to print the host's TypeScript declarations, search and describe one method at a time, and list what a paused run is waiting on. It dials ws://computer.internal/codemode by default and needs no credentials.

.changeset/codemode-rpc-interface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@cloudflare/computer-rpc": minor
33
---
44

5-
Adds the CodemodeRPC interface, the code surface a process inside the container reaches by opening a WebSocket to the host's egress endpoint at /codemode. It carries describe, which returns the TypeScript declarations of the globals a script may call, and execute, which runs a script body and reports completed, paused, or error rather than rejecting.
5+
Adds the CodemodeRPC interface, the code surface a process inside the container reaches by opening a WebSocket to the host's egress endpoint at /codemode. It carries types, search, and describe for discovering the globals a script may call, execute, which runs a script body and reports completed, paused, or error rather than rejecting, and pending, which lists what a paused run is waiting on. Approval is deliberately not on the surface.

docs/07_injected_service.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ back through, and the container backend answers it with a capnweb
6868
session whose bootstrap stub is `CodemodeRPC` (doc 08). The `codemode`
6969
binary that ships next to `computerd` is the client for it: a command
7070
the workspace runs can execute a script on the host with
71-
`codemode < script.js` and print the host's TypeScript declarations
72-
with `codemode --types`. The route answers `404` unless the backend was
71+
`codemode < script.js`, print the host's TypeScript declarations with
72+
`codemode types`, and find one method with `codemode search` or
73+
`codemode describe`. Approving a paused run is deliberately not on
74+
this surface. The route answers `404` unless the backend was
7375
constructed with the `codemode` option, and it needs no credential:
7476
reaching it at all means running inside this workspace's container.
7577

docs/08_capnweb_interface.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,25 +220,33 @@ session whose bootstrap stub is:
220220

221221
```ts
222222
interface CodemodeRPC {
223-
describe(): Promise<{ types: string; connectors: string[] }>;
223+
types(): Promise<{ types: string; connectors: string[] }>;
224+
search(query: string): Promise<CodemodeSearch>;
225+
describe(target: string): Promise<CodemodeDescription>;
224226
execute(input: { code: string }): Promise<CodemodeResult>;
227+
pending(executionId?: string): Promise<CodemodePendingAction[]>;
225228
}
226229

227230
type CodemodeResult =
228231
| { status: "completed"; executionId: string; result?: unknown; logs?: string[] }
229-
| { status: "paused"; executionId: string; pending: unknown[] }
232+
| { status: "paused"; executionId: string; pending: CodemodePendingAction[] }
230233
| { status: "error"; executionId: string; error: string; logs?: string[] };
231234
```
232235

233236
`code` is the body of an async function. The host runs it through a
234237
codemode runtime in a dynamic worker, with the connectors the
235238
container backend was configured with as typed globals; `types` is
236-
the TypeScript declaration of those globals. `execute` never rejects:
237-
a script that throws comes back as an `error` result, and a run that
238-
stops for approval on the host comes back as `paused`. Each
239-
connection is its own session and is disposed when the client closes
240-
the socket. The `codemode` binary in the container image is the
241-
reference client.
239+
the TypeScript declaration of all of them, and `search` and
240+
`describe` are the runtime's own discovery helpers for one method at
241+
a time. `execute` never rejects: a script that throws comes back as
242+
an `error` result, and a run that stops for approval on the host
243+
comes back as `paused` with the actions it waits on, which `pending`
244+
also lists. Approving or rejecting is deliberately absent: a run
245+
pauses because a connector asked for a human's decision, and handing
246+
that decision to the process that wrote the script would make the
247+
gate meaningless. Each connection is its own session and
248+
is disposed when the client closes the socket. The `codemode` binary
249+
in the container image is the reference client.
242250

243251
## Push and fetch semantics
244252

examples/mcp/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,15 @@ a script back to the Durable Object, where it runs in a dynamic worker
128128
with the example's `notes` connector in scope:
129129

130130
```js
131-
const types = await codemode.exec({ command: "codemode --types", backend: "container-shell" });
131+
const types = await codemode.exec({ command: "codemode types", backend: "container-shell" });
132132
const added = await codemode.exec({
133133
command: `echo 'await notes.add({ text: "hello" }); return await notes.list({})' | codemode`,
134134
backend: "container-shell",
135135
});
136136
```
137137

138+
`codemode search <query>` and `codemode describe notes.add` find and
139+
document one method at a time instead of printing every declaration.
138140
`src/notes-connector.ts` is the whole connector. Swap it for connectors
139141
over whatever the workspace should reach.
140142

examples/mcp/src/index.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,22 @@ describe("codemode from inside the container", () => {
163163
it("describes the notes connector and runs scripts against it", async () => {
164164
using api = await connect("codemode-run");
165165

166-
const description = await api.describe();
167-
expect(description.connectors).toEqual(["notes"]);
168-
expect(description.types).toContain("declare const notes:");
169-
expect(description.types).toContain("add: (input: AddInput) => Promise<AddOutput>;");
166+
const declared = await api.types();
167+
expect(declared.connectors).toEqual(["notes"]);
168+
expect(declared.types).toContain("declare const notes:");
169+
expect(declared.types).toContain("add: (input: AddInput) => Promise<AddOutput>;");
170+
171+
const found = await api.search("append a note");
172+
expect(found.results[0]?.path).toBe("notes.add");
173+
const described = await api.describe("notes.add");
174+
expect(described.kind).toBe("method");
175+
expect(described.types).toContain("AddInput");
176+
177+
expect(await api.pending()).toEqual([]);
178+
// A capnweb stub proxies any name, so the proof is that the host
179+
// refuses the call: approving is not on the surface.
180+
const offSurface = api as unknown as { approve(input: unknown): Promise<unknown> };
181+
await expect(offSurface.approve({ executionId: "none" })).rejects.toThrow();
170182

171183
const added = await api.execute({
172184
code: 'await notes.add({ text: "hello" }); console.log("added"); return await notes.list({});',

packages/computer/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -488,9 +488,12 @@ new CloudflareContainerBackend({
488488
Then forward `/codemode` to `backend.handleFetch` from the Durable
489489
Object's `fetch`, next to `/api`, and list `CodemodeRuntime` as a
490490
Durable Object binding so the runtime can find its facet class. Inside
491-
the container, `codemode --types` prints the declarations and
492-
`codemode < script.js` runs a script. `@cloudflare/codemode` is an
493-
optional peer dependency; install it when you use this option.
491+
the container, `codemode types` prints the declarations, `codemode
492+
search` and `codemode describe` find one method at a time, and
493+
`codemode < script.js` runs a script. A run that pauses for approval
494+
stays paused until the host decides; the container can list what it
495+
is waiting on but is never given a way to approve it. `@cloudflare/codemode` is an optional peer dependency;
496+
install it when you use this option.
494497

495498
### Constructing without the mixin
496499

packages/computer/src/backends/container/cloudflare-container.test.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -560,23 +560,6 @@ describe("CloudflareContainerBackend", () => {
560560
const res = await backend.handleFetch(new Request("http://computer.internal/codemode"));
561561
expect(res.status).toBe(400);
562562
expect(await res.text()).toMatch(/\/codemode requires a websocket upgrade/);
563-
564-
const custom = new CloudflareContainerBackend({
565-
container: () => ({ getWorkspaceContainer: () => fake.host }),
566-
workspace: fakeWorkspace,
567-
codemode: {
568-
ctx: {} as DurableObjectState,
569-
loader: {} as WorkerLoader,
570-
connectors: () => [],
571-
path: "/scripts",
572-
},
573-
});
574-
expect(
575-
(await custom.handleFetch(new Request("http://computer.internal/codemode"))).status,
576-
).toBe(404);
577-
expect((await custom.handleFetch(new Request("http://computer.internal/scripts"))).status).toBe(
578-
400,
579-
);
580563
});
581564

582565
test("handleFetch refuses a dial-back that does not present the secret", async () => {

packages/computer/src/backends/container/cloudflare-container.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ import {
5555
type WorkspaceEgressPolicy,
5656
} from "../../runtime/egress.js";
5757
import { WorkspaceTransportError } from "../../transport-failure.js";
58-
import { type CodemodeSessionOptions, createCodemodeSession } from "./codemode-session.js";
58+
import {
59+
CODEMODE_PATH,
60+
type CodemodeSessionOptions,
61+
createCodemodeSession,
62+
} from "./codemode-session.js";
5963
import type { IWorkspaceContainerAPI, WorkspaceRef } from "./container-host.js";
6064
import { probeComputerdHealth } from "./health-probe.js";
6165

@@ -137,10 +141,7 @@ export interface CloudflareContainerBackendOptions {
137141
// interception computerd dials back through, and runs in a dynamic
138142
// worker with the configured connectors as typed globals. Left unset,
139143
// the /codemode path answers 404.
140-
codemode?: CodemodeSessionOptions & {
141-
// Path on the egress host the CLI dials. Defaults to "/codemode".
142-
path?: string;
143-
};
144+
codemode?: CodemodeSessionOptions;
144145

145146
// Selector this backend is registered under in Workspace.
146147
// Defaults to "container-shell"; override when the
@@ -155,7 +156,6 @@ const DEFAULT_EGRESS_HOST = "computer.internal";
155156
// in step from one place.
156157
const EGRESS_HEALTH_PATH = "/health";
157158
const EGRESS_API_PATH = "/api";
158-
const EGRESS_CODEMODE_PATH = "/codemode";
159159
const DEFAULT_CONTAINER_PORT = 8080;
160160
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
161161
const DEFAULT_HEARTBEAT_INTERVAL_MS = 20_000;
@@ -414,7 +414,7 @@ export class CloudflareContainerBackend implements WorkspaceBackend {
414414
return this.#egress.gateway.fetch(new Request(parsedUrl, sanitized));
415415
}
416416
const url = new URL(req.url);
417-
if (this.#codemode !== undefined && url.pathname === this.#codemodePath()) {
417+
if (this.#codemode !== undefined && url.pathname === CODEMODE_PATH) {
418418
return this.#handleCodemodeFetch(req);
419419
}
420420
if (url.pathname !== EGRESS_API_PATH) {
@@ -458,10 +458,6 @@ export class CloudflareContainerBackend implements WorkspaceBackend {
458458

459459
// --- internals --------------------------------------------------
460460

461-
#codemodePath(): string {
462-
return this.#codemode?.path ?? EGRESS_CODEMODE_PATH;
463-
}
464-
465461
// Serves one codemode session per upgrade. No bearer check, unlike
466462
// /api: a request can only arrive here through the egress
467463
// interception bound to this workspace, and any process in the
@@ -472,9 +468,7 @@ export class CloudflareContainerBackend implements WorkspaceBackend {
472468
const codemode = this.#codemode;
473469
if (codemode === undefined) return new Response("not found", { status: 404 });
474470
if (req.headers.get("upgrade") !== "websocket") {
475-
return new Response(`${this.#codemodePath()} requires a websocket upgrade`, {
476-
status: 400,
477-
});
471+
return new Response(`${CODEMODE_PATH} requires a websocket upgrade`, { status: 400 });
478472
}
479473
const target = await createCodemodeSession(codemode);
480474
const pair = new WebSocketPair();

packages/computer/src/backends/container/codemode-session.test.ts

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// CodemodeRPCTarget is the bootstrap stub a container process talks to
22
// over /codemode. It adapts a codemode runtime handle to the wire
33
// contract and, because a rejection would surface on the host rather
4-
// than at the caller, folds every failure into an "error" result.
4+
// than at the caller, folds every run failure into an "error" result.
5+
// Approval is deliberately absent from the surface.
56

67
import { describe, expect, test } from "vitest";
78

@@ -16,39 +17,64 @@ function connector(name: string, types: string): CodemodeConnectorLike {
1617
return { name: () => name, getTypeScriptTypes: async () => types };
1718
}
1819

19-
function runtime(execute: CodemodeRuntimeLike["execute"]): CodemodeRuntimeLike {
20-
return { execute };
20+
const pendingAction = { executionId: "p", seq: 1, connector: "kv", method: "put", args: {} };
21+
22+
function runtime(overrides: Partial<CodemodeRuntimeLike> = {}): CodemodeRuntimeLike {
23+
return {
24+
execute: async () => completed(1),
25+
search: async (query) => ({
26+
results: [{ path: `kv.${query}`, connector: "kv", method: query, kind: "method", score: 1 }],
27+
total: 1,
28+
truncated: false,
29+
}),
30+
describe: async (target) => ({
31+
path: target,
32+
types: `declare const ${target}: {}`,
33+
kind: "connector",
34+
}),
35+
pending: async () => [pendingAction],
36+
...overrides,
37+
};
2138
}
2239

2340
describe("CodemodeRPCTarget", () => {
24-
test("describe joins each connector's declarations and lists their names", async () => {
25-
const target = new CodemodeRPCTarget(
26-
runtime(async () => completed(1)),
27-
[connector("kv", "declare const kv: {}"), connector("github", "declare const github: {}")],
28-
);
29-
expect(await target.describe()).toEqual({
41+
test("types joins each connector's declarations and lists their names", async () => {
42+
const target = new CodemodeRPCTarget(runtime(), [
43+
connector("kv", "declare const kv: {}"),
44+
connector("github", "declare const github: {}"),
45+
]);
46+
expect(await target.types()).toEqual({
3047
types: "declare const kv: {}\ndeclare const github: {}",
3148
connectors: ["kv", "github"],
3249
});
3350
});
3451

35-
test("describe with no connectors is empty rather than an error", async () => {
36-
const target = new CodemodeRPCTarget(
37-
runtime(async () => completed(1)),
38-
[],
39-
);
40-
expect(await target.describe()).toEqual({ types: "", connectors: [] });
52+
test("types with no connectors is empty rather than an error", async () => {
53+
expect(await new CodemodeRPCTarget(runtime(), []).types()).toEqual({
54+
types: "",
55+
connectors: [],
56+
});
57+
});
58+
59+
test("search, describe, and pending pass straight through to the runtime", async () => {
60+
const target = new CodemodeRPCTarget(runtime(), []);
61+
expect((await target.search("get")).results[0]?.path).toBe("kv.get");
62+
expect((await target.describe("kv")).types).toBe("declare const kv: {}");
63+
expect(await target.pending("p")).toEqual([pendingAction]);
4164
});
4265

4366
test("execute forwards the code and maps every runtime status", async () => {
4467
const seen: string[] = [];
4568
const target = new CodemodeRPCTarget(
46-
runtime(async ({ code }) => {
47-
seen.push(code);
48-
if (code === "pause") return { status: "paused", executionId: "p", pending: [{ seq: 1 }] };
49-
if (code === "fail")
50-
return { status: "error", executionId: "e", error: "bad", logs: ["l"] };
51-
return { status: "completed", executionId: "c", result: 42, logs: ["hi"] };
69+
runtime({
70+
execute: async ({ code }) => {
71+
seen.push(code);
72+
if (code === "pause")
73+
return { status: "paused", executionId: "p", pending: [pendingAction] };
74+
if (code === "fail")
75+
return { status: "error", executionId: "e", error: "bad", logs: ["l"] };
76+
return { status: "completed", executionId: "c", result: 42, logs: ["hi"] };
77+
},
5278
}),
5379
[],
5480
);
@@ -62,7 +88,7 @@ describe("CodemodeRPCTarget", () => {
6288
expect(await target.execute({ code: "pause" })).toEqual({
6389
status: "paused",
6490
executionId: "p",
65-
pending: [{ seq: 1 }],
91+
pending: [pendingAction],
6692
});
6793
expect(await target.execute({ code: "fail" })).toEqual({
6894
status: "error",
@@ -74,8 +100,10 @@ describe("CodemodeRPCTarget", () => {
74100

75101
test("execute never rejects: empty code and a throwing runtime become error results", async () => {
76102
const target = new CodemodeRPCTarget(
77-
runtime(async () => {
78-
throw new Error("boom");
103+
runtime({
104+
execute: async () => {
105+
throw new Error("boom");
106+
},
79107
}),
80108
[],
81109
);
@@ -85,6 +113,13 @@ describe("CodemodeRPCTarget", () => {
85113
});
86114
expect(await target.execute({ code: "x" })).toMatchObject({ status: "error", error: "boom" });
87115
});
116+
117+
test("the surface has no way to approve or reject", () => {
118+
const target = new CodemodeRPCTarget(runtime(), []) as unknown as Record<string, unknown>;
119+
expect(target.approve).toBeUndefined();
120+
expect(target.reject).toBeUndefined();
121+
expect(target.rollback).toBeUndefined();
122+
});
88123
});
89124

90125
function completed(result: unknown): CodemodeRuntimeOutput {

0 commit comments

Comments
 (0)