From a3e6461e742b7441958531f7dfefab99cf6f0f26 Mon Sep 17 00:00:00 2001 From: ado11231 Date: Sat, 17 Oct 2026 23:15:46 -0400 Subject: [PATCH 1/2] feat: forward the Agent's ports while run or attach is connected A server started on the Agent should open in the Client's browser with no setup. While run or attach is connected, the Client asks the Agent for ports every 2 seconds and adds each one to the shared ssh connection with ssh -O forward, on 127.0.0.1 only, moving to the next free port when one is taken. Each port is claimed with a lock so only one Ally command forwards it, and a command still running takes over when the holder ends. Sessions show the ports in the bar. --- crates/ally-cli/src/commands/attach.rs | 11 + crates/ally-cli/src/commands/run.rs | 3 + crates/ally-cli/src/forward.rs | 484 +++++++++++++++++++++++++ crates/ally-cli/src/lib.rs | 1 + crates/ally-cli/src/ssh.rs | 37 +- 5 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 crates/ally-cli/src/forward.rs diff --git a/crates/ally-cli/src/commands/attach.rs b/crates/ally-cli/src/commands/attach.rs index 927b7ad..8e82153 100644 --- a/crates/ally-cli/src/commands/attach.rs +++ b/crates/ally-cli/src/commands/attach.rs @@ -2,6 +2,7 @@ //! current project's copy, or in the Agent's home folder when there is no project. use crate::client::{self, Refused, unexpected}; +use crate::forward::{Forwarding, Shown}; use crate::project::{self, Local}; use crate::route; use crate::ssh::{self, RemoteCommand}; @@ -54,6 +55,15 @@ pub async fn attach( &place, style.colored(), ); + let forwarding = Forwarding::start( + target, + Shown::Session { + tmux: session.tmux.clone(), + socket: session.socket.clone(), + id: session.job.id.clone(), + label: label.clone(), + }, + ); let mut remote = RemoteCommand::to( target, session.tmux, @@ -67,6 +77,7 @@ pub async fn attach( presentation::row("Return", "ally attach").trim_end() ); let code = super::run::interact(target, &remote, lost).await?; + forwarding.stop().await; if pulls_after(code) && let Err(error) = after_leaving(target, local.as_ref(), &session.job.id).await { diff --git a/crates/ally-cli/src/commands/run.rs b/crates/ally-cli/src/commands/run.rs index 6520a60..a5ee3ac 100644 --- a/crates/ally-cli/src/commands/run.rs +++ b/crates/ally-cli/src/commands/run.rs @@ -1,6 +1,7 @@ //! `ally run `: sync the project, then run a command in the Agent copy. use crate::client::{self, Refused}; +use crate::forward::{Forwarding, Shown}; use crate::project::{self, Local}; use crate::route; use crate::ssh::{Disconnected, RemoteCommand}; @@ -62,7 +63,9 @@ pub async fn run(agent: Option, cmd: Vec) -> anyhow::Result ); let remote = RemoteCommand::to(target, target.program().to_string(), args); + let forwarding = Forwarding::start(target, Shown::Terminal { raw: remote.tty }); let code = interact(target, &remote, lost_connection(&target.name)).await?; + forwarding.stop().await; eprintln!("\n{}", finished(code, started.elapsed(), style)); Ok(code) } diff --git a/crates/ally-cli/src/forward.rs b/crates/ally-cli/src/forward.rs new file mode 100644 index 0000000..49cccad --- /dev/null +++ b/crates/ally-cli/src/forward.rs @@ -0,0 +1,484 @@ +//! Automatic port forwarding while `ally run` or `ally attach` is connected. The Agent +//! reports the ports its work listens on, each one is added to the shared ssh connection +//! with `ssh -O forward`, and removed with `ssh -O cancel` once it closes or the command ends. + +use crate::client::Control; +use crate::ssh::RemoteCommand; +use ally_core::config::Agent; +use ally_core::control::{Listening, Request, Response}; +use ally_core::preflight; +use ally_core::presentation::{Style, Tone}; +use ally_core::storage; +use std::collections::BTreeMap; +use std::fs::File; +use std::net::{Ipv6Addr, SocketAddr, TcpListener}; +use std::path::Path; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +/// How often the Agent is asked for its ports. The first time waits too, so `attach` has +/// labeled the session before its bar is redrawn. +const POLL_EVERY: Duration = Duration::from_secs(2); + +/// How many ports after a taken one are tried before the system picks one. +const NEARBY: u16 = 100; + +/// Where news of a forward goes. +pub enum Shown { + /// Lines in this terminal. `raw` when ssh holds the terminal in raw mode, where a line + /// needs its own carriage return. + Terminal { raw: bool }, + /// The bar and a short message inside a tmux session on the Agent, since tmux owns the + /// screen there. `label` is the bar without ports. + Session { + tmux: String, + socket: String, + id: String, + label: String, + }, +} + +impl Shown { + /// tmux shows text as it is, so only the terminal gets color. + fn style(&self) -> Style { + match self { + Shown::Terminal { .. } => Style::stderr(), + Shown::Session { .. } => Style::new(false), + } + } + + /// Show this round's news. A session's bar is redrawn whenever the forwards changed. + async fn tell( + &self, + agent: &Agent, + news: &[String], + forwards: &BTreeMap, + changed: bool, + ) { + match self { + Shown::Terminal { raw } => { + for text in news { + print(text, *raw); + } + } + Shown::Session { + tmux, + socket, + id, + label, + } if changed || !news.is_empty() => { + let mut remote = RemoteCommand::to( + agent, + tmux.clone(), + session_args(socket, id, &bar(label, forwards), news), + ); + remote.tty = false; + let _ = tokio::process::Command::new("ssh") + .args(remote.to_ssh_args()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .status() + .await; + } + Shown::Session { .. } => {} + } + } +} + +/// A port forwarded from the Agent: where it listens here, the `-L` value that removes it +/// again, and the claim that keeps other Ally commands from forwarding it too. +struct Forward { + local: u16, + spec: String, + _claim: File, +} + +/// Forwarding running in the background until `stop`, or until it is dropped. +pub struct Forwarding { + stop: oneshot::Sender<()>, + task: JoinHandle<()>, +} + +impl Forwarding { + pub fn start(agent: &Agent, shown: Shown) -> Forwarding { + let (stop, stopped) = oneshot::channel(); + let task = tokio::spawn(forward(agent.clone(), shown, stopped)); + Forwarding { stop, task } + } + + /// Remove every forward before returning, so none outlives the command. + pub async fn stop(self) { + let _ = self.stop.send(()); + let _ = tokio::time::timeout(Duration::from_secs(5), self.task).await; + } +} + +async fn forward(agent: Agent, shown: Shown, mut stopped: oneshot::Receiver<()>) { + let style = shown.style(); + let mut forwards: BTreeMap = BTreeMap::new(); + let mut remote = RemoteCommand::to(&agent, String::new(), Vec::new()); + remote.tty = false; + let Some(socket) = remote.shared.clone() else { + let text = off( + "no private folder could hold the shared ssh connection\n Check that the temporary folder can be written", + style, + ); + shown.tell(&agent, &[text], &forwards, false).await; + return; + }; + let mut control = match Control::connect(&agent).await { + Ok(control) => control, + Err(error) => { + let text = off(&format!("{error:#}"), style); + shown.tell(&agent, &[text], &forwards, false).await; + return; + } + }; + let mut failed: Vec = Vec::new(); + let mut first = true; + loop { + tokio::select! { + _ = &mut stopped => break, + _ = tokio::time::sleep(POLL_EVERY) => {} + } + let ports = match control.call(Request::Ports).await { + Ok(Response::Ports(ports)) => ports, + Ok(_) => break, + Err(error) => { + if first { + let text = off(&format!("{error:#}"), style); + shown.tell(&agent, &[text], &forwards, false).await; + } + break; + } + }; + first = false; + let mut news = Vec::new(); + let mut changed = false; + let open: Vec = ports.iter().map(|listening| listening.port).collect(); + let closed: Vec = forwards + .keys() + .filter(|port| !open.contains(port)) + .copied() + .collect(); + for port in closed { + if let Some(forward) = forwards.remove(&port) { + remote.change_forward("cancel", &forward.spec).await; + changed = true; + } + } + failed.retain(|port| open.contains(port)); + for listening in ports { + if forwards.contains_key(&listening.port) || failed.contains(&listening.port) { + continue; + } + let Some(claim) = claim(&socket, listening.port) else { + continue; + }; + remote + .change_forward("cancel", &spec(listening.port, &listening)) + .await; + let wanted = listening.port; + let local = tokio::task::spawn_blocking(move || free_port(wanted)) + .await + .ok() + .flatten(); + let spec = local.map(|local| spec(local, &listening)); + match (local, spec) { + (Some(local), Some(spec)) if remote.change_forward("forward", &spec).await => { + news.push(added(&agent.name, &listening, local, style)); + forwards.insert( + listening.port, + Forward { + local, + spec, + _claim: claim, + }, + ); + changed = true; + } + _ => { + news.push(not_forwarded(&agent.name, &listening, style)); + failed.push(listening.port); + } + } + } + shown.tell(&agent, &news, &forwards, changed).await; + } + for forward in forwards.values() { + remote.change_forward("cancel", &forward.spec).await; + } + control.close().await; +} + +/// Claim an Agent port for this process. Every Ally command to the same Agent shares one +/// ssh connection, so a port another command already forwards is left to it. The claim is +/// a lock that the system drops when its process ends, however it ends, so the next round +/// of a command still running takes the port over. A command killed by a signal leaves its +/// forward in the shared connection, so holding the claim also means any forward of the +/// port to the same port here is left over, and is removed first. +fn claim(socket: &Path, port: u16) -> Option { + storage::try_lock(&socket.with_extension(format!("{port}.lock"))) + .ok() + .flatten() +} + +fn off(reason: &str, style: Style) -> String { + style.status(format!("Port forwarding is off: {reason}"), Tone::Warning) +} + +fn not_forwarded(agent: &str, listening: &Listening, style: Style) -> String { + style.status( + format!( + "Could not forward {agent}:{} to this machine\n Restart the server on the Agent to try again", + listening.port + ), + Tone::Warning, + ) +} + +fn print(text: &str, raw: bool) { + match raw { + true => eprint!("\r{}\r\n", text.replace('\n', "\r\n")), + false => eprintln!("{text}"), + } +} + +/// The session bar with the forwarded ports, as in `▶ archbox via tailnet · app · +/// localhost:5173 :3001`. +fn bar(label: &str, forwards: &BTreeMap) -> String { + let ports: Vec = forwards + .values() + .enumerate() + .map(|(index, forward)| match index { + 0 => format!("localhost:{}", forward.local), + _ => format!(":{}", forward.local), + }) + .collect(); + match ports.is_empty() { + true => label.to_string(), + false => format!("{label} · {}", ports.join(" ")), + } +} + +/// One tmux call that redraws the bar and, when there is news, shows it for 4 seconds. +/// `#` starts tmux formatting, so it is dropped from the news, which names programs. +fn session_args(socket: &str, id: &str, bar: &str, news: &[String]) -> Vec { + let pane = format!("={id}:"); + let mut args: Vec = ["-u", "-S", socket, "set-option", "-t", &pane, "@ally", bar] + .map(String::from) + .to_vec(); + if !news.is_empty() { + let message = news.join(" · ").replace("\n ", " · ").replace('#', ""); + args.extend( + [";", "display-message", "-t", &pane, "-d", "4000", &message].map(String::from), + ); + } + args +} + +/// `▶ Forwarding archbox:5173 to localhost:5173 · node`, with a second line when the +/// port was taken here. A Docker port names its container. +fn added(agent: &str, listening: &Listening, local: u16, style: Style) -> String { + let owner = match listening.job { + Some(_) => listening.program.clone(), + None => format!("Docker {}", listening.program), + }; + let mut text = format!( + "Forwarding {} to {} · {owner}", + style.paint(format!("{agent}:{}", listening.port), Tone::Info), + style.paint(format!("localhost:{local}"), Tone::Info) + ); + if local != listening.port { + text.push_str(&format!("\n Port {} is in use here", listening.port)); + } + style.status(text, Tone::Info) +} + +/// ssh's `-L` value. The forward listens on this machine's loopback address only, and +/// reaches the server at the address it listens on, or at loopback when it listens on all. +fn spec(local: u16, listening: &Listening) -> String { + let target = match listening.address.as_str() { + "0.0.0.0" => "127.0.0.1".to_string(), + "::" => "[::1]".to_string(), + address if address.contains(':') => format!("[{address}]"), + address => address.to_string(), + }; + format!("127.0.0.1:{local}:{target}:{}", listening.port) +} + +/// The port to listen on here: `wanted` when nothing on this machine uses it, else the +/// next free one nearby, else one the system picks, which also covers ports below 1024 +/// that only an administrator may open. +fn free_port(wanted: u16) -> Option { + (wanted..=wanted.saturating_add(NEARBY)) + .filter(|port| *port != 0) + .find(|port| free(*port)) + .or_else(|| { + let listener = TcpListener::bind(("127.0.0.1", 0)).ok()?; + listener.local_addr().ok().map(|address| address.port()) + }) +} + +/// Whether nothing here uses `port`. Something answering on either loopback address +/// counts, since a browser tries `localhost` on IPv6 first, and a server listening on +/// every address may not stop this process from opening the port too. +fn free(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).is_ok() + && !preflight::is_listening(SocketAddr::from(([127, 0, 0, 1], port))) + && !preflight::is_listening(SocketAddr::from((Ipv6Addr::LOCALHOST, port))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn listening(address: &str, port: u16, job: Option<&str>, program: &str) -> Listening { + Listening { + port, + address: address.to_string(), + program: program.to_string(), + job: job.map(str::to_string), + } + } + + #[test] + fn forwards_listen_on_loopback_and_reach_the_servers_own_address() { + for (address, expected) in [ + ("127.0.0.1", "127.0.0.1:3001:127.0.0.1:3000"), + ("0.0.0.0", "127.0.0.1:3001:127.0.0.1:3000"), + ("::1", "127.0.0.1:3001:[::1]:3000"), + ("::", "127.0.0.1:3001:[::1]:3000"), + ("100.67.90.119", "127.0.0.1:3001:100.67.90.119:3000"), + ] { + assert_eq!( + spec(3001, &listening(address, 3000, Some("ab12"), "node")), + expected + ); + } + } + + #[test] + fn a_taken_port_moves_to_the_next_free_one() { + let taken = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = taken.local_addr().unwrap().port(); + let chosen = free_port(port).unwrap(); + assert_ne!(chosen, port); + assert!(TcpListener::bind(("127.0.0.1", chosen)).is_ok()); + } + + fn forward(local: u16) -> Forward { + Forward { + local, + spec: String::new(), + _claim: tempfile(), + } + } + + fn tempfile() -> File { + let path = std::env::temp_dir().join(format!("ally-test-{}", storage::new_id())); + let file = File::create(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + file + } + + /// Two commands to one Agent must not both forward a port, and a claim ends with its holder. + #[test] + fn only_one_holder_may_claim_a_port_until_it_lets_go() { + let socket = std::env::temp_dir().join(format!("ally-test-{}.sock", storage::new_id())); + let first = claim(&socket, 5173).unwrap(); + assert!(claim(&socket, 5173).is_none()); + assert!(claim(&socket, 3000).is_some()); + drop(first); + assert!(claim(&socket, 5173).is_some()); + let _ = std::fs::remove_file(socket.with_extension("5173.lock")); + let _ = std::fs::remove_file(socket.with_extension("3000.lock")); + } + + #[test] + fn the_bar_lists_where_each_port_opens_here() { + let mut forwards = BTreeMap::new(); + assert_eq!(bar("▶ archbox · app", &forwards), "▶ archbox · app"); + forwards.insert(5173, forward(5173)); + forwards.insert(3000, forward(3001)); + assert_eq!( + bar("▶ archbox · app", &forwards), + "▶ archbox · app · localhost:3001 :5173" + ); + } + + #[test] + fn session_news_is_one_message_without_tmux_formatting() { + let quiet = session_args("/s", "ab12", "bar", &[]); + assert_eq!( + quiet, + [ + "-u", + "-S", + "/s", + "set-option", + "-t", + "=ab12:", + "@ally", + "bar" + ] + ); + let news = [ + "▶ Forwarding archbox:3000 to localhost:3001 · node\n Port 3000 is in use here" + .to_string(), + "▶ Forwarding archbox:5432 to localhost:5432 · Docker #db".to_string(), + ]; + let args = session_args("/s", "ab12", "bar", &news); + assert_eq!( + args[8..], + [ + ";", + "display-message", + "-t", + "=ab12:", + "-d", + "4000", + "▶ Forwarding archbox:3000 to localhost:3001 · node · Port 3000 is in use here · ▶ Forwarding archbox:5432 to localhost:5432 · Docker db" + ] + ); + } + + #[test] + fn the_line_names_both_ends_and_who_listens() { + let style = Style::new(false); + assert_eq!( + added( + "archbox", + &listening("127.0.0.1", 5173, Some("ab12"), "node"), + 5173, + style + ), + "▶ Forwarding archbox:5173 to localhost:5173 · node" + ); + assert_eq!( + added( + "archbox", + &listening("0.0.0.0", 3000, Some("ab12"), "node"), + 3001, + style + ), + "▶ Forwarding archbox:3000 to localhost:3001 · node\n Port 3000 is in use here" + ); + assert_eq!( + added( + "archbox", + &listening("0.0.0.0", 5432, None, "db"), + 5432, + style + ), + "▶ Forwarding archbox:5432 to localhost:5432 · Docker db" + ); + assert_eq!( + not_forwarded("archbox", &listening("0.0.0.0", 3000, None, "db"), style), + "! Could not forward archbox:3000 to this machine\n Restart the server on the Agent to try again" + ); + } +} diff --git a/crates/ally-cli/src/lib.rs b/crates/ally-cli/src/lib.rs index b555e28..d8ad703 100644 --- a/crates/ally-cli/src/lib.rs +++ b/crates/ally-cli/src/lib.rs @@ -2,6 +2,7 @@ mod client; mod commands; +mod forward; mod keys; mod live; mod project; diff --git a/crates/ally-cli/src/ssh.rs b/crates/ally-cli/src/ssh.rs index ef2dc51..51888b4 100644 --- a/crates/ally-cli/src/ssh.rs +++ b/crates/ally-cli/src/ssh.rs @@ -167,7 +167,7 @@ impl RemoteCommand { return; } let _ = std::process::Command::new("ssh") - .args(self.share_exit_args()) + .args(self.master_args(&["exit"])) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -190,14 +190,30 @@ impl RemoteCommand { argv } - /// Ask the process holding the shared connection to end it. - fn share_exit_args(&self) -> Vec { + /// Ask the process holding the shared connection to do `request`, such as `exit`. + fn master_args(&self, request: &[&str]) -> Vec { let mut argv = self.connection_args(); let destination = argv.pop().unwrap_or_default(); - argv.extend(["-O".to_string(), "exit".to_string(), destination]); + argv.push("-O".to_string()); + argv.extend(request.iter().map(|part| part.to_string())); + argv.push(destination); argv } + /// Add (`forward`) or remove (`cancel`) a port forward on the shared connection while it + /// runs, where `spec` is ssh's `-L` value. Returns whether ssh did it. + pub async fn change_forward(&self, operation: &str, spec: &str) -> bool { + Command::new("ssh") + .args(self.master_args(&[operation, "-L", spec])) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status() + .await + .is_ok_and(|status| status.success()) + } + /// Who to log in as and where, in the form ssh expects. fn destination(&self) -> String { match &self.user { @@ -727,11 +743,22 @@ mod tests { assert_eq!(&check[..4], ["-o", "ControlMaster=no", "-W", "127.0.0.1:9"]); assert!(!check.contains(&"-t".to_string()), "{check:?}"); assert_eq!(check.last(), Some(&command.destination())); - let exit = command.share_exit_args(); + let exit = command.master_args(&["exit"]); assert_eq!( exit[exit.len() - 3..], ["-O".to_string(), "exit".to_string(), command.destination()] ); + let forward = command.master_args(&["forward", "-L", "127.0.0.1:3001:127.0.0.1:3000"]); + assert_eq!( + forward[forward.len() - 5..], + [ + "-O", + "forward", + "-L", + "127.0.0.1:3001:127.0.0.1:3000", + &command.destination() + ] + ); } #[test] From 8f50b0d1fd47fa2b3989f53cc059805bdb311157 Mon Sep 17 00:00:00 2001 From: ado11231 Date: Sat, 17 Oct 2026 23:17:27 -0400 Subject: [PATCH 2/2] docs: port forwarding during run and attach --- docs/ARCHITECTURE.md | 9 +++++---- docs/ROADMAP.md | 12 +++++++++++- docs/TROUBLESHOOTING.md | 3 +++ docs/USAGE.md | 10 ++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d43f59b..c217fa1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -321,9 +321,10 @@ ally/ | `lib.rs` | Reads the command line with `clap`, calls the command, and defines the hidden `internal-` helpers. | every command; Agent `lib`, `runner`, `service` | `main` | | `main.rs` | The `ally` program. Calls `lib`. | `lib` | none, it is the entry point | | `build.rs` | Puts the build's commit into `--version`, using `git` when present. | `git` | `lib` | -| `client.rs` | The pairing connection, and the `Control` connection over `ssh`. | `route`, `ssh`, `tunnel` | `transfer`, `watch`, most commands | +| `client.rs` | The pairing connection, and the `Control` connection over `ssh`. | `route`, `ssh`, `tunnel` | `forward`, `transfer`, `watch`, most commands | | `route.rs` | Chooses the path: local network, tailnet, or iroh. Prints the `▶` location line. | none | `client`, `ssh`, `transfer`, `watch`, `run`, `attach`, `tools` | -| `ssh.rs` | Builds every `ssh` call, with safe quoting and a connection shared on every route. | `route`, `tunnel`, `source` | `client`, `run`, `attach`, `unlink` | +| `ssh.rs` | Builds every `ssh` call, with safe quoting and a connection shared on every route. | `route`, `tunnel`, `source` | `client`, `forward`, `run`, `attach`, `unlink` | +| `forward.rs` | Port forwarding while `run` or `attach` is connected: asks the Agent for ports, adds and removes them on the shared ssh connection, and claims each one so only one command forwards it. | `client`, `ssh`, `storage`, `preflight` | `run`, `attach` | | `tunnel.rs` | `ally internal-tunnel`: the Client's end of an iroh connection. | `project` | `client`, `ssh` | | `transfer.rs` | Performs a sync: lists files, plans, calls `rsync`, and reports. | `client`, `project`, `route` | `run`, `attach`, `sync` | | `project.rs` | Finds the current project and its ID on each Agent. | none | `transfer`, `tunnel`, most commands | @@ -343,8 +344,8 @@ ally/ | `tools.rs` | `ally tools`, and the tools step of `link` | `client`, `project`, `route`, `ssh`, `run` | | `uninstall.rs` | `ally uninstall`: the Client's settings, key, app, and links, plus the Agent's part on an Agent | `keys`, `menubar`, `unlink`, Agent `uninstall` | | `unlink.rs` | `ally unlink` | `client`, `project`, `ssh` | -| `run.rs` | `ally run` | `client`, `project`, `route`, `ssh`, `transfer` | -| `attach.rs` | `ally attach` | `client`, `project`, `route`, `ssh`, `transfer`, `run` | +| `run.rs` | `ally run` | `client`, `forward`, `project`, `route`, `ssh`, `transfer` | +| `attach.rs` | `ally attach` | `client`, `forward`, `project`, `route`, `ssh`, `transfer`, `run` | | `sync.rs` | `ally sync` | `project`, `transfer` | | `env.rs` | `ally env` | `client`, `project` | | `projects.rs` | `ally projects`, `clean`, and `remove` | `client`, `project`, `ps` | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2d6b3c2..bd39bda 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -66,6 +66,7 @@ | Sync | `--overwrite` keeps one side of a conflict, with a backup. Pulls refuse names that differ only in case. A Python `.venv` works on the Agent. | | Output | Problems on one line with dimmed fixes. `link` and `tools` in sections. Stopped runs are not failed, and `ps --all` shows `Lost connection`. Version errors name the older machine. | | Removal | `ally uninstall` lists everything on a Client or Agent, asks once with No as the default, and keeps the owner's own files and keys. | +| Port forwarding | While `run` or `attach` is connected, every port that Ally's work listens on, and every port a Docker container publishes, opens at `localhost` on the Client. A taken port moves to the next free one. Sessions show ports in the bar. | | Projects | `ally projects` lists each copy on the Agent with its size, build output, last sync, and edits waiting. `clean` frees build output, and `remove` deletes a copy after asking. | ## Planned @@ -93,7 +94,7 @@ * `ally.toml` supports only `sync.exclude`. * Linking needs a shared network or tailnet. * The back online notification and resource warnings have not been seen on real machines. -* A dev server or container on the Agent cannot open in the Client's browser yet ([#9](https://github.com/ado11231/ally/issues/9)). +* Port forwarding is tested only over the local network, and not with a Docker container running. `ally forward`, `ally ports`, and the menu bar's port list are not built yet ([#9](https://github.com/ado11231/ally/issues/9)). * The tools step on an account without `sudo` is checked only by unit tests. * Installing Ollama and OpenCode through the tools step is checked only by unit tests. On archbox, the step found both already installed. @@ -196,3 +197,12 @@ 1. With archbox's service killed by `kill -9` while a shared iroh connection was open, the next `run` took 29.4 seconds before the fix and 3.8 seconds after. 2. Commands back to back over iroh went from 0.30 to 0.44 seconds on average. Runs over the local network and tailnet are unchanged. + +### Port Forwarding: October 17, 2026, Local Network + +1. A second command over the local network took 0.07 seconds instead of 0.27, through the shared connection. +2. A Python server started with `ally run` answered at `localhost:8765` on the Mac about 2 seconds after it started. +3. With 8766 taken on the Mac, the Agent's 8766 opened at 8767 with `Port 8766 is in use here`. +4. In a session, the bar showed `localhost:8768` and the message appeared. Leaving removed the forward. +5. Two runs at once forwarded each port once. When the run holding a port was killed, the other took it over on the same port. +6. In a real terminal, the forwarding line printed cleanly during the run. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 71389a0..3d42034 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -103,6 +103,9 @@ | `! Did not bring back edits: NOTES.md changed on both this machine and archbox` | The same files changed in both places. Both versions are kept. | Follow the conflict row under [Syncing Files](#syncing-files), then `ally sync --pull`. | | `RAM is 92% used` or `Only 1.2 GiB free on the Agent workspace disk` | A warning. The run still starts, but may be slow or fail. | Free memory or disk on the Agent. | | `No swap configured` | No swap, the disk space used when RAM runs out. A large job may be stopped when memory fills. | Add swap on the Agent. | +| `! Port forwarding is off: ss is not installed on the Agent` | A Linux Agent finds ports with `ss`. Commands still run. | Run the install command it shows, then run again. | +| `! Port forwarding is off: no private folder could hold the shared ssh connection` | Forwarding needs a private folder in the temporary folder. | Check that the temporary folder can be written. | +| `! Could not forward archbox:3000 to this machine` | ssh refused the forward. | Restart the server on the Agent to try again. | | `Live views need an interactive terminal` | `ally health --watch` redraws the screen. | Run it in a terminal, or use `ally health` to pipe the output. | ## Syncing Files diff --git a/docs/USAGE.md b/docs/USAGE.md index 3911a5a..01f445a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -137,6 +137,16 @@ ally attach [path] | Leave it running | Ctrl B, then D | | End it | `exit`, or `ally stop ` | +### Open A Server In Your Browser + +* While `ally run` or `ally attach` is connected, a server started on the Agent opens at the same port on the Client: `▶ Forwarding archbox:5173 to localhost:5173 · node`. +* This covers every port that Ally's work listens on, and every port a Docker container publishes. +* If the port is in use on the Client, the next free one is used, and a second line says so. +* In a session, the bar lists the ports, and a short message shows each new one. +* Forwards answer only on the Client itself, and end when the port closes or the command ends. +* With several Ally commands open, one of them forwards each port and prints its line. +* To reach a server again after leaving its session, run `ally attach`. + ## Keep Files In Step * `run` and `attach` sync for you. `sync` does it alone.