Skip to content

feat: railway code — launch Codex or Claude Code in a Railway sandbox with your own plan#1013

Draft
codyde wants to merge 12 commits into
masterfrom
feat/code-agents
Draft

feat: railway code — launch Codex or Claude Code in a Railway sandbox with your own plan#1013
codyde wants to merge 12 commits into
masterfrom
feat/code-agents

Conversation

@codyde

@codyde codyde commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Status: out for testing — not intended to merge soon. Draft on purpose; feedback on the two agent flows is what we're after.

What

railway code launches a coding agent in a Railway sandbox on the user's own plan — authenticated, interactive, ~6 seconds end-to-end:

railway code --codex               # sandbox + your local Codex sign-in
railway code --claude              # sandbox + an auto-minted Claude setup-token
railway code --claude --gh         # also inject your GitHub auth (gh auth token)
railway code --codex --new --variable DB_URL=postgres.DATABASE_URL
railway code --codex -- exec "explain this codebase"

Requires the PROJECT_SANDBOXES feature.

The two agent flows

--codex

Uses the flow OpenAI documents for remote machines/containers: the local ~/.codex/auth.json sign-in is read client-side and rides ssh stdin into a 0600 file in the sandbox — never argv, never Railway variables, images, or server-side config. The copy is announced to the user (no data leaves the machine silently).

--claude

Deliberately not a copy of the local sign-in: ~/.claude/.credentials.json carries the OAuth refresh token, and two machines racing one rotating refresh token can sign the laptop out. Instead the CLI mints a purpose-made long-lived token — the same shape as mono's agent-vm Connect tab flow (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY accepted as alternative):

  1. CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY in the local env → used silently.
  2. Otherwise claude setup-token runs invisibly under script(1)'s pty (claude's TUI needs a tty on both ends). The browser round-trip stays interactive — an existing session with prior consent completes hands-free — while the terminal shows only a spinner. Nothing renders on screen, least of all the minted token; it's harvested from a 0600 recording that's deleted immediately, with ANSI-stripping + soft-wrap reassembly. A 120s timeout catches the one path that can't work hidden (paste-a-code-into-the-terminal) and falls back to a masked paste prompt.
  3. The token rides ssh stdin into a 0600 env file (KEY=VALUE, so both var names work), sourced at launch and on every reconnect.

Two sandbox-image gotchas handled: the image ships a ~/.claude.json without hasCompletedOnboarding (the build stamps it), so the flag is merged in via jq (node fallback) rather than written-when-absent — otherwise claude ignores the token and shows the login picker. And the user's local ~/.claude/settings.json (permissions mode, model, plugins) is mirrored into the sandbox when present.

Keeping agents current

Fresh sandboxes launch the image's baked agent version, so a background update always loses the race against the launch (codex then greets the user with its update-available banner). Updates therefore run synchronously during provisioning, behind the spinner, kept cheap: codex compares installed vs registry (~2s when current) and npm-installs only on a real version gap; claude update no-ops when current (the image sets autoUpdates: false, so this is the only updater). Both are timeout-bounded so a wedged registry can't hang provisioning.

DX details

  • One connection, one host-key decision per run: provisioning is a single script; every ssh multiplexes over one ControlMaster. Relay connections verify against a CLI-owned known-hosts file (~/.railway/known_hosts_relay, accept-new, self-healing) — the user's ~/.ssh/known_hosts is never touched. Works around the relay fleet presenting per-instance host keys; commented for revert to strict checking once the relay ships a shared key/CA.
  • Quiet output: one-line sandbox create, no ssh-key announce, single credential line — the launch reads as one flow, not sandbox plumbing. (The relay's "Railway durable session…" banner needs a small mono change to suppress; tracked separately.)
  • Terminal hygiene: agent TUIs enable the kitty keyboard protocol; an unclean exit (ctrl-c, dropped link) used to leave the terminal emitting junk like 9;5:3u. A reset sequence now runs remotely after the agent exits and locally after the session ends — no-ops on a clean terminal.
  • Auto-launch on reconnect: a guarded ~/.profile seed reads ~/.railway-code-agent, so any interactive railway sandbox ssh drops back into whichever agent was last provisioned; quitting the agent lands in a shell.
  • Sane reuse: repeated runs reuse the active RUNNING/CREATING sandbox; --new forces fresh; disconnect prints reconnect breadcrumbs.
  • --variable / --env-file have full sandbox create parity; --gh provisions a 0600 token file + GH_TOKEN export + git credential helper.

Validation

  • Codex: 10/10 consecutive fresh create→provision→launch→destroy cycles against production, ~6s each.
  • Claude: end-to-end verified against a production sandbox — token minted hands-free, onboarding merge applied, claude -p responded using the injected token; update snippet verified to no-op at current version.
  • 457 tests pass (unit coverage for provision scripts, token extraction/reassembly from real recordings, token validation, terminal-reset escaping).

Notes for reviewers

🤖 Generated with Claude Code

codyde and others added 12 commits July 8, 2026 23:31
…th your local sign-in

New top-level `railway code` agent launcher. `--codex` copies the user's
existing ~/.codex/auth.json (the flow OpenAI documents for remote machines
and containers) into a fresh or reused sandbox and drops into an interactive
codex session over the relay.

- consent-gated, client-side read of the credential; it rides ssh stdin into
  a 0600 file in the sandbox — never argv, Railway variables, or images
- seeds COLORTERM + codex folder-trust config (only when absent)
- installs @openai/codex in the sandbox when missing (marker-based errors
  distinguish no-npm / install-failed / relay failure)
- reuses the active RUNNING sandbox; --new forces a fresh one; 30m idle cap
- ports run_native_ssh_captured (stdin-payload ssh) into ssh::native
- create_and_store now returns the sandbox id for reuse by launchers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… real errors

Plumbing ssh now captures stderr and retries up to 3x with backoff for
transient relay/boot failures. Host-key verification failures fail fast
with remediation instead of being retried — that's a security signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dbox

Provisioning seeds a guarded ~/.profile block: plain connects (railway
sandbox ssh) run bash as a login shell (verified against a live sandbox),
so interactive sessions drop straight into codex. Not exec'd — quitting
codex lands in a shell. [ -t 1 ] keeps command/scp sessions out; env guard
prevents re-entry; grep guard keeps the seed idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ING boxes

- print how to get back in on disconnect (sandbox ssh / code --codex) and
  the exact env-scoped 'sandbox list' invocation — 'railway sandbox ls'
  from a differently-linked dir silently shows a different environment
- reuse now also matches CREATING sandboxes so a re-run seconds after a
  launch doesn't mint duplicates
- --idle-timeout <minutes> (default 30) controls how long the box
  survives after disconnect

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n script

The relay fleet answers with per-instance host keys, so every fresh TCP
connection is a new host-key lottery. railway code was making four
(seed, inject, install, launch) versus sandbox ssh's one — which is why
it failed host-key verification far more often than sandbox ssh against
the same sandbox.

- provisioning is now ONE script over ONE connection (credential still
  arrives via stdin, markers still distinguish failure modes)
- every ssh in a run shares an OpenSSH ControlMaster (ControlPersist=90s):
  the provisioning connection is verified once and the interactive launch
  multiplexes over it — verified the Go relay accepts muxed sessions
- run_native_ssh_with_opts / extra_opts on the captured variant let the
  launcher opt in without changing the shared ssh paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rministic

Relay connections verify against the CLI's own file
(~/.railway/known_hosts_relay, accept-new) and heal it on mismatch —
never the user's ~/.ssh/known_hosts. With the relay fleet presenting 7+
per-instance host keys behind one hostname, single-key pinning was both
futile (most connections mismatch) and security theater (TOFU re-accept
is indistinguishable from MITM). Documented for revert to strict checking
once the relay ships a shared host key / SSH CA.

Validated: 10/10 consecutive fresh create→provision→launch→destroy
cycles, ~6s each (image ships codex preinstalled; install step
short-circuits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h GitHub auth

--variable / --env-file ride the same server-side resolution as sandbox
create (reference forms like postgres.DATABASE_URL work); a note warns
when they're ignored because an existing sandbox is being reused.

--gh reads the host token via `gh auth token` (works regardless of
where gh stores it — macOS keychain included), fails fast before a
sandbox is spent, and provisions the sandbox with a 0600 token file, a
GH_TOKEN profile export, and a git credential helper for
github.com/gist — no gh install required in the box; gh honors GH_TOKEN
natively if present. Token rides ssh stdin over the same multiplexed
connection; the consent prompt names it when --gh is set.

Verified e2e: fresh sandbox, variable visible in env, token file 0600,
api.github.com authenticates, credential helper set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the session

The interactive launch exec'd codex, so quitting it tore down the whole
ssh session. Now (matching the ~/.profile autostart behavior) codex runs
un-exec'd and hands off to a login shell on exit; the exported
RAILWAY_CODE_AUTOSTARTED guard keeps that shell's profile from
relaunching codex on top of the user. Scripted runs (-- args) keep the
exec-and-exit behavior so pipelines don't hang on a trailing shell.

Verified live: Ctrl-C out of codex → shell responds → exit closes the
session; `-- --version` still exits cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ning

The sandbox image doesn't ship distro bubblewrap yet, so codex warns at
startup and falls back to its bundled copy (cosmetic — sandboxing works
either way). Provisioning now apt-installs it when absent (~8s on fresh
boxes); the command -v guard makes this a free no-op once the image
ships bwrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…us updates

- railway code --claude: mints a long-lived token by running `claude
  setup-token` invisibly under script(1)'s pty (the browser round-trip
  stays interactive; nothing — least of all the token — renders in the
  terminal), with CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY env
  passthrough and a masked paste prompt as fallbacks. The token rides
  ssh stdin into a 0600 env file sourced at launch and on reconnect
- onboarding disable MERGES into the image's pre-baked ~/.claude.json
  via jq (node fallback) — the write-when-absent seed never fired
  because the image build stamps that file; local ~/.claude/settings.json
  is mirrored into the sandbox when present
- credential copy is announced instead of consent-prompted (drops --yes)
- quiet launch output: one-line sandbox create (CreateReport::Quiet),
  ensure_ssh_key_quiet drops the key announce for plumbing connections
- terminal-state reset after the agent TUI exits (remote and local)
  kills the kitty keyboard protocol leak ("9;5:3u") on unclean exits
- agents refresh synchronously during provisioning: codex npm-installs
  only on a real registry version gap (~2s check when current), claude
  update no-ops when current — a background update always lost the race
  against launch on fresh sandboxes, greeting users with codex's
  update-available banner

Co-Authored-By: Claude Fable 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