Add a codemode CLI for scripts from inside the container - #137
Add a codemode CLI for scripts from inside the container#137mattzcarey wants to merge 8 commits into
Conversation
A process inside the container can open a WebSocket to the host's egress endpoint at /codemode and receive a capnweb session whose bootstrap stub is CodemodeRPC. Unlike WorkspaceRPC, the host is the server here and a short-lived command is the client. describe returns the TypeScript declarations of the globals a script may call; execute runs a script body and reports completed, paused, or error rather than rejecting, so a failure reaches the caller as a printable result.
CloudflareContainerBackend takes a codemode option naming the Worker Loader, the Durable Object state, and a factory for the connectors a script may call. When set, a websocket upgrade on /codemode gets its own capnweb session over a codemode runtime built from those connectors; the script runs in a dynamic worker with them in scope. The route needs no credential: a request reaches it only through the outbound interception bound to this workspace, and any process in the container is meant to be able to run scripts. The /api bearer token has no counterpart here because every codemode session is its own. WorkspaceProxy forwards /codemode to the Durable Object the way it forwards /api. @cloudflare/codemode becomes an optional peer dependency and is imported lazily, so consumers that never set the option do not need it installed.
codemode reads a script from stdin, a file, or -e, sends it to the host over a capnweb WebSocket at ws://computer.internal/codemode, and prints the result, with console output on stderr. --types prints the host's TypeScript declarations. Exit codes are 0 for a completed run, 1 when the script threw, 2 for a usage or connection error, and 3 when the run paused for approval on the host. The SEA build now produces a binary per CLI and platform. computerd keeps the FUSE assets and the compiled-in port; codemode carries only its bundle. Both are staged into the computer-computerd-linux-x64 image and by the release image workflow. codemode starts its main unconditionally, as computerd does, because the SEA imports the bundle from a data: URL and there is no require.main to compare.
The example lists a small connector over the Durable Object's storage in the container backend's codemode option, forwards /codemode next to /api, exports CodemodeRuntime from the Worker entry, and copies the codemode binary into its image. The workers test opens the route on the Durable Object and runs a script through the real runtime and dynamic worker against the connector.
Doc 07 covers the host-served /codemode route and the second binary; doc 08 documents CodemodeRPC alongside the daemon-served interfaces.
🦋 Changeset detectedLatest commit: f629c63 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
|
Thanks for your interest in Cloudflare Computer. This repository does not accept unsolicited pull requests. Please use one of the accepted contribution paths instead:
If a maintainer asked you to open this pull request, they can add the |
commit: |
The publish script copied only computerd into the image context, so the Dockerfile's COPY of bin/codemode had no source and every release image build would have failed. It now stages both binaries, matching build-docker.mjs and the release-candidate image workflow.
…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.
There was a problem hiding this comment.
Note
This report is out of date. Scroll down for Devin Review's latest report on this PR.
Devin Review found 3 new potential issues.
4 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| import { newWebSocketRpcSession } from "capnweb"; | ||
| import { WebSocket } from "ws"; | ||
|
|
||
| const DEFAULT_URL = "ws://computer.internal/codemode"; |
There was a problem hiding this comment.
🟡 Custom egress hosts strand codemode
With a custom egressHost, codemode still dials DEFAULT_URL at computer.internal. Only the custom hostname is intercepted, so every command fails unless callers also inject CODEMODE_URL.
Learn more
The backend supports replacing its intercepted hostname through egressHost. The CLI independently defaults to computer.internal, and the backend does not add a matching CODEMODE_URL to the container environment. Therefore the CLI sends its WebSocket request to a hostname with no corresponding interception whenever the option changes.
Example: Configure egressHost: "sandbox-a.internal" to avoid a collision. The backend intercepts sandbox-a.internal, but codemode types opens ws://computer.internal/codemode and fails to connect.
Recommended fix: When codemode is configured, derive and inject CODEMODE_URL from #options.egressHost unless containerEnv.CODEMODE_URL already overrides it. Ensure the launch record includes that derived environment value so an already-running container is relaunched when the hostname changes.
Was this helpful? React with 👍 or 👎 to provide feedback.
| function openSocket(url: string): Promise<WebSocket> { | ||
| return new Promise((resolve, reject) => { | ||
| const ws = new WebSocket(url); | ||
| ws.once("open", () => resolve(ws)); | ||
| ws.once("unexpected-response", (_request, response) => { | ||
| reject(new Error(`${url} answered HTTP ${response.statusCode} instead of upgrading`)); | ||
| }); | ||
| ws.once("error", (error) => reject(new Error(`could not connect to ${url}: ${error.message}`))); | ||
| }); |
There was a problem hiding this comment.
🟡 Connection timeout leaves socket alive
withTimeout rejects while openSocket leaves its pending WebSocket open. A blackholed endpoint keeps the event loop alive, so the CLI hangs beyond the requested timeout.
Learn more
The timeout races the connection promise but cannot cancel the work behind it. If no open, error, or unexpected-response event arrives, the WebSocket remains active after the race rejects. Node then keeps waiting on that handle even though main has set exit code 2.
Example: Set CODEMODE_URL to an address whose firewall silently drops packets and run codemode types --timeout 2000. The CLI prints the timeout after two seconds but remains alive while the WebSocket connection attempt continues.
Recommended fix: Make connection timeout cleanup own the socket. Pass an abort signal or timeout into openSocket, close or terminate the socket on expiry, and remove its listeners when the promise settles. Add a test using a server or socket that accepts TCP without completing the WebSocket handshake.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const text = | ||
| typeof outcome.result === "string" | ||
| ? outcome.result | ||
| : JSON.stringify(outcome.result, null, 2); | ||
| io.stdout(`${text}\n`); |
There was a problem hiding this comment.
🟡 BigInt results become connection failures
When a completed script returns bigint, JSON.stringify throws while report formats the successful result. The CLI exits 2 without printing it, despite capnweb supporting bigint values.
Learn more
Capnweb carries bigint by value, and the codemode runtime stores bigint results durably. The result reaches the CLI successfully, but JavaScript's standard JSON.stringify rejects bigint values. Both the regular structured-result branch and the earlier --json branch use this serializer.
Example: Run codemode -e 'return 1n'. The host records a completed execution, but the client prints Do not know how to serialize a BigInt and exits 2 instead of printing the value and exiting 0.
Recommended fix: Define a stable CLI JSON representation for bigint and use one shared serializer in print and report. Add coverage for top-level and nested bigint results in normal and --json output.
Was this helpful? React with 👍 or 👎 to provide feedback.
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.
|
|
||
| function errorJSON(error: unknown, status: number): Response { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return new Response(JSON.stringify({ error: message }), { |
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
5 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| ws.once("unexpected-response", (_request, response) => { | ||
| reject(new Error(`${url} answered HTTP ${response.statusCode} instead of upgrading`)); | ||
| }); |
There was a problem hiding this comment.
🟡 Rejected handshakes retain open sockets
When the host refuses an upgrade, openSocket rejects without closing response. A keep-alive response can prevent the failed CLI from exiting.
| ws.once("unexpected-response", (_request, response) => { | |
| reject(new Error(`${url} answered HTTP ${response.statusCode} instead of upgrading`)); | |
| }); | |
| ws.once("unexpected-response", (_request, response) => { | |
| response.destroy(); | |
| reject(new Error(`${url} answered HTTP ${response.statusCode} instead of upgrading`)); | |
| }); |
Was this helpful? React with 👍 or 👎 to provide feedback.
Code inside a Computer container can't talk to the host today. This adds a
codemodebinary to the image so a command can runcodemode < script.js. The script runs on the Durable Object in a dynamic worker, via@cloudflare/codemode, with the connectors you configured as globals.codemode typesprints their TypeScript,codemode searchandcodemode describefind one method at a time, andcodemode pendingshows what a paused run is waiting on. It needs no token: it reaches the host through the same outbound interceptioncomputerduses, so being in the container is the permission. Approving a paused run is deliberately not on this surface; that decision stays on the host.sequenceDiagram participant C as codemode (container) participant P as WorkspaceProxy participant B as CloudflareContainerBackend participant W as dynamic worker C->>P: ws://computer.internal/codemode P->>B: handleFetch(/codemode) B-->>C: 101, capnweb session (CodemodeRPC) C->>B: execute({ code }) B->>W: codemode runtime, connectors as globals W-->>B: result, logs B-->>C: { status, result, logs }Turn it on with the
codemodeoption onCloudflareContainerBackend, forward/codemodenext to/api, and exportCodemodeRuntimefrom your Worker. The newexamples/codemodedoes all of that with one exec route and a smallnotesconnector:npm run devthen./script/runsendscodemode types, asearch, adescribe, and a script into the container and you get the types, then["hello"].Each layer has tests, including a workers test in
examples/codemodethat runs a script through the real runtime against the connector and checks the host refuses anapprovecall. I couldn't run it in a real container (no Docker here); the built binaries run and fail cleanly without a host. Docs 07 and 08, both READMEs, and three changesets are updated. The image grows by one more Node-sized binary.