railwayapp 5.35.0

Interact with Railway via CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
use anyhow::{Context, Result, bail};
use is_terminal::IsTerminal;
use reqwest::Client;
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread::sleep;
use std::time::{Duration, Instant};

use crate::client::post_graphql;
use crate::config::Configs;
use crate::controllers::ssh::keys::{SshKeySource, find_local_ssh_keys, register_ssh_key};
use crate::gql::queries::{ServiceInstance, service_instance};
use crate::util::prompt::{prompt_confirm_with_default, prompt_select};

/// SSH relay endpoint (host, non-default port) for the current environment —
/// must track `Configs::get_backboard()`'s environment, or key registration
/// is checked against one backboard while the relay authenticates against
/// another (dev-mode CLIs used to dial the prod relay and get publickey
/// denials for keys that were registered fine).
pub(super) fn ssh_relay() -> (&'static str, Option<u16>) {
    Configs::get_ssh_relay()
}

/// Append `-p <port>` when the relay listens on a non-default port (the
/// develop relay uses 2222).
fn apply_relay_port(cmd: &mut Command, port: Option<u16>) {
    if let Some(port) = port {
        cmd.args(["-p", &port.to_string()]);
    }
}

/// Base `ssh` invocation for the current environment's relay: the binary, the
/// non-default relay port, and the `-i` identity when one was resolved.
/// Returns the command plus the `<target>@<relay-host>` to append *after* any
/// mode-specific options (interactive `-t`/`-T`, forward `-N`/`-L`, …). Shared
/// so the relay/port/identity setup can't drift between the interactive and
/// forward paths.
fn base_ssh_command(ssh_target: &str, identity_file: Option<&Path>) -> (Command, String) {
    let (host, port) = ssh_relay();
    let mut cmd = Command::new("ssh");
    apply_relay_port(&mut cmd, port);
    if let Some(key) = identity_file {
        cmd.arg("-i").arg(key);
    }
    (cmd, format!("{ssh_target}@{host}"))
}

/// The relay destination for a target: `<target>@<relay-host>`.
///
/// A relay target (`agent:<env>:<id>`, `service:…`) is a *username* on the
/// relay host, not a hostname. Handing the bare target to ssh asks the resolver
/// to look it up as a host, which fails with "Could not resolve hostname" —
/// exposed here so callers that build their own invocation, like the TUI's pty
/// session, cannot rediscover that the hard way.
pub fn relay_destination(ssh_target: &str) -> String {
    let (host, _) = ssh_relay();
    format!("{ssh_target}@{host}")
}

/// `-p <port>` when the relay listens somewhere other than 22, empty otherwise.
pub fn relay_port_args() -> Vec<String> {
    match ssh_relay() {
        (_, Some(port)) => vec!["-p".to_string(), port.to_string()],
        (_, None) => Vec::new(),
    }
}

/// Get the service instance ID for a service in an environment
pub async fn get_service_instance_id(
    client: &Client,
    configs: &Configs,
    environment_id: &str,
    service_id: &str,
) -> Result<String> {
    let vars = service_instance::Variables {
        environment_id: environment_id.to_string(),
        service_id: service_id.to_string(),
    };

    let response =
        post_graphql::<ServiceInstance, _>(client, configs.get_backboard(), vars).await?;

    Ok(response.service_instance.id)
}

/// Ensure SSH key is registered, prompting user if needed.
///
/// Queries/registers against whichever key bucket the backend picks from
/// the caller's auth context: a workspace-scoped `RAILWAY_API_TOKEN` gets
/// its workspace's keys; session and user tokens get personal keys. The
/// CLI doesn't need to distinguish — it passes `workspaceId: null` and
/// the resolver defaults from `ctx.workspace.id` when present.
pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<Option<PathBuf>> {
    ensure_ssh_key_impl(client, configs, true).await
}

/// `ensure_ssh_key` without the "Using SSH key from ..." announcement when a
/// registered key is already in place — for flows like `railway code` where
/// ssh is plumbing, not the product. First-run registration still prompts
/// and prints normally.
pub async fn ensure_ssh_key_quiet(client: &Client, configs: &Configs) -> Result<Option<PathBuf>> {
    ensure_ssh_key_impl(client, configs, false).await
}

async fn ensure_ssh_key_impl(
    client: &Client,
    configs: &Configs,
    announce: bool,
) -> Result<Option<PathBuf>> {
    let local_keys = find_local_ssh_keys().await?;

    if local_keys.is_empty() {
        bail!(
            "No SSH keys found in your SSH agent or ~/.ssh/\n\n\
            Generate one with:\n  ssh-keygen -t ed25519\n\n\
            Then run this command again."
        );
    }

    let registered_keys =
        crate::controllers::ssh::keys::get_registered_ssh_keys(client, configs, None).await?;

    // Find a local key that's already registered
    let registered_local = local_keys.iter().find(|local| {
        registered_keys
            .iter()
            .any(|r| r.fingerprint == local.fingerprint)
    });

    if let Some(key) = registered_local {
        if announce {
            match &key.source {
                SshKeySource::File(path) => eprintln!(
                    "Using SSH key from file {}: {}",
                    path.display(),
                    key.key_name()
                ),
                SshKeySource::Agent => eprintln!("Using SSH key from agent: {}", key.key_name()),
            }
        }
        return Ok(identity_for(key));
    }

    // No local key is registered - need to register one
    if !std::io::stdin().is_terminal() {
        bail!(
            "No registered SSH keys found. Register one with:\n  railway ssh keys add\n\n\
            Or import from GitHub:\n  railway ssh keys github"
        );
    }

    println!("No SSH keys registered with Railway.");

    let key_to_register = if local_keys.len() == 1 {
        &local_keys[0]
    } else {
        // Let the user pick which key to register
        use std::fmt;
        struct KeyOption<'a>(&'a crate::controllers::ssh::keys::LocalSshKey);
        impl fmt::Display for KeyOption<'_> {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                write!(f, "{} ({})", self.0.key_name(), self.0.fingerprint)
            }
        }
        let options: Vec<KeyOption> = local_keys.iter().map(KeyOption).collect();
        let selected = prompt_select("Which SSH key would you like to register?", options)?;
        selected.0
    };

    println!(
        "Key: {} ({})",
        key_to_register.key_name(),
        key_to_register.fingerprint
    );
    println!();

    let should_register = prompt_confirm_with_default("Register this SSH key with Railway?", true)?;

    if !should_register {
        bail!(
            "SSH key registration required for native SSH access.\n\
               You can also register your key at: https://railway.com/account/ssh-keys"
        );
    }

    register_ssh_key(
        client,
        configs,
        &key_to_register.key_name(),
        &key_to_register.public_key.to_string(),
        None,
    )
    .await?;

    println!("SSH key registered successfully!");

    Ok(identity_for(key_to_register))
}

/// The path to hand `ssh -i` for a registered local key. File-backed keys point
/// at the private key beside the `.pub`; agent-backed keys return `None` (the
/// agent offers them automatically, and there's no file to pass).
fn identity_for(key: &crate::controllers::ssh::keys::LocalSshKey) -> Option<PathBuf> {
    match &key.source {
        SshKeySource::File(path) => {
            if path.extension().and_then(|e| e.to_str()) == Some("pub") {
                Some(path.with_extension(""))
            } else {
                Some(path.to_path_buf())
            }
        }
        SshKeySource::Agent => None,
    }
}

/// Ensure tmux is installed inside the target container.
///
/// Split out from the session loop so that a tmux-install failure is
/// distinguishable from a session connect failure in telemetry.
pub fn ensure_tmux_installed(ssh_target: &str, identity_file: Option<&Path>) -> Result<()> {
    let (host, port) = ssh_relay();
    let target = format!("{ssh_target}@{host}");

    eprintln!("Ensuring tmux is installed...");
    let mut install_cmd = Command::new("ssh");
    apply_relay_port(&mut install_cmd, port);
    if let Some(key) = identity_file {
        install_cmd.arg("-i").arg(key);
    }
    let install = install_cmd
        .args(["-T", &target])
        .arg("which tmux || (apt-get update -qq && apt-get install -y -qq tmux)")
        .stdin(Stdio::inherit())
        .stdout(Stdio::null())
        .stderr(Stdio::inherit())
        .status()
        .context("Failed to check/install tmux")?;

    if !install.success() {
        bail!("Failed to install tmux in the container");
    }

    Ok(())
}

/// Connect to a persistent tmux session, reconnecting on dropped connections.
/// Assumes `ensure_tmux_installed` has already succeeded.
///
/// Returns `Err` only if the local `ssh` binary fails to spawn. The loop
/// itself retries on any non-zero exit until the user disconnects cleanly,
/// which matches users' expectation that a tmux session survives flaps.
pub fn run_tmux_session(
    ssh_target: &str,
    session_name: &str,
    identity_file: Option<&Path>,
) -> Result<()> {
    let (host, port) = ssh_relay();
    let target = format!("{ssh_target}@{host}");
    let tmux_cmd = format!(
        "exec tmux new-session -A -s {} \\; set -g mouse on",
        session_name
    );

    loop {
        let mut session_cmd = Command::new("ssh");
        apply_relay_port(&mut session_cmd, port);
        if let Some(key) = identity_file {
            session_cmd.arg("-i").arg(key);
        }
        let status = session_cmd
            .args(["-t", &target, "--", &tmux_cmd])
            .stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .context("Failed to execute ssh command")?;

        match status.code().unwrap_or(255) {
            0 => break, // clean exit or detach — don't reconnect
            _ => {
                eprintln!("\r\nConnection lost. Reconnecting...");
                sleep(Duration::from_millis(500));
            }
        }
    }

    Ok(())
}

/// Resume request for a relay durable session, delivered via SSH `SetEnv`
/// (the relay intercepts these env keys; they are not forwarded to the VM).
pub struct DurableResume<'a> {
    pub session_name: &'a str,
    /// Resume from the server's last-read cursor instead of replaying the
    /// full retained scrollback.
    pub resume_from_last_read: bool,
}

/// Run SSH command with the given service instance ID.
/// Optionally executes a command instead of starting an interactive shell.
///
/// PTY allocation is autodetected from stdin/stdout TTY state, mirroring
/// the behavior of `docker exec` / `kubectl exec`:
///   - command + both TTYs  → `-t` (vim/htop work)
///   - command + non-TTY    → `-T` (clean pipes for scripts)
///   - no command + TTY     → ssh default (interactive shell with PTY)
///   - no command + non-TTY → `-T` (avoid mangling piped stdin)
pub fn run_native_ssh(
    service_instance_id: &str,
    command: Option<&[String]>,
    identity_file: Option<&Path>,
    durable: Option<DurableResume<'_>>,
) -> Result<i32> {
    run_native_ssh_with_opts(service_instance_id, command, identity_file, durable, &[])
}

/// `run_native_ssh` with extra `ssh` options (each element one argv entry,
/// e.g. `["-o", "ControlMaster=auto"]`). Lets callers opt into connection
/// multiplexing without changing the shared default path.
/// Turn off mouse tracking after an interactive session.
///
/// An agent or editor on the far side enables it for its own use. Exiting
/// cleanly turns it off again; being detached from — which is the normal way to
/// leave a durable session — does not, and the modes stay set on the terminal
/// that gets handed back. The symptom is unmistakable and cannot be cleared
/// from the shell: every pointer movement arrives at the prompt as `35;21;32M`.
pub fn clear_mouse_tracking() {
    use std::io::Write;
    if !std::io::stdout().is_terminal() {
        return;
    }
    let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
    let _ = std::io::stdout().flush();
}

/// Prepare the remote command words for OpenSSH.
///
/// ssh(1) concatenates every remote-command word with single spaces into ONE
/// string and hands that to the remote login shell — argv boundaries do not
/// survive the trip. Passed through raw, a multi-arg invocation like
/// `railway ssh sh -c 'redis-cli -a "$PW" PING && echo ok'` reaches the
/// remote shell as `sh -c redis-cli -a "$PW" PING && echo ok`, so `sh -c`
/// takes only `redis-cli` as its script and the rest leaks into `$0`/`$1`
/// and a second `&&` command.
///
/// Quoting each word when the caller passed MORE than one makes the remote
/// shell re-split back to exactly the caller's argv (`docker exec` /
/// `kubectl exec` semantics). A single word keeps passing through raw: it IS
/// the remote shell line — today's documented usage — and quoting it would
/// collapse the whole line into one command word.
fn quote_remote_command(args: &[String]) -> Vec<String> {
    if args.len() <= 1 {
        return args.to_vec();
    }
    args.iter()
        .map(|arg| match shlex::try_quote(arg) {
            Ok(quoted) => quoted.into_owned(),
            // try_quote only fails on interior NUL bytes, which cannot occur
            // in OS-provided argv; keep the raw word rather than dying.
            Err(_) => arg.clone(),
        })
        .collect()
}

pub fn run_native_ssh_with_opts(
    service_instance_id: &str,
    command: Option<&[String]>,
    identity_file: Option<&Path>,
    durable: Option<DurableResume<'_>>,
    extra_opts: &[String],
) -> Result<i32> {
    let stdin_tty = std::io::stdin().is_terminal();
    let stdout_tty = std::io::stdout().is_terminal();

    let (mut ssh_cmd, target) = base_ssh_command(service_instance_id, identity_file);
    for opt in extra_opts {
        ssh_cmd.arg(opt);
    }

    if let Some(durable) = durable {
        // Both env keys ride a single SetEnv directive: pre-8.7 OpenSSH only
        // honors the first SetEnv it encounters.
        let mut set_env = format!(
            "SetEnv RAILWAY_DURABLE_SESSION_NAME={}",
            durable.session_name
        );
        if durable.resume_from_last_read {
            set_env.push_str(" RAILWAY_DURABLE_RESUME=lastread");
        }
        ssh_cmd.arg("-o").arg(set_env);
    }

    match command {
        Some(_) if stdin_tty && stdout_tty => {
            ssh_cmd.arg("-t");
        }
        Some(_) => {
            ssh_cmd.arg("-T");
        }
        None if !stdin_tty => {
            ssh_cmd.arg("-T");
        }
        None => {}
    }

    ssh_cmd.arg(&target);

    if let Some(cmd_args) = command {
        for arg in quote_remote_command(cmd_args) {
            ssh_cmd.arg(arg);
        }
    }

    ssh_cmd.stdin(Stdio::inherit());
    ssh_cmd.stdout(Stdio::inherit());
    ssh_cmd.stderr(Stdio::inherit());

    let status = ssh_cmd.status().context("Failed to execute ssh command")?;
    Ok(status.code().unwrap_or(1))
}

/// One `-L` style forward: localhost:`local_port` → 127.0.0.1:`remote_port`
/// inside the target.
#[derive(Clone)]
pub struct PortForward {
    pub local_port: u16,
    pub remote_port: u16,
}

/// Run a forward-only SSH session (`ssh -N -L ...`) against the relay.
///
/// The remote side is pinned to loopback — forwards reach ports the target
/// itself listens on, mirroring the `/ws/tcpip` bridge's behavior. Blocks
/// until the connection drops or the user interrupts; a signal-death (Ctrl+C)
/// is reported as exit code 0 since that's the normal way to stop a forward.
pub fn run_native_ssh_forward(
    ssh_target: &str,
    identity_file: Option<&Path>,
    forwards: &[PortForward],
) -> Result<i32> {
    let (mut ssh_cmd, target) = base_ssh_command(ssh_target, identity_file);
    apply_forward_options(&mut ssh_cmd, forwards);
    ssh_cmd.arg(&target);

    // `-N` runs no command; keep stderr inherited so relay/auth errors and
    // per-connection forward failures stay visible.
    ssh_cmd.stdin(Stdio::null());
    ssh_cmd.stdout(Stdio::null());
    ssh_cmd.stderr(Stdio::inherit());

    let status = ssh_cmd.status().context("Failed to execute ssh command")?;
    // `code()` is None when ssh died from a signal — for a forward that's the
    // user's Ctrl+C (delivered to the whole foreground process group).
    Ok(status.code().unwrap_or(0))
}

/// Apply the `-N` forward-mode options and `-L` specs shared by the blocking
/// (`run_native_ssh_forward`) and backgrounded (`spawn_native_ssh_forward`)
/// paths, so their flags can't drift. The remote side of every forward is
/// pinned to the `127.0.0.1` literal — names like `localhost` go through the
/// relay's resolver (which doesn't honor the target's `/etc/hosts`) and resolve
/// to unreachable mesh addresses, so a literal is the only thing that reliably
/// reaches the port the target itself listens on.
fn apply_forward_options(cmd: &mut Command, forwards: &[PortForward]) {
    cmd.args([
        "-N",
        // `-N` runs with stdin closed, so an interactive host-key prompt can't
        // be answered — a fresh machine that hasn't trusted the relay yet would
        // die with "Host key verification failed". accept-new auto-trusts on
        // first contact (TOFU) while still rejecting a *changed* key (MITM
        // protection). The interactive `sandbox ssh` path doesn't need this: it
        // inherits stdin and the user can type "yes".
        "-o",
        "StrictHostKeyChecking=accept-new",
        // Fail loudly if a forward can't be established instead of sitting
        // connected with nothing bound.
        "-o",
        "ExitOnForwardFailure=yes",
        // Long-lived idle forwards: detect a dead relay connection within
        // ~90s instead of hanging until the next local connection fails.
        "-o",
        "ServerAliveInterval=30",
        "-o",
        "ServerAliveCountMax=3",
    ]);

    for forward in forwards {
        cmd.args([
            "-L",
            &format!(
                "127.0.0.1:{}:127.0.0.1:{}",
                forward.local_port, forward.remote_port
            ),
        ]);
    }
}

/// A backgrounded `ssh -N -L` forward. The child ssh process is killed when the
/// guard is dropped, so a caller can keep the tunnel up for the lifetime of a
/// foreground client (e.g. `railway connect` launching psql) and tear it down
/// automatically when that client exits.
pub struct ForwardGuard {
    child: std::process::Child,
}

impl Drop for ForwardGuard {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// Spawn a forward-only SSH session in the background and block until the
/// forwarded local port(s) accept connections, so the caller can immediately
/// point a client at `127.0.0.1:<local_port>` without racing an unbound
/// listener. Returns a guard that kills ssh on drop.
pub fn spawn_native_ssh_forward(
    ssh_target: &str,
    identity_file: Option<&Path>,
    forwards: &[PortForward],
) -> Result<ForwardGuard> {
    // Refuse to start if something already owns a requested local port. The
    // readiness probe below can only prove that *someone* is listening, not
    // that it is our ssh child, so a pre-existing listener would otherwise be
    // accepted as the tunnel and receive whatever the caller sends through it.
    for forward in forwards {
        ensure_local_port_free(forward.local_port)?;
    }

    let (mut ssh_cmd, target) = base_ssh_command(ssh_target, identity_file);
    apply_forward_options(&mut ssh_cmd, forwards);
    ssh_cmd.arg(&target);

    // Backgrounded: no stdin (host-key prompts are handled by accept-new),
    // stderr stays visible so relay/auth errors surface, stdout is unused.
    ssh_cmd.stdin(Stdio::null());
    ssh_cmd.stdout(Stdio::null());
    ssh_cmd.stderr(Stdio::inherit());

    // Detach ssh from the terminal's foreground process group. Ctrl+C in the
    // foreground client (e.g. cancelling a long psql query) is delivered to
    // the whole group, and ssh doesn't trap SIGINT — left in the group, a
    // routine query-cancel would kill the tunnel mid-session. Detached, ssh
    // only goes down via the guard.
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        ssh_cmd.process_group(0);
    }
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
        ssh_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
    }

    let child = ssh_cmd.spawn().context("Failed to spawn ssh forward")?;
    let mut guard = ForwardGuard { child };
    wait_for_forward_ready(&mut guard, forwards)?;
    Ok(guard)
}

/// Fails unless nothing is currently listening on `127.0.0.1:<port>`. Binding
/// is the only portable way to ask; the socket is closed immediately so ssh can
/// take the port, which leaves a small window that the post-probe liveness
/// re-check in `wait_for_forward_ready` closes.
fn ensure_local_port_free(port: u16) -> Result<()> {
    match std::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port))) {
        Ok(listener) => {
            drop(listener);
            Ok(())
        }
        Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => bail!(
            "Local port {port} is already in use. Pick a free port with --port, or stop the process using it.\n\
             Railway will not send traffic to a port it did not open."
        ),
        Err(err) => Err(err).with_context(|| format!("Failed to check local port {port}")),
    }
}

/// Poll the forwarded local ports until they accept a TCP connection. Bails if
/// ssh exits first (e.g. `ExitOnForwardFailure` tripping on a busy local port)
/// or the tunnel doesn't come up within the deadline.
fn wait_for_forward_ready(guard: &mut ForwardGuard, forwards: &[PortForward]) -> Result<()> {
    let deadline = Instant::now() + Duration::from_secs(10);
    loop {
        if let Some(status) = guard.child.try_wait()? {
            bail!("SSH tunnel exited before it was ready ({status})");
        }
        let all_up = forwards.iter().all(|forward| {
            let addr = SocketAddr::from(([127, 0, 0, 1], forward.local_port));
            TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok()
        });
        if all_up {
            // A successful connect only proves someone is listening. ssh reports
            // a failed bind asynchronously, so re-check that our child is still
            // alive before treating the listener as ours — otherwise a port that
            // was taken between the pre-flight check and the bind would be
            // reported as a ready tunnel.
            if let Some(status) = guard.child.try_wait()? {
                bail!(
                    "SSH tunnel exited while coming up ({status}). The local port is owned by another process."
                );
            }
            return Ok(());
        }
        if Instant::now() >= deadline {
            bail!("Timed out waiting for the SSH tunnel to become ready on 127.0.0.1");
        }
        sleep(Duration::from_millis(150));
    }
}

/// Run a non-interactive command on the target with optional bytes fed to its
/// stdin, and stdout + stderr captured. Built for agent-launcher plumbing
/// (`railway code`): secret payloads (credentials) ride stdin so they never
/// appear in an argv, small reads come back on stdout, and stderr comes back
/// so callers can tell transport failures (host key, relay refusal) apart
/// from remote-command failures instead of showing a bare exit code.
pub fn run_native_ssh_captured(
    ssh_target: &str,
    command: &str,
    identity_file: Option<&Path>,
    stdin_payload: Option<&[u8]>,
    extra_opts: &[String],
) -> Result<(i32, Vec<u8>, Vec<u8>)> {
    use std::io::Write;

    let (mut ssh_cmd, target) = base_ssh_command(ssh_target, identity_file);
    for opt in extra_opts {
        ssh_cmd.arg(opt);
    }
    ssh_cmd.arg("-T");
    ssh_cmd.arg(&target);
    ssh_cmd.arg(command);
    ssh_cmd.stdin(if stdin_payload.is_some() {
        Stdio::piped()
    } else {
        Stdio::null()
    });
    ssh_cmd.stdout(Stdio::piped());
    ssh_cmd.stderr(Stdio::piped());

    let mut child = ssh_cmd.spawn().context("Failed to execute ssh command")?;
    if let Some(payload) = stdin_payload {
        let mut stdin = child.stdin.take().expect("stdin was piped");
        stdin.write_all(payload)?;
        // Drop closes the pipe so the remote `cat` sees EOF.
    }
    let output = child.wait_with_output()?;
    Ok((
        output.status.code().unwrap_or(1),
        output.stdout,
        output.stderr,
    ))
}

#[cfg(test)]
mod quote_remote_command_tests {
    use super::*;

    fn args(list: &[&str]) -> Vec<String> {
        list.iter().map(|s| s.to_string()).collect()
    }

    /// The invariant that matters: after ssh joins the words with spaces and
    /// the remote shell re-splits them, the original argv comes back.
    fn remote_shell_split(quoted: &[String]) -> Vec<String> {
        shlex::split(&quoted.join(" ")).expect("quoted words must re-split")
    }

    #[test]
    fn a_single_word_is_the_remote_shell_line_and_passes_through_raw() {
        let input = args(&["redis-cli -a \"$PW\" PING && echo ok"]);
        assert_eq!(quote_remote_command(&input), input);
    }

    #[test]
    fn plain_words_survive_unchanged() {
        let input = args(&["ls", "-la", "/data"]);
        assert_eq!(quote_remote_command(&input), input);
    }

    #[test]
    fn argv_boundaries_survive_the_join_and_remote_resplit() {
        let input = args(&["sh", "-c", "redis-cli -a \"$PW\" PING && echo ok"]);
        let quoted = quote_remote_command(&input);
        assert_eq!(remote_shell_split(&quoted), input);
    }

    #[test]
    fn spaces_quotes_and_dollars_stay_inside_their_word() {
        let input = args(&["echo", "a b", "it's", "$HOME", "x;y|z"]);
        let quoted = quote_remote_command(&input);
        assert_eq!(remote_shell_split(&quoted), input);
    }

    #[test]
    fn empty_words_are_preserved_as_empty_words() {
        let input = args(&["printf", "%s", ""]);
        let quoted = quote_remote_command(&input);
        assert_eq!(remote_shell_split(&quoted), input);
    }
}