use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::Parser;
use colored::Colorize;
use serde_json::{Value as JsonValue, json};
use crate::client::GQLClient;
use crate::commands::code::{self, LaunchArgs, Progress as _};
use crate::commands::ssh::config as ssh_config;
use crate::config::Configs;
use crate::controllers::cloud_agent as ca;
use crate::util::shell::shell_join;
use super::opencode;
mod codex_config;
mod opencode_config;
pub(crate) use opencode_config::configure_installed as configure_installed_opencode;
#[derive(Parser)]
#[clap(after_help = r#"Examples:
railway ca desktop --claude
railway ca desktop --codex
railway ca desktop --opencode --agent my-box
railway ca desktop --opencode2 --new
Sets up the selected app's connection and credentials without opening the app.
Uses an existing VM when available; --new creates one, --agent selects one.
Restart the app after setup to load its connection. The VM stays running;
railway ca sleep <agent> stops compute. Rerun setup after sleep or restart.
--dry-run previews changes without modifying the VM or local configuration.
--remove removes local configuration and stops the managed OpenCode server.
Already-imported Codex connections must also be removed in the app.
Guide: https://github.com/railwayapp/cli/blob/master/docs/cloud-agents.md"#)]
pub struct Args {
#[clap(long)]
claude: bool,
#[clap(long)]
codex: bool,
#[clap(long)]
opencode: bool,
#[clap(long, conflicts_with = "opencode")]
opencode2: bool,
#[clap(long, value_name = "NAME_OR_ID")]
agent: Option<String>,
#[clap(long, conflicts_with_all = ["agent", "remove"])]
new: bool,
#[clap(long, default_value = "/app", value_name = "PATH")]
dir: String,
#[clap(long)]
alias: Option<String>,
#[clap(long, default_value = "~/.ssh/config", value_name = "PATH")]
ssh_config: PathBuf,
#[clap(long)]
dry_run: bool,
#[clap(long, conflicts_with_all = ["dry_run", "dir"])]
remove: bool,
#[clap(long)]
no_verify: bool,
#[clap(long, short)]
environment: Option<String>,
#[clap(long, short)]
project: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum App {
Claude,
Codex,
OpenCode,
OpenCode2,
}
impl App {
fn is_opencode(self) -> bool {
matches!(self, Self::OpenCode | Self::OpenCode2)
}
fn harness(self) -> &'static str {
match self {
App::Claude => "claude",
App::Codex => "codex",
App::OpenCode => "opencode",
App::OpenCode2 => "opencode2",
}
}
fn name(self) -> &'static str {
match self {
App::Claude => "Claude Code Desktop",
App::Codex => "Codex",
App::OpenCode => "OpenCode Desktop",
App::OpenCode2 => "OpenCode2 [Beta] Desktop",
}
}
fn remote_bin(self) -> &'static str {
match self {
App::Claude => "claude",
App::Codex => "codex",
App::OpenCode => "opencode",
App::OpenCode2 => "opencode2",
}
}
fn where_it_appears(self) -> &'static str {
match self {
App::Claude => "the environment dropdown, under the name below",
App::Codex => "Connections and the project sidebar",
App::OpenCode | App::OpenCode2 => "the server picker",
}
}
}
pub async fn command(args: Args) -> Result<()> {
let apps = selected_apps(&args)?;
if let Some(alias) = &args.alias
&& !ssh_config::is_valid_agent_name(alias)
{
bail!("--alias must be a single SSH host name (letters, digits, '.', '_' or '-').");
}
let home = dirs::home_dir().context("Unable to get home directory")?;
let ssh_config_path = std::path::absolute(ssh_config::expand_tilde(&args.ssh_config)?)?;
if args.remove {
return remove(&args, &apps, &home, &ssh_config_path).await;
}
if args.dry_run {
return dry_run(&args, &apps, &home, &ssh_config_path).await;
}
if apps == [App::Codex] {
return code::codex_desktop_only(args.codex_launch_args(), args.codex_options()).await;
}
if apps.iter().any(|app| app.is_opencode()) {
opencode_config::preflight(args.opencode2)?;
}
if apps.contains(&App::Codex) {
preflight_codex_desktop()?;
}
let opencode_password = apps
.iter()
.any(|app| app.is_opencode())
.then(opencode::generate_password);
{
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
super::access::ensure_enabled(&client, &configs).await?;
}
let pinned = args.pinned_agent().await?;
let (project_id, environment_id, local_name_project) = if let Some(agent) = &pinned {
(agent.project_id.clone(), agent.environment_id.clone(), None)
} else {
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let target = code::resolve_launch(
&args.launch_args(apps[0], None, None),
&mut configs,
&client,
)
.await?;
(
target.project_id,
target.environment_id,
target.local_name_project,
)
};
let mut prepared: Option<code::Prepared> = None;
for app in &apps {
let progress = code::CliProgress::default();
let mut launch = args.launch_args(
*app,
prepared
.as_ref()
.map(|p| p.agent_id.clone())
.or_else(|| pinned.as_ref().map(|a| a.id.clone())),
Some((&project_id, &environment_id)),
);
launch.local_name_project = local_name_project.clone();
if let Some(password) = &opencode_password {
launch
.boot_variables
.insert("OPENCODE_SERVER_USERNAME".into(), "opencode".into());
launch
.boot_variables
.insert("OPENCODE_SERVER_PASSWORD".into(), password.clone());
}
let result = code::prepare(&launch, &progress, code::SessionStyle::FullTerminal).await;
progress.finish();
prepared = Some(result.with_context(|| format!("Preparing the agent for {}", app.name()))?);
}
let prepared = prepared.expect("selected_apps guarantees at least one app");
let alias = register_ssh(
&ssh_config_path,
&prepared.agent_name,
&prepared.environment_id,
prepared.identity.as_deref(),
args.alias.as_deref(),
apps.contains(&App::Codex),
)?;
let claude_entry = apps
.contains(&App::Claude)
.then(|| claude_ssh_entry(&prepared.agent_id, &prepared.agent_name, &alias, &args.dir));
if let Some(entry) = claude_entry {
upsert_claude_ssh_config(&home, entry)?;
}
let custom_config = ssh_config_path != default_ssh_config_path()?;
let checks = if args.no_verify {
Vec::new()
} else {
let ssh_apps = apps
.iter()
.copied()
.filter(|app| !app.is_opencode())
.collect::<Vec<_>>();
if ssh_apps.is_empty() {
Vec::new()
} else {
verify(&alias, &ssh_apps, &ssh_config_path).await
}
};
if custom_config && apps.iter().any(|app| !app.is_opencode()) {
println!(
"\n{} {} is not where the apps look. Make sure {} pulls it in:\n {}",
"!".yellow().bold(),
ssh_config_path.display(),
default_ssh_config_path()?.display(),
format!("Include {}", ssh_config_path.display()).cyan()
);
}
if apps.contains(&App::Codex) {
require_codex_checks(&checks)?;
codex_config::configure(
&alias,
&prepared.agent_name,
&args.dir,
&ssh_config_path,
&code::CliProgress::default(),
)
.await?;
}
let connection = if let Some(password) = &opencode_password {
if args.opencode2 {
println!(
"\nStarting OpenCode2 [Beta] and checking its HTTPS connection. The first startup downloads the latest Beta and may take several minutes..."
);
} else {
println!("\nStarting OpenCode and checking its HTTPS connection...");
}
Some(
opencode::start(
&alias,
&args.dir,
&ssh_config_path,
password,
args.opencode2,
)
.await?,
)
} else {
None
};
summarize(
&prepared,
&apps,
&alias,
&args,
&ssh_config_path,
&home,
&checks,
);
super::telemetry::track_desktop_configured(
&apps
.iter()
.map(|a| a.harness())
.collect::<Vec<_>>()
.join(","),
)
.await;
if let Some(connection) = connection {
println!(
"\n{}",
if connection.reused {
"OpenCode is already running"
} else {
"OpenCode is ready"
}
.bold()
);
let desktop = opencode_config::configure(
args.opencode2,
&connection,
&prepared.agent_id,
&prepared.agent_name,
)
.await
.map(|()| true);
code::save_desktop_configuration(&prepared, Some(&connection), args.opencode2, &desktop);
opencode::show_connection(
&connection,
args.opencode2,
&prepared.agent_name,
desktop.is_ok(),
)?;
desktop.context("OpenCode is running, but Desktop configuration failed. Rerun this command to finish setup.")?;
println!(
"You can close this terminal. Rerun this command after sleeping or restarting the agent."
);
} else {
code::save_desktop_configuration(&prepared, None, false, &Ok(true));
}
Ok(())
}
impl Args {
fn codex_launch_args(&self) -> LaunchArgs {
LaunchArgs::for_codex_desktop(
self.project.clone(),
self.environment.clone(),
self.agent.clone(),
self.dir.clone(),
self.new,
)
}
fn codex_options(&self) -> CodexOptions {
CodexOptions {
alias: self.alias.clone(),
ssh_config: Some(self.ssh_config.clone()),
no_verify: self.no_verify,
}
}
fn launch_args(
&self,
app: App,
agent_id: Option<String>,
target: Option<(&str, &str)>,
) -> LaunchArgs {
let mut launch = LaunchArgs::for_app_mode(
app.harness(),
target
.map(|(project, _)| project.to_owned())
.or_else(|| self.project.clone()),
target
.map(|(_, environment)| environment.to_owned())
.or_else(|| self.environment.clone()),
);
launch.new = self.new && agent_id.is_none();
launch.agent_id = agent_id;
launch.code_endpoint = self.opencode || self.opencode2;
launch
}
async fn pinned_agent(&self) -> Result<Option<ca::Agent>> {
let Some(selector) = self.agent.as_deref() else {
return Ok(None);
};
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let (agent, _) = ca::resolve(&configs, &client, Some(selector), None).await?;
Ok(Some(agent))
}
}
fn default_ssh_config_path() -> Result<PathBuf> {
ssh_config::expand_tilde(Path::new("~/.ssh/config"))
}
fn register_ssh(
path: &Path,
agent_name: &str,
environment_id: &str,
identity: Option<&Path>,
alias: Option<&str>,
codex: bool,
) -> Result<String> {
let alias = registered_ssh_alias(path, agent_name, environment_id, alias, codex)?;
let marker = ssh_config::agent_marker(environment_id, agent_name);
let block = render_block(agent_name, environment_id, &alias, identity)?;
ssh_config::upsert_marked_block(path, &marker, &block)
.with_context(|| format!("Failed to update {}", path.display()))?;
Ok(alias)
}
fn registered_ssh_alias(
path: &Path,
agent_name: &str,
environment_id: &str,
alias: Option<&str>,
codex: bool,
) -> Result<String> {
if alias.is_some_and(|alias| !ssh_config::is_valid_agent_name(alias)) {
bail!("SSH alias must be a single host name (letters, digits, '.', '_' or '-').");
}
let marker = ssh_config::agent_marker(environment_id, agent_name);
if let Some(alias) = alias {
return Ok(alias.to_owned());
}
let previous = ssh_config::marked_host_alias(path, &marker)?;
let legacy_default = ssh_config::agent_alias(agent_name);
if codex
&& previous
.as_deref()
.is_none_or(|alias| alias == legacy_default)
{
return Ok(ssh_config::codex_agent_alias(agent_name));
}
Ok(previous.unwrap_or(legacy_default))
}
pub(crate) fn preflight_codex_desktop() -> Result<()> {
codex_config::preflight()
}
#[derive(Default)]
pub(crate) struct CodexOptions {
pub alias: Option<String>,
pub ssh_config: Option<PathBuf>,
pub no_verify: bool,
}
impl CodexOptions {
pub(crate) fn config_path(&self) -> Result<PathBuf> {
std::path::absolute(ssh_config::expand_tilde(
self.ssh_config
.as_deref()
.unwrap_or(Path::new("~/.ssh/config")),
)?)
.map_err(Into::into)
}
pub(crate) fn validate(&self) -> Result<()> {
if self
.alias
.as_deref()
.is_some_and(|alias| !ssh_config::is_valid_agent_name(alias))
{
bail!("--alias must be a single SSH host name (letters, digits, '.', '_' or '-').");
}
self.config_path()?;
Ok(())
}
}
#[derive(Clone, serde::Deserialize, serde::Serialize)]
pub(crate) struct CodexDesktop {
pub ssh_alias: String,
pub ssh_config_path: PathBuf,
pub config_path: PathBuf,
pub project_label: String,
pub remote_path: String,
pub apply_url: String,
pub apply_sent: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub apply_error: Option<String>,
}
pub(crate) async fn configure_codex(
agent_name: &str,
environment_id: &str,
identity: Option<&Path>,
directory: &str,
options: &CodexOptions,
progress: &dyn code::Progress,
) -> Result<CodexDesktop> {
let path = options.config_path()?;
let alias = register_ssh(
&path,
agent_name,
environment_id,
identity,
options.alias.as_deref(),
true,
)?;
if !options.no_verify {
require_codex_checks(&verify(&alias, &[App::Codex], &path).await)?;
}
if path != default_ssh_config_path()? {
progress.note(&format!(
"Codex reads {}. Make sure it contains: Include {}",
default_ssh_config_path()?.display(),
path.display()
));
}
codex_config::configure(&alias, agent_name, directory, &path, progress).await
}
fn require_codex_checks(checks: &[Check]) -> Result<()> {
for check in checks {
if !check.ok {
bail!(
"Codex Desktop SSH check failed ({}): {}",
check.label,
check.detail.as_deref().unwrap_or_default()
);
}
}
Ok(())
}
fn render_block(
agent_name: &str,
environment_id: &str,
alias: &str,
identity_file: Option<&Path>,
) -> Result<String> {
if !ssh_config::is_valid_agent_name(agent_name) {
bail!(
"Refusing to write an SSH config for agent {agent_name:?}: its name is not a safe \
single token (letters, digits, and '.', '_' or '-', starting with a letter or \
digit). An agent created before name validation shipped can have such a name; \
recreate it with a valid name and re-run."
);
}
let known_hosts = code::relay_known_hosts()?;
Ok(ssh_config::render_agent_config_block(
&ssh_config::AgentBlock {
agent_name,
environment_id,
alias,
identity_file,
known_hosts: &known_hosts,
},
))
}
async fn dry_run(args: &Args, apps: &[App], home: &Path, ssh_config_path: &Path) -> Result<()> {
let (agent_id, agent_name, environment_id) = if args.new {
println!(
"Would create a fresh cloud agent (--new). Agent and environment IDs below are placeholders."
);
println!(
"Target: project {}, environment {}",
args.project
.as_deref()
.unwrap_or("linked or configured default"),
args.environment
.as_deref()
.unwrap_or("linked or configured default")
);
(
"NEW_AGENT_ID".into(),
"new-agent".into(),
"ENVIRONMENT_ID".into(),
)
} else {
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let (agent, _) = ca::resolve(&configs, &client, args.agent.as_deref(), None).await?;
(agent.id, agent.name, agent.environment_id)
};
let identity = preferred_local_key().await;
let alias = registered_ssh_alias(
ssh_config_path,
&agent_name,
&environment_id,
args.alias.as_deref(),
apps.contains(&App::Codex),
)?;
let block = render_block(&agent_name, &environment_id, &alias, identity.as_deref())?;
println!("\n{}", ssh_config_path.display().to_string().cyan());
print!("{block}");
if apps.contains(&App::Codex) {
if apps == [App::Codex] {
println!(
"Would request a code endpoint for a new VM, then start or reuse the authenticated Codex App Server on its configured port (default 4096; legacy connections use 8080) and verify its public WebSocket endpoint."
);
}
codex_config::preview(&alias, &agent_name, &args.dir)?;
}
if apps.contains(&App::Claude) {
let entry = claude_ssh_entry(&agent_id, &agent_name, &alias, &args.dir);
println!(
"\n{}",
claude_settings_path(home).display().to_string().cyan()
);
println!(
"{}",
serde_json::to_string_pretty(&json!({ "sshConfigs": [entry] }))?
);
}
if apps.iter().any(|app| app.is_opencode()) {
println!("\nWould generate credentials for a new agent, or reuse its saved credentials.");
println!(
"Would request a code endpoint for a new VM, then start password-protected OpenCode on its configured port (default 4096; legacy connections use 8080) from {}.",
args.dir
);
println!(
"Would verify the agent's existing HTTPS address and print its connection details."
);
for target in opencode_config::targets(args.opencode2)? {
println!(
"Would save the authenticated server, default server, and project in {} ({}).",
target.name(),
target.root.display()
);
}
println!("You may need to restart OpenCode Desktop to load the updated configuration.");
}
if identity.is_none() {
println!(
"\n{}",
"No local key file found, so no IdentityFile line — a real run registers a key and may add one."
.dimmed()
);
}
println!(
"\n{}",
"Nothing written, no agent created or woken, and no process started (--dry-run).".dimmed()
);
Ok(())
}
async fn preferred_local_key() -> Option<PathBuf> {
use crate::controllers::ssh::keys::{SshKeySource, find_local_ssh_keys};
find_local_ssh_keys()
.await
.ok()?
.into_iter()
.find_map(|key| match key.source {
SshKeySource::File(path) => Some(path),
SshKeySource::Agent => None,
})
}
fn selected_apps(args: &Args) -> Result<Vec<App>> {
let mut apps = Vec::new();
if args.claude {
apps.push(App::Claude);
}
if args.codex {
apps.push(App::Codex);
}
if args.opencode {
apps.push(App::OpenCode);
}
if args.opencode2 {
apps.push(App::OpenCode2);
}
if apps.is_empty() {
bail!(
"Name an app: --claude, --codex, --opencode, or --opencode2. OpenCode editions must be configured separately."
);
}
Ok(apps)
}
async fn remove(args: &Args, apps: &[App], home: &Path, ssh_config_path: &Path) -> Result<()> {
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let (agent, _) = ca::resolve(&configs, &client, args.agent.as_deref(), None).await?;
let removed_codex = if apps.contains(&App::Codex) {
let alias = registered_ssh_alias(
ssh_config_path,
&agent.name,
&agent.environment_id,
args.alias.as_deref(),
false,
)?;
let mut removed = codex_config::remove(&alias)?;
if args.alias.is_none() {
removed |= codex_config::remove(&ssh_config::codex_agent_alias(&agent.name))?;
}
removed
} else {
false
};
let removed_opencode = if apps.iter().any(|app| app.is_opencode()) {
opencode_config::remove(&agent.id, args.opencode2).await?
} else {
false
};
let stopped_opencode =
apps.iter().any(|app| app.is_opencode()) && agent.status == ca::Status::Running;
if stopped_opencode {
let alias = registered_ssh_alias(
ssh_config_path,
&agent.name,
&agent.environment_id,
args.alias.as_deref(),
false,
)?;
opencode::stop(&alias, ssh_config_path, args.opencode2).await?;
}
let marker = ssh_config::agent_marker(&agent.environment_id, &agent.name);
let removed_block = ssh_config::remove_marked_block(ssh_config_path, &marker)?;
let removed_entry = if apps.contains(&App::Claude) {
remove_claude_ssh_config(home, &agent.id)?
} else {
false
};
match (
removed_block,
removed_entry,
stopped_opencode,
removed_opencode,
removed_codex,
) {
(false, false, false, false, false) => println!(
"No `railway ca desktop` config found for agent {}.",
agent.name.cyan()
),
_ => {
if removed_block {
println!(
"{} Removed the SSH block from {}",
"✓".green(),
ssh_config_path.display()
);
}
if removed_entry {
println!(
"{} Removed the connection from {}",
"✓".green(),
claude_settings_path(home).display()
);
}
if stopped_opencode {
println!("{} Stopped the managed OpenCode server.", "✓".green());
}
if removed_opencode {
println!(
"{} Removed the managed OpenCode Desktop connection and project.",
"✓".green()
);
}
if removed_codex {
println!(
"{} Removed the Codex Desktop import declaration. Remove the already-imported host in Codex Settings → Connections and its project from the sidebar.",
"✓".green()
);
}
println!(
"\nThe agent itself is untouched. {} deletes it.",
format!("railway ca delete {}", agent.name).cyan()
);
}
}
Ok(())
}
fn claude_settings_path(home: &Path) -> PathBuf {
home.join(".claude").join("settings.json")
}
fn claude_ssh_entry(agent_id: &str, agent_name: &str, alias: &str, dir: &str) -> JsonValue {
json!({
"id": claude_entry_id(agent_id),
"name": format!("Railway · {agent_name}"),
"sshHost": alias,
"startDirectory": dir,
})
}
fn claude_entry_id(agent_id: &str) -> String {
format!("railway-agent-{agent_id}")
}
fn upsert_claude_ssh_config(home: &Path, entry: JsonValue) -> Result<()> {
let path = claude_settings_path(home);
let mut root = read_json_or_empty(&path)?;
let id = entry.get("id").and_then(JsonValue::as_str).unwrap_or("");
let configs = root
.as_object_mut()
.context("settings.json is not a JSON object")?
.entry("sshConfigs")
.or_insert_with(|| json!([]));
let list = configs
.as_array_mut()
.context("`sshConfigs` in settings.json is not an array")?;
match list
.iter()
.position(|e| e.get("id").and_then(JsonValue::as_str) == Some(id))
{
Some(at) => list[at] = entry,
None => list.push(entry),
}
write_json_pretty(&path, &root)
}
fn remove_claude_ssh_config(home: &Path, agent_id: &str) -> Result<bool> {
let path = claude_settings_path(home);
if !path.exists() {
return Ok(false);
}
let mut root = read_json_or_empty(&path)?;
let id = claude_entry_id(agent_id);
let Some(list) = root.get_mut("sshConfigs").and_then(JsonValue::as_array_mut) else {
return Ok(false);
};
let before = list.len();
list.retain(|e| e.get("id").and_then(JsonValue::as_str) != Some(id.as_str()));
if list.len() == before {
return Ok(false);
}
write_json_pretty(&path, &root)?;
Ok(true)
}
fn read_json_or_empty(path: &Path) -> Result<JsonValue> {
match std::fs::read_to_string(path) {
Ok(text) if text.trim().is_empty() => Ok(json!({})),
Ok(text) => serde_json::from_str(&text)
.with_context(|| format!("Failed to parse existing JSON at {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({})),
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
}
fn write_json_pretty(path: &Path, value: &JsonValue) -> Result<()> {
let mut text = serde_json::to_string_pretty(value)?;
text.push('\n');
crate::util::write_atomic(path, &text)
.with_context(|| format!("Failed to write {}", path.display()))
}
struct Check {
label: String,
ok: bool,
detail: Option<String>,
}
async fn verify(alias: &str, apps: &[App], ssh_config_path: &Path) -> Vec<Check> {
let mut checks = Vec::new();
let connect = ssh_alias(alias, &["true"], ssh_config_path).await;
let connected = connect.is_ok();
checks.push(Check {
label: format!("ssh {alias}"),
ok: connected,
detail: connect.err().map(|e| format!("{e:#}")),
});
if !connected {
return checks;
}
for app in apps {
let bin = app.remote_bin();
let probe = ssh_alias(
alias,
&["bash", "-lc", &format!("command -v {bin}")],
ssh_config_path,
)
.await;
checks.push(Check {
label: format!("{bin} on PATH (login shell)"),
ok: probe.is_ok(),
detail: probe.err().map(|e| format!("{e:#}")),
});
if app.is_opencode() {
let probe = ssh_alias(
alias,
&["bash", "-lc", "opencode serve --help"],
ssh_config_path,
)
.await;
checks.push(Check {
label: "opencode serve available".into(),
ok: probe.is_ok(),
detail: probe.err().map(|e| format!("{e:#}")),
});
}
}
checks
}
async fn ssh_alias(alias: &str, command: &[&str], ssh_config_path: &Path) -> Result<()> {
let mut cmd = tokio::process::Command::new("ssh");
cmd.arg("-F")
.arg(ssh_config_path)
.arg("-o")
.arg("BatchMode=yes")
.arg("-o")
.arg("ConnectTimeout=20")
.arg("-T")
.arg("--")
.arg(alias)
.arg(shell_join(
&command.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
))
.stdin(std::process::Stdio::null());
let out = cmd.output().await.context("Failed to run `ssh`")?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let reason = stderr
.lines()
.rfind(|l| !l.trim().is_empty())
.unwrap_or("ssh failed with no output")
.trim();
bail!("{reason}")
}
fn summarize(
prepared: &code::Prepared,
apps: &[App],
alias: &str,
args: &Args,
ssh_config_path: &Path,
home: &Path,
checks: &[Check],
) {
let dir = &args.dir;
println!("\n{}", "Cloud agent ready for your desktop app".bold());
println!(" {} {}", "Agent ".dimmed(), prepared.agent_name.bold());
println!(" {} {}", "Host ".dimmed(), alias.bold());
println!(" {} {}", "Opens ".dimmed(), dir.bold());
println!(
" {} {}",
"Wrote ".dimmed(),
ssh_config_path.display().to_string().bold()
);
if apps.contains(&App::Claude) {
println!(
" {} {}",
" ".dimmed(),
claude_settings_path(home).display().to_string().bold()
);
}
if !checks.is_empty() {
println!();
for check in checks {
let mark = if check.ok { "✓".green() } else { "✗".red() };
match &check.detail {
Some(detail) => println!(" {mark} {} {}", check.label, detail.dimmed()),
None => println!(" {mark} {}", check.label),
}
}
}
if apps.iter().any(|app| !app.is_opencode()) {
println!("\n{}", "Next:".bold());
}
for app in apps {
if app.is_opencode() {
continue;
}
if *app == App::Codex {
println!(
" Codex — find Railway: {} in {}",
prepared.agent_name,
app.where_it_appears()
);
continue;
}
println!(
" {} — restart it, then find the agent in {}",
app.name(),
app.where_it_appears()
);
}
if !apps.iter().any(|app| app.is_opencode()) {
println!(
"\n{} The agent must be awake when the app connects — the relay refuses a\nsleeping one, and a desktop app can't wake it. {} does.",
"!".yellow().bold(),
format!("railway ca wake {}", prepared.agent_name).cyan()
);
}
println!(
"{} stops the compute bill when you're done.",
format!("railway ca sleep {}", prepared.agent_name).cyan()
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn args_for(argv: &[&str]) -> Args {
Args::parse_from(std::iter::once("desktop").chain(argv.iter().copied()))
}
#[test]
fn codex_alias_maps_to_the_canonical_desktop_only_launch() {
for extra in [
vec![],
vec!["--new"],
vec![
"--agent",
"box",
"--dir",
"/app/a project",
"-p",
"project",
"-e",
"env",
],
] {
let legacy = args_for(&[vec!["--codex"], extra.clone()].concat());
let mut canonical = vec!["code", "--codex", "desktop-only"];
if !extra.contains(&"--dir") {
canonical.extend(["--dir", "/app"]);
}
canonical.extend(extra);
assert_eq!(
legacy.codex_launch_args(),
LaunchArgs::try_parse_from(canonical).unwrap()
);
}
let args = args_for(&[
"--codex",
"--alias",
"my-desktop",
"--ssh-config",
"/custom/ssh config",
"--no-verify",
]);
let options = args.codex_options();
assert_eq!(options.alias.as_deref(), Some("my-desktop"));
assert_eq!(
options.ssh_config.as_deref(),
Some(Path::new("/custom/ssh config"))
);
assert!(options.no_verify);
assert!(options.validate().is_ok());
}
#[test]
fn shared_ssh_registration_preserves_desktop_alias_and_unrelated_hosts() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".ssh/config");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let personal = "Host personal\n HostName personal.example.com\n";
fs::write(&path, personal).unwrap();
let old_key = home.path().join("old key");
let new_key = home.path().join("new key");
let alias = register_ssh(
&path,
"box",
"env-id",
Some(&old_key),
Some("my-codex"),
true,
)
.unwrap();
assert_eq!(alias, "my-codex");
let alias = register_ssh(&path, "box", "env-id", Some(&new_key), None, true).unwrap();
assert_eq!(alias, "my-codex");
assert_eq!(
registered_ssh_alias(&path, "box", "env-id", None, true).unwrap(),
alias
);
let updated = fs::read_to_string(&path).unwrap();
assert!(updated.starts_with(personal));
assert_eq!(
updated.matches("# BEGIN railway:agent:env-id:box").count(),
1
);
assert!(updated.contains("User agent:env-id:box"));
assert!(updated.contains("new key"));
assert!(!updated.contains("old key"));
register_ssh(&path, "box", "env-id", Some(&new_key), None, true).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), updated);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[test]
fn shared_ssh_registration_creates_a_default_and_leaves_ambiguous_blocks_intact() {
for (codex, name, expected) in [
(false, "box", "railway-agent-box"),
(true, "codex-railg-3ed", "railway-codex-railg-3ed"),
] {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".ssh/config");
assert_eq!(
register_ssh(&path, name, "env-id", None, None, codex).unwrap(),
expected
);
let existing = fs::read_to_string(&path).unwrap();
let ambiguous = existing.replace(&format!("Host {expected}\n"), "Host box other\n");
fs::write(&path, &ambiguous).unwrap();
assert!(register_ssh(&path, name, "env-id", None, None, codex).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), ambiguous);
assert!(
register_ssh(
&path,
name,
"env-id",
None,
Some("bad\nHost injected"),
codex
)
.is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), ambiguous);
}
}
#[test]
fn codex_registration_migrates_the_legacy_default_in_place() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join(".ssh/config");
let name = "codex-railg-3ed";
register_ssh(&path, name, "env-id", None, None, false).unwrap();
let legacy = fs::read_to_string(&path).unwrap();
assert_eq!(
registered_ssh_alias(&path, name, "env-id", None, true).unwrap(),
"railway-codex-railg-3ed"
);
assert_eq!(fs::read_to_string(&path).unwrap(), legacy);
for _ in 0..2 {
assert_eq!(
register_ssh(&path, name, "env-id", None, None, true).unwrap(),
"railway-codex-railg-3ed"
);
assert_eq!(
fs::read_to_string(&path).unwrap(),
legacy.replace(
"Host railway-agent-codex-railg-3ed\n",
"Host railway-codex-railg-3ed\n"
)
);
assert_eq!(
registered_ssh_alias(&path, name, "env-id", None, false).unwrap(),
"railway-codex-railg-3ed"
);
}
}
#[test]
fn an_app_must_be_named() {
assert!(selected_apps(&args_for(&[])).is_err());
assert_eq!(selected_apps(&args_for(&["--claude"])).unwrap().len(), 1);
assert_eq!(
selected_apps(&args_for(&["--claude", "--codex"]))
.unwrap()
.len(),
2
);
}
#[test]
fn opencode_editions_are_explicit_and_mutually_exclusive() {
assert_eq!(
selected_apps(&args_for(&["--opencode2"])).unwrap(),
vec![App::OpenCode2]
);
assert!(Args::try_parse_from(["desktop", "--opencode", "--opencode2"]).is_err());
assert!(Args::try_parse_from(["desktop", "--opencode2", "--new"]).is_ok());
}
#[test]
fn opencode_can_be_configured_alone_or_with_other_apps() {
assert_eq!(
selected_apps(&args_for(&["--opencode"])).unwrap(),
vec![App::OpenCode]
);
assert_eq!(
selected_apps(&args_for(&["--claude", "--codex", "--opencode"])).unwrap(),
vec![App::Claude, App::Codex, App::OpenCode]
);
for flag in ["--dry-run", "--remove", "--no-verify"] {
assert!(Args::try_parse_from(["desktop", "--opencode", flag]).is_ok());
}
for flag in ["--port", "--remote-port"] {
assert!(Args::try_parse_from(["desktop", "--opencode", flag, "4096"]).is_err());
}
}
#[test]
fn new_creates_once_and_later_apps_reuse_the_created_agent() {
let args = args_for(&[
"--claude",
"--codex",
"--opencode",
"--new",
"-e",
"production",
]);
let first = args.launch_args(App::Claude, None, None);
assert!(first.new);
assert!(first.code_endpoint);
assert!(first.agent_id.is_none());
assert_eq!(first.environment.as_deref(), Some("production"));
for app in [App::Codex, App::OpenCode] {
let next = args.launch_args(
app,
Some("created-agent".into()),
Some(("created-project", "created-env")),
);
assert!(!next.new);
assert_eq!(next.agent_id.as_deref(), Some("created-agent"));
assert_eq!(next.environment.as_deref(), Some("created-env"));
assert_eq!(next.project.as_deref(), Some("created-project"));
}
}
#[test]
fn desktop_reuses_by_default_and_new_conflicts_with_existing_agent_actions() {
let args = args_for(&["--opencode"]);
assert!(!args.launch_args(App::OpenCode, None, None).new);
let pinned = args.launch_args(
App::OpenCode,
Some("existing-agent".into()),
Some(("other-project", "other-env")),
);
assert!(!pinned.new);
assert_eq!(pinned.agent_id.as_deref(), Some("existing-agent"));
assert_eq!(pinned.environment.as_deref(), Some("other-env"));
assert_eq!(pinned.project.as_deref(), Some("other-project"));
assert!(
Args::try_parse_from(["desktop", "--opencode", "--new", "--agent", "existing"])
.is_err()
);
assert!(Args::try_parse_from(["desktop", "--opencode", "--new", "--remove"]).is_err());
assert!(Args::try_parse_from(["desktop", "--opencode", "--new", "--dry-run"]).is_ok());
}
#[test]
fn ssh_only_desktop_setup_does_not_request_a_code_endpoint() {
let args = args_for(&["--claude", "--codex", "--new"]);
assert!(!args.launch_args(App::Claude, None, None).code_endpoint);
}
#[test]
fn render_block_refuses_an_unsafe_agent_name() {
let err = render_block(
"bbp\n ProxyCommand /bin/sh -c 'id'",
"env-1",
"railway-agent-bbp",
None,
)
.unwrap_err();
assert!(
err.to_string().contains("not a safe single token"),
"unexpected error: {err}"
);
}
#[test]
fn the_claude_entry_omits_what_the_ssh_block_owns() {
let entry = claude_ssh_entry("ca_1", "my-box", "railway-agent-my-box", "/app");
assert_eq!(entry["sshHost"], "railway-agent-my-box");
assert_eq!(entry["id"], "railway-agent-ca_1");
assert_eq!(entry["startDirectory"], "/app");
assert!(entry.get("sshPort").is_none());
assert!(entry.get("sshIdentityFile").is_none());
}
#[test]
fn the_settings_merge_is_idempotent() {
let home = tempfile::tempdir().unwrap();
let path = claude_settings_path(home.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{"sshConfigs":[{"id":"mine","name":"Mine","sshHost":"box"}],"theme":"dark"}"#,
)
.unwrap();
let entry = claude_ssh_entry("ca_1", "my-box", "railway-agent-my-box", "/app");
upsert_claude_ssh_config(home.path(), entry.clone()).unwrap();
upsert_claude_ssh_config(home.path(), entry).unwrap();
let root: JsonValue =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let list = root["sshConfigs"].as_array().unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0]["id"], "mine");
assert_eq!(list[1]["id"], "railway-agent-ca_1");
assert_eq!(root["theme"], "dark");
}
#[test]
fn removal_takes_only_our_entry() {
let home = tempfile::tempdir().unwrap();
let path = claude_settings_path(home.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"sshConfigs":[{"id":"mine"}]}"#).unwrap();
upsert_claude_ssh_config(
home.path(),
claude_ssh_entry("ca_1", "my-box", "railway-agent-my-box", "/app"),
)
.unwrap();
assert!(remove_claude_ssh_config(home.path(), "ca_1").unwrap());
assert!(!remove_claude_ssh_config(home.path(), "ca_1").unwrap());
let root: JsonValue =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let list = root["sshConfigs"].as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["id"], "mine");
}
#[test]
fn a_missing_settings_file_is_a_first_run_not_an_error() {
let home = tempfile::tempdir().unwrap();
upsert_claude_ssh_config(
home.path(),
claude_ssh_entry("ca_1", "my-box", "railway-agent-my-box", "/app"),
)
.unwrap();
let root: JsonValue = serde_json::from_str(
&std::fs::read_to_string(claude_settings_path(home.path())).unwrap(),
)
.unwrap();
assert_eq!(root["sshConfigs"][0]["id"], "railway-agent-ca_1");
assert!(!remove_claude_ssh_config(home.path(), "nope").unwrap());
}
#[test]
fn corrupt_settings_are_reported_not_overwritten() {
let home = tempfile::tempdir().unwrap();
let path = claude_settings_path(home.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{not json").unwrap();
assert!(
upsert_claude_ssh_config(
home.path(),
claude_ssh_entry("ca_1", "b", "railway-agent-b", "/app")
)
.is_err()
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{not json");
}
}