Skip to content

codeaf devbox: an always-on computer on your own Railway account - #2081

Merged
AbirAbbas merged 5 commits into
devfrom
feat/devbox
Oct 31, 2026
Merged

AbirAbbas merged 5 commits into
devfrom
feat/devbox

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

codeaf devbox up <name> gives you an always-on computer on your own Railway account. The box is paired into your identity, listed in /devices and Settings → Devices, and reachable with codeaf chat --at <name>. codeaf devbox status and codeaf devbox down inspect it and remove it. The Terraform is written to a folder you own (default <codeaf home>/devbox/<name>/), so you can read every resource it creates.

Nothing changes for anyone who never runs devbox. codeaf serve starts its new home-state and presence workers only when CODEAF_SERVE_SYNC=1 is set, and only the box's boot script sets it. Otherwise runServe gains one deferred call that starts nothing. The pairing changes only take effect for a machine record that has an alias, which only devbox up writes. Probes can now be cancelled mid-handshake. The details are under "Touches pairing and serve" below.

What changed

  • codeaf devbox up | status | down (internal/devbox, cmd/codeaf/devbox.go). It creates one private project, one service and one persistent volume through the Railway Terraform provider. The box boots from a stock image, installs the released codeaf for its channel the way the public installer does, joins with a one-time pairing code and serves.

  • Up runs three guarded applies. On the first real run, Railway's provider triggered four deployments within seconds. A redeploy reused a snapshot without the variables, and it killed the deployment holding the code while it was pairing. So:

    1. Up creates the box with no secrets and waits for Railway to settle.
    2. It sends the code, plus the box's remote key and admission book. The plan may change only railway_variable_collection.box.
    3. Once the pinned remote key answers, it removes those one-time values from Railway and from the state, and deletes the state backup that still held them.

    The remote key is generated once per up and reused across up to three codes. A reachability probe runs during the join, so a lost acknowledgement is not mistaken for a failure. Up prints "is up" only after a successful connection.

  • The first chat works right away. Once the box answers, up pushes this computer's saved keys once (one bounded home-state round). Without that, a laptop that had been a fleet of one has never sent its keys anywhere, and the first --at chat stopped on a missing key.

  • One relay per channel (One relay per release channel, deployed by CI when relay code changes #2079). Joining a box doesn't save a relay when the laptop uses its own build's default. So the box's boot script saves the laptop's relay before the first join, the same way a pairing that named one would.

  • GitHub's API limit. On a real run, a box's first install failed for seven minutes with "GitHub's API rate limit for this address is used up", because Railway's egress addresses are shared. The first install now uses this computer's own release tag (VERSION=<tag>, no API call) when both share a channel. Later boots refresh to the channel's newest build and keep the earlier one if that fails.

  • The identity grant is unchanged. One code still never grants both chats and remote access: internal/pair/grant.go and join.go are untouched. Remote access comes from a host key and admission book that the laptop generates and Terraform seeds on the box, so only the creating laptop is admitted.

  • down destroys every resource first, then revokes the joined device by the id saved at up, so a renamed box or a reused name is handled. Failure receipts name the exact command, with --dir when the folder is not the default.

  • Manual: internal/manual/chat/a-dev-box-on-your-own-railway.md answers "can I run codeaf on my own server" and "set up a dev box". It covers who pays (you pay Railway), what the box holds (the whole identity, including saved provider keys, like any paired computer), and the AWS state.

Touches pairing and serve: what changes for everyone else

  • codeaf serve: unchanged unless CODEAF_SERVE_SYNC=1 is set. TestServeKeepsStateOnlyWhenADevBoxAsks pins this: only the value 1 opts in.
  • pair.Book: an optional alias field, written only by devbox up. Lookup and dedupe change only for records that have one. codeaf devices prints the alias for those records.
  • pair.Reach.Open and relay raw upgrade: a cancelled or deadlined context now closes the stream mid-handshake, and the deadline is the earlier of the caller's and the built-in one. Callers that never cancel see no difference.
  • The engine caches only a successful first profile load; it used to cache a failure forever. Success paths are unchanged.

AWS is unproven

internal/devbox/terraform/aws has passed terraform validate and nothing more. It has never been applied, because no AWS credentials were available, and codeaf devbox does not offer it. The manual says the same.

Validation contract, proven by hand on real Railway

Everything was run against a real Railway account from throwaway homes, on binaries built from this branch, including dev builds that use the dev relay. Test projects were named codeaf-l2-test-<n>, and every one was destroyed.

  1. No token: one sentence saying where to create one, exit 1, nothing created.
  2. up on a fresh name: created exactly one project, one service and one volume. The fastest run took 73 s to "is up", including the secret clear. The box was listed as joined in codeaf devices, TUI /devices and Settings → Devices.
  3. chat --at <name>: ran a turn on the box. Every model key on the test machine was expired, so the repository's scripted model stub (test/remote/stub) ran on the box. Its bash pwd tool call answered /data/home/work, the box's own work folder. From a brand-new, never-paired laptop on a dev build, devbox up followed directly by chat --at worked with nothing opened in between, because up pushed the keys.
  4. Redeploy or restart: the same device, with no second pairing. This held for a plain redeploy and for the restart caused by the secret clear.
  5. down: destroyed all 3 resources on four joined boxes. Each device shows as removed, and the Railway projects listing shows no codeaf-l2-* project.
  6. status: <name>: up; region …; project codeaf-<name>; last seen …, through a pinned-key probe.
  7. Manual: the probes in internal/manual/chat_test.go reach the new page, and the packed corpus answers the same questions (internal/manual/devbox_test.go).

After step 3 of up, Railway's variables and terraform.tfstate were checked: neither held CODEAF_DEVBOX_CODE, CODEAF_DEVBOX_REMOTE_KEY or CODEAF_DEVBOX_REMOTE_DEVICES, and no state backup was left. On a dev-build laptop, the box saved the laptop's dev relay as its sync relay and served through it.

Tests

The new tests use a fake Terraform executable, a fake Railway GraphQL server and the in-process relay fixtures. No test reaches the network. They cover:

  • the three-apply sequence and its plan guard (a plan touching any other resource is refused without applying, and the sensitive plan JSON never reaches output);
  • code retries reusing one remote key, and a lost acknowledgement found by the probe;
  • the setup clear and its resume, and the one-time key share;
  • boot.sh: idle without a code, a failed pair, a failed install, atomic and independent seeding, the relay pin, the pinned first install and its unpinned retry;
  • up → down → up → down through the real removal wiring;
  • the serve opt-in, key arrival at start and on the interval, quiet, revoked and ineligible homes, worker joins, and an engine that reads a key arriving after it started.

Three tests outside this lane fail on origin/dev too, under the same conditions: TestHostedTakeBackReachesAcquireWithoutSharedUploadsOrReleaseDelay (a 1 s timing ceiling) and TestScopesCountsEveryRelayRequest / TestPublicTakeRestoresEveryWorkspaceSetRoot (take path). They are not caused by this change.

Mac check (stability)

PR #2081 PASS: c9182cf (feat/devbox) on the Mac. make build took 9 s and the tree stayed clean.
Handcheck PASS (relay :8803): pair, 3 of 3 chats registered, 3 continues in one window (400 ms each), a↔b repeat in 700–800 ms, doctor clean on both homes, both windows quit with EXIT=0, 0 leftovers, and nothing reappeared 60 s after the rm.
Named tests, all ok: cmd/codeaf 77 tests, devbox 34, pair 9, syncsetup 10, relay 6. 5 skipped for host reasons: 2 "host has no proc memory reading" (do_machine_gate_test.go) and 3 "CODEAF_RELAY_HOSTED_URL is not set" (hosted_test.go). TestScopesCountsEveryRelayRequest doesn't match the regex, so it didn't run here.

Recheck after the head moved to 7fc2ae8 (one commit registering the new environment names as plumbing):

PR #2081 PASS: 7fc2ae8 (feat/devbox recheck) on the Mac. make build took 9 s and the tree stayed clean.
./internal/config TestRegistryCoversEveryUserFacingEnvironmentPin ok (0.08 s).
Handcheck PASS: 3 of 3 chats registered, 3 continues in one window, a↔b repeat, doctor clean on both homes, both windows quit with EXIT=0, 0 leftovers after kill, and nothing reappeared 60 s after the rm.

🤖 Generated with Claude Code

AbirAbbas and others added 5 commits October 30, 2026 19:04
…te key

A dev box is created by `codeaf devbox up`, and the laptop that creates it
generates the box's remote host key and admission book itself, so the box can
be reached with `codeaf chat --at <name>` without a second approval and
without widening the identity grant (one code still never gives both).

DevboxRemote produces those files with the same readers serve uses, and
returns the pinned machine record. The machine book now keeps an optional
alias beside the key-derived relay name; Reach.Open resolves the alias to
the pinned record, and `codeaf devices` prints the alias when there is one.

A status probe must be able to give up on a box that is not serving yet: a
cancelled or deadlined context now closes the stream during the handshake,
and the raw relay upgrade honours the caller's deadline instead of always
waiting out its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A computer that only runs `codeaf serve` never received the identity's
saved provider keys, preferences or shared memories, because only the
terminal surface ran the home-state loop; every new `--at` chat on such a
computer stopped on a missing key. A dev box only serves, so it needs them.

serve keeps that state in step only when started with CODEAF_SERVE_SYNC=1,
which the dev box's boot script sets. UNSET, SERVE BEHAVES EXACTLY AS IT
ALWAYS HAS: runServe gains one deferred call that starts nothing. When asked,
it runs syncsetup.RunHomeState (one round at start and one per sync
interval, without starting a cluster member; quiet, unpaired, unavailable
and removed homes end it silently, other failures are said once each) and a
presence worker on syncsetup's gated clients that keeps the device record
fresh for `devbox status` and stops when the identity, device or relay
binding changes. Both start through guard.Go and are joined before serve
returns.

The engine cached its first profile load forever, including a failure, so
an engine started before the keys arrived kept refusing after they had. It
now caches only a successful load; success paths are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`codeaf devbox up <name>` writes a readable Terraform folder the person owns
(default <codeaf home>/devbox/<name>/), shows the plan, and on one
confirmation creates one private project, one service and one persistent
volume on their Railway account with their RAILWAY_TOKEN. Without a token
it says in one sentence where to create one and creates nothing.

Railway's provider triggers several deployments within seconds and a
redeploy can reuse a snapshot without the variables, which killed a box
mid-pair on the first real run. So up applies in three guarded steps:
create the box with no secrets and let Railway settle; send a one-time
pairing code plus the box's seeded remote key and admission book, where the
plan may change only the variable collection; and, once the pinned remote
key answers, remove those one-time values from Railway and from the state,
deleting the state backup that still held them. The remote key is generated
once per up and kept across up to three codes, and a reachability probe runs
alongside the join so a lost acknowledgement is not mistaken for a failure.
"is up" is said only after a successful connection. Once the box answers,
up also sends this computer's saved keys once, so the first `--at` chat from
a laptop that was a fleet of one has them.

The box boots from boot.sh on a stock image. Its first install uses this
computer's own release when they share a channel, because the installer's
GitHub API lookup is rate limited per address and Railway's egress is
shared; later boots refresh to the channel's newest build and keep the
earlier one on failure. It waits idle until it has a code, joins once (a
marker on the volume keeps redeploys from pairing again), pins its sync
relay to the laptop's (each release channel has its own), seeds its remote
files atomically and independently, and serves with CODEAF_SERVE_SYNC=1.

`status` probes the box through its pinned key and reports up or down,
region, project and last seen. `down` destroys every resource, then revokes
the joined device by the id saved at up, and forgets the alias. Failure
receipts name the exact command, with --dir for a non-default folder.

Nothing changes for anyone who never runs devbox. The AWS module is
`terraform validate`d only; it has never been applied and the command does
not offer it. The manual's new page answers "can I run codeaf on my own
server" and "set up a dev box".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…plumbing

The registry law reads every CODEAF_ name spelled in Go and wants each one to
be a settings row or operator plumbing. The three CODEAF_DEVBOX_ setup values
are service variables for the box's boot script that codeaf only looks for in
Terraform state to remove them, and CODEAF_SERVE_SYNC is one serve process's
posture set by that script; none is a preference a person keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit 152134c into dev Oct 31, 2026
21 checks passed
@AbirAbbas
AbirAbbas deleted the feat/devbox branch October 31, 2026 02:07
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