Skip to content

feat(ai): coding-agent harnesses — Claude Code, Codex, OpenCode, ACP - #2019

Draft
sam-goodwin wants to merge 122 commits into
mainfrom
feat/ai-harnesses
Draft

sam-goodwin wants to merge 122 commits into
mainfrom
feat/ai-harnesses

Conversation

@sam-goodwin

@sam-goodwin sam-goodwin commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

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

// src/Sandbox.ts
import * as AI from "alchemy/AI";
import * as Anthropic from "alchemy/Anthropic";
import * as GitHub from "alchemy/GitHub";

export class Sandbox extends AI.Sandbox<
  Sandbox,
  {
    size: "small" | "large";
    egress: GitHub.Egress | Anthropic.Egress;
  }
>()("Sandbox", { egress: [GitHub.Egress, Anthropic.Egress] }) {}

Host it on Cloudflare

// src/Sandbox.cloudflare.ts
import * as Anthropic from "alchemy/Anthropic";
import * as Cloudflare from "alchemy/Cloudflare";
import { Sandbox } from "./Sandbox.ts";

export default Cloudflare.Containers.Sandbox(
  Sandbox,
  {
    main: import.meta.url,
    runtime: "node",
    image: "node:24-bookworm",
    sizes: { small: "lite", large: "standard-4" }, // instance type, chosen at start()
  },
  Anthropic.ClaudeCodeServer("Claude", { cwd: "/workspace/app" }),
);

Or on an AWS Lambda microVM

// src/Sandbox.microvm.ts
import * as Anthropic from "alchemy/Anthropic";
import * as AWS from "alchemy/AWS";
import { Sandbox } from "./Sandbox.ts";

export default AWS.Lambda.MicrovmSandbox(
  Sandbox,
  {
    main: import.meta.url,
    sizes: { small: "1 GB", large: "2 GB" }, // one microVM image per size
  },
  Anthropic.ClaudeCodeServer("Claude", { cwd: "/workspace/app" }),
);
AWS.Lambda.MicrovmSandbox(Sandbox, { main: import.meta.url, sizes: { small: "1 GB" } }, program);
// ✗ Property 'large' is missing in type '{ small: "1 GB" }'

Declare a gateway

// src/gateway.ts
import * as Anthropic from "alchemy/Anthropic";
import * as Egress from "alchemy/Egress";
import * as GitHub from "alchemy/GitHub";

export class WorkGateway extends Egress.Gateway<WorkGateway>()("WorkGateway", {
  model: Anthropic.Egress,
  repo: GitHub.Egress,
}) {}

Serve it

// src/gateway.worker.ts
import * as Anthropic from "alchemy/Anthropic";
import * as Cloudflare from "alchemy/Cloudflare";
import * as GitHub from "alchemy/GitHub";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import { WorkGateway } from "./gateway.ts";

export default class WorkGatewayWorker extends Cloudflare.Worker<WorkGatewayWorker>()(
  "WorkGateway",
  { main: import.meta.url },
  WorkGateway.serve(
    Effect.gen(function* () {
      return {
        model: yield* Anthropic.Egress({ apiKey: Config.Redacted("ANTHROPIC_API_KEY") }),
        repo: yield* GitHub.Egress({
          token: GitHub.ProfileToken,
          repositories: [
            {
              repository: "alchemy-run/alchemy",
              contents: "write",
              pullRequests: "write",
              issues: "write",
              push: { branches: ["agent/**"] },
            },
          ],
        }),
      };
    }),
  ),
) {}

Or serve it on AWS Lambda

// src/gateway.lambda.ts
import * as AWS from "alchemy/AWS";
import { WorkGateway } from "./gateway.ts";
import { implementation } from "./gateway.impl.ts";

// Sandbox traffic → the function URL; mint → lambda:InvokeFunction (IAM), never the URL
export default AWS.Lambda.Function(
  "WorkGateway",
  { main: import.meta.url, url: true },
  WorkGateway.serve(implementation),
);

Credentials per user

// src/gateway.user.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as GitHub from "alchemy/GitHub";
import { Tokens } from "./db.ts";
import { UserGateway } from "./gateway.ts";

export default class UserGatewayWorker extends Cloudflare.Worker<UserGatewayWorker>()(
  "UserGateway",
  { main: import.meta.url },
  UserGateway.serve(
    Effect.gen(function* () {
      const tokens = yield* Cloudflare.KV.ReadNamespace(Tokens);
      return {
        // `subject` comes from the signed grant; resolved per request, in the gateway
        repo: yield* GitHub.Egress({
          token: ({ subject }) => tokens.get(subject).pipe(Effect.map((token) => token!)),
        }),
      };
    }),
  ),
) {}

Grant egresses to a sandbox

// src/Agent.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { UserGateway, WorkGateway } from "./gateway.ts";
import { Sandbox } from "./Sandbox.ts";

export class Agent extends Cloudflare.DurableObject<Agent>()(
  "Agent",
  Effect.gen(function* () {
    const sandbox = yield* Sandbox;
    const work = yield* WorkGateway;
    const user = yield* UserGateway;

    return Effect.gen(function* () {
      return {
        work: (issue: number, userId: string) =>
          sandbox.start({
            size: "large",
            egress: [
              work.model,
              work.repo.narrow({ repository: "alchemy-run/alchemy", issue }),
              user.repo.narrow({ repository: "sam/dotfiles", graphql: false }).for(userId),
            ],
            ttl: "5 minutes",
          }),
        modelOnly: () => sandbox.setEgress({ egress: [work.model] }), // applies to the running sandbox
        settle: () => sandbox.stop(),
      };
    });
  }),
) {}
work.stripe;
// ✗ Property 'stripe' does not exist on type 'WorkGateway'
work.repo.narrow({ repo: "alchemy-run/alchemy" });
// ✗ 'repo' does not exist in type 'GitHub.Scope'

Provide the hosts

// alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as GitHub from "alchemy/GitHub";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { UserGateway, WorkGateway } from "./src/gateway.ts";
import UserGatewayWorker from "./src/gateway.user.ts";
import WorkGatewayWorker from "./src/gateway.worker.ts";
import SandboxLive from "./src/Sandbox.cloudflare.ts"; // or ./src/Sandbox.microvm.ts
import Api from "./src/worker.ts";

export default Alchemy.Stack(
  "Agents",
  {
    providers: Layer.mergeAll(Cloudflare.providers(), GitHub.providers()),
    state: Alchemy.localState(),
  },
  Effect.gen(function* () {
    const api = yield* Api;
    return { api: api.url.as<string>() };
  }).pipe(
    Effect.provide(
      Layer.mergeAll(
        SandboxLive,
        WorkGateway.host(WorkGatewayWorker),
        UserGateway.host(UserGatewayWorker),
      ),
    ),
  ),
);

Close the internet on a microVM

// with egress, start() defaults to enableInternet: false: the proxy refuses hosts without a route
yield* sandbox.start({ size: "large", egress: [work.repo.narrow({ repository, issue })] });

// a microVM can't firewall itself (no CAP_NET_ADMIN): a VPC egress connector closes tools that ignore HTTPS_PROXY
export default AWS.Lambda.MicrovmSandbox(
  Sandbox,
  {
    main: import.meta.url,
    sizes: { small: "2 GB", large: "8 GB" },
    networkConnector: AWS.Lambda.NetworkConnector("SandboxEgress", {
      subnetIds: [privateSubnetId],
      securityGroupIds: [gatewaysOnlySecurityGroupId],
    }),
  },
  program,
);

What the sandbox sees

$ echo $GH_TOKEN
alchemy-egress
$ git push origin HEAD:main
 ! [remote rejected] HEAD -> main (alchemy egress: push to refs/heads/main is not allowed)
$ gh api repos/alchemy-run/distilled   # not granted: goes out with no credential

How a request reaches the gateway

Cloudflare Container                      AWS microVM (and anything else)
  git push                                  git push
   └ interceptOutboundHttps                  └ proxy :3128 (HTTPS_PROXY, per-sandbox CA)
     → AlchemyEgress relay entrypoint          routes by host + path
       (routes in props; mints grants          grant pushed by the Durable Object,
        over the gateway's service binding)    renewed before it expires
     → gateway (service binding)              → gateway (HTTPS + grant)
                                   ▼
            gateway: verify grant → rules → credential → github.com

Harness: sessions that survive restarts

const harness = yield* Cloudflare.Containers.Harness(Sandbox, { workspace: "/workspace/app" });

const claude = yield* harness.start({ size: "large", egress: [work.model, work.repo] }); // restores snapshot or checkpoint
const session = yield* claude.start({ id: "issue-42" });
yield* session.prompt("Fix the flaky test");

harness.events("issue-42", { after: 0 }); // from the DO's SQLite, no container
yield* harness.stop(); // checkpoint, snapshot, stop

Harness servers

// the whole program
Anthropic.ClaudeCodeServer("Claude", { cwd: "/workspace/app" });
// or OpenAI.CodexServer, OpenCode.Server, AI.AcpServer({ command, args })
// next to your own routes
Effect.gen(function* () {
  const claude = yield* Anthropic.ClaudeCodeServer("Claude", { cwd: "/workspace/app" });
  return {
    fetch: Effect.gen(function* () {
      const request = yield* HttpServerRequest;
      if (request.url === "/health") return HttpServerResponse.text("ok");
      return yield* claude.fetch;
    }),
  };
});

Vendor providers

const project = yield* OpenAI.Project("agents", {});
const fleet = yield* OpenAI.ServiceAccount("fleet", { projectId: project.projectId });
const budget = yield* OpenRouter.ApiKey("agent-42", { limit: 20, limitReset: "monthly" });
const workspace = yield* Anthropic.Workspace("agents", {});
const ModelLive = Anthropic.LanguageModel({ model: "claude-opus-5-5" });

services/harness

cd services/harness
bun alchemy deploy   # one Durable Object per issue, terminal, machine stats, pause / resume / settle

Removed

- GitHub.MountRepository / Git.MountRepository / Cloudflare.Artifacts.MountRepository
- AI.LocalHarness, Cloudflare.ContainerHarness
- AI.SessionRpcs, AI.makeSessionHandlers, AI.serveHarnessHttp
- Cloudflare.Containers.Egress, GitHub.Egress(container, …), Anthropic.Egress(container, …)
- sandbox.addEgress / sandbox.removeEgress

Design: packages/alchemy/src/Egress/DESIGN.md. Bumps distilled to alchemy-run/distilled#754.

🤖 Generated with Claude Code

sam-goodwin and others added 18 commits October 5, 2026 23:12
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
@alchemy-version-bot

alchemy-version-bot Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

alchemy

pnpm install https://pkg.alchemy.run/alchemy/pr:2019:884c091
@alchemy.run (6)
pnpm install https://pkg.alchemy.run/@alchemy.run/better-auth/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@alchemy.run/cloudflare-runtime/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@alchemy.run/node-utils/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@alchemy.run/floci/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@alchemy.run/pkg/pr:2019:884c091
Transitive Dependencies (25)
pnpm install https://pkg.alchemy.run/@distilled.cloud/acme/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/acp/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/anthropic/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/aws/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/axiom/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/cloudflare/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/codex/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/core/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/doppler/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/fly-io/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/gcp/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/hetzner/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/iceberg/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/infisical/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/neon/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/openai/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/opencode/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/openrouter/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/planetscale/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/prisma/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/railway/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/stripe/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/turso/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/zerossl/pr:2019:884c091
pnpm install https://pkg.alchemy.run/@distilled.cloud/github/pr:2019:884c091

Published Oct 16, 2026, 3:11 AM UTC. Expires Oct 23, 2026, 3:11 AM UTC, extended while this pull request is open.

sam-goodwin and others added 11 commits October 7, 2026 12:13
… 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>
sam-goodwin and others added 30 commits October 14, 2026 18:19
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant