Repository navigation
feat(ai): coding-agent harnesses — Claude Code, Codex, OpenCode, ACP - #2019
Draft
sam-goodwin wants to merge 122 commits into
Draft
sam-goodwin wants to merge 122 commits into
sam-goodwin wants to merge 122 commits into
Conversation
Checkpoint of the coding-agent harness foundation: - AI.Environment / AI.GitSource and the `environment` prop + `image` binding contract on Cloudflare.Container (bindings install layers into their host's image) - AI.Session vocabulary (SessionEvent, Capabilities, Prompt, …), AI.SessionStore (replay-then-tail event log), AI.SessionRpcs / HarnessRpcs with SessionHandlers, serveHarness, remoteHarness, followEvents - AI.makeHarness engine (turn tracking, steering degradation, session scopes) with native drivers for Claude Code (official Agent SDK) and any ACP agent - harness server scaffolding (deploy-time host binding, HTTP serving) - bumps distilled to the AI SDKs branch (openai, anthropic, openrouter, acp, codex; SSE + JSON-RPC core) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Anthropic.ClaudeCodeServer, OpenAI.CodexServer, OpenCode.Server and AI.AcpServer: bindings that install a harness into the container they are yielded in (image layer + env) and run it there as an AI.Harness - OpenAI Codex driver over `codex app-server` (@distilled.cloud/codex) - AI.serveHarnessHttp / AI.connectHarness: HarnessRpcs over HTTP (NDJSON), health probes answered without RPC - AI.SessionHandlers resolves the harness per call, so a Durable Object never does I/O while it is constructed - Cloudflare.DurableSessionStore: the session log in DO SQLite - Claude Code driver: fail the running turn when the process dies; IS_SANDBOX for permission bypass as root in a container - end-to-end test: Worker → RPC Durable Object (AI.SessionRpcs) → Cloudflare Container running Claude Code → one real turn Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… example
- Anthropic.ClaudeCodeServer `accounts`: several `claude setup-token`
tokens by name; a session picks one with `start({ account })`. Each
token only reaches the unmodified `claude` binary for sessions that
name it — no automatic rotation
- AI.toUIMessageStream: session events → AI SDK UI message chunks
(useChat / useAgentChat), with plans, diffs, subagents, approvals and
usage as typed data parts
- examples/cloudflare-coding-agent: many Claude Code agents behind one
HTTP API, a Durable Object + Container per session
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- auth providers (API + admin/management keys), registered in builtinAuth - LanguageModel layers over the Responses API (OpenAI), Messages API (Anthropic) and chat completions (OpenRouter): tools, structured output, streaming, reasoning, typed AiError mapping - control plane: OpenAI.Project / ServiceAccount / ProjectRateLimit, Anthropic.Workspace / SpendLimit, OpenRouter.ApiKey / Guardrail - live LanguageModel tests (Anthropic passing; OpenAI reaches the typed quota error); admin lifecycle tests gated on admin keys Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- OpenAI and OpenRouter resources catch the new typed tags (ProjectNotFound, ServiceAccountNotFound, ProjectApiKeyNotFound, KeyNotFound, GuardrailNotFound) instead of a generic NotFound - harness servers turn a failed harness start into a defect, so container programs keep a ConfigError-only error channel - withImageLayers accepts mixed binding data - bumps distilled (typed errors, sensitive key values) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Distilled no longer spawns processes; AcpDriver and CodexDriver start the harness via ChildProcessSpawner and hand its stdio to Acp.connect / Codex.connect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…iners
- AI.Environment(id, props) is a binding yielded inside the container
program: it installs its setup + source checkout as staged image layers
and returns { workdir }. Many per box; the host picks the base image.
Drops the Container environment prop.
- Harness servers take cwd, and install ca-certificates + git (Codex hung
on TLS in slim images). OpenCode.Server takes a model.
- Fix: container RPC streams (events) were cut off when the handler
returned; run them in the server's request scope.
- Fix: local container diff rewrote the build context without binding
layers, so a dev reload rebuilt the image without the harness.
- Codex surfaces its error notifications as session events.
- Dev e2e test: one image with an environment and Claude Code, Codex and
OpenCode; one DO + container per session.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Cloudflare.Artifacts.Repository Replaces AI.Environment with single-purpose mount bindings, always at an absolute path, each with git access (none | read | write): - FS.MountFile / FS.MountFolder - GitHub.MountRepository, Git.MountRepository, Cloudflare.Artifacts.MountRepository Checkouts are fetched on the deploying machine (credentials per command, never on disk) and baked into the image with .git on a tracking branch; read/write access binds credentials for a git credential helper. New resources: Git.Repository (a repo on an Alchemy Git service, via its own HttpApi contract) and Cloudflare.Artifacts.Repository / RepositoryToken (distilled artifacts service), with Effect-wrapped Read/Write/ReadWrite namespace bindings over the whole runtime API. Fix: under alchemy dev, a container whose bindings add image layers after precreate now restarts its host Workers so they rebuild from the final context. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Session.setModel (SessionRpcs/HarnessRpcs setModel): Claude Code via
query.setModel, Codex via the next turn/start override, ACP agents via
their model config option (session/set_config_option). Unsupported
when a harness can't switch (capabilities.modelSwitching).
- SessionInfo.model, a model.changed event (data-model UI part); ACP
sessions now honor start({ model }).
- Rename AI.SessionHandlers -> AI.makeSessionHandlers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Steering an idle session starts a new turn, and Claude output that arrives with no open turn (a steer racing the end of a turn) opens one, so every answer is attributed and result() can await it. The example gains model switching and session info routes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- examples/cloudflare-coding-agent/web: a React SPA — sessions sidebar, live transcript from the SSE event log (resumes via Last-Event-ID), send / steer / interrupt, model picker, cost. - The API worker gains CORS, Last-Event-ID and ?wait=false. - The session log now records user messages, so it replays as a full transcript. - Claude driver: deltas of one content block share an item id (they were keyed per stream event, fragmenting every answer). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- alchemy/AI/Client: a browser-safe entry (session schemas, SessionRpcs, and AI.reduceTranscript — a pure fold from session events into messages of text / reasoning / tool / plan / approval parts). - examples/cloudflare-coding-agent/web: Tailwind v4 + shadcn + vendored AI Elements (typed against our transcript, no AI SDK), driven by an Effect RPC client over the session DO's hibernating WebSocket. Layout after T3 Code: sessions sidebar with live status, conversation with tool/reasoning/plan/approval parts, composer with model switching, steer and interrupt, and a session inspector. - Worker: /agents/:id/rpc forwards the WebSocket upgrade to the DO (and leaves 101 responses untouched by the CORS wrapper). - Mounts: unchanged sources are no longer rewritten on every plan (raced container image builds); git copies happen under the cache lock. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A prompt sent while a new session was still connecting was rejected and lost. Session calls now await the connected client. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d git mounts - AI.Harness service with two layers: Cloudflare.ContainerHarness (container per session) and AI.LocalHarness (alchemy dev: program runs on the host, one git worktree per session under .alchemy/worktrees, build outputs copied across) - Git mounts take submodules/install/build; checkouts are prepared on the host under .alchemy/repos and copied into the image context - GitHub.MountRepository installs gh and binds GH_TOKEN Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Session worktrees seed ignored files with APFS clonefile (copy-on-write) and keep one spare worktree ready: a new session claims it with a rename (~5s to a first reply, was ~90s) - Submodule worktrees pin core.worktree per worktree; repo hooks are skipped - UI shows a prompt immediately while the workspace is prepared - Playwright e2e suite for the API and UI (`pnpm test:e2e`) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the coding agent UI - Transcript records when turns, thinking, and tool calls start and end - ClaudeCodeServer/claudeCodeDriver take `thinking` (shown summarized) - UI: per-kind tool rows (command, read, edit with +/- and diff, search, web, MCP), grouped tool calls, "Thinking…" / "Thought for Ns" with the trace, and settled turns folded behind "Worked for 1m 12s" - Local container programs restart after an `alchemy dev` restart - Spares of checkouts no longer mounted are cleaned up Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # packages/alchemy/src/Cloudflare/Providers.ts # pnpm-lock.yaml # submodules/distilled
Contributor
|
Install the packages built from this commit: alchemypnpm install https://pkg.alchemy.run/alchemy/pr:2019:884c091@alchemy.run (6)pnpm install https://pkg.alchemy.run/@alchemy.run/better-auth/pr:2019:884c091pnpm install https://pkg.alchemy.run/@alchemy.run/cloudflare-runtime/pr:2019:884c091pnpm install https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:2019:884c091pnpm install https://pkg.alchemy.run/@alchemy.run/node-utils/pr:2019:884c091pnpm install https://pkg.alchemy.run/@alchemy.run/floci/pr:2019:884c091pnpm install https://pkg.alchemy.run/@alchemy.run/pkg/pr:2019:884c091Transitive Dependencies (25)pnpm install https://pkg.alchemy.run/@distilled.cloud/acme/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/acp/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/anthropic/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/aws/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/axiom/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/cloudflare/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/codex/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/core/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/doppler/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/fly-io/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/gcp/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/hetzner/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/iceberg/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/infisical/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/neon/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/openai/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/opencode/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/openrouter/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/planetscale/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/prisma/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/railway/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/stripe/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/turso/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/zerossl/pr:2019:884c091pnpm install https://pkg.alchemy.run/@distilled.cloud/github/pr:2019:884c091Published |
… given - GitHub.DeployKey: an ed25519 key pair generated at deploy time, its public half registered on the repository (read-only or read-write) - GitHub.MountRepository without `token` creates a deploy key with the stack's GitHub credentials; git in the container uses it over SSH - `token` stays as the override (and still enables `gh`) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # packages/alchemy/src/Cloudflare/Containers/LocalContainerProvider.ts # packages/alchemy/src/Cloudflare/Containers/index.ts # pnpm-lock.yaml # submodules/distilled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
interceptOutboundHttps injects the interception CA and routes TLS requests to the Worker's own entrypoint; verified locally and live (a Durable Object stub only works locally). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Durable Object intercepts its container's outbound HTTP(S) per host and
answers or forwards each request from Worker code, so credentials stay in
the Worker:
const echo = yield* Cloudflare.Egress(Sandbox, "api.example.com", (request) =>
Effect.succeed(HttpClientRequest.bearerToken(request, key)),
);
yield* sandbox.start({ egress: [echo] }); // only egress hosts are reachable
- Interception is registered before the container starts; setEgress /
addEgress / removeEgress change it on a running container
- Intercepted requests reach the Worker's own entrypoint (tagged by
ctx.props) and are forwarded to the owning Durable Object
- The container trusts the interception CA via a bundle the bootstrap keeps
current (SSL_CERT_FILE, GIT_SSL_CAINFO, NODE_EXTRA_CA_CERTS)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…arness working across restarts
const harness = yield* Cloudflare.Containers.Harness(Sandbox);
const live = harness.start({ egress: [model, github] }); // restores the last snapshot
harness.list(); harness.events(session, { after }); // from the Durable Object's storage
- Sessions and their events are mirrored into the owning Durable Object's
SQLite, with its own cursors, so history survives the container
- The container is snapshotted before it stops: on stop(), and when it
reports SIGTERM (ALCHEMY_SNAPSHOT_ON_SHUTDOWN) over the harness-control
egress; start() restores the latest snapshot and reopens sessions
- Claude Code sessions run under a session id derived from ours and resume
from their transcript when the container's files were restored
- TODO(harness-recovery) marks where a missed snapshot would be reported to
the agent
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…heduling
export default Sandbox.make(
{ main: import.meta.url, runtime: "node", schedulingPolicy: "durable_object" },
program,
);
yield* sandbox.start({}); // runs the program (the `main` image)
const snapshot = yield* sandbox.snapshotContainer();
A Container.make program can now be scheduled by its Durable Object, which
is what snapshots require. The program is published as the named image
`main`, with its bindings' image layers and plain env baked in (secrets are
refused: pass them with start({ env }) or use egress), and start() runs it
when no image or snapshot is given.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ble_object programs live Buildx's default provenance/SBOM attestations push an image index, and a Durable Object-managed container fails to start from one (Cloudflare answers "internal error"). Build without attestations. ProgramContainer.test.ts covers a bundled program under durable_object scheduling locally and live: start() runs it, and a snapshot restores its files into a fresh container. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The live build of a main program never copied its image layers' context sources (mounted files, folders, git checkouts) into the build context, so a deployed GitHub.MountRepository had nothing to COPY. Materialize them like the local build does, and include their digest in the image hash. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… exit code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… reuse a one-request connection An upstream that kept the connection alive (ignoring the request's Connection: close) let the client send a second request on it, which the proxy spliced through raw: no grant, no target, or a hang. Responses are now relayed with their head rewritten to Connection: close. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…requests Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t releases the gateway The proxy sends every request to its gateway as a POST, so a GET without a body must say Content-Length: 0. When the client goes away first, the upstream is destroyed instead of left streaming to nobody. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # packages/alchemy/package.json # submodules/distilled
Gateway.host(webFunction) serves the sandbox's traffic and mint on the function's endpoint (multi-Region with `regions`). Each consumer Worker binds its own mint key into the gateway and mints with a POST; IamAuth endpoints can't serve this (an IAM role is refused, and the global domain needs SigV4A). The gateway suite runs against both hosts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n Web Function Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, and why git goes there Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… is refused The proxy sent unmatched paths on a routed host (github.com/cli/cli) straight out even with internet off; now they get 403, like a host without a route. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…set tested live The narrow test now expects the proxy's 403 for the dropped repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lti-Region Lambda Web Function src/gateway.worker.ts → src/Gateway.aws.ts (a Web Function in four Regions: a Worker's github.com requests stall behind a slowly read clone), and gateway/machine/rpcs/terminals/worker → Gateway, Machine, Rpcs, Terminals, Worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent by its layer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on the service A static on the class keeps the layer (and the Sandbox behind it) in every bundle that imports the Terminals tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s host Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ched Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sandbox.aws.ts (a Lambda MicroVM) next to Sandbox.cf.ts, and Gateway.cf.ts (a Worker) next to Gateway.aws.ts. Each pair shares one implementation (Sandbox.impl.ts, Gateway.impl.ts); the declarations stay bare. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SessionError.make over new, schema statements discard their cursors, a pipeable Effect.scoped, and no unused binding in unmarkSettled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HarnessRpcs is every session's AgentRpcs plus list, served by the Worker over HTTP (NDJSON) and forwarded to each session's Durable Object. The web UI and the e2e tests use one RPC client; the REST routes and the per-session WebSocket are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A moving base tag rebuilds every layer whenever Docker Hub republishes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An RpcGroup has no pipe; the lint's pipeable rewrite failed at runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for Effect's HttpClient adds trace headers (traceparent, b3) to every RPC request; allowing only content-type blocked every call from the web UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
turn() and newSession() track each session; afterEach settles them, pass or fail, so a run leaves no machine running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # packages/alchemy/package.json # packages/alchemy/src/AWS/Lambda/MicrovmBundle.ts
…v2/dev) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… doppler run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Coding-agent harnesses in sandboxes. A sandbox holds no credentials: its requests to GitHub, Anthropic and other egress hosts go through an egress gateway that checks them and adds the real credential.
Declare a sandbox
Host it on Cloudflare
Or on an AWS Lambda microVM
Declare a gateway
Serve it
Or serve it on AWS Lambda
Credentials per user
Grant egresses to a sandbox
Provide the hosts
Close the internet on a microVM
What the sandbox sees
How a request reaches the gateway
Harness: sessions that survive restarts
Harness servers
Vendor providers
services/harness
Removed
Design:
packages/alchemy/src/Egress/DESIGN.md. Bumps distilled to alchemy-run/distilled#754.🤖 Generated with Claude Code