api: platform forwarder behind PLATFORM_SURFACES (port plan 1d), default off

Merged
TE
5d ago
+289-0

Port plan step 1d (manaflow-ai/cmux-platform docs/port-cloud-worker.md 3.1, row 1d): the strangler forwarder in cmux-api, wired for NEXT Cloud's machine surface. Default off: nothing changes until a deploy adds the binding and the var.

Leo, 2026-10-31: "try to not disrupt classic;s cloud code too much there's still stuff on there we'll migrate them eventually but next cloud can just yolo yolo yolo in cmux-platform"

Leo, 2026-10-31: "best if you guys just merge stuff tbh and write more in rust instead of swift and put code into cmux-platform if it's cloud related"

What it does

  • New backend/apps/api/src/platform-forward.ts. PLATFORM_SURFACES (comma-separated, per environment) names the surfaces the platform serves; the only one wired so far is cloud. When a request belongs to a listed surface, it goes to env.PLATFORM.fetch(request), the service binding to cmux-platform-<env>. Every other request runs the existing handler.
  • The cloud surface covers:
    • the hand-written Cloud machine routes: /v1/cloud/bind, /v1/cloud/keyset, /v1/cloud/machines/:vm/acpmux/*, /v1/cloud/machines/:vm/client-session, /v1/cloud/machines/:vm/{exec,fs/*};
    • /v1/ops and /v1/read, routed by op owner rather than by path (3.1). The forwarder reads op from a clone of the body and looks it up in the @cmux/protocol catalog. Only a cloud:CloudDO op of the endpoint's class is forwarded, with the original body intact. A body that does not parse, a body with no string op, an unknown op, or an op on the wrong endpoint stays in cmux-api, so the existing validation.invalid answers do not change.
  • Entry order (3.1): spendTicketAtEntry still runs first on every path, and the forwarder runs after it. A forwarded request therefore carries a ticket that cmux-api has already spent, and each ticket is spent once. /v1/wire/cloud belongs to the separate wire surface (plan 2b/2c) and is not forwarded here.
  • If a surface is listed but the PLATFORM binding is absent, the request stays local and the Worker logs platform.forward.unbound, so setting the var before the binding exists breaks nothing. If the platform throws, the Worker answers a retryable 503 owner.unreachable and does not replay the request locally, because the platform may already have acted on it.
  • env.ts gains two optional fields: PLATFORM?: Fetcher and PLATFORM_SURFACES?: string.

Why classic and NEXT are unchanged by default

  • wrangler.jsonc is untouched. No environment declares the binding or the var, so platformForward returns on its first line without reading the request.
  • Classic Cloud (web/services/*, Next.js /api/vm) is not touched.
  • Tests (backend/apps/api/test/platform-forward.test.ts):
    • With the var unset, empty, or naming only unknown surfaces (each tried with the binding present and absent), every Cloud route, Cloud op, staying op and /v1/health gets the same status from the existing handler, and the fake platform is never called.
    • With the surface on, every Cloud route plus a CloudDO mutation and a CloudDO read is forwarded with its URL, method and body intact.
    • With the surface on, these stay local with a status and body identical to the off case: an op of an owner that stays (mutation and read), a malformed body, null, a non-string op, an unknown op, a read on /v1/ops, a mutation on /v1/read, GET /v1/ops, /v1/health and /v1/wire/cloud.
    • With the surface listed and no binding, requests stay local and platform.forward.unbound is logged.
    • When the platform throws, the answer is a retryable 503.
    • A forwarded request with a fresh ticket spends it exactly once (counted at WIRE_TICKET_DO), before the forward, and the replayed ticket is then refused on its socket. A request that stays local also spends its ticket once.

The deploy step (not in this PR)

  • Who deploys cmux-api. .github/workflows/backend.yml, using the CLOUDFLARE_API_TOKEN secret of each GitHub environment. A push to feat-cmux-next deploys cmux-api-development (environment cmux-next-development) and cmux-api-staging (cmux-next-staging, job deploy-staging). Production (cmux-next-production) is deployed by promote-production after gates G1-G7, the CMUX_NEXT_PRODUCTION_HOLD var and 6 h spacing, or by a push to main. None of these environments requires reviewers; each has only a branch policy.

  • The flip, staging first. One cmux PR adds this to env.staging in backend/apps/api/wrangler.jsonc:

    • "services": [{ "binding": "PLATFORM", "service": "cmux-platform-staging" }], and
    • "PLATFORM_SURFACES": "cloud" in env.staging.vars.

    Merging it to feat-cmux-next deploys it through deploy-staging. Production gets the same change with cmux-platform-production after the staging soak (plan 5.3-5.4). Rollback is removing the var.

  • Targets. cmux-platform-development, -staging and -production all exist: cmux-platform's ci.yml deploys all three on every push to its main, and recent runs are green.

  • Blocked on the platform. cmux-platform does not serve the Cloud machine routes or the CloudDO ops yet. The ports of the cloud driver, VM API and CloudDO layers are in progress. Under plan rule 5, the cloud surface is switched on only after those surfaces and their parity tests have landed there.

Agent session: agent session

šŸ¤– Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Adds a strangler forwarder that hands cmux-api routes to the platform Worker through a new optional PLATFORM service binding, gated behind the PLATFORM_SURFACES environment variable. Default is off: no environment sets the var or the binding, so every request runs the existing handler unchanged.

Behavior

  • With the cloud surface on, the Cloud machine routes (/v1/cloud/bind, /v1/cloud/keyset, the acpmux, client-session, exec, and fs paths) and cloud:CloudDO ops on /v1/ops and /v1/read go to the PLATFORM binding with URL, method, and body intact.
  • Ops are routed by owner rather than path: a malformed body, an unknown op, or an op on the wrong endpoint stays local with its existing validation.invalid answer.
  • The entry ticket spend still runs first; a forwarded request carries its already-spent ticket, spent exactly once. /v1/wire/cloud is not forwarded here.
  • A listed surface with no binding stays local and logs platform.forward.unbound; a platform failure answers a retryable 503 without replaying locally.

Rollout

  • Switching the surface on is a separate deploy step that adds the binding and PLATFORM_SURFACES: "cloud" to staging first, then production; rollback is removing the var.
  • The cloud surface goes on only after cmux-platform serves the Cloud machine routes and CloudDO ops.