Skip to content

feat(code): coding agents on cloud agent VMs, and railway ca to drive them - #1013

Merged
codyde merged 18 commits into
masterfrom
feat/code-agents
Aug 7, 2026
Merged

feat(code): coding agents on cloud agent VMs, and railway ca to drive them#1013
codyde merged 18 commits into
masterfrom
feat/code-agents

Conversation

@codyde

@codyde codyde commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Coding agents on Railway cloud agent VMs, with your own credentials. railway ca is a TUI to drive them; railway code is the same launcher without one.

Requires railwayapp/mono#34349 and railwayapp/mono#34350 deployed — the cloud agent API is Internal-only until then.

Commands

Command Does
railway ca TUI: prompt, project tree, live sessions
railway ca setup Default project, harness, skills sync, theme → ~/.railway/agent-prefs.json
railway ca setup --show Print saved preferences
railway ca start [flags] Launch without the TUI
railway code --claude | --codex | --grok Launch a harness on a cloud agent VM, hand over the terminal
railway code -- <args> Run the harness non-interactively and exit

railway ca with any launch flag, or with no terminal, behaves as railway code. Both take the same flags, read the same preferences, and resolve a target the same way.

Launch flags

  • --claude / --codex / --grok — harness; the saved default applies when omitted (RAILWAY_CA_AGENT overrides for one run)
  • --new — force a new VM instead of reusing this environment's agent
  • --rm — destroy the agent on exit; --keep-awake — leave it running instead of sleeping
  • --gh — forward GitHub auth; --refresh-auth — re-deliver credentials to a reused agent
  • --variable K=V / --env-file <path> — environment for the session
  • -p <project> / -e <environment> — target; --name <name> names a newly created agent

railway ca — menu

  • Prompt targeted at a default project, set in setup, changed with ^t from the same card (which updates the default, not just this run)
  • New Session — work on an existing agent; asks which when there are several, skips when there is one, says so instead of provisioning when there are none
  • New Cloud Agent — fresh VM in the target project
  • Manage Cloud Agents — tree: workspace → project → environment → agent → session
  • Mouse: click the prompt to focus it, click a card to open it
  • Four themes (Railway violet default), ⌥t cycles, persisted on exit
  • First run walks setup in the same card style, and offers to create a "Cloud Agents" project

railway ca — manage

  • Tree with per-project agent counts and per-agent session counts; default project pinned first
  • Sessions are pty-backed ssh into the relay's durable sessions, emulated with vt100, drawn in the right pane; several run at once
  • enter connect · shift+esc / ^] release the keyboard · n new session · x end session
  • s sleep · w wake · d delete (confirmed) · c copy ssh command · r refresh · ? full key list
  • ⌥f maximize the pane, again to restore · shift+enter hand over the real terminal and return to the same session
  • Wheel and shift+pgup/pgdn forward to the agent when it owns the mouse, else the emulator's scrollback
  • Click a link to open it, including one wrapped across rows (OAuth URLs always are); drag from it to copy instead
  • Clicks reach an agent that has taken the mouse, so its own clickable output works; shift+drag takes one back to select
  • Drag-to-copy in either pane, with a corner toast confirming
  • Wake ends at running, not at accepted — the platform reports the old status until the VM boots, so the environment is re-asked every 1.5s until it arrives (3 min cap). Clearing the label on the first answer is what made a working wake look rolled back.
  • Ending a session matches RAILWAY_DURABLE_SESSION_NAME in /proc/*/environ and sends TERM — there is no API for it, and never pkill -f

Skills sync

  • Packs the local skills directory, unpacks on the VM add-only — never takes a name the agent already has
  • Never ships Railway's own skills (read from the CLI manifest, not a hardcoded name); they are baked into the image
  • 2 MB warns, 10 MB refuses; content hash skips an unchanged upload
  • Excludes .git/node_modules/.venv/__pycache__/target, .env*, symlinks, empty skills

railway code

Target resolution, shared with railway ca:

  1. -p/-e win outright
  2. The default project from agent-prefs.json — beats the linked directory, because a link is about deploys and shouldn't put an agent in some service's checkout
  3. The linked directory
  4. Nothing to go on and a terminal to ask in → runs railway ca setup, so the answer is remembered rather than asked again next launch

Within that environment: the remembered agent, then your sole existing one, then a fresh one — so a second railway code lands on the agent already there rather than billing for a new box. A TUI launch always carries an explicit target, which is what keeps any prompt from being drawn underneath a frame.

Everything else:

  • Installs and configures nothing. cloud-agent-base bakes every harness and express-agent serve --agents reconciles their config each boot. Removed as dead code: npm/x.ai installers, AGENT-NO-NPM, codex/claude update snippets, codex trust_level, grok [ui] yolo, the ~/.claude.json onboarding merge, bubblewrap install.
  • Credentials over ssh stdin into a 0600 file — never create-time variables, so they never reach the VM spec, an image, an argv, or server-side config. codex/grok copy the local auth.json; claude uses a claude setup-token grant or ANTHROPIC_API_KEY, never the local sign-in's rotating refresh token.
  • Sleep on disconnect, wake on next run, disk intact — agents have no idle timeout.
  • Reuse keyed per environment. With no local pointer (second machine, wiped config) it adopts your sole existing agent rather than minting a billing duplicate; scoped mine: true so it can't adopt a teammate's.
  • ~/.claude/settings.json merged, not overwritten — express-agent co-owns that file, so the previous truncating write stripped the harness's Railway MCP tools until next boot.
  • No cd — the machine spec sets workDir (/app) for the workload and every session; forcing $HOME landed outside the dirs express-agent reconciles trust into.
  • railway logout clears the cached Claude setup-token.

Structure

  • code::launch splits into prepare / run_session / sleep_agent behind a Progress trait, so one pipeline reports into either the TUI's loading pane or spinners
  • tui/app.rs is state and keys only — no rendering, no I/O; every key yields state plus at most one Effect, which is what makes navigation testable without a terminal or network
  • relay_destination / relay_port_args exposed: a relay target is a username, not a hostname, and the pty session builds its own invocation
  • clear_mouse_tracking after any interactive relay session — detaching from a durable session leaves the remote agent's mouse tracking on, and every pointer movement then arrives at the shell prompt as 35;21;32M
  • vt100 pinned at 0.15 (0.16 conflicts with ratatui's pinned unicode-width): caps scrollback at one screenful, and hides OSC 8 hyperlinks from link detection

Known gaps

  • Per-project counts come from a background sweep of every environment; the list query takes one environment at a time. cloudAgents(workspaceId:) would replace it and enable live status.
  • No telemetry event for ca setup — needs a backboard schema change.

Verification

781 tests, cargo fmt --all -- --check and cargo clippy --all-targets --all-features clean. Seven pty tests are #[cfg(unix)]: they need a mode-setting escape sequence to survive the pty, and ConPTY interprets those itself. Driven against prod: launch, reattach, sleep, wake, delete, skills sync, full-screen handoff.

@codyde
codyde force-pushed the feat/code-agents branch from 99bbcfd to 90e731b Compare August 3, 2026 22:57
@codyde codyde changed the title feat: railway code — launch Codex or Claude Code in a Railway sandbox with your own plan feat(code): railway code — launch a coding agent on a Railway cloud agent VM Aug 3, 2026
codyde and others added 14 commits August 5, 2026 08:17
…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>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`railway code` launches one agent and hands over the terminal. `railway ca`
is the same launcher with somewhere to stand first: a prompt that says what
to build, a tree of every project you can reach, and the sessions running on
each agent — with the session itself in a pane beside them rather than a
screen you have to leave for.

Both commands read the same preferences (~/.railway/agent-prefs.json) and
take the same launch flags, so the split is only about whether you want to
browse. `railway ca` with any flag, or without a terminal, is `railway code`.

The screen:

  - A prompt aimed at a default project — chosen in setup, changed with ^t.
    New Session puts work on an agent you already have (asking which, when
    there is more than one); New Cloud Agent makes one; Manage opens the tree.
  - Sessions are pty-backed ssh into the relay's durable sessions, emulated
    with vt100 and drawn in the right-hand pane. Several run at once, ⌥f gives
    one the whole screen, and shift+enter hands it the real terminal and comes
    back to the same session.
  - Lifecycle without leaving: sleep, wake, delete behind a confirmation, end
    a session, copy its ssh command.
  - First run walks through setup in the same card style — where agents live,
    which harness, whether to bring your skills, and a theme.

Supporting changes:

  - `code::launch` splits into `prepare`/`run_session`/`sleep_agent` behind a
    `Progress` trait, so the launch pipeline can report into a TUI rather than
    only into spinners.
  - Skills sync packs the local skills directory and unpacks it on the VM
    add-only: it never takes a name the agent already has, and never ships
    Railway's own, which are baked into the image.
  - `relay_destination`/`relay_port_args` are exposed because a relay target is
    a username rather than a hostname, and the pty session builds its own
    invocation.
  - Detaching from a durable session leaves the remote agent's mouse tracking
    switched on. `clear_mouse_tracking` turns it off on the way out, so pointer
    movement stops arriving at the shell prompt as `35;21;32M`.
  - `railway logout` drops the cached Claude setup-token.

A wake now ends at "running" rather than at "accepted": the platform reports
the old status until the VM has booted, and clearing the label on the first
answer made a wake that was working look like one that had rolled back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codyde
codyde force-pushed the feat/code-agents branch from 90e731b to 11643d7 Compare August 7, 2026 19:54
@codyde codyde added the release/minor Author minor release label Aug 7, 2026
@codyde codyde changed the title feat(code): railway code — launch a coding agent on a Railway cloud agent VM feat(code): coding agents on cloud agent VMs, and railway ca to drive them Aug 7, 2026
codyde and others added 2 commits August 7, 2026 13:00
Seven pty-backed tests assert on a mode-setting escape sequence making it
through the pty — the alternate screen, mouse reporting. Windows' ConPTY
interprets those for itself rather than passing them along, so the emulator
never sees the mode change and the fixture cannot set up. Plain text
round-trips fine, which is why the rest run everywhere; these are `#[cfg(unix)]`
with the reason recorded at each one.

`display_path` also hardcoded `/` after the `~`, which on Windows produced
`~/.claude\skills` — half of each convention. Uses the platform separator now,
since the string is shown to someone looking at their own filesystem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`railway code` asked its own question. Where an agent goes is the same question
`railway ca` answers, so it now answers it the same way:

  1. `-p`/`-e` win outright — the caller said it.
  2. The default project from `agent-prefs.json`, which is what "where do
     agents go" means. It beats the linked directory: a link is about deploys,
     and running `railway code` inside some service's checkout should not put
     an agent there.
  3. The linked directory, which is a worse answer than a configured default
     but a better one than a question.
  4. Nothing to go on, and a terminal to ask in: run `railway ca setup`. The
     old workspace → project → environment picker answered one launch; setup
     answers every launch after it, and asks the same question. Skipping the
     project step there falls back to the picker so the launch still happens.

Within that environment the agent is chosen as before — the remembered one,
then your sole existing one, then a fresh one — so a second `railway code` in
the default project lands on the agent already there rather than billing for a
new box.

The precedence is a pure function over (flags, configured, linked) so the order
is checked by tests rather than by reading it. One of those tests pins the
property that keeps this safe: a TUI launch always carries an explicit target,
so neither setup nor any other prompt can be drawn underneath a frame.

Also retargets the multi-agent error at `railway ca`, which can now pick one —
the advice to use the dashboard predates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codyde
codyde marked this pull request as ready for review August 7, 2026 20:51
codyde and others added 2 commits August 7, 2026 13:55
- The saved-default line now names the preferences file it came from
  (~/.railway/agent-prefs.json) and how to change it.
- Drop --gh and its VM-side provisioning: the GitHub token ships in the
  agent config by default now, so copying the host's token in is
  redundant. The remote-side ~/.gh-token sourcing stays so agents
  provisioned with --gh keep working.
- Drop the --refresh-auth-only-applies-to-claude note; the flag is
  simply a no-op for harnesses that read credentials from disk.
- Replace "Provisioning {harness}" and the skills-already-synced note
  with a single "Finalizing Configuration..." step; the skills sync
  failure warning stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* origin/master:
  feat: shared container-exec primitive + Patroni probe controller (#1048)
  chore: regenerate schema.json from the Public subgraph introspection (#1047)
  chore: Release railwayapp version 5.31.1
  test: scripted-backboard testkit + first GraphQL-layer tests (#1046)

# Conflicts:
#	src/commands/sandbox.rs
#	src/gql/schema.json
@codyde
codyde merged commit 112c019 into master Aug 7, 2026
8 checks passed
@codyde
codyde deleted the feat/code-agents branch August 7, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release/minor Author minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant