use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use clap::Parser;
use colored::Colorize;
use is_terminal::IsTerminal;
use crate::client::{GQLClient, post_graphql};
use crate::commands::cloud_agent::mcp_sync;
use crate::commands::cloud_agent::prefs::{AgentPrefs, DefaultProject};
use crate::commands::cloud_agent::skills_sync;
use crate::commands::sandbox::{resolve_project_and_env, variables_to_input};
use crate::commands::ssh::tel as ssh_tel;
use crate::commands::ssh::{
ensure_ssh_key_quiet, probe_native_ssh, run_native_ssh_captured, run_native_ssh_with_opts,
};
use crate::config::Configs;
use crate::controllers::cloud_agent as ca;
use crate::controllers::project::get_project;
use crate::errors::RailwayError;
use crate::gql::{mutations, queries};
use crate::macros::is_stdout_terminal;
use crate::util::progress::create_shimmer_spinner;
use crate::util::shell::shell_join;
pub(crate) mod bootstrap_setup;
pub(crate) mod client;
mod names;
mod plumbing;
pub(crate) mod saved_config;
pub(crate) fn clear_saved_config() {
if let Some(home) = dirs::home_dir() {
saved_config::clear_in(&home);
}
}
pub(crate) fn save_desktop_configuration(
prepared: &Prepared,
connection: Option<&crate::commands::cloud_agent::opencode::Connection>,
beta: bool,
desktop: &Result<bool>,
) {
let result = saved_config::SavedConfig::from_prepared(prepared)
.map(|saved| match connection {
Some(connection) => saved.with_opencode(connection, beta, desktop),
None => saved,
})
.and_then(|saved| saved.save());
if let Err(error) = result {
eprintln!("Could not save connection details for railway code get-config: {error:#}");
}
}
#[derive(Parser)]
#[clap(
args_conflicts_with_subcommands = true,
after_help = r#"Examples:
railway code --codex # start Codex on a new VM
railway code --claude # start Claude Code on a new VM
railway code --codex connect my-box # connect to an existing server
railway code --codex desktop-only # configure Codex Desktop
railway code get-config my-box # show saved connection details
An explicit agent flag creates a new VM unless you use connect or --agent.
Without an agent flag, uses the default from railway ca setup.
Flags override preferences; the linked project overrides the saved project.
Codex and OpenCode open a local client connected to the VM, with command
approvals disabled. Use remote to run the client on the VM, or -- to pass
arguments to the agent. Available local credentials are copied to the VM;
Claude can mint a setup token. Codex saves Desktop setup; OpenCode configures
detected Desktop installs.
Disconnecting leaves the VM running. Use railway ca sleep <agent> to stop compute.
Requires Cloud Agents access.
Guide: https://github.com/railwayapp/cli/blob/master/docs/cloud-agents.md"#
)]
pub struct Args {
#[clap(subcommand)]
command: Option<Commands>,
#[clap(flatten)]
launch: LaunchArgs,
}
#[derive(clap::Subcommand)]
enum Commands {
GetConfig(saved_config::Args),
}
pub async fn command(args: Args) -> Result<()> {
if let Some(Commands::GetConfig(args)) = args.command {
return saved_config::command(args);
}
let mut args = args.launch;
if let Some(action) = args.prepare_code_launch()? {
let harness = if args.codex {
client::Harness::Codex
} else if args.opencode2 {
client::Harness::OpenCode2
} else {
client::Harness::OpenCode
};
match action {
ClientAction::Local => {
return client::start(args, harness, client::LaunchMode::LocalClient).await;
}
ClientAction::DesktopOnly => {
return codex_desktop_only(args, Default::default()).await;
}
ClientAction::Connect(selector) => {
return client::connect(args, harness, selector).await;
}
ClientAction::Remote => {
args.agent_args.clear();
args.client_on_agent = true;
client::pin_agent(&mut args).await?;
}
}
}
launch_in_cloud(args).await
}
pub(crate) async fn codex_desktop_only(
mut args: LaunchArgs,
options: crate::commands::cloud_agent::desktop::CodexOptions,
) -> Result<()> {
if args.client_action()? != Some(ClientAction::DesktopOnly) {
bail!("Expected railway code --codex desktop-only");
}
args.agent_args.clear();
client::start(
args,
client::Harness::Codex,
client::LaunchMode::DesktopOnly(options),
)
.await
}
pub(crate) async fn launch_in_cloud(args: LaunchArgs) -> Result<()> {
if args.connection_json {
bail!("--connection-json requires railway code --codex or --opencode2 [connect].");
}
if args.agent_args.first().is_some_and(|a| a == "setup") {
bail!(
"`railway code` passes arguments straight to the agent, so this would run `setup` on the VM.\nDid you mean `railway ca setup`?"
);
}
match args.wants_pane() {
true => crate::commands::cloud_agent::launch_in_pane(args).await,
false => launch(args).await,
}
}
#[derive(Parser, Default, Clone, Debug, PartialEq, Eq)]
#[clap(
group(clap::ArgGroup::new("client_json_harness").args(["codex", "opencode2"]).multiple(true))
)]
pub struct LaunchArgs {
#[clap(long, help_heading = "Agent")]
codex: bool,
#[clap(long, help_heading = "Agent")]
opencode: bool,
#[clap(long, help_heading = "Agent")]
opencode2: bool,
#[clap(
long,
requires = "client_json_harness",
help_heading = "Authentication and output"
)]
connection_json: bool,
#[clap(long, help_heading = "Agent")]
claude: bool,
#[clap(long, help_heading = "Agent")]
grok: bool,
#[clap(long, help_heading = "Agent")]
railway: bool,
#[clap(long, help_heading = "VM creation")]
pub new: bool,
#[clap(long, conflicts_with_all = ["no_bootstrap", "remote_agent", "rm"], help_heading = "VM creation")]
bootstrap: Option<String>,
#[clap(long, conflicts_with_all = ["remote_agent", "rm"], help_heading = "VM creation")]
no_bootstrap: bool,
#[clap(long, hide = true)]
keep_awake: bool,
#[clap(long, help_heading = "Lifecycle")]
rm: bool,
#[clap(long, help_heading = "Authentication and output")]
refresh_auth: bool,
#[clap(long, help_heading = "VM creation")]
name: Option<String>,
#[clap(
long = "variable",
value_name = "KEY=VALUE[,KEY=VALUE...]",
help_heading = "VM creation"
)]
variables: Vec<String>,
#[clap(skip)]
pub(crate) boot_variables: std::collections::BTreeMap<String, String>,
#[clap(long, requires = "new", help_heading = "VM creation")]
pub(crate) code_endpoint: bool,
#[clap(long, requires = "new", value_parser = ca::parse_code_port, help_heading = "VM creation")]
code_port: Option<u16>,
#[clap(long = "env-file", value_name = "PATH", help_heading = "VM creation")]
env_files: Vec<std::path::PathBuf>,
#[clap(long, short, help_heading = "Target")]
pub environment: Option<String>,
#[clap(skip)]
pub(crate) local_name_project: Option<String>,
#[clap(long, short, help_heading = "Target")]
pub project: Option<String>,
agent_args: Vec<String>,
#[clap(long = "dir", value_name = "PATH", help_heading = "Target")]
remote_dir: Option<String>,
#[clap(
long = "agent",
value_name = "NAME_OR_ID",
conflicts_with = "new",
help_heading = "Target"
)]
remote_agent: Option<String>,
#[clap(skip)]
pub initial_prompt: Option<String>,
#[clap(skip)]
pub agent_id: Option<String>,
#[clap(skip)]
pub shell: bool,
#[clap(skip)]
pub app_mode: bool,
#[clap(skip)]
pub(crate) bootstrap_setup: bool,
#[clap(skip)]
pub client_on_agent: bool,
#[clap(skip)]
pub resume_session_id: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum ClientAction {
Local,
DesktopOnly,
Remote,
Connect(Option<String>),
}
impl LaunchArgs {
fn prepare_code_launch(&mut self) -> Result<Option<ClientAction>> {
let action = self.client_action()?;
if matches!(action, Some(ClientAction::Connect(_)))
&& (self.bootstrap.is_some() || self.no_bootstrap)
{
bail!("Bootstrap options create a new VM and cannot be used with connect.");
}
let harness_selected = self.codex
|| self.opencode
|| self.opencode2
|| self.claude
|| self.grok
|| self.railway;
if harness_selected
&& !self.rm
&& self.remote_agent.is_none()
&& self.agent_id.is_none()
&& !matches!(action, Some(ClientAction::Connect(_)))
{
self.new = true;
}
Ok(action)
}
fn client_action(&self) -> Result<Option<ClientAction>> {
let verb = self.agent_args.first().map(String::as_str);
if self.connection_json
&& ((!self.codex && !self.opencode2)
|| self.rm
|| self.app_mode
|| !(matches!(verb, None | Some("connect"))
|| (self.codex && verb == Some("desktop-only"))))
{
bail!(
"--connection-json requires railway code --codex or --opencode2 [connect], or --codex desktop-only."
);
}
let reserved = matches!(verb, Some("remote" | "connect" | "desktop-only"));
let selected = self.codex || self.opencode || self.opencode2;
if !reserved && (!selected || !self.agent_args.is_empty() || self.rm || self.app_mode) {
if self.remote_dir.is_some() || self.remote_agent.is_some() {
bail!("--dir and --agent require a Codex or OpenCode client command.");
}
return Ok(None);
}
if [self.codex, self.opencode, self.opencode2]
.into_iter()
.filter(|selected| *selected)
.count()
!= 1
|| self.claude
|| self.grok
|| self.railway
|| self.shell
{
bail!("Pick exactly one client: --codex, --opencode, or --opencode2.");
}
if self.rm || self.initial_prompt.is_some() {
bail!("Local-client commands cannot be combined with --rm or an initial prompt.");
}
if self
.remote_dir
.as_deref()
.is_some_and(|dir| dir.trim().is_empty())
{
bail!("--dir must name a directory on the agent.");
}
match verb {
None => Ok(Some(ClientAction::Local)),
Some("desktop-only") => {
if !self.codex || self.agent_args.len() != 1 || self.app_mode {
bail!(
"Use railway code --codex desktop-only [--agent NAME_OR_ID] [--dir PATH] [--new]."
);
}
Ok(Some(ClientAction::DesktopOnly))
}
Some("remote") => {
if self.agent_args.len() != 1 || self.remote_dir.is_some() {
bail!(
"Use railway code --codex remote (or --opencode/--opencode2) to open the UI inside the cloud agent; --dir is for local clients."
);
}
Ok(Some(ClientAction::Remote))
}
Some("connect") => {
if self.agent_args.len() > 2
|| self.new
|| self.name.is_some()
|| self.code_endpoint
|| self.code_port.is_some()
|| !self.variables.is_empty()
|| !self.env_files.is_empty()
|| self.refresh_auth
|| self.remote_dir.is_some()
{
bail!(
"connect uses an existing server. Use railway code --codex, --opencode, or --opencode2 to set one up on a fresh VM."
);
}
let positional = self.agent_args.get(1).cloned();
if positional.is_some() && self.remote_agent.is_some() {
bail!("Name the agent after connect or with --agent, not both.");
}
let selector = positional.or_else(|| self.remote_agent.clone());
if selector
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
bail!("The cloud agent name cannot be empty.");
}
Ok(Some(ClientAction::Connect(selector)))
}
_ => unreachable!("other harness arguments returned above"),
}
}
pub fn is_bare(&self) -> bool {
!self.codex
&& !self.opencode
&& !self.opencode2
&& !self.connection_json
&& !self.claude
&& !self.grok
&& !self.railway
&& !self.new
&& self.bootstrap.is_none()
&& !self.no_bootstrap
&& !self.keep_awake
&& !self.rm
&& !self.refresh_auth
&& self.name.is_none()
&& self.environment.is_none()
&& self.project.is_none()
&& self.initial_prompt.is_none()
&& self.resume_session_id.is_none()
&& self.variables.is_empty()
&& !self.code_endpoint
&& self.code_port.is_none()
&& self.env_files.is_empty()
&& self.agent_args.is_empty()
&& self.remote_dir.is_none()
&& self.remote_agent.is_none()
&& !self.shell
}
pub fn wants_pane(&self) -> bool {
self.pane_shaped() && is_stdout_terminal()
}
fn pane_shaped(&self) -> bool {
!self.rm && self.agent_args.is_empty()
}
pub fn set_harness(&mut self, slug: &str) {
self.claude = slug == "claude";
self.codex = slug == "codex";
self.opencode = slug == "opencode";
self.opencode2 = slug == "opencode2";
self.grok = slug == "grok";
self.railway = slug == "railway";
self.shell = slug == "shell";
}
pub fn for_target(
project_id: String,
environment_id: String,
harness: &str,
force_new: bool,
prompt: Option<String>,
agent_id: Option<String>,
) -> Self {
Self::default().retargeted(
project_id,
environment_id,
harness,
force_new,
prompt,
agent_id,
)
}
pub(crate) fn set_bootstrap_choice(&mut self, name: Option<String>, none: bool) {
self.bootstrap = name;
self.no_bootstrap = none;
}
pub fn retargeted(
mut self,
project_id: String,
environment_id: String,
harness: &str,
force_new: bool,
prompt: Option<String>,
agent_id: Option<String>,
) -> Self {
self.project = Some(project_id);
self.environment = Some(environment_id);
self.new = force_new;
self.initial_prompt = prompt;
self.agent_id = agent_id;
self.set_harness(harness);
self
}
pub fn for_app_mode(
harness: &str,
project: Option<String>,
environment: Option<String>,
) -> Self {
let mut args = Self {
project,
environment,
app_mode: true,
..Self::default()
};
args.set_harness(harness);
args
}
pub(crate) fn for_codex_desktop(
project: Option<String>,
environment: Option<String>,
agent: Option<String>,
directory: String,
new: bool,
) -> Self {
Self {
codex: true,
project,
environment,
remote_agent: agent,
remote_dir: Some(directory),
new,
agent_args: vec!["desktop-only".into()],
..Self::default()
}
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Agent {
Codex,
OpenCode,
OpenCode2,
Claude,
Grok,
Railway,
Shell,
}
impl Agent {
fn name(self) -> &'static str {
match self {
Agent::Codex => "codex",
Agent::OpenCode => "opencode",
Agent::OpenCode2 => "opencode2",
Agent::Claude => "claude",
Agent::Grok => "grok",
Agent::Railway => "railway-agent-tui",
Agent::Shell => "bash",
}
}
fn slug(self) -> &'static str {
match self {
Agent::Codex => "codex",
Agent::OpenCode => "opencode",
Agent::OpenCode2 => "opencode2",
Agent::Claude => "claude",
Agent::Grok => "grok",
Agent::Railway => "railway",
Agent::Shell => "shell",
}
}
fn from_slug(slug: &str) -> Option<Self> {
match slug {
"claude" => Some(Agent::Claude),
"codex" => Some(Agent::Codex),
"opencode" => Some(Agent::OpenCode),
"opencode2" => Some(Agent::OpenCode2),
"grok" => Some(Agent::Grok),
"railway" => Some(Agent::Railway),
"shell" => Some(Agent::Shell),
_ => None,
}
}
fn display(self) -> &'static str {
match self {
Agent::Codex => "Codex",
Agent::OpenCode => "OpenCode",
Agent::OpenCode2 => "OpenCode2 [Beta]",
Agent::Claude => "Claude Code",
Agent::Grok => "Grok",
Agent::Railway => "Railway",
Agent::Shell => "a plain shell",
}
}
fn credential_seed(self) -> &'static str {
match self {
Agent::Codex => CODEX_SEED,
Agent::OpenCode => OPENCODE_SEED,
Agent::OpenCode2 => crate::commands::cloud_agent::opencode2::auth::SEED,
Agent::Claude => CLAUDE_SEED,
Agent::Grok => GROK_SEED,
Agent::Railway | Agent::Shell => "",
}
}
fn credential_seed_framed(self, len: usize) -> String {
match self {
Agent::Codex => format!("mkdir -p ~/.codex\nhead -c {len} > ~/.codex/auth.json"),
Agent::OpenCode => format!(
"{OPENCODE_DATA_DIR}\nmkdir -p \"$opencode_data\"\nhead -c {len} > \"$opencode_data/auth.json\"\nchmod 600 \"$opencode_data/auth.json\""
),
Agent::Claude => {
format!("head -c {len} > ~/.claude-code-env\nchmod 600 ~/.claude-code-env")
}
Agent::Grok => format!("mkdir -p ~/.grok\nhead -c {len} > ~/.grok/auth.json"),
Agent::OpenCode2 => crate::commands::cloud_agent::opencode2::auth::seed_framed(len),
Agent::Railway | Agent::Shell => "true".to_string(),
}
}
fn local_signin_path(self, home: &Path, xdg_data_home: Option<&Path>) -> Option<PathBuf> {
match self {
Agent::Codex => Some(home.join(".codex").join("auth.json")),
Agent::Grok => Some(home.join(".grok").join("auth.json")),
Agent::OpenCode => Some(
xdg_data_home
.filter(|p| p.is_absolute())
.map(Path::to_path_buf)
.unwrap_or_else(|| home.join(".local/share"))
.join("opencode/auth.json"),
),
Agent::OpenCode2 | Agent::Claude | Agent::Railway | Agent::Shell => None,
}
}
fn sign_in_on_agent_hint(self) -> &'static str {
match self {
Agent::Codex => "sign in there with `codex login --device-auth`",
Agent::OpenCode => {
"connect a provider in OpenCode Desktop or run `opencode auth login` on the agent"
}
Agent::OpenCode2 => {
"connect a provider in OpenCode Beta or run `opencode2 auth login` on the agent"
}
Agent::Claude => "sign in there with `/login`",
Agent::Grok => "sign in there when it asks",
Agent::Railway => "no sign-in needed — the agent carries its own",
Agent::Shell => "no sign-in needed — nothing starts but a shell",
}
}
}
const CODEX_SEED: &str = r#"mkdir -p ~/.codex
cat > ~/.codex/auth.json"#;
const CLAUDE_SEED: &str = r#"cat > ~/.claude-code-env
chmod 600 ~/.claude-code-env"#;
const CLAUDE_ENV_GUARD: &str =
r#"[ -f "$HOME/.claude-code-env" ] && set -a && . "$HOME/.claude-code-env" && set +a"#;
const GROK_SEED: &str = r#"mkdir -p ~/.grok
cat > ~/.grok/auth.json"#;
const OPENCODE_DATA_DIR: &str = r#"opencode_data="${XDG_DATA_HOME:-$HOME/.local/share}/opencode""#;
const OPENCODE_SEED: &str = r#"opencode_data="${XDG_DATA_HOME:-$HOME/.local/share}/opencode"
mkdir -p "$opencode_data"
cat > "$opencode_data/auth.json"
chmod 600 "$opencode_data/auth.json""#;
const HARNESS_PATH: &str = r#"export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$HOME/.grok/bin:$HOME/.local/share/mise/shims:$PATH""#;
const COMMON_SEED: &str = r#"grep -q "^COLORTERM=" /etc/environment 2>/dev/null || echo "COLORTERM=truecolor" >> /etc/environment 2>/dev/null || true
if ! grep -q "railway-code agent autostart v4" ~/.profile 2>/dev/null; then
sed -i '/# railway-code agent autostart/,/^fi$/d' ~/.profile 2>/dev/null || true
cat >> ~/.profile <<'PROFEOF'
# railway-code agent autostart v4 (connecting drops into the agent; exit it for a shell)
if [ -z "$RAILWAY_CODE_AUTOSTARTED" ] && [ -t 1 ] && [ ! -f "$HOME/.railway-app-mode" ] && [ -s "$HOME/.railway-code-agent" ]; then
agent="$(cat "$HOME/.railway-code-agent")"
[ -d "$HOME/.grok/bin" ] && export PATH="$HOME/.grok/bin:$PATH"
if command -v "$agent" >/dev/null 2>&1; then
export RAILWAY_CODE_AUTOSTARTED=1
[ -f "$HOME/.gh-token" ] && export GH_TOKEN="$(cat "$HOME/.gh-token")"
[ -f "$HOME/.claude-code-env" ] && set -a && . "$HOME/.claude-code-env" && set +a
"$agent"
printf '\033[<u\033[<u\033[=0;1u\033[?2004l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?1004l\033[?25h'
fi
fi
PROFEOF
fi"#;
const CLAUDE_CREDENTIAL_PROBE: &str =
r#"[ -s ~/.claude-code-env ] && echo CRED-PRESENT || echo CRED-ABSENT"#;
fn provision_script(agent: Agent, write_credential: bool, app_mode: bool) -> String {
let seed = if write_credential {
agent.credential_seed()
} else {
"true"
};
let name = agent.name();
let hash_marker = skills_sync::REMOTE_HASH_MARKER;
let hash_file = skills_sync::REMOTE_HASH_FILE;
let mcp_marker = mcp_sync::REMOTE_HASH_MARKER;
let mcp_file = mcp_sync::REMOTE_HASH_FILE;
let mode_seed = mode_seed(agent, app_mode);
let runtime_seed = if agent == Agent::OpenCode2 {
crate::commands::cloud_agent::opencode2::seed_script()
} else {
"true".to_string()
};
format!(
r#"umask 077
{HARNESS_PATH}
{seed}
{COMMON_SEED}
{runtime_seed}
{mode_seed}
printf '{hash_marker}%s\n' "$(cat "{hash_file}" 2>/dev/null || true)"
printf '{mcp_marker}%s\n' "$(cat "{mcp_file}" 2>/dev/null || true)"
if command -v {name} >/dev/null 2>&1; then echo AGENT-READY; else echo AGENT-MISSING; fi"#
)
}
fn provision_script_with_skills(
agent: Agent,
credential_len: Option<usize>,
app_mode: bool,
skills_hash: &str,
) -> String {
let seed = match credential_len {
Some(len) => agent.credential_seed_framed(len),
None => "true".to_string(),
};
let name = agent.name();
let mode_seed = mode_seed(agent, app_mode);
let runtime_seed = if agent == Agent::OpenCode2 {
crate::commands::cloud_agent::opencode2::seed_script()
} else {
"true".to_string()
};
let sync = skills_sync::sync_body(skills_hash);
format!(
r#"umask 077
{HARNESS_PATH}
{seed}
payload="$HOME/.railway-skills-payload.tgz"
cat > "$payload"
{COMMON_SEED}
{runtime_seed}
{mode_seed}
if command -v {name} >/dev/null 2>&1; then echo AGENT-READY; else echo AGENT-MISSING; fi
{sync}"#
)
}
fn mode_seed(agent: Agent, app_mode: bool) -> String {
if app_mode {
"touch ~/.railway-app-mode\nrm -f ~/.railway-code-agent".to_string()
} else {
let record = match agent {
Agent::Shell => "true".to_string(),
_ => format!("echo {} > ~/.railway-code-agent", agent.name()),
};
format!("rm -f ~/.railway-app-mode\n{record}")
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SessionStyle {
FullTerminal,
Pane,
}
pub(crate) const LOGIN_SHELL_COMMAND: &str = "export RAILWAY_CODE_AUTOSTARTED=1; exec bash -l";
pub(crate) fn harness_env_prefix() -> String {
format!(
"{HARNESS_PATH}; [ -f ~/.gh-token ] && export GH_TOKEN=\"$(cat ~/.gh-token)\"; {CLAUDE_ENV_GUARD}; "
)
}
fn remote_command(
agent: Agent,
env_prefix: &str,
initial_prompt: Option<&str>,
resume_session_id: Option<&str>,
agent_args: &[String],
style: SessionStyle,
) -> String {
if agent == Agent::Shell {
return format!("{env_prefix}{LOGIN_SHELL_COMMAND}");
}
let name = match agent {
Agent::OpenCode2 => "opencode2 --standalone",
Agent::Railway => {
"railway-agent-tui --session \"${RAILWAY_DURABLE_SESSION_NAME:-railway-adhoc-$$}\""
}
_ => agent.name(),
};
let after = match style {
SessionStyle::FullTerminal => "; exec bash -l",
SessionStyle::Pane => "; exit \"$railway_code_status\"",
};
let reset = format!("railway_code_status=$?; {}", terminal_reset_printf());
if matches!(agent, Agent::Claude | Agent::Grok)
&& let Some(id) = resume_session_id.map(str::trim).filter(|id| !id.is_empty())
{
return format!(
"{env_prefix}export RAILWAY_CODE_AUTOSTARTED=1; {name} --resume {}; {reset}{after}",
shell_join(std::slice::from_ref(&id.to_string())),
);
}
match initial_prompt.map(str::trim).filter(|p| !p.is_empty()) {
Some(prompt) if matches!(agent, Agent::OpenCode | Agent::OpenCode2) => format!(
"{env_prefix}export RAILWAY_CODE_AUTOSTARTED=1; {name} --prompt {}; {reset}{after}",
shell_join(std::slice::from_ref(&prompt.to_string())),
),
Some(prompt) => format!(
"{env_prefix}export RAILWAY_CODE_AUTOSTARTED=1; {name} {}; {reset}{after}",
shell_join(std::slice::from_ref(&prompt.to_string())),
),
None if agent_args.is_empty() => {
format!("{env_prefix}export RAILWAY_CODE_AUTOSTARTED=1; {name}; {reset}{after}")
}
None => format!(
"{env_prefix}exec {} {}",
agent.name(),
shell_join(agent_args)
),
}
}
#[derive(Clone)]
struct RelaySsh {
opts: Vec<String>,
known_hosts: std::path::PathBuf,
host_pattern: String,
}
fn mux_socket() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"railway-cm-{}-{:08x}.sock",
std::process::id(),
rand::random::<u32>()
))
}
fn mux_usable(socket: &std::path::Path) -> bool {
!cfg!(windows) && socket.as_os_str().len() <= 100
}
fn mux_master_opts(socket: &std::path::Path, persist: &str) -> Vec<String> {
if !mux_usable(socket) {
return Vec::new();
}
vec![
"-o".into(),
"ControlMaster=auto".into(),
"-o".into(),
format!("ControlPath={}", socket.display()),
"-o".into(),
format!("ControlPersist={persist}"),
]
}
fn mux_client_opts(socket: &std::path::Path) -> Vec<String> {
if !mux_usable(socket) {
return Vec::new();
}
vec!["-o".into(), format!("ControlPath={}", socket.display())]
}
pub fn relay_known_hosts() -> Result<std::path::PathBuf> {
Ok(relay_ssh()?.known_hosts)
}
fn relay_ssh() -> Result<RelaySsh> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("Unable to get home directory"))?;
let railway_dir = home.join(".railway");
std::fs::create_dir_all(&railway_dir)?;
let known_hosts = railway_dir.join("known_hosts_relay");
let (host, port) = Configs::get_ssh_relay();
let host_pattern = match port {
Some(p) if p != 22 => format!("[{host}]:{p}"),
_ => host.to_string(),
};
Ok(RelaySsh {
opts: vec![
"-o".into(),
format!("UserKnownHostsFile={}", known_hosts.display()),
"-o".into(),
"StrictHostKeyChecking=accept-new".into(),
"-o".into(),
"ServerAliveInterval=30".into(),
"-o".into(),
"ServerAliveCountMax=3".into(),
],
known_hosts,
host_pattern,
})
}
impl RelaySsh {
fn heal_known_hosts(&self) {
let _ = std::process::Command::new("ssh-keygen")
.arg("-R")
.arg(&self.host_pattern)
.arg("-f")
.arg(&self.known_hosts)
.output();
}
}
const TERMINAL_RESET: &str = "\x1b[<u\x1b[<u\x1b[=0;1u\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?25h";
fn terminal_reset_printf() -> String {
format!("printf '{}'", TERMINAL_RESET.replace('\x1b', "\\033"))
}
fn local_signin(agent: Agent, home: &Path) -> Result<PendingAuth> {
let xdg_data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from);
if agent == Agent::OpenCode2 {
let database = std::env::var("OPENCODE_DB").ok();
return Ok(
match crate::commands::cloud_agent::opencode2::auth::read(
home,
xdg_data_home.as_deref(),
database.as_deref(),
)? {
Some((line, source)) => PendingAuth::Ready {
line,
source: source.display().to_string(),
},
None => PendingAuth::SignInOnAgent {
note: format!(
"No OpenCode2 provider sign-in to copy from this machine; {}.",
agent.sign_in_on_agent_hint()
),
},
},
);
}
let auth_path = agent.local_signin_path(home, xdg_data_home.as_deref());
read_local_signin(agent, auth_path.as_deref())
}
fn read_local_signin(agent: Agent, auth_path: Option<&Path>) -> Result<PendingAuth> {
let Some(auth_path) = auth_path else {
return Err(anyhow!(
"{} has no local sign-in file to copy",
agent.display()
));
};
let missing = || PendingAuth::SignInOnAgent {
note: format!(
"No {} sign-in on this machine ({}) — starting {} unauthenticated; {}.",
agent.display(),
auth_path.display(),
agent.display(),
agent.sign_in_on_agent_hint()
),
};
if !auth_path.exists() {
return Ok(missing());
}
let bytes = std::fs::read(auth_path)
.with_context(|| format!("Couldn't read {}", auth_path.display()))?;
if bytes.is_empty() {
return Ok(missing());
}
Ok(PendingAuth::Ready {
line: bytes,
source: auth_path.display().to_string(),
})
}
fn claude_token_cache_path() -> Option<std::path::PathBuf> {
Some(claude_token_cache_path_in(&dirs::home_dir()?))
}
fn claude_token_cache_path_in(home: &Path) -> std::path::PathBuf {
home.join(".railway").join("claude-code-token")
}
fn cached_claude_token() -> Option<String> {
let tok = std::fs::read_to_string(claude_token_cache_path()?).ok()?;
let tok = tok.trim().to_string();
(!tok.is_empty() && validate_claude_token(&tok).is_ok()).then_some(tok)
}
fn cache_claude_token(token: &str) {
if let Some(path) = claude_token_cache_path() {
write_token_0600(&path, token);
}
}
fn write_token_0600(path: &std::path::Path, token: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
if let Ok(mut f) = opts.open(path) {
use std::io::Write;
let _ = f.write_all(format!("{token}\n").as_bytes());
}
}
fn claude_token_age_days() -> Option<u64> {
let meta = std::fs::metadata(claude_token_cache_path()?).ok()?;
Some(meta.modified().ok()?.elapsed().ok()?.as_secs() / 86_400)
}
fn cached_token_source(age_days: Option<u64>) -> String {
match age_days {
None | Some(0) => "cached setup-token".to_string(),
Some(days @ 1..30) => format!("cached setup-token from {days}d ago"),
Some(days) => {
format!("cached setup-token from {days}d ago — --refresh-auth re-mints")
}
}
}
pub fn clear_claude_token_cache() {
if let Some(home) = dirs::home_dir() {
clear_claude_token_cache_in(&home);
}
}
pub fn clear_claude_token_cache_in(home: &Path) {
let _ = std::fs::remove_file(claude_token_cache_path_in(home));
}
enum PendingAuth {
Ready { line: Vec<u8>, source: String },
MintClaude,
SignInOnAgent { note: String },
None,
}
static CLAUDE_MINT_DECLINED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn claude_sign_in_note() -> String {
format!(
"No {} credential to carry from this machine — starting it unauthenticated; {}.",
Agent::Claude.display(),
Agent::Claude.sign_in_on_agent_hint()
)
}
fn claude_sign_in_on_agent() -> PendingAuth {
PendingAuth::SignInOnAgent {
note: claude_sign_in_note(),
}
}
fn claude_credentials_cheap(refresh_auth: bool) -> Result<PendingAuth> {
for var in ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] {
if let Ok(tok) = std::env::var(var) {
let tok = tok.trim().to_string();
if !tok.is_empty() {
validate_claude_token(&tok)?;
return Ok(PendingAuth::Ready {
line: format!("{var}={tok}\n").into_bytes(),
source: format!("${var}"),
});
}
}
}
if refresh_auth {
clear_claude_token_cache();
} else if let Some(tok) = cached_claude_token() {
return Ok(PendingAuth::Ready {
line: format!("CLAUDE_CODE_OAUTH_TOKEN={tok}\n").into_bytes(),
source: cached_token_source(claude_token_age_days()),
});
}
if CLAUDE_MINT_DECLINED.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(claude_sign_in_on_agent());
}
if which::which("claude").is_err() {
return Ok(claude_sign_in_on_agent());
}
Ok(PendingAuth::MintClaude)
}
fn mint_claude_credentials() -> Result<Option<(Vec<u8>, String)>> {
use colored::Colorize;
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
eprintln!(
"{}",
"No Claude credential found — set CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or ANTHROPIC_API_KEY to carry one from this machine."
.yellow()
);
CLAUDE_MINT_DECLINED.store(true, std::sync::atomic::Ordering::Relaxed);
return Ok(None);
}
if !CLAUDE_AUTO_MINT_FAILED.load(std::sync::atomic::Ordering::Relaxed) {
let spinner = create_shimmer_spinner(
"Minting a Claude token — approve the browser prompt if one appears",
);
match mint_claude_credential_headless() {
Ok(tok) => {
spinner.finish_and_clear();
return Ok(Some((
format!("CLAUDE_CODE_OAUTH_TOKEN={tok}\n").into_bytes(),
"claude setup-token".to_string(),
)));
}
Err(e) => {
spinner.finish_and_clear();
eprintln!(
"{}",
format!(
"Couldn't mint a token automatically ({e}) — run `claude setup-token` in another terminal instead."
)
.yellow()
)
}
}
}
let tok = crate::util::prompt::prompt_secret(
"Run `claude setup-token` on this machine, then paste the token (enter to skip and sign in on the agent)",
)?;
let tok = tok.trim().to_string();
if tok.is_empty() {
CLAUDE_MINT_DECLINED.store(true, std::sync::atomic::Ordering::Relaxed);
return Ok(None);
}
validate_claude_token(&tok)?;
cache_claude_token(&tok);
Ok(Some((
format!("CLAUDE_CODE_OAUTH_TOKEN={tok}\n").into_bytes(),
"claude setup-token".to_string(),
)))
}
const SETUP_TOKEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
static CLAUDE_AUTO_MINT_FAILED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn mint_claude_credential_headless() -> Result<String> {
let minted = run_claude_setup_token().and_then(|tok| {
validate_claude_token(&tok)?;
Ok(tok)
});
match minted {
Ok(tok) => {
cache_claude_token(&tok);
Ok(tok)
}
Err(e) => {
CLAUDE_AUTO_MINT_FAILED.store(true, std::sync::atomic::Ordering::Relaxed);
Err(e)
}
}
}
fn run_claude_setup_token() -> Result<String> {
let capture = std::env::temp_dir().join(format!(
"railway-setup-token-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
{
let f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&capture)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
}
#[cfg(not(unix))]
{
let _ = std::fs::remove_file(&capture);
bail!("automatic token capture needs a unix pty");
}
#[cfg(unix)]
{
use std::process::Stdio;
let inner = "stty cols 500 rows 50 2>/dev/null; exec claude setup-token";
#[cfg(target_os = "macos")]
let mut cmd = {
let mut c = std::process::Command::new("script");
c.arg("-q").arg(&capture).args(["/bin/sh", "-c", inner]);
c
};
#[cfg(not(target_os = "macos"))]
let mut cmd = {
let mut c = std::process::Command::new("script");
c.args(["-q", "-e", "-c", inner]).arg(&capture);
c
};
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = cmd
.spawn()
.map_err(|e| anyhow!("couldn't spawn script(1): {e}"))?;
let deadline = std::time::Instant::now() + SETUP_TOKEN_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
break None;
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(200)),
Err(e) => {
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&capture);
return Err(anyhow!("couldn't wait on script(1): {e}"));
}
}
};
let recorded = std::fs::read_to_string(&capture);
let _ = std::fs::remove_file(&capture);
match extract_claude_token(&recorded.unwrap_or_default()) {
Some(tok) => Ok(tok),
None => match status {
None => Err(anyhow!(
"the browser sign-in didn't complete within {}s",
SETUP_TOKEN_TIMEOUT.as_secs()
)),
Some(s) if !s.success() => Err(anyhow!(
"`claude setup-token` exited without minting a token — is claude installed locally?"
)),
Some(_) => Err(anyhow!("`claude setup-token` finished without a token")),
},
}
}
}
fn extract_claude_token(raw: &str) -> Option<String> {
let text = strip_ansi(raw);
let is_tok = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
let lines: Vec<&str> = text.split(['\r', '\n']).collect();
let mut best: Option<String> = None;
for (i, line) in lines.iter().enumerate() {
let Some(pos) = line.find("sk-ant-oat01-") else {
continue;
};
let mut tok: String = line[pos..].chars().take_while(|&c| is_tok(c)).collect();
let mut at_eol = pos + tok.len() >= line.trim_end().len();
let mut j = i + 1;
while at_eol {
while j < lines.len() && lines[j].is_empty() {
j += 1;
}
let Some(cont) = lines.get(j) else { break };
let cont = cont.strip_prefix(' ').unwrap_or(cont);
let cont = cont.trim_end();
if !cont.is_empty() && cont.chars().all(is_tok) {
tok.push_str(cont);
j += 1;
at_eol = true;
} else {
break;
}
}
if best.as_ref().is_none_or(|b| tok.len() > b.len()) {
best = Some(tok);
}
}
best.filter(|t| t.len() >= 60)
}
fn strip_ansi(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\x1b' => match chars.next() {
Some('[') => {
for n in chars.by_ref() {
if ('\x40'..='\x7e').contains(&n) {
break;
}
}
}
Some(']') | Some('P') | Some('X') | Some('^') | Some('_') => {
while let Some(n) = chars.next() {
if n == '\x07' {
break;
}
if n == '\x1b' {
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
}
}
Some('(') | Some(')') => {
chars.next();
}
_ => {}
},
'\x0e' | '\x0f' => {}
_ => out.push(c),
}
}
out
}
fn validate_claude_token(tok: &str) -> Result<()> {
if tok
.chars()
.any(|c| c.is_whitespace() || c.is_control() || "'\"\\$`;#".contains(c))
{
bail!(
"That doesn't look like a Claude token (it contains whitespace or shell-special characters)."
);
}
Ok(())
}
fn ssh_plumbing(
target: &str,
command: &str,
identity: Option<&std::path::Path>,
stdin_payload: Option<&[u8]>,
relay: &RelaySsh,
mux_socket: Option<&std::path::Path>,
) -> Result<Vec<u8>> {
const BACKOFF_SECS: [u64; 5] = [2, 3, 5, 5, 5];
let attempts = BACKOFF_SECS.len() + 1;
let (command, payload) = plumbing::script_input(command, stdin_payload);
let mut last: (i32, String) = (1, String::new());
for attempt in 1..=attempts {
let (code, out, err) =
run_native_ssh_captured(target, &command, identity, Some(&payload), &relay.opts)?;
if code == 0 {
return Ok(out);
}
let stderr_text = String::from_utf8_lossy(&err).trim().to_string();
let stdout_text = String::from_utf8_lossy(&out).trim().to_string();
let reason = if stderr_text.is_empty() {
stdout_text
} else if stdout_text.is_empty() {
stderr_text.clone()
} else {
format!("{stderr_text}\n{stdout_text}")
};
let hostkey_mismatch = stderr_text.contains("Host key verification failed")
|| stderr_text.contains("REMOTE HOST IDENTIFICATION HAS CHANGED");
if hostkey_mismatch {
relay.heal_known_hosts();
}
if let Some(socket) = mux_socket {
let _ = std::fs::remove_file(socket);
}
last = (code, reason);
if attempt < attempts {
let wait = if hostkey_mismatch {
0
} else {
BACKOFF_SECS[attempt - 1]
};
if wait > 0 {
std::thread::sleep(std::time::Duration::from_secs(wait));
}
}
}
let (code, reason) = last;
if reason.is_empty() {
bail!(
"SSH to the agent failed after {attempts} attempts (exit {code}), with no message from the relay."
)
}
bail!("SSH to the agent failed after {attempts} attempts (exit {code}):\n{reason}")
}
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
pub(crate) struct RelayAccess {
pub identity: Option<std::path::PathBuf>,
pub relay_opts: Vec<String>,
}
pub(crate) async fn relay_access() -> Result<RelayAccess> {
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let identity = ensure_ssh_key_quiet(&client, &configs).await?;
let relay = relay_ssh()?;
Ok(RelayAccess {
identity,
relay_opts: relay.opts,
})
}
pub(crate) async fn wait_until_connectable(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
id: &str,
access: &RelayAccess,
initial_delay: std::time::Duration,
) -> Result<(ca::Agent, Option<std::path::PathBuf>)> {
use ca::Status as S;
let wait_started = std::time::Instant::now();
let diagnostics = ssh_tel::timing_diagnostics();
let ssh_target = format!("agent:{environment_id}:{id}");
let deadline = std::time::Instant::now() + READY_TIMEOUT;
if !initial_delay.is_zero() {
tokio::time::sleep(initial_delay).await;
}
let mut round = 0u32;
let mut last_fetch: Option<std::time::Instant> = None;
let mut last_agent: Option<ca::Agent> = None;
loop {
round += 1;
let round_started = std::time::Instant::now();
let socket = mux_socket();
let target = ssh_target.clone();
let identity = access.identity.clone();
let mut opts = access.relay_opts.clone();
opts.extend(mux_master_opts(&socket, "3s"));
let probe = tokio::task::spawn_blocking(move || {
probe_native_ssh(&target, identity.as_deref(), &opts)
});
let fetch_due = last_fetch.is_none_or(|at| at.elapsed().as_millis() >= 700);
if fetch_due {
let agent = ca::get(client, backboard, environment_id, id)
.await?
.ok_or_else(|| anyhow!("Agent {id} disappeared while starting."))?;
last_fetch = Some(std::time::Instant::now());
match agent.status {
S::Running | S::Starting | S::Sleeping => {}
S::Crashed => bail!(
"Agent {} crashed while starting. `railway code --new` for a fresh one.",
agent.name
),
S::Failed => bail!(
"Agent {} failed to start. `railway code --new` for a fresh one.",
agent.name
),
S::Deleting => bail!("Agent {} is being deleted.", agent.name),
S::Unknown(ref s) => bail!("Agent {} is in an unknown state ({s}).", agent.name),
}
if agent.status == S::Running {
ssh_tel::record_stage("wait_connectable", wait_started.elapsed(), true);
return Ok((agent, None));
}
last_agent = Some(agent);
}
let routed = probe.await?.unwrap_or(false);
if diagnostics {
eprintln!(
"[wait_connectable] round {round}: status={:?} round={}ms routed={routed} fetched={fetch_due}",
last_agent.as_ref().map(|a| &a.status),
round_started.elapsed().as_millis()
);
}
if routed {
ssh_tel::record_stage("wait_connectable", wait_started.elapsed(), true);
let agent = last_agent
.ok_or_else(|| anyhow!("Agent {id} was never observed while starting."))?;
return Ok((agent, Some(socket)));
}
release_probe_master(&socket, &ssh_target);
if std::time::Instant::now() >= deadline {
bail!(
"Agent {id} did not become connectable within {}s (last state: {:?}).",
READY_TIMEOUT.as_secs(),
last_agent.map(|a| a.status)
);
}
let cadence = if wait_started.elapsed() < std::time::Duration::from_secs(4) {
std::time::Duration::from_millis(250)
} else {
std::time::Duration::from_millis(750)
};
if let Some(rest) = cadence.checked_sub(round_started.elapsed()) {
tokio::time::sleep(rest).await;
}
}
}
fn release_probe_master(socket: &std::path::Path, target: &str) {
if !mux_usable(socket) {
return;
}
let _ = std::process::Command::new("ssh")
.arg("-O")
.arg("exit")
.arg("-o")
.arg(format!("ControlPath={}", socket.display()))
.arg(target)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
async fn ready_existing_agent(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
agent: ca::Agent,
progress: &dyn Progress,
access: &RelayAccess,
) -> Result<(ca::Agent, Option<std::path::PathBuf>)> {
use ca::Status as S;
match agent.status {
S::Running => {
progress.note(&format!(
"Using agent {} (--new for a fresh one)",
agent.name
));
Ok((agent, None))
}
S::Sleeping | S::Starting => {
progress.step(&format!("Waking agent {}", agent.name));
let mut probe_delay = std::time::Duration::ZERO;
if agent.status == S::Sleeping {
let wake_started = std::time::Instant::now();
let wake = post_graphql::<mutations::CloudAgentWake, _>(
client,
backboard,
mutations::cloud_agent_wake::Variables {
id: agent.id.clone(),
},
)
.await;
ssh_tel::record_stage("wake_mutation", wake_started.elapsed(), wake.is_ok());
if let Err(e) = wake {
return Err(e.into());
}
probe_delay = std::time::Duration::from_millis(350);
}
let (running, probe_master) = wait_until_connectable(
client,
backboard,
environment_id,
&agent.id,
access,
probe_delay,
)
.await?;
progress.note(&format!(
"Woke agent {} — your work is on its disk",
running.name
));
Ok((running, probe_master))
}
S::Crashed | S::Failed | S::Deleting | S::Unknown(_) => bail!(
"Agent {} is reported as {} and cannot be connected to. Check `railway ca list` and retry, or use `railway code --new` to create a separate agent.",
agent.name,
agent.status.label()
),
}
}
async fn sole_owned_agent_id(
client: &reqwest::Client,
backboard: &str,
environment_id: &str,
) -> Result<Option<String>> {
let agents = ca::list_in_environment(client, backboard, environment_id, true).await?;
match agents.as_slice() {
[] => Ok(None),
[only] => Ok(Some(only.id.clone())),
many => bail!(
"You have {} cloud agents in this environment and no local record of which one `railway code` should use:\n{}\nPick one with `railway ca`, or `railway code --new` to add another.",
many.len(),
many.iter()
.map(|a| format!(" {} ({})", a.name, a.id))
.collect::<Vec<_>>()
.join("\n")
),
}
}
pub(crate) async fn resolve_target(
configs: &mut Configs,
client: &reqwest::Client,
args: &LaunchArgs,
prefs: &mut AgentPrefs,
home: &Path,
) -> Result<names::Target> {
let linked = if args.project.is_none() && args.environment.is_none() {
configs
.get_linked_project()
.await
.ok()
.and_then(|l| l.environment.clone().map(|env| (l.project, env)))
} else {
None
};
let linked = match linked {
Some((project_id, environment_id)) => {
let lookup = get_project(client, configs, project_id.clone()).await;
match stale_link_reason(&lookup, &environment_id) {
Some(reason) => {
eprintln!(
"{}",
format!(
"This directory is linked to a {reason} — ignoring the link (`railway link` to fix it)."
)
.yellow()
);
if let Some(default) = prefs.default_project.as_ref() {
eprintln!(
"{}",
format!(
"Using your default cloud agents project instead: {} ({}).",
default.project_name, default.environment_name
)
.dimmed()
);
}
None
}
None => Some((project_id, environment_id)),
}
}
None => None,
};
let mut target = match choose_target(args, prefs.default_project.as_ref(), linked) {
TargetSource::Flags => {
if let (Some(project), Some(environment)) = (&args.project, &args.environment)
&& is_uuid(project)
&& is_uuid(environment)
{
names::Target::new((project.clone(), environment.clone()), false)
} else {
names::Target::new(
resolve_project_and_env(
configs,
client,
args.project.clone(),
args.environment.clone(),
)
.await?,
false,
)
}
}
TargetSource::Configured(project_id, environment_id) => {
names::Target::new((project_id, environment_id), true)
}
TargetSource::Linked(project_id, environment_id) => {
names::Target::new((project_id, environment_id), false)
}
TargetSource::Setup => {
println!(
"{}",
"No default project for cloud agents yet — let's set one up.".dimmed()
);
crate::commands::cloud_agent::setup::command(Default::default()).await?;
*prefs = AgentPrefs::load_in(home).unwrap_or_default();
match prefs.default_project.clone() {
Some(default) => {
names::Target::new((default.project_id, default.environment_id), true)
}
None => names::Target::new(
resolve_project_and_env(configs, client, None, None).await?,
false,
),
}
}
TargetSource::Ask => names::Target::new(
resolve_project_and_env(configs, client, None, None).await?,
false,
),
};
target.use_local_name |= args.local_name_project.as_deref() == Some(target.project_id.as_str());
Ok(target)
}
fn combined_provision_payload(
credential: Option<&[u8]>,
tarball: &[u8],
) -> (Vec<u8>, Option<usize>) {
let len = credential.map(<[u8]>::len);
let mut payload = Vec::with_capacity(len.unwrap_or(0) + tarball.len());
if let Some(credential) = credential {
payload.extend_from_slice(credential);
}
payload.extend_from_slice(tarball);
(payload, len)
}
fn is_uuid(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() != 36 {
return false;
}
bytes.iter().enumerate().all(|(i, b)| match i {
8 | 13 | 18 | 23 => *b == b'-',
_ => b.is_ascii_hexdigit(),
})
}
#[derive(Debug, PartialEq, Eq)]
enum TargetSource {
Flags,
Configured(String, String),
Linked(String, String),
Setup,
Ask,
}
fn choose_target(
args: &LaunchArgs,
configured: Option<&DefaultProject>,
linked: Option<(String, String)>,
) -> TargetSource {
if args.project.is_some() || args.environment.is_some() {
return TargetSource::Flags;
}
if let Some((project_id, environment_id)) = linked {
return TargetSource::Linked(project_id, environment_id);
}
if let Some(default) = configured {
return TargetSource::Configured(
default.project_id.clone(),
default.environment_id.clone(),
);
}
match is_stdout_terminal() {
true => TargetSource::Setup,
false => TargetSource::Ask,
}
}
fn stale_link_reason(
lookup: &std::result::Result<queries::RailwayProject, RailwayError>,
environment_id: &str,
) -> Option<&'static str> {
match lookup {
Err(RailwayError::ProjectNotFound) => Some("project that no longer exists"),
Err(_) => None,
Ok(project) if project.deleted_at.is_some() => Some("project that was deleted"),
Ok(project) => {
let live = project
.environments
.edges
.iter()
.any(|edge| edge.node.id == environment_id && edge.node.deleted_at.is_none());
(!live).then_some("environment that no longer exists")
}
}
}
async fn resolve_agent(
configs: &mut Configs,
client: &reqwest::Client,
args: &LaunchArgs,
target: &names::Target,
harness: Agent,
progress: &dyn Progress,
access: &RelayAccess,
) -> Result<(ca::Agent, bool, Option<std::path::PathBuf>)> {
let environment_id = target.environment_id.as_str();
let backboard = configs.get_backboard();
if args.agent_id.is_some() && (args.bootstrap.is_some() || args.no_bootstrap) {
bail!(
"Bootstrap options create a new VM and cannot be used when connecting to an existing agent."
);
}
let candidate = match (
&args.agent_id,
args.new || args.bootstrap.is_some() || args.no_bootstrap,
) {
(Some(id), _) => Some(id.clone()),
(None, true) => None,
(None, false) => match configs.get_code_agent(environment_id) {
Some(id) => Some(id),
None => sole_owned_agent_id(client, &backboard, environment_id).await?,
},
};
if let Some(id) = candidate {
let agent = ca::get(client, &backboard, environment_id, &id)
.await?
.ok_or_else(|| anyhow!(
"Agent {id} is unavailable in this environment. Check `railway ca list`, or use `railway code --new` to create a separate agent."
))?;
let (ready, probe_master) =
ready_existing_agent(client, &backboard, environment_id, agent, progress, access)
.await?;
warn_ignored_variables(args, progress);
if !args.bootstrap_setup {
configs.set_code_agent(environment_id, &ready.id);
configs.write()?;
}
return Ok((ready, false, probe_master));
}
if args.connection_json && args.agent_id.is_some() {
bail!(
"The selected cloud agent is unavailable. Refresh the agent list before reconnecting."
);
}
let bootstrap = crate::controllers::agent_bootstrap::resolve_for_create(
configs,
client,
&backboard,
environment_id,
args.bootstrap.as_deref(),
args.no_bootstrap,
)
.await?;
let variables = create_variables(args)?;
let name = names::for_launch(client, configs, args, harness, target).await?;
if let Some(b) = &bootstrap {
progress.step(&format!("Restoring bootstrap '{}'", b.name));
} else {
progress.step("Creating a cloud agent");
}
let create_started = std::time::Instant::now();
let create = post_graphql::<mutations::CloudAgentCreate, _>(
client,
crate::controllers::agent_bootstrap::internal_url(&backboard),
mutations::cloud_agent_create::Variables {
input: mutations::cloud_agent_create::CloudAgentCreateInput {
environment_id: environment_id.to_owned(),
name,
variables,
code_endpoint: create_code_endpoint(args),
cloud_agent_checkpoint_id: None,
agent_bootstrap_id: bootstrap.map(|b| b.id),
},
},
)
.await;
ssh_tel::record_stage("create_mutation", create_started.elapsed(), create.is_ok());
let created = match create {
Ok(res) => res.cloud_agent_create,
Err(e) => return Err(e.into()),
};
configs.set_code_agent(environment_id, &created.id);
configs.write()?;
match wait_until_connectable(
client,
&backboard,
environment_id,
&created.id,
access,
std::time::Duration::from_millis(350),
)
.await
{
Ok((running, probe_master)) => {
progress.note(&format!("Created agent {}", running.name));
Ok((running, true, probe_master))
}
Err(e) => {
progress.finish();
Err(e)
}
}
}
fn create_code_endpoint(
args: &LaunchArgs,
) -> Option<mutations::cloud_agent_create::CloudAgentCodeEndpointInput> {
(args.code_endpoint || args.code_port.is_some()).then(|| {
mutations::cloud_agent_create::CloudAgentCodeEndpointInput {
port: args.code_port.map(i64::from),
}
})
}
fn create_variables(args: &LaunchArgs) -> Result<Option<serde_json::Value>> {
let mut variables = variables_to_input(&args.env_files, &args.variables)?.unwrap_or_default();
variables.extend(args.boot_variables.clone());
Ok(ca::with_default_variables(Some(serde_json::to_value(
variables,
)?)))
}
fn warn_ignored_variables(args: &LaunchArgs, progress: &dyn Progress) {
use colored::Colorize;
if !args.variables.is_empty() || !args.env_files.is_empty() {
progress.note(
&"Note: --variable/--env-file only apply when an agent is created — reusing this environment's. Add --new to create with these variables."
.yellow()
.to_string(),
);
}
}
async fn destroy_agent(
configs: &mut Configs,
client: &reqwest::Client,
environment_id: &str,
) -> Result<()> {
use colored::Colorize;
eprintln!(
"{}",
"Note: `railway ca delete` supersedes --rm — it names any agent and confirms first."
.dimmed()
);
let backboard = configs.get_backboard();
let Some(id) = configs.get_code_agent(environment_id) else {
println!("No agent recorded for this environment.");
return Ok(());
};
let name = ca::get(client, &backboard, environment_id, &id)
.await?
.map(|a| a.name);
configs.remove_code_agent(environment_id);
configs.write()?;
match name {
Some(name) => {
post_graphql::<mutations::CloudAgentDelete, _>(
client,
&backboard,
mutations::cloud_agent_delete::Variables { id },
)
.await?;
println!("✓ Deleted agent {name}");
}
None => println!("Agent {id} is already gone."),
}
Ok(())
}
pub fn default_harness() -> Result<&'static str> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("Unable to get home directory"))?;
let mut prefs = AgentPrefs::load_in(&home).unwrap_or_default();
Ok(resolve_agent_choice(&LaunchArgs::default(), &mut prefs, &home)?.slug())
}
const AGENT_ENV_VAR: &str = "RAILWAY_CA_AGENT";
fn resolve_agent_choice(args: &LaunchArgs, prefs: &mut AgentPrefs, home: &Path) -> Result<Agent> {
let flagged: Vec<Agent> = [
(args.codex, Agent::Codex),
(args.opencode, Agent::OpenCode),
(args.opencode2, Agent::OpenCode2),
(args.claude, Agent::Claude),
(args.grok, Agent::Grok),
(args.railway, Agent::Railway),
(args.shell, Agent::Shell),
]
.into_iter()
.filter_map(|(set, agent)| set.then_some(agent))
.collect();
match flagged.as_slice() {
[agent] => return Ok(*agent),
[] => {}
_ => bail!(
"Pick one agent: --codex, --claude, --opencode, --opencode2, --grok, or --railway."
),
}
if let Ok(slug) = std::env::var(AGENT_ENV_VAR) {
let slug = slug.trim().to_lowercase();
if !slug.is_empty() {
let agent = Agent::from_slug(&slug).ok_or_else(|| {
anyhow!(
"{AGENT_ENV_VAR}={slug} is not a known agent (claude, codex, opencode, opencode2, grok, railway, or shell)."
)
})?;
return Ok(agent);
}
}
if let Some(agent) = prefs.agent.as_deref().and_then(Agent::from_slug) {
eprintln!(
"{}",
format!(
"Launching {} — your default configuration in {}. You can change this by running `railway ca setup`.",
agent.display(),
AgentPrefs::path_in(home).display()
)
.dimmed()
);
return Ok(agent);
}
if !is_stdout_terminal() {
bail!(
"No default agent configured. Run `railway ca setup`, pass a flag \
(`railway ca --claude`), or set {AGENT_ENV_VAR}=claude."
);
}
let slug = crate::commands::cloud_agent::setup::prompt_agent(home, None)?;
let agent = Agent::from_slug(&slug).ok_or_else(|| anyhow!("Unknown agent selected: {slug}"))?;
prefs.agent = Some(slug);
match prefs.save_in(home) {
Ok(()) => eprintln!(
"{}",
"Saved as your default (`railway ca setup` to change).".dimmed()
),
Err(err) => eprintln!("{}", format!("Couldn't save your choice: {err}").yellow()),
}
Ok(agent)
}
pub trait Progress: Send + Sync {
fn step(&self, text: &str);
fn note(&self, text: &str);
fn finish(&self);
}
#[derive(Default)]
pub struct CliProgress {
spinner: std::sync::Mutex<Option<indicatif::ProgressBar>>,
}
impl Progress for CliProgress {
fn step(&self, text: &str) {
let mut slot = self.spinner.lock().unwrap_or_else(|e| e.into_inner());
if let Some(previous) = slot.take() {
previous.finish_and_clear();
}
*slot = Some(create_shimmer_spinner(text));
}
fn note(&self, text: &str) {
let slot = self.spinner.lock().unwrap_or_else(|e| e.into_inner());
match slot.as_ref() {
Some(spinner) => spinner.suspend(|| eprintln!("{text}")),
None => eprintln!("{text}"),
}
}
fn finish(&self) {
let mut slot = self.spinner.lock().unwrap_or_else(|e| e.into_inner());
if let Some(spinner) = slot.take() {
spinner.finish_and_clear();
}
}
}
pub struct Prepared {
pub remote_cmd: String,
pub ssh_target: String,
pub identity: Option<std::path::PathBuf>,
pub relay_opts: Vec<String>,
pub agent_id: String,
pub agent_name: String,
pub environment_id: String,
pub harness: &'static str,
pub created: bool,
}
pub struct ResolvedLaunch {
pub project_id: String,
pub environment_id: String,
pub(crate) local_name_project: Option<String>,
pub harness: &'static str,
}
pub async fn resolve_launch(
args: &LaunchArgs,
configs: &mut Configs,
client: &reqwest::Client,
) -> Result<ResolvedLaunch> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("Unable to get home directory"))?;
let mut prefs = AgentPrefs::load_in(&home).unwrap_or_default();
let target = resolve_target(configs, client, args, &mut prefs, &home).await?;
let harness = resolve_agent_choice(args, &mut prefs, &home)?.slug();
Ok(ResolvedLaunch {
local_name_project: target.local_name_project(),
project_id: target.project_id,
environment_id: target.environment_id,
harness,
})
}
pub async fn launch(args: LaunchArgs) -> Result<()> {
use colored::Colorize;
if args.connection_json {
bail!("--connection-json requires railway code --codex or --opencode2 [connect].");
}
if args.rm {
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let (_project_id, environment_id) =
resolve_project_and_env(&mut configs, &client, args.project, args.environment).await?;
return destroy_agent(&mut configs, &client, &environment_id).await;
}
eprintln!(
"{}",
"Warning: Railway cloud agents are experimental and APIs may change or break during testing."
.yellow()
);
let progress = CliProgress::default();
let ensure_fut = async {
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
crate::commands::cloud_agent::access::ensure_enabled(&client, &configs).await
};
let prepare_fut = prepare(&args, &progress, SessionStyle::FullTerminal);
tokio::pin!(ensure_fut);
tokio::pin!(prepare_fut);
let prepared = tokio::select! {
enabled = &mut ensure_fut => {
enabled?;
prepare_fut.await?
}
prepared = &mut prepare_fut => {
ensure_fut.await?;
prepared?
}
};
progress.finish();
println!("Launching {}…", prepared.harness);
let exit_code = run_session(&prepared)?;
ssh_tel::drain_detached(std::time::Duration::from_secs(2)).await;
if std::io::stdout().is_terminal() {
use std::io::Write;
let mut out = std::io::stdout();
let _ = out.write_all(TERMINAL_RESET.as_bytes());
let _ = out.flush();
}
println!(
"\nDisconnected — agent {} is still running. `railway ca sleep {}` stops the compute bill.",
prepared.agent_name.cyan(),
prepared.agent_name
);
if prepared.created {
println!("Agents persist between runs — this one is yours until you --rm it.");
}
println!("Get back in:");
if prepared.harness == "shell" {
println!(
" railway ca ssh {} -- bash # wakes it and opens a plain shell",
prepared.agent_name
);
} else {
println!(
" railway code --{} # wakes it and drops back into {}",
prepared.harness, prepared.harness
);
}
println!(
" railway ca ssh {} # same, by name — and reattaches your session",
prepared.agent_name
);
println!(
" railway ca ssh {} -- bash # plain shell",
prepared.agent_name
);
println!("Destroy it:");
println!(" railway ca delete {}", prepared.agent_name);
if exit_code != 0 {
std::process::exit(exit_code);
}
Ok(())
}
pub fn run_session(prepared: &Prepared) -> Result<i32> {
let cmd = vec![prepared.remote_cmd.clone()];
let code = run_native_ssh_with_opts(
&prepared.ssh_target,
Some(&cmd),
prepared.identity.as_deref(),
None,
&prepared.relay_opts,
);
crate::commands::ssh::native::clear_mouse_tracking();
code
}
pub async fn prepare(
args: &LaunchArgs,
progress: &dyn Progress,
style: SessionStyle,
) -> Result<Prepared> {
let start = std::time::Instant::now();
let home = dirs::home_dir().ok_or_else(|| anyhow!("Unable to get home directory"))?;
let mut prefs = AgentPrefs::load_in(&home).unwrap_or_default();
let agent = match resolve_agent_choice(args, &mut prefs, &home) {
Ok(agent) => agent,
Err(err) => {
crate::commands::cloud_agent::telemetry::track_launch_outcome(
"unresolved",
None,
start.elapsed(),
Some(&format!("{err:#}")),
)
.await;
return Err(err);
}
};
let result = prepare_inner(args, progress, agent, prefs, &home, style).await;
if !args.app_mode
&& let Ok(prepared) = &result
&& let Err(error) =
saved_config::SavedConfig::from_prepared(prepared).and_then(|saved| saved.save())
{
progress.note(&format!(
"Could not save connection details for railway code get-config: {error:#}"
));
}
ssh_tel::flush_stages("cloud_agent_launch");
match &result {
Ok(prepared) => crate::commands::cloud_agent::telemetry::track_launch_outcome_detached(
agent.slug(),
Some(prepared.created),
start.elapsed(),
),
Err(e) => {
crate::commands::cloud_agent::telemetry::track_launch_outcome(
agent.slug(),
None,
start.elapsed(),
Some(&format!("{e:#}")),
)
.await
}
}
result
}
async fn prepare_inner(
args: &LaunchArgs,
progress: &dyn Progress,
agent: Agent,
mut prefs: AgentPrefs,
home: &Path,
style: SessionStyle,
) -> Result<Prepared> {
let pending = match agent {
Agent::Codex | Agent::Grok | Agent::OpenCode | Agent::OpenCode2 => {
ssh_tel::timed_for("cloud_agent_launch", "credential", async {
local_signin(agent, home)
})
.await?
}
Agent::Claude => {
ssh_tel::timed_for("cloud_agent_launch", "credential", async {
claude_credentials_cheap(args.refresh_auth)
})
.await?
}
Agent::Railway | Agent::Shell => PendingAuth::None,
};
if args.bootstrap_setup && matches!(pending, PendingAuth::MintClaude) {
bail!(
"Claude sign-in was not cached. Sign in with `railway code --claude`, then retry bootstrap setup."
);
}
match pending {
PendingAuth::Ready { ref source, .. } => progress.note(&format!(
"Using your {} credential ({source}) on the agent",
agent.display()
)),
PendingAuth::SignInOnAgent { ref note } => progress.note(note),
PendingAuth::None if agent == Agent::Shell => {
progress.note("No coding agent — opening a plain shell on the VM")
}
PendingAuth::None => progress.note("Using the agent's own integrated Railway credentials"),
PendingAuth::MintClaude => {}
}
let packed_skills = ssh_tel::timed_for("cloud_agent_launch", "skills_pack", async {
skills_sync::pack(&prefs, home, &|note| progress.note(note))
})
.await?;
if let Some(packed) = &packed_skills {
progress.note(&format!(
"Including {} of your skills ({})",
packed.names.len(),
packed.source_dir.display()
));
}
let packed_mcp = match std::env::current_dir().map(|cwd| mcp_sync::pack(&prefs, &cwd)) {
Ok(Ok(packed)) => packed,
Ok(Err(err)) => {
if args.bootstrap_setup {
return Err(err.context("Could not copy project MCP configuration"));
}
progress.note(&format!("Skipping MCP import: {err:#}"));
None
}
Err(_) => None,
};
if let Some(packed) = &packed_mcp {
progress.note(&format!(
"Including {} MCP servers from the project ({})",
packed.names.len(),
packed.source_path.display()
));
}
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let (target_res, identity) = tokio::join!(
ssh_tel::timed_for(
"cloud_agent_launch",
"resolve_target",
resolve_target(&mut configs, &client, args, &mut prefs, home),
),
ssh_tel::timed_for("cloud_agent_launch", "ssh_key", async {
let key_configs = Configs::new()?;
crate::commands::ssh::native::ensure_ssh_key_noninteractive(&client, &key_configs).await
}),
);
let launch_target = target_res?;
let environment_id = launch_target.environment_id.clone();
let identity = match identity {
Ok(identity) => identity,
Err(err) if args.bootstrap_setup => return Err(err),
Err(_) => {
let key_configs = Configs::new()?;
ssh_tel::timed_for(
"cloud_agent_launch",
"ssh_key_interactive",
ensure_ssh_key_quiet(&client, &key_configs),
)
.await?
}
};
let relay = ssh_tel::timed_for("cloud_agent_launch", "relay", async { relay_ssh() }).await?;
let access = RelayAccess {
identity: identity.clone(),
relay_opts: relay.opts.clone(),
};
let (cloud_agent, created, probe_master) = ssh_tel::timed_for(
"cloud_agent_launch",
"resolve_agent",
resolve_agent(
&mut configs,
&client,
args,
&launch_target,
agent,
progress,
&access,
),
)
.await?;
if !args.bootstrap_setup {
configs.set_code_agent(&environment_id, &cloud_agent.id);
configs.write()?;
}
let target = format!("agent:{environment_id}:{}", cloud_agent.id);
let auth = match pending {
PendingAuth::Ready { line, source } => Some((line, source)),
PendingAuth::SignInOnAgent { .. } | PendingAuth::None => None,
PendingAuth::MintClaude => {
let needs_probe = !created && !args.refresh_auth;
let inherit = if needs_probe {
let probe = ssh_tel::timed_for("cloud_agent_launch", "claude_probe", async {
ssh_plumbing(
&target,
CLAUDE_CREDENTIAL_PROBE,
identity.as_deref(),
None,
&relay,
None,
)
})
.await?;
String::from_utf8_lossy(&probe).contains("CRED-PRESENT")
} else {
false
};
if inherit {
progress.note(
"Reusing the Claude credential already on this agent (--refresh-auth to replace it)",
);
None
} else {
match ssh_tel::timed_for("cloud_agent_launch", "claude_mint", async {
mint_claude_credentials()
})
.await?
{
Some((line, source)) => {
progress.note(&format!(
"Using your Claude Code credential ({source}) on the agent"
));
Some((line, source))
}
None => {
progress.note(&claude_sign_in_note());
None
}
}
}
}
};
progress.step("Finalizing Configuration...");
let (master_socket, mux_provision) = match probe_master {
Some(socket) => {
let client_opts = mux_client_opts(&socket);
(socket, client_opts)
}
None => {
let socket = mux_socket();
let master_opts = mux_master_opts(&socket, "30s");
(socket, master_opts)
}
};
let mux_client = mux_client_opts(&master_socket);
let provision_relay = {
let mut r = relay.clone();
r.opts.extend(mux_provision);
r
};
let provision = async {
let target = target.clone();
let identity = identity.clone();
let relay = provision_relay.clone();
let master_socket = master_socket.clone();
let app_mode = args.app_mode;
let skills_note = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let notes = skills_note.clone();
let result = tokio::task::spawn_blocking(move || -> Result<()> {
let push = |line: String| {
if let Ok(mut n) = notes.lock() {
n.push(line);
}
};
let skills_note = |out: &str| {
let reason = if out.contains("SKILLS-NO-TAR") {
"the agent has no `tar`"
} else if out.contains("SKILLS-EXTRACT-FAILED") {
"the transfer did not unpack"
} else {
"the sync did not report success"
};
format!(
"Couldn't sync your skills onto the agent ({reason}); continuing without them."
)
};
let mcp_note = |out: &str| {
let reason = if out.contains("MCP-NO-JQ") {
"the agent has no `jq`"
} else if out.contains("MCP-BAD-JSON") {
"the payload did not survive the trip"
} else if out.contains("MCP-MERGE-FAILED") {
"a harness config would not merge"
} else {
"the sync did not report success"
};
format!(
"Couldn't sync the project's MCP servers ({reason}); continuing without them."
)
};
let sync_mcp = |packed: &mcp_sync::PackedMcp| -> Result<()> {
let out = ssh_plumbing(
&target,
&mcp_sync::provision_script(&packed.hash),
identity.as_deref(),
Some(&packed.payload),
&relay,
Some(&master_socket),
)?;
let out = String::from_utf8_lossy(&out);
if !out.contains("MCP-OK") {
push(mcp_note(&out));
}
Ok(())
};
let check_ready = |out: &str| -> Result<()> {
if out.contains("AGENT-READY") {
Ok(())
} else if out.contains("AGENT-MISSING") {
bail!(
"`{}` was not found on the agent (PATH: ~/.local/bin, ~/.opencode/bin, ~/.grok/bin, mise shims). The harness could not be prepared; report this with the agent id.",
agent.name()
)
} else {
bail!(
"Provisioning produced no status marker — the connection likely dropped mid-script."
)
}
};
if created && let Some(packed) = &packed_skills {
let (payload, credential_len) = combined_provision_payload(
auth.as_ref().map(|(line, _)| line.as_slice()),
&packed.tarball,
);
let out = ssh_plumbing(
&target,
&provision_script_with_skills(agent, credential_len, app_mode, &packed.hash),
identity.as_deref(),
Some(&payload),
&relay,
Some(&master_socket),
)?;
let out = String::from_utf8_lossy(&out);
check_ready(&out)?;
if !out.contains("SKILLS-OK") {
push(skills_note(&out));
}
if let Some(packed) = &packed_mcp {
sync_mcp(packed)?;
}
return Ok(());
}
let out = ssh_plumbing(
&target,
&provision_script(agent, auth.is_some(), app_mode),
identity.as_deref(),
auth.as_ref().map(|(line, _)| line.as_slice()),
&relay,
Some(&master_socket),
)?;
let out = String::from_utf8_lossy(&out);
check_ready(&out)?;
if let Some(packed) = packed_skills {
if skills_sync::parse_remote_hash(&out).as_deref() != Some(packed.hash.as_str()) {
let out = ssh_plumbing(
&target,
&skills_sync::provision_script(&packed.hash),
identity.as_deref(),
Some(&packed.tarball),
&relay,
Some(&master_socket),
)?;
let out = String::from_utf8_lossy(&out);
if !out.contains("SKILLS-OK") {
push(skills_note(&out));
}
}
}
if let Some(packed) = &packed_mcp
&& mcp_sync::parse_remote_hash(&out).as_deref() != Some(packed.hash.as_str())
{
sync_mcp(packed)?;
}
Ok(())
})
.await
.map_err(anyhow::Error::from)
.and_then(|r| r);
let notes = skills_note.lock().unwrap_or_else(|e| e.into_inner());
for line in notes.iter() {
progress.note(line);
}
if args.bootstrap_setup && !notes.is_empty() {
bail!(
"Bootstrap configuration sync was incomplete: {}",
notes.join("; ")
);
}
result
};
ssh_tel::timed_for("cloud_agent_launch", "provision", provision).await?;
let env_prefix = harness_env_prefix();
let remote_cmd = remote_command(
agent,
&env_prefix,
args.initial_prompt.as_deref(),
args.resume_session_id.as_deref(),
&args.agent_args,
style,
);
Ok(Prepared {
remote_cmd,
ssh_target: target,
identity,
relay_opts: {
let mut opts = relay.opts;
opts.extend(mux_client);
opts
},
agent_id: cloud_agent.id,
agent_name: cloud_agent.name,
environment_id,
harness: agent.slug(),
created,
})
}
pub struct ConnectInfo {
pub ssh_target: String,
pub identity: Option<std::path::PathBuf>,
pub relay_opts: Vec<String>,
}
pub async fn connect_info(environment_id: &str, agent_id: &str) -> Result<ConnectInfo> {
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let identity = ensure_ssh_key_quiet(&client, &configs).await?;
let relay = relay_ssh()?;
Ok(ConnectInfo {
ssh_target: format!("agent:{environment_id}:{agent_id}"),
identity,
relay_opts: relay.opts,
})
}
const FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub async fn flush_disk(environment_id: &str, agent_id: &str) {
let flush = async {
let info = connect_info(environment_id, agent_id).await.ok()?;
let mut opts = info.relay_opts;
opts.push("-o".to_string());
opts.push(format!("ConnectTimeout={}", FLUSH_TIMEOUT.as_secs()));
tokio::task::spawn_blocking(move || {
run_native_ssh_captured(
&info.ssh_target,
"sync",
info.identity.as_deref(),
None,
&opts,
)
})
.await
.ok()
};
let _ = tokio::time::timeout(FLUSH_TIMEOUT, flush).await;
}
pub async fn kill_session(environment_id: &str, agent_id: &str, session_name: &str) -> Result<()> {
let info = connect_info(environment_id, agent_id).await?;
let script = kill_script(session_name);
let relay = relay_ssh()?;
let out = tokio::task::spawn_blocking(move || {
ssh_plumbing(
&info.ssh_target,
&script,
info.identity.as_deref(),
None,
&relay,
None,
)
})
.await??;
let out = String::from_utf8_lossy(&out);
match out.split("KILLED:").nth(1).and_then(|n| {
n.trim()
.lines()
.next()
.and_then(|n| n.trim().parse::<u32>().ok())
}) {
Some(0) => bail!("nothing was running under that session"),
Some(_) => Ok(()),
None => bail!("the agent did not confirm the session ended"),
}
}
fn kill_script(session_name: &str) -> String {
format!(
r#"killed=0
for p in /proc/[0-9]*; do
pid="${{p#/proc/}}"
[ "$pid" = "$$" ] && continue
if grep -qzxF 'RAILWAY_DURABLE_SESSION_NAME={session_name}' "$p/environ" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null && killed=$((killed+1))
fi
done
echo "KILLED:$killed""#
)
}
pub fn claude_needs_local_mint() -> bool {
!matches!(
claude_credentials_cheap(false),
Ok(PendingAuth::Ready { .. } | PendingAuth::SignInOnAgent { .. })
)
}
pub fn ensure_claude_credential_cached(harness: &str) -> Result<()> {
if harness != "claude" {
return Ok(());
}
if let PendingAuth::MintClaude = claude_credentials_cheap(false)? {
match mint_claude_credentials()? {
Some((_line, source)) => {
eprintln!("Using your Claude Code credential ({source}) on the agent")
}
None => eprintln!("{}", claude_sign_in_note()),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testkit::MockBackboard;
use clap::Parser;
use serde_json::json;
#[test]
fn bootstrap_flags_require_a_new_vm_and_survive_tui_retargeting() {
let args = LaunchArgs::try_parse_from(["code", "--codex", "--bootstrap", "dev"]).unwrap();
assert!(!args.is_bare());
let args = args.retargeted("project".into(), "env".into(), "codex", true, None, None);
assert_eq!(args.bootstrap.as_deref(), Some("dev"));
let mut connect = LaunchArgs::try_parse_from([
"code",
"--codex",
"--bootstrap",
"dev",
"connect",
"existing",
])
.unwrap();
assert!(
connect
.prepare_code_launch()
.unwrap_err()
.to_string()
.contains("cannot be used with connect")
);
for flags in [
vec!["code", "--bootstrap", "dev", "--no-bootstrap"],
vec![
"code",
"--codex",
"--agent",
"existing",
"--bootstrap",
"dev",
],
vec!["code", "--codex", "--agent", "existing", "--no-bootstrap"],
] {
assert!(LaunchArgs::try_parse_from(flags).is_err());
}
}
#[tokio::test]
async fn bootstrap_launch_passes_selected_bootstrap_to_create() {
for harness in [
Agent::Codex,
Agent::OpenCode,
Agent::OpenCode2,
Agent::Claude,
Agent::Grok,
Agent::Railway,
Agent::Shell,
] {
for (override_name, clean, expected) in [
(None, false, Some("default")),
(Some("dev"), false, Some("dev")),
(None, true, None),
] {
let server = MockBackboard::spawn();
let dir = tempfile::tempdir().unwrap();
let mut configs = server.configs(&dir);
let row = |name| {
json!({"id": name, "name": name, "environmentId": "env", "status": "READY",
"failureReason": null, "updatedAt": "2026-09-11T00:00:00Z",
"activeVersion": {"sourceCloudAgentId": "codex-source", "checkpoint": {"id": "disk-checkpoint"}}})
};
configs
.set_agent_bootstrap_default("env", "default", false)
.await
.unwrap();
server.stub(
"AgentBootstraps",
json!({"agentBootstraps": [row("default"), row("dev")]}),
);
server.stub_graphql_error("CloudAgentCreate", "creation reached");
let args = LaunchArgs {
new: true,
name: Some("fresh".into()),
bootstrap: override_name.map(str::to_owned),
no_bootstrap: clean,
..Default::default()
}
.retargeted(
"project".into(),
"env".into(),
harness.slug(),
true,
None,
None,
);
let error = resolve_agent(
&mut configs,
&reqwest::Client::new(),
&args,
&names::Target::new(("project".into(), "env".into()), false),
harness,
&CliProgress::default(),
&RelayAccess {
identity: None,
relay_opts: vec![],
},
)
.await
.unwrap_err();
assert!(error.to_string().contains("creation reached"), "{error}");
let request = &server.variables_for("CloudAgentCreate")[0]["input"];
assert_eq!(request["agentBootstrapId"].as_str(), expected);
assert_eq!(request["environmentId"], "env");
if clean {
assert!(server.variables_for("AgentBootstraps").is_empty());
}
}
}
}
#[tokio::test]
async fn bootstrap_setup_does_not_remember_its_temporary_vm() {
let server = MockBackboard::spawn();
let dir = tempfile::tempdir().unwrap();
let mut configs = server.configs(&dir);
configs.set_code_agent("env", "existing");
configs.write().unwrap();
server.stub("CloudAgent", json!({"cloudAgent": {"id": "setup", "name": "setup", "status": "RUNNING", "projectId": "project", "environmentId": "env", "createdAt": "2026-09-11T00:00:00Z"}}));
let args = LaunchArgs {
agent_id: Some("setup".into()),
bootstrap_setup: true,
app_mode: true,
..Default::default()
};
let (agent, created, _) = resolve_agent(
&mut configs,
&reqwest::Client::new(),
&args,
&names::Target::new(("project".into(), "env".into()), false),
Agent::Railway,
&CliProgress::default(),
&RelayAccess {
identity: None,
relay_opts: vec![],
},
)
.await
.unwrap();
assert_eq!(agent.id, "setup");
assert!(!created);
configs.reload().unwrap();
assert_eq!(configs.get_code_agent("env").as_deref(), Some("existing"));
}
#[tokio::test]
async fn an_unusable_selected_vm_is_never_replaced() {
for selection in ["explicit", "remembered", "sole"] {
for status in [
"FAILED",
"CRASHED",
"DELETING",
"FUTURE_STATE",
"missing",
"lookup_error",
] {
let server = MockBackboard::spawn();
let dir = tempfile::tempdir().unwrap();
let mut configs = server.configs(&dir);
let mut args = LaunchArgs::default();
if selection == "explicit" {
args.agent_id = Some("existing".into());
} else if selection == "remembered" {
configs.set_code_agent("env", "existing");
}
configs.write().unwrap();
let saved = std::fs::read(dir.path().join("config.json")).unwrap();
let node = json!({
"id": "existing", "name": "my-agent", "status": status,
"projectId": "project", "environmentId": "env",
"createdAt": "2026-09-10T00:00:00Z"
});
server.stub("CloudAgents", json!({"cloudAgents": [node.clone()]}));
match status {
"missing" => server.stub("CloudAgent", json!({"cloudAgent": null})),
"lookup_error" => {
server.stub_graphql_error("CloudAgent", "temporarily unavailable")
}
_ => server.stub("CloudAgent", json!({"cloudAgent": node})),
}
let error = resolve_agent(
&mut configs,
&reqwest::Client::new(),
&args,
&names::Target::new(("project".into(), "env".into()), false),
Agent::Claude,
&CliProgress::default(),
&RelayAccess {
identity: None,
relay_opts: vec![],
},
)
.await
.unwrap_err();
assert!(
!error.to_string().contains("no scripted response"),
"{selection}/{status}: {error}"
);
let operations: Vec<_> = server
.requests()
.iter()
.map(|r| r["operationName"].as_str().unwrap().to_owned())
.collect();
let expected = if selection == "sole" {
vec!["CloudAgents", "CloudAgent"]
} else {
vec!["CloudAgent"]
};
assert_eq!(operations, expected, "{selection}/{status}");
assert_eq!(
std::fs::read(dir.path().join("config.json")).unwrap(),
saved
);
if selection == "remembered" {
assert_eq!(configs.get_code_agent("env").as_deref(), Some("existing"));
}
}
}
}
#[tokio::test]
async fn creation_is_reached_only_for_new_or_an_empty_inventory() {
for new in [false, true] {
let server = MockBackboard::spawn();
let dir = tempfile::tempdir().unwrap();
let mut configs = server.configs(&dir);
if new {
configs.set_code_agent("env", "existing");
}
server.stub("CloudAgents", json!({"cloudAgents": []}));
server.stub_graphql_error("CloudAgentCreate", "creation reached");
let args = LaunchArgs {
new,
name: Some("fresh-agent".into()),
..Default::default()
};
let error = resolve_agent(
&mut configs,
&reqwest::Client::new(),
&args,
&names::Target::new(("project".into(), "env".into()), false),
Agent::Claude,
&CliProgress::default(),
&RelayAccess {
identity: None,
relay_opts: vec![],
},
)
.await
.unwrap_err();
assert!(error.to_string().contains("creation reached"), "{error}");
assert_eq!(server.variables_for("CloudAgentCreate").len(), 1);
assert!(server.variables_for("CloudAgent").is_empty());
assert_eq!(server.variables_for("CloudAgents").len(), usize::from(!new));
}
}
#[test]
fn an_ordinary_launch_opens_in_the_pane() {
for argv in [
vec!["code"],
vec!["code", "--claude"],
vec!["code", "--new", "--name", "api"],
vec!["code", "-p", "proj_1", "-e", "env_prod"],
vec!["code", "--variable", "K=V", "--refresh-auth"],
] {
let args = LaunchArgs::parse_from(&argv);
assert!(args.pane_shaped(), "{argv:?} should open in the pane");
}
}
#[test]
fn destroying_and_exec_take_the_terminal_instead() {
assert!(!LaunchArgs::parse_from(["code", "--rm"]).pane_shaped());
assert!(
!LaunchArgs::parse_from(["code", "--codex", "--", "exec", "explain this"])
.pane_shaped()
);
}
#[test]
fn retargeting_overrides_what_the_tui_decides() {
let args = LaunchArgs::parse_from(["code", "--codex", "-p", "old_p", "-e", "old_e"])
.retargeted(
"new_p".into(),
"new_e".into(),
"claude",
true,
Some("fix the tests".into()),
Some("ca_1".into()),
);
assert_eq!(args.project.as_deref(), Some("new_p"));
assert_eq!(args.environment.as_deref(), Some("new_e"));
assert_eq!(args.agent_id.as_deref(), Some("ca_1"));
assert_eq!(args.initial_prompt.as_deref(), Some("fix the tests"));
assert!(args.new);
assert!(args.claude, "the harness the TUI chose");
assert!(!args.codex, "and only that one");
}
#[test]
fn retargeting_carries_the_flags_the_tui_cannot_ask_for() {
let args = LaunchArgs::parse_from([
"code",
"--new",
"--name",
"api",
"--variable",
"DB=postgres.DATABASE_URL",
"--env-file",
".env",
"--refresh-auth",
])
.retargeted("p".into(), "e".into(), "claude", true, None, None);
assert_eq!(args.name.as_deref(), Some("api"));
assert_eq!(args.variables, ["DB=postgres.DATABASE_URL"]);
assert_eq!(args.env_files, [std::path::PathBuf::from(".env")]);
assert!(args.refresh_auth);
}
fn note_of(pending: PendingAuth) -> String {
match pending {
PendingAuth::SignInOnAgent { note } => note,
PendingAuth::Ready { source, .. } => panic!("expected a fallback, got {source}"),
PendingAuth::MintClaude => panic!("expected a fallback, got a mint"),
PendingAuth::None => panic!("expected a fallback, got a harness needing no credential"),
}
}
#[test]
fn a_missing_local_signin_falls_back_to_signing_in_on_the_agent() {
let home = tempfile::tempdir().unwrap();
let note = note_of(local_signin(Agent::Codex, home.path()).unwrap());
assert!(note.contains("codex login --device-auth"), "{note}");
let note = note_of(local_signin(Agent::Grok, home.path()).unwrap());
assert!(note.contains("Grok"), "{note}");
}
#[test]
fn an_empty_local_signin_falls_back_too() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".codex")).unwrap();
std::fs::write(home.path().join(".codex").join("auth.json"), "").unwrap();
note_of(local_signin(Agent::Codex, home.path()).unwrap());
}
#[test]
fn a_local_signin_is_carried_verbatim() {
let home = tempfile::tempdir().unwrap();
let auth = home.path().join(".grok").join("auth.json");
std::fs::create_dir_all(auth.parent().unwrap()).unwrap();
std::fs::write(&auth, r#"{"k":1}"#).unwrap();
match local_signin(Agent::Grok, home.path()).unwrap() {
PendingAuth::Ready { line, source } => {
assert_eq!(line, br#"{"k":1}"#);
assert_eq!(source, auth.display().to_string());
}
_ => panic!("expected the local sign-in to be carried"),
}
}
#[test]
fn opencode_auth_uses_xdg_data_and_preserves_the_provider_map() {
let home = tempfile::tempdir().unwrap();
let xdg = home.path().join("custom data");
assert_eq!(
Agent::OpenCode
.local_signin_path(home.path(), None)
.unwrap(),
home.path().join(".local/share/opencode/auth.json")
);
let path = Agent::OpenCode
.local_signin_path(home.path(), Some(&xdg))
.unwrap();
assert_eq!(path, xdg.join("opencode/auth.json"));
assert!(matches!(
read_local_signin(Agent::OpenCode, Some(&path)).unwrap(),
PendingAuth::SignInOnAgent { .. }
));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let auth = br#"{"openai":{"type":"api","key":"test-only"},"example":{"type":"oauth","access":"test-access","refresh":"test-refresh","expires":123}}"#;
std::fs::write(&path, auth).unwrap();
let PendingAuth::Ready { line, .. } =
read_local_signin(Agent::OpenCode, Some(&path)).unwrap()
else {
panic!("expected provider credentials")
};
assert_eq!(line, auth);
}
#[cfg(unix)]
#[test]
fn opencode_credential_seed_frames_stdin_and_protects_existing_file() {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::process::{Command, Stdio};
for framed in [false, true] {
let data = tempfile::tempdir().unwrap();
let auth_path = data.path().join("opencode/auth.json");
std::fs::create_dir_all(auth_path.parent().unwrap()).unwrap();
std::fs::write(&auth_path, "old auth").unwrap();
std::fs::set_permissions(&auth_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let auth = br#"{"provider":{"type":"api","key":"test"}}"#;
let script = if framed {
format!(
"{}\ncat",
Agent::OpenCode.credential_seed_framed(auth.len())
)
} else {
Agent::OpenCode.credential_seed().to_string()
};
let mut child = Command::new("sh")
.args(["-c", &script])
.env("XDG_DATA_HOME", data.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(auth).unwrap();
if framed {
stdin.write_all(b"skills payload").unwrap();
}
drop(stdin);
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(std::fs::read(&auth_path).unwrap(), auth);
assert_eq!(
std::fs::metadata(&auth_path).unwrap().permissions().mode() & 0o777,
0o600
);
if framed && cfg!(target_os = "linux") {
assert_eq!(output.stdout, b"skills payload");
}
}
}
#[test]
fn code_harness_launches_create_new_agents_by_default() {
for flag in [
"--codex",
"--opencode",
"--opencode2",
"--claude",
"--grok",
"--railway",
] {
for extra in [
vec![],
vec!["--new"],
vec![
"--name",
"my-box",
"--variable",
"K=V",
"--env-file",
".env",
],
vec!["--", "exec", "explain this codebase"],
] {
let argv = [vec!["code", flag], extra].concat();
let mut args = LaunchArgs::try_parse_from(&argv).unwrap();
args.prepare_code_launch().unwrap();
assert!(args.new, "{argv:?} must create a fresh VM");
}
}
for argv in [
vec!["code", "--codex", "remote"],
vec!["code", "--opencode", "remote"],
vec!["code", "--opencode2", "remote"],
vec!["code", "--codex", "desktop-only"],
vec!["code", "--codex", "--connection-json"],
vec!["code", "--opencode2", "--connection-json"],
vec!["code", "--codex", "desktop-only", "--connection-json"],
] {
let mut args = LaunchArgs::try_parse_from(&argv).unwrap();
assert!(args.prepare_code_launch().unwrap().is_some());
assert!(args.new, "{argv:?} must create a fresh VM");
}
}
#[test]
fn code_connect_and_explicit_targets_do_not_request_new_agents() {
for flag in ["--codex", "--opencode", "--opencode2"] {
for (extra, selector) in [
(vec!["connect"], None),
(vec!["connect", "my-box"], Some("my-box".to_string())),
(
vec!["connect", "--agent", "my-box"],
Some("my-box".to_string()),
),
] {
let mut args =
LaunchArgs::try_parse_from([vec!["code", flag], extra].concat()).unwrap();
assert_eq!(
args.prepare_code_launch().unwrap(),
Some(ClientAction::Connect(selector))
);
assert!(!args.new, "connect must never create a VM");
}
for extra in [vec![], vec!["remote"]] {
let mut args = LaunchArgs::try_parse_from(
[vec!["code", flag, "--agent", "my-box"], extra].concat(),
)
.unwrap();
args.prepare_code_launch().unwrap();
assert!(!args.new);
assert_eq!(args.remote_agent.as_deref(), Some("my-box"));
}
let mut args = LaunchArgs::try_parse_from(["code", flag, "connect", "--new"]).unwrap();
assert!(args.prepare_code_launch().is_err());
}
let mut args =
LaunchArgs::try_parse_from(["code", "--codex", "desktop-only", "--agent", "my-box"])
.unwrap();
assert_eq!(
args.prepare_code_launch().unwrap(),
Some(ClientAction::DesktopOnly)
);
assert!(!args.new);
}
#[test]
fn code_creation_default_is_scoped_to_harness_launches() {
for argv in [
vec!["code"],
vec!["code", "--rm"],
vec!["code", "--codex", "--rm"],
vec!["code", "--claude", "--rm"],
] {
let mut args = LaunchArgs::try_parse_from(&argv).unwrap();
assert_eq!(args.prepare_code_launch().unwrap(), None);
assert!(!args.new, "{argv:?} must retain its existing behavior");
}
let mut args = LaunchArgs::try_parse_from(["code", "--new"]).unwrap();
args.prepare_code_launch().unwrap();
assert!(args.new);
let args = LaunchArgs::try_parse_from(["ca", "--codex"]).unwrap();
assert!(!args.new);
let args = LaunchArgs::for_target(
"project".into(),
"environment".into(),
"codex",
false,
None,
Some("existing-agent".into()),
);
assert!(!args.new);
assert_eq!(args.agent_id.as_deref(), Some("existing-agent"));
}
#[test]
fn connection_json_only_accepts_server_connection_actions() {
for flag in ["--codex", "--opencode2"] {
for extra in [vec![], vec!["connect", "box"], vec!["--new"]] {
let args = LaunchArgs::try_parse_from(
[vec!["code", flag, "--connection-json"], extra].concat(),
)
.unwrap();
assert!(args.connection_json);
assert!(args.client_action().unwrap().is_some());
}
for extra in [vec!["remote"], vec!["--rm"], vec!["--", "run", "hello"]] {
let args = LaunchArgs::try_parse_from(
[vec!["code", flag, "--connection-json"], extra].concat(),
)
.unwrap();
assert!(args.client_action().is_err());
}
}
assert!(LaunchArgs::try_parse_from(["code", "--connection-json"]).is_err());
}
#[test]
fn codex_desktop_only_accepts_backend_setup_flags_and_json() {
for argv in [
vec!["code", "--codex", "desktop-only"],
vec![
"code",
"--codex",
"desktop-only",
"--agent",
"box",
"--dir",
"/app/project",
],
vec![
"code",
"--codex",
"--new",
"desktop-only",
"--name",
"desktop-box",
"--variable",
"A=B",
"--env-file",
".env",
],
vec![
"code",
"--codex",
"--connection-json",
"desktop-only",
"-p",
"project",
"-e",
"env",
],
] {
let args = LaunchArgs::try_parse_from(argv).unwrap();
assert_eq!(
args.client_action().unwrap(),
Some(ClientAction::DesktopOnly)
);
assert!(!args.pane_shaped());
}
}
#[test]
fn desktop_only_rejects_other_harnesses_and_session_arguments() {
for argv in [
vec!["code", "desktop-only"],
vec!["code", "--opencode", "desktop-only"],
vec!["code", "--opencode2", "desktop-only"],
vec!["code", "--grok", "desktop-only"],
vec!["code", "--claude", "desktop-only"],
vec!["code", "--codex", "--claude", "desktop-only"],
vec!["code", "--codex", "desktop-only", "box"],
vec!["code", "--codex", "desktop-only", "connect"],
vec!["code", "--codex", "desktop-only", "--rm"],
vec!["code", "--codex", "desktop-only", "--dir", " "],
] {
let args = LaunchArgs::try_parse_from(argv).unwrap();
assert!(args.client_action().is_err(), "{args:?}");
}
let mut args = LaunchArgs::try_parse_from(["code", "--codex", "desktop-only"]).unwrap();
args.initial_prompt = Some("run tests".into());
assert!(args.client_action().is_err());
}
#[test]
fn client_actions_separate_local_clients_from_cloud_terminal_sessions() {
for flag in ["--codex", "--opencode", "--opencode2"] {
let local =
LaunchArgs::try_parse_from(["code", flag, "--new", "--dir", "/app/project"])
.unwrap();
assert_eq!(local.client_action().unwrap(), Some(ClientAction::Local));
for argv in [
vec!["code", flag, "remote", "--new"],
vec!["code", flag, "--new", "remote"],
] {
let mut remote = LaunchArgs::try_parse_from(argv).unwrap();
assert_eq!(remote.client_action().unwrap(), Some(ClientAction::Remote));
remote.agent_args.clear();
assert!(remote.pane_shaped());
}
for (extra, expected) in [
(vec!["connect"], None),
(vec!["connect", "my-box"], Some("my-box".to_string())),
(
vec!["connect", "--agent", "my-box"],
Some("my-box".to_string()),
),
] {
let args =
LaunchArgs::try_parse_from([vec!["code", flag], extra].concat()).unwrap();
assert_eq!(
args.client_action().unwrap(),
Some(ClientAction::Connect(expected))
);
}
}
}
#[test]
fn opencode_rejects_ambiguous_or_destructive_client_actions() {
for argv in [
vec!["code", "--codex", "--opencode", "connect"],
vec!["code", "--codex", "connect", "--new"],
vec!["code", "--codex", "remote", "--dir", "/app"],
vec!["code", "remote"],
vec!["code", "--opencode", "--opencode2"],
vec!["code", "--opencode", "remote", "extra"],
vec!["code", "--opencode", "remote", "--rm"],
vec!["code", "--opencode", "remote", "--dir", "/app"],
vec!["code", "--opencode", "connect", "--new"],
vec!["code", "--opencode", "connect", "box", "--agent", "other"],
vec!["code", "--opencode", "connect", "box", "extra"],
vec!["code", "--opencode", "connect", "--variable", "A=B"],
vec!["code", "--opencode", "--dir", " "],
] {
assert!(
LaunchArgs::try_parse_from(argv)
.unwrap()
.client_action()
.is_err()
);
}
assert!(
LaunchArgs::try_parse_from(["code", "--opencode", "--new", "--agent", "box"]).is_err()
);
assert_eq!(
LaunchArgs::try_parse_from(["code", "--opencode", "--rm"])
.unwrap()
.client_action()
.unwrap(),
None
);
}
#[test]
fn code_endpoint_creation_is_typed_and_ports_are_validated() {
let mut args = LaunchArgs::try_parse_from(["code", "--opencode2", "--new"]).unwrap();
assert!(create_code_endpoint(&args).is_none());
args.code_endpoint = true;
assert!(create_code_endpoint(&args).unwrap().port.is_none());
args.code_port = Some(5000);
assert_eq!(create_code_endpoint(&args).unwrap().port, Some(5000));
args.boot_variables
.insert("OPENCODE_SERVER_USERNAME".into(), "opencode".into());
let variables = create_variables(&args).unwrap().unwrap();
assert!(variables.get("RAILWAY_CODE_PORT").is_none());
assert_eq!(variables["OPENCODE_SERVER_USERNAME"], "opencode");
assert_eq!(variables["SHELL"], "/bin/bash");
for port in ["0", "1023", "8080", "8790", "65536", "nope"] {
assert!(LaunchArgs::try_parse_from(["code", "--new", "--code-port", port]).is_err());
}
assert!(LaunchArgs::try_parse_from(["code", "--new", "--code-port", "5000"]).is_ok());
assert!(LaunchArgs::try_parse_from(["code", "--code-port", "5000"]).is_err());
}
#[test]
fn explicit_harness_passthrough_keeps_flags_owned_by_the_harness() {
let args = LaunchArgs::try_parse_from([
"code",
"--opencode2",
"--",
"run",
"--server",
"https://server.example",
"--dir",
"/project",
])
.unwrap();
assert_eq!(
args.agent_args,
[
"run",
"--server",
"https://server.example",
"--dir",
"/project"
]
);
assert_eq!(args.client_action().unwrap(), None);
assert_eq!(args.remote_dir, None);
}
#[test]
fn opencode2_launch_seeds_the_shim_and_preserves_prompt_arguments() {
let args = LaunchArgs::for_app_mode("opencode2", None, None);
assert_eq!(
resolve_agent_choice(&args, &mut AgentPrefs::default(), Path::new("/tmp")).unwrap(),
Agent::OpenCode2
);
let script = provision_script(Agent::OpenCode2, false, true);
assert!(script.contains("~/.local/bin/opencode2"));
assert!(script.contains("command -v opencode2"));
assert!(!provision_script(Agent::OpenCode, false, true).contains("opencode-beta/releases"));
let command = remote_command(
Agent::OpenCode2,
"",
Some("explain this project"),
None,
&[],
SessionStyle::Pane,
);
assert!(command.contains("opencode2 --standalone --prompt 'explain this project'"));
}
#[test]
fn opencode_desktop_provisioning_overrides_a_saved_default() {
let home = tempfile::tempdir().unwrap();
let args = LaunchArgs::for_app_mode("opencode", None, None);
let mut prefs = AgentPrefs {
agent: Some("claude".into()),
..Default::default()
};
assert_eq!(
resolve_agent_choice(&args, &mut prefs, home.path()).unwrap(),
Agent::OpenCode
);
assert!(!args.is_bare());
let script = provision_script(Agent::OpenCode, false, args.app_mode);
assert!(script.contains("touch ~/.railway-app-mode"));
assert!(script.contains("command -v opencode"));
assert!(!script.contains("$opencode_data/auth.json"));
let command = remote_command(
Agent::OpenCode,
"",
Some("explain this project"),
None,
&[],
SessionStyle::Pane,
);
assert!(
command.contains("opencode --prompt 'explain this project'"),
"{command}"
);
}
#[test]
fn an_unauthenticated_launch_still_provisions_the_agent() {
for agent in [Agent::Codex, Agent::Claude, Agent::Grok, Agent::OpenCode] {
let script = provision_script(agent, false, false);
assert!(!script.contains("cat > ~/"), "{script}");
assert!(script.contains("railway-code agent autostart"));
assert!(script.contains("AGENT-READY"));
assert!(script.contains(&format!("echo {} > ~/.railway-code-agent", agent.name())));
}
}
#[test]
fn the_claude_fallback_says_how_to_sign_in_on_the_agent() {
let note = claude_sign_in_note();
assert!(note.contains("Claude Code"), "{note}");
assert!(note.contains("/login"), "{note}");
}
#[test]
fn provision_script_delivers_credentials_only() {
let codex = provision_script(Agent::Codex, true, false);
assert!(codex.contains("cat > ~/.codex/auth.json"));
assert!(codex.contains("echo codex > ~/.railway-code-agent"));
let claude = provision_script(Agent::Claude, true, false);
assert!(claude.contains("cat > ~/.claude-code-env"));
assert!(claude.contains("echo claude > ~/.railway-code-agent"));
let grok = provision_script(Agent::Grok, true, false);
assert!(grok.contains("cat > ~/.grok/auth.json"));
assert!(grok.contains("echo grok > ~/.railway-code-agent"));
for script in [&codex, &claude, &grok] {
assert!(script.contains("railway-code agent autostart"));
assert!(script.contains(". \"$HOME/.claude-code-env\""));
assert!(script.contains("AGENT-READY"));
assert!(script.contains("AGENT-MISSING"));
assert!(script.contains("$HOME/.local/bin"));
assert!(script.contains("$HOME/.grok/bin"));
assert!(!script.contains("cd \"$HOME\""));
assert!(!script.contains("cd ~"));
assert!(!script.contains("npm install"));
assert!(!script.contains("install.sh"));
assert!(!script.contains("apt-get"));
assert!(!script.contains("hasCompletedOnboarding"));
assert!(!script.contains("trust_level"));
assert!(!script.contains("yolo"));
assert!(!script.contains("config.toml"));
}
}
#[test]
fn the_carried_claude_token_is_always_sourced() {
for guard in [CLAUDE_ENV_GUARD, COMMON_SEED] {
assert!(
!guard.contains("credentials.json"),
"login must not outrank the carried token: {guard}"
);
assert!(guard.contains(".claude-code-env"), "{guard}");
}
assert!(COMMON_SEED.contains("railway-code agent autostart v4"));
assert!(COMMON_SEED.contains("sed -i '/# railway-code agent autostart/,/^fi$/d'"));
}
#[test]
fn app_mode_and_session_mode_disagree_about_the_autostart() {
let app = provision_script(Agent::Claude, false, true);
assert!(app.contains("touch ~/.railway-app-mode"));
assert!(app.contains("rm -f ~/.railway-code-agent"));
assert!(!app.contains("> ~/.railway-code-agent"));
let session = provision_script(Agent::Claude, false, false);
assert!(session.contains("rm -f ~/.railway-app-mode"));
assert!(session.contains("echo claude > ~/.railway-code-agent"));
assert!(!session.contains("touch ~/.railway-app-mode"));
assert!(COMMON_SEED.contains(r#"[ ! -f "$HOME/.railway-app-mode" ]"#));
}
#[test]
fn the_cached_token_source_carries_its_age() {
assert_eq!(cached_token_source(None), "cached setup-token");
assert_eq!(cached_token_source(Some(0)), "cached setup-token");
assert_eq!(
cached_token_source(Some(12)),
"cached setup-token from 12d ago"
);
assert_eq!(
cached_token_source(Some(92)),
"cached setup-token from 92d ago — --refresh-auth re-mints"
);
}
#[test]
fn provision_script_omits_the_seed_when_reusing_a_credential() {
let claude = provision_script(Agent::Claude, false, false);
assert!(!claude.contains("cat > ~/.claude-code-env"));
assert!(claude.contains("$HOME/.local/bin"));
assert!(claude.contains("railway-code agent autostart"));
assert!(claude.contains("echo claude > ~/.railway-code-agent"));
assert!(claude.contains("AGENT-READY"));
for (agent, seed) in [
(Agent::Codex, "cat > ~/.codex/auth.json"),
(Agent::Grok, "cat > ~/.grok/auth.json"),
] {
assert!(provision_script(agent, true, false).contains(seed));
assert!(!provision_script(agent, false, false).contains(seed));
}
}
#[test]
fn railway_needs_no_credential_seed() {
let script = provision_script(Agent::Railway, false, false);
for other_seed in [
"cat > ~/.claude-code-env",
"cat > ~/.codex/auth.json",
"cat > ~/.grok/auth.json",
] {
assert!(!script.contains(other_seed), "{script}");
}
assert!(script.contains("echo railway-agent-tui > ~/.railway-code-agent"));
assert!(script.contains("if command -v railway-agent-tui"));
assert!(script.contains("railway-code agent autostart"));
assert!(script.contains("AGENT-READY"));
}
#[test]
fn combined_provision_frames_the_credential() {
let script = provision_script_with_skills(Agent::Claude, Some(42), false, "abc123");
assert!(
script.contains("head -c 42 > ~/.claude-code-env"),
"{script}"
);
assert!(!script.contains("cat > ~/.claude-code-env"), "{script}");
assert!(script.contains(r#"cat > "$payload""#), "{script}");
assert!(script.contains("'abc123'"), "{script}");
}
#[test]
fn combined_provision_reports_ready_before_skills() {
let script = provision_script_with_skills(Agent::Claude, Some(10), false, "h");
let ready = script.find("AGENT-READY").expect("ready marker");
let sync = script.find("command -v tar").expect("sync block");
assert!(ready < sync, "{script}");
let cred = script.find("head -c 10").expect("framed credential");
let drain = script.find(r#"cat > "$payload""#).expect("payload drain");
assert!(cred < drain, "{script}");
}
#[test]
fn combined_provision_without_credential_reads_only_the_tarball() {
let script = provision_script_with_skills(Agent::Claude, None, false, "h");
assert!(!script.contains("head -c"), "{script}");
assert!(script.contains(r#"cat > "$payload""#), "{script}");
assert!(script.contains("AGENT-READY"), "{script}");
}
#[test]
fn combined_payload_framing_splits_back_exactly() {
let credential = b"CLAUDE_CODE_OAUTH_TOKEN=tok-123\n";
let tarball = [0x1f, 0x8b, 0x08, 0x00, 0x42];
let (payload, len) = combined_provision_payload(Some(credential), &tarball);
let len = len.expect("credential present");
assert_eq!(&payload[..len], credential);
assert_eq!(&payload[len..], &tarball);
let script = provision_script_with_skills(Agent::Claude, Some(len), false, "h");
assert!(script.contains(&format!("head -c {len} ")), "{script}");
let (payload, len) = combined_provision_payload(None, &tarball);
assert!(len.is_none());
assert_eq!(payload, tarball);
}
#[test]
fn uuid_shapes() {
assert!(is_uuid("ddcba2f8-a773-4929-bfbd-52450cdf0356"));
assert!(is_uuid("DDCBA2F8-A773-4929-BFBD-52450CDF0356"));
assert!(!is_uuid("production"));
assert!(!is_uuid("ddcba2f8-a773-4929-bfbd-52450cdf035")); assert!(!is_uuid("ddcba2f8-a773-4929-bfbd-52450cdf035g")); assert!(!is_uuid("ddcba2f8_a773_4929_bfbd_52450cdf0356")); }
#[test]
fn mux_gating() {
let short = std::path::Path::new("/tmp/railway-cm-1-abc.sock");
if cfg!(windows) {
assert!(mux_master_opts(short, "10s").is_empty());
assert!(mux_client_opts(short).is_empty());
} else {
assert!(!mux_master_opts(short, "10s").is_empty());
assert!(!mux_client_opts(short).is_empty());
let long = std::path::PathBuf::from(format!("/{}/cm.sock", "x".repeat(120)));
assert!(mux_master_opts(&long, "10s").is_empty());
assert!(mux_client_opts(&long).is_empty());
}
}
#[test]
fn a_shell_launch_starts_no_harness_and_retargets_nothing() {
for (prompt, args) in [
(None, vec![]),
(Some("fix the tests"), vec![]),
(None, vec!["exec".to_string(), "explain this".to_string()]),
] {
let cmd = remote_command(
Agent::Shell,
"P; ",
prompt,
None,
&args,
SessionStyle::FullTerminal,
);
assert_eq!(cmd, "P; export RAILWAY_CODE_AUTOSTARTED=1; exec bash -l");
}
let script = provision_script(Agent::Shell, false, false);
assert!(
!script.contains("~/.railway-code-agent"),
"a shell launch must not retarget reconnects: {script}"
);
for seed in [
"cat > ~/.claude-code-env",
"cat > ~/.codex/auth.json",
"cat > ~/.grok/auth.json",
] {
assert!(!script.contains(seed), "{script}");
}
assert!(script.contains("railway-code agent autostart"));
assert!(script.contains("if command -v bash"), "{script}");
assert!(script.contains("AGENT-READY"));
}
#[test]
fn no_provision_step_writes_harness_config() {
for agent in [
Agent::Claude,
Agent::Codex,
Agent::Grok,
Agent::OpenCode,
Agent::OpenCode2,
Agent::Railway,
Agent::Shell,
] {
for write_credential in [true, false] {
let script = provision_script(agent, write_credential, false);
assert!(!script.contains(".claude/settings.json"), "{script}");
assert!(!script.contains(".claude.json"), "{script}");
assert!(!script.contains("apiKeyHelper"), "{script}");
}
}
assert!(!CLAUDE_SEED.contains("hasCompletedOnboarding"));
}
#[test]
fn provision_script_reports_the_agents_skills_hash() {
let script = provision_script(Agent::Claude, true, false);
assert!(script.contains(skills_sync::REMOTE_HASH_MARKER));
assert!(script.contains(skills_sync::REMOTE_HASH_FILE));
assert!(script.contains(mcp_sync::REMOTE_HASH_MARKER));
assert!(script.contains(mcp_sync::REMOTE_HASH_FILE));
assert!(script.contains("2>/dev/null || true"));
}
#[cfg(unix)]
#[test]
fn every_generated_provision_script_parses_as_shell() {
use std::io::Write;
use std::process::{Command, Stdio};
let mut scripts: Vec<(String, String)> = Vec::new();
for agent in [
Agent::Codex,
Agent::Claude,
Agent::Grok,
Agent::OpenCode,
Agent::OpenCode2,
Agent::Railway,
Agent::Shell,
] {
for write_credential in [true, false] {
for app_mode in [true, false] {
scripts.push((
format!("provision {agent:?} cred={write_credential} app={app_mode}"),
provision_script(agent, write_credential, app_mode),
));
}
}
scripts.push((
format!("provision+skills {agent:?}"),
provision_script_with_skills(agent, Some(42), false, "deadbeef"),
));
}
scripts.push((
"skills follow-up".into(),
skills_sync::provision_script("deadbeef"),
));
scripts.push((
"mcp follow-up".into(),
mcp_sync::provision_script("deadbeef"),
));
for (label, script) in scripts {
let mut child = Command::new("sh")
.arg("-n")
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(script.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"`{label}` is not valid shell:\n{}\n--- script ---\n{script}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn remote_command_shapes() {
use SessionStyle::FullTerminal;
let seeded = remote_command(
Agent::Claude,
"P; ",
Some("fix the tests"),
None,
&[],
FullTerminal,
);
assert!(seeded.contains("claude 'fix the tests';"), "{seeded}");
assert!(seeded.ends_with("exec bash -l"));
assert!(!seeded.contains("exec claude"));
let interactive = remote_command(Agent::Claude, "P; ", None, None, &[], FullTerminal);
assert!(interactive.contains("claude;"), "{interactive}");
assert!(interactive.ends_with("exec bash -l"));
let scripted = remote_command(
Agent::Codex,
"P; ",
None,
None,
&["exec".into(), "explain this".into()],
FullTerminal,
);
assert!(
scripted.contains("exec codex exec 'explain this'"),
"{scripted}"
);
assert!(!scripted.contains("bash -l"));
let blank = remote_command(Agent::Grok, "P; ", Some(" "), None, &[], FullTerminal);
assert_eq!(
blank,
remote_command(Agent::Grok, "P; ", None, None, &[], FullTerminal)
);
let railway = remote_command(
Agent::Railway,
"P; ",
Some("fix the tests"),
None,
&[],
FullTerminal,
);
assert!(
railway.contains(
"railway-agent-tui --session \"${RAILWAY_DURABLE_SESSION_NAME:-railway-adhoc-$$}\" 'fix the tests';"
),
"{railway}"
);
let railway_bare = remote_command(Agent::Railway, "P; ", None, None, &[], FullTerminal);
assert!(
railway_bare.contains(
"railway-agent-tui --session \"${RAILWAY_DURABLE_SESSION_NAME:-railway-adhoc-$$}\";"
),
"{railway_bare}"
);
let railway_exec = remote_command(
Agent::Railway,
"P; ",
None,
None,
&["--continue".into()],
FullTerminal,
);
assert!(
railway_exec.contains("exec railway-agent-tui --continue"),
"{railway_exec}"
);
assert!(!railway_exec.contains("--session"), "{railway_exec}");
}
#[test]
fn remote_command_resume_shapes() {
use SessionStyle::FullTerminal;
let resumed = remote_command(
Agent::Claude,
"P; ",
None,
Some("abc-123"),
&[],
FullTerminal,
);
assert!(resumed.contains("claude --resume abc-123;"), "{resumed}");
assert!(resumed.ends_with("exec bash -l"));
let hostile = remote_command(
Agent::Claude,
"P; ",
None,
Some("a'; rm -rf /'"),
&[],
FullTerminal,
);
assert!(
hostile.contains(r"claude --resume 'a'\''; rm -rf /'\''';"),
"{hostile}"
);
let codex = remote_command(
Agent::Codex,
"P; ",
None,
Some("abc-123"),
&[],
FullTerminal,
);
assert!(!codex.contains("--resume"), "{codex}");
let blank = remote_command(Agent::Claude, "P; ", None, Some(" "), &[], FullTerminal);
assert_eq!(
blank,
remote_command(Agent::Claude, "P; ", None, None, &[], FullTerminal)
);
}
#[test]
fn a_pane_session_ends_with_the_harness() {
for prompt in [None, Some("fix the tests")] {
let pane = remote_command(Agent::Claude, "P; ", prompt, None, &[], SessionStyle::Pane);
assert!(!pane.contains("bash -l"), "{pane}");
assert!(pane.contains("\\033[?25h'"), "{pane}");
assert!(pane.ends_with("exit \"$railway_code_status\""), "{pane}");
}
}
#[cfg(unix)]
#[test]
fn a_pane_preserves_the_harness_exit_status_after_resetting_the_terminal() {
for agent in [Agent::Railway, Agent::Claude, Agent::OpenCode] {
for prompt in [None, Some("fix the tests")] {
for status in [0, 1, 42, 127] {
let prefix = format!(
"{}() {{ echo harness-output; return {status}; }}; ",
agent.name()
);
let command =
remote_command(agent, &prefix, prompt, None, &[], SessionStyle::Pane);
let output = std::process::Command::new("bash")
.args(["--noprofile", "--norc", "-c", &command])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(status), "{command}");
assert!(output.stdout.starts_with(b"harness-output\n"));
assert!(output.stdout.ends_with(TERMINAL_RESET.as_bytes()));
}
}
}
}
#[test]
fn a_prompt_cannot_break_out_of_its_quoting() {
let nasty = remote_command(
Agent::Claude,
"P; ",
Some("'; rm -rf / #"),
None,
&[],
SessionStyle::FullTerminal,
);
assert!(!nasty.contains("; rm -rf / #;"), "{nasty}");
assert!(
nasty.contains(r"'\''"),
"expected shell-escaped quoting: {nasty}"
);
}
fn default_project() -> DefaultProject {
DefaultProject {
project_id: "proj_default".into(),
project_name: "Cloud Agents".into(),
environment_id: "env_default".into(),
environment_name: "production".into(),
}
}
#[test]
fn the_linked_directory_beats_the_configured_default() {
let linked = || Some(("proj_linked".to_string(), "env_linked".to_string()));
let args = LaunchArgs {
project: Some("proj_flag".into()),
..Default::default()
};
assert_eq!(
choose_target(&args, Some(&default_project()), linked()),
TargetSource::Flags
);
let args = LaunchArgs {
environment: Some("env_flag".into()),
..Default::default()
};
assert_eq!(
choose_target(&args, Some(&default_project()), linked()),
TargetSource::Flags
);
assert_eq!(
choose_target(&LaunchArgs::default(), Some(&default_project()), linked()),
TargetSource::Linked("proj_linked".into(), "env_linked".into())
);
assert_eq!(
choose_target(&LaunchArgs::default(), Some(&default_project()), None),
TargetSource::Configured("proj_default".into(), "env_default".into())
);
}
fn project_lookup(deleted: bool, envs: &[(&str, bool)]) -> queries::RailwayProject {
let stamp = "2026-01-01T00:00:00Z";
serde_json::from_value(serde_json::json!({
"id": "proj_linked",
"name": "linked",
"workspaceId": "ws",
"deletedAt": deleted.then_some(stamp),
"workspace": { "name": "ws" },
"buckets": { "edges": [] },
"environments": { "edges": envs.iter().map(|(id, dead)| serde_json::json!({
"node": {
"id": id,
"name": id,
"canAccess": true,
"deletedAt": dead.then_some(stamp),
"unmergedChangesCount": 0,
}
})).collect::<Vec<_>>() },
"services": { "edges": [] },
}))
.expect("a RailwayProject deserializes from the fields the query selects")
}
#[test]
fn only_a_definitely_dead_link_is_demoted() {
assert_eq!(
stale_link_reason(&Err(RailwayError::ProjectNotFound), "env_linked"),
Some("project that no longer exists")
);
assert_eq!(
stale_link_reason(
&Ok(project_lookup(true, &[("env_linked", false)])),
"env_linked"
),
Some("project that was deleted")
);
assert_eq!(
stale_link_reason(
&Ok(project_lookup(false, &[("env_other", false)])),
"env_linked"
),
Some("environment that no longer exists")
);
assert_eq!(
stale_link_reason(
&Ok(project_lookup(false, &[("env_linked", true)])),
"env_linked"
),
Some("environment that no longer exists")
);
assert_eq!(
stale_link_reason(
&Ok(project_lookup(false, &[("env_linked", false)])),
"env_linked"
),
None
);
assert_eq!(
stale_link_reason(
&Err(RailwayError::GraphQLError("connection reset".into())),
"env_linked"
),
None
);
}
#[test]
fn nothing_to_go_on_runs_setup_when_there_is_a_terminal() {
let want = match is_stdout_terminal() {
true => TargetSource::Setup,
false => TargetSource::Ask,
};
assert_eq!(choose_target(&LaunchArgs::default(), None, None), want);
}
#[test]
fn a_tui_launch_always_targets_by_flag() {
let args = LaunchArgs::for_target(
"proj_1".into(),
"env_prod".into(),
"claude",
false,
None,
None,
);
assert!(args.project.is_some() && args.environment.is_some());
assert_eq!(
choose_target(&args, None, None),
TargetSource::Flags,
"a TUI launch must never reach a prompt"
);
}
#[test]
fn launch_args_bareness_and_targeting() {
assert!(LaunchArgs::default().is_bare());
let mut flagged = LaunchArgs::default();
flagged.set_harness("codex");
assert!(
!flagged.is_bare(),
"a harness flag is not a bare invocation"
);
let mut railway = LaunchArgs::default();
railway.set_harness("railway");
assert!(!railway.is_bare());
let mut shell = LaunchArgs::default();
shell.set_harness("shell");
assert!(shell.shell, "the shell choice must survive the mapping");
assert!(!shell.is_bare());
shell.set_harness("claude");
assert!(!shell.shell, "picking a harness clears it");
let targeted = LaunchArgs::for_target(
"proj_1".into(),
"env_1".into(),
"grok",
true,
Some("do the thing".into()),
None,
);
assert!(!targeted.is_bare());
assert_eq!(targeted.project.as_deref(), Some("proj_1"));
assert_eq!(targeted.environment.as_deref(), Some("env_1"));
assert!(targeted.new);
assert_eq!(targeted.initial_prompt.as_deref(), Some("do the thing"));
let pinned = LaunchArgs::for_target(
"proj_1".into(),
"env_1".into(),
"claude",
false,
None,
Some("ca_7".into()),
);
assert_eq!(pinned.agent_id.as_deref(), Some("ca_7"));
assert!(!pinned.new, "pinning an agent must not also create one");
assert_eq!(
[targeted.claude, targeted.codex, targeted.grok]
.iter()
.filter(|x| **x)
.count(),
1
);
}
#[test]
fn the_kill_script_is_plain_text() {
let script = kill_script("claude-3s9r89");
let bad: Vec<char> = script
.chars()
.filter(|c| c.is_control() && *c != '\n')
.collect();
assert!(bad.is_empty(), "control characters in the script: {bad:?}");
assert!(!script.contains('\0'));
assert!(script.contains("claude-3s9r89"));
}
#[test]
fn the_kill_script_matches_on_the_environment() {
let script = kill_script("claude-3s9r89");
assert!(script.contains("RAILWAY_DURABLE_SESSION_NAME=claude-3s9r89"));
assert!(script.contains("/environ"));
assert!(
!script.contains("pkill"),
"pkill -f would take the whole box"
);
assert!(
script.contains("kill -TERM"),
"TERM lets a harness save first"
);
assert!(
script.contains("KILLED:"),
"the caller counts what it ended"
);
}
#[test]
fn agent_slugs_round_trip() {
for agent in [Agent::Claude, Agent::Codex, Agent::Grok] {
assert_eq!(agent.slug(), agent.name());
assert_eq!(Agent::from_slug(agent.name()), Some(agent));
}
assert_eq!(Agent::from_slug("railway"), Some(Agent::Railway));
assert_eq!(Agent::Railway.slug(), "railway");
assert_eq!(Agent::Railway.name(), "railway-agent-tui");
assert_eq!(Agent::from_slug("shell"), Some(Agent::Shell));
assert_eq!(Agent::Shell.slug(), "shell");
assert_eq!(Agent::Shell.name(), "bash");
assert!(Agent::from_slug("droid").is_none());
assert!(Agent::from_slug("").is_none());
}
#[cfg(unix)]
#[test]
fn cached_token_is_created_0600_regardless_of_umask() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("railway-tok-{}", std::process::id()));
let path = dir.join("nested").join("claude-code-token");
let _ = std::fs::remove_dir_all(&dir);
write_token_0600(&path, "sk-ant-oat01-abc");
let mode = std::fs::metadata(&path)
.expect("written")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "cached token was {mode:o}, not 0600");
assert_eq!(
std::fs::read_to_string(&path).unwrap().trim(),
"sk-ant-oat01-abc"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn claude_token_validation_rejects_shell_specials() {
assert!(validate_claude_token("sk-ant-oat01-abc_DEF-123").is_ok());
for bad in ["has space", "quote'", "semi;colon", "dollar$var", "tick`"] {
assert!(validate_claude_token(bad).is_err(), "accepted: {bad}");
}
}
const FAKE_A: &str =
"sk-ant-oat01-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const FAKE_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn fake_recording() -> String {
format!(
"\x1b[?25l\x1b[<u\x1b[>1u\x1b[38;2;78;186;101m Long-lived authentication token created successfully!\r\x1b[1B\x1b[39m\x1b[K\r\x1b[1B Your OAuth token (valid for 1 year):\x1b[K\r\x1b[1B\x1b[K\r\x1b[1B \x1b[38;2;255;193;7m{FAKE_A}\r\x1b[1B\x1b[39m \x1b[38;2;255;193;7m{FAKE_B}\r\x1b[1C\x1b[2B\x1b[38;2;153;153;153mStore this token securely. You won't be able to see it again.\r\x1b[1C\x1b[1B\x1b[39m\x1b[K\r\r\n\x1b]8;id=1;https://example.com\x07link\x1b]8;;\x07\r\n"
)
}
#[test]
fn extracts_wrapped_token_from_recording() {
let tok = extract_claude_token(&fake_recording()).expect("token");
assert_eq!(tok, format!("{FAKE_A}{FAKE_B}"));
}
#[test]
fn extracts_unwrapped_token() {
let raw = format!("junk\r\n \x1b[33m{FAKE_A}\x1b[39m\r\nStore this token securely.\r\n");
assert_eq!(extract_claude_token(&raw).as_deref(), Some(FAKE_A));
}
#[test]
fn rejects_fragments_and_noise() {
assert_eq!(extract_claude_token("sk-ant-oat01-tooshort\r\n"), None);
assert_eq!(
extract_claude_token("\x1b]8;;https://claude.com/oauth?x=1\x07sign in\x1b]8;;\x07\r\n"),
None
);
}
#[test]
fn terminal_reset_printf_has_no_raw_escapes() {
let printf = terminal_reset_printf();
assert!(!printf.contains('\x1b'));
assert!(printf.starts_with("printf '") && printf.ends_with('\''));
}
}