Add, rename and remove machines from the phone -- without letting it name commands

The gap PLAN.md recorded: setups were readable but only hand-editable, so
adding a machine meant a shell on the backend.

**The design decision, made with Bryan, is that the phone never composes a
command.** A setup carries providers, and a provider carries something to
run -- so a route that accepted a command from the request body would make
the enrolled token arbitrary code execution on every machine a setup names,
and the transport already reaches those over ssh. Instead the phone sends
connection details, and the server asks the machine itself what it has:
one `command -v` round trip per setup, matched against a table of the
drivers this server knows. The phone's authority is "add this machine",
never "run this".

Worth recording that this was a narrower change than it first appeared: the
token could already run anything on the backend, because the spawn screen
offers `bypassPermissions` with a free-text working directory. Discovery
does not close that door. What it does is keep the *list of what can run*
out of the phone's reach, and make adding a machine a thing you cannot get
wrong by typing.

It is also simply better to use. Nobody wants to type an absolute path on a
phone keyboard, and a machine whose binaries have moved answers correctly
on the next probe. The cost is that a program somewhere unusual is
invisible -- `command -v` follows PATH under a non-interactive ssh session,
which is not the PATH a person sees when they log in. That is the trade,
and the escape hatch is editing config.ron on the backend, which is exactly
the authority the phone is not being given.

Setups now have an **id separate from their label**, so renaming a machine
does not orphan the sessions that name it; a session stores the id, and
every row resolves the current label when it is built. `POST /setups/probe`
tries a machine without saving anything, so a wrong address or an
unauthorised key is caught while the form that caused it is still on
screen. Deleting is refused while sessions still run there, and says which
ones rather than cascading.

Every mutation goes through one `update`: clone, apply, save, then commit,
so a failed write leaves the previous state intact and reports why.

Verified against a running server, including a real ssh machine (this VM,
via a throwaway loopback key since removed): probing here found echo and
claude-cli; probing over ssh found claude-cli and correctly no echo, which
runs in-process and exists only where this server does; an unreachable
machine came back with ssh's own words ("connect to host ... Connection
timed out"); adding derived the id `loopback-vm` from "loopback vm";
renaming kept the id; deleting was refused while a session used it, naming
it, and succeeded once nothing did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 13:08:44 -04:00
1 parent ef019a7aea
commit 19e3531c5d
5 files changed
+508 -20

No files matched your search

+26 -7
View File
@@ -104,8 +104,12 @@ pub struct Config {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupConfig {
/// What the spawn screen shows and sessions store. Unique; renaming
/// one orphans the sessions that reference it.
/// Stable identifier, minted when the setup is added and never
/// changed. Sessions reference this rather than the label, so
/// renaming a machine on the phone does not orphan its sessions --
/// which is the whole reason the two are separate fields.
pub id: String,
/// The label a person reads and may edit.
pub name: String,
/// How to reach it, absent for this machine. A setup with no `ssh` is
/// where the server itself runs.
@@ -202,7 +206,8 @@ pub struct TokenEntry {
pub struct SessionConfig {
/// Stable identifier; names the session's directory and its routes.
pub id: String,
/// Name of the [`SetupConfig`] this session runs on.
/// Id of the [`SetupConfig`] this session runs on -- the id, not the
/// label, so the machine can be renamed without losing its sessions.
pub setup: String,
/// Name of the provider within that setup. Both stored by name rather
/// than resolved, so an edited setup (a new command path, another
@@ -247,9 +252,19 @@ pub struct SessionConfig {
/// a choice.
pub const ECHO_PROVIDER: &str = "echo";
pub const LOCAL_SETUP: &str = "this machine";
/// The id of the setup a fresh install seeds. Fixed rather than random so
/// a hand-written config can name it without looking one up.
pub const LOCAL_SETUP_ID: &str = "local";
impl Config {
pub fn setup(&self, name: &str) -> Option<&SetupConfig> {
pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.id == id)
}
/// A setup by the label a person sees, for messages and for the one
/// place a name still arrives from outside: nothing else should look
/// one up this way, since labels are editable and ids are not.
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.name == name)
}
@@ -261,6 +276,7 @@ impl Config {
/// remote machine would be a choice that changes nothing.
pub fn seed() -> SetupConfig {
SetupConfig {
id: LOCAL_SETUP_ID.to_string(),
name: LOCAL_SETUP.to_string(),
ssh: None,
providers: vec![
@@ -380,7 +396,8 @@ mod tests {
setups: vec![
Config::seed(),
SetupConfig {
name: "vm".to_string(),
id: "vm".to_string(),
name: "the vm".to_string(),
ssh: Some(SshConfig {
address: "bob@10.0.2.15".to_string(),
port: Some(2222),
@@ -412,6 +429,8 @@ mod tests {
let loaded = Config::load(&path).expect("reload");
assert_eq!(loaded.tokens[0].name, "phone");
assert_eq!(loaded.sessions[0].setup, "vm");
// The label and the id are separate, and the session holds the id.
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm");
assert_eq!(loaded.sessions[0].provider, "claude-cli");
assert_eq!(
loaded
@@ -427,12 +446,12 @@ mod tests {
// collision: names are unique within a setup and only within one.
assert!(
loaded
.setup(LOCAL_SETUP)
.setup(LOCAL_SETUP_ID)
.expect("local")
.provider("claude-cli")
.is_some()
);
assert!(loaded.setup(LOCAL_SETUP).expect("local").ssh.is_none());
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
// The house rule both halves of `format` depend on: what is written
// is the *body* of the struct, with no outer parentheses and
+1
View File
@@ -21,6 +21,7 @@ mod models;
mod private;
mod routes;
mod session;
mod setups;
mod ssh;
mod usage;
+190 -6
View File
@@ -4,6 +4,11 @@
//!
//! ```text
//! GET /setups machines, each with what it can run
//! POST /setups add {name, ssh?} -- providers are discovered
//! POST /setups/probe dry run {ssh?}: what would be found there
//! GET /setups/{id} one machine, for refetching after a change
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
//! DELETE /setups/{id} remove, refused while sessions use it
//! GET /sessions list (id, provider, title, model, status, last activity)
//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?}
//! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live
@@ -45,7 +50,12 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
pub fn router(manager: Arc<SessionManager>) -> Router {
Router::new()
.route("/setups", get(list_setups))
.route("/setups", get(list_setups).post(add_setup))
.route("/setups/probe", post(probe_setup))
.route(
"/setups/{id}",
get(read_setup).put(update_setup).delete(delete_setup),
)
.route("/sessions", get(list_sessions).post(spawn_session))
.route("/sessions/{id}", delete(delete_session))
.route("/sessions/{id}/events", get(events))
@@ -122,6 +132,9 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SetupInfo {
/// Stable; what a session stores and what these routes address.
id: String,
/// The editable label.
name: String,
/// Where it runs, for telling two setups apart. Absent for the one
/// that is this machine.
@@ -139,11 +152,12 @@ struct ProviderInfo {
}
async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> {
axum::Json(
manager
.setups()
.into_iter()
.map(|setup| SetupInfo {
axum::Json(manager.setups().into_iter().map(info_for).collect())
}
fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
SetupInfo {
id: setup.id,
name: setup.name,
address: setup.ssh.map(|ssh| ssh.address),
providers: setup
@@ -155,9 +169,179 @@ async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<V
models: provider.models,
})
.collect(),
}
}
/// How to reach a machine, as the phone describes it.
///
/// Note what is absent: nothing here names a program. Providers are found
/// by asking the machine (`crate::setups`), never sent, so the enrolled
/// token cannot introduce something to run.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SshRequest {
address: String,
#[serde(default)]
port: Option<u16>,
/// A path on the *backend*, not a key itself: private keys do not
/// travel, so this names one that must already be there.
#[serde(default)]
identity_file: Option<String>,
#[serde(default)]
options: Vec<String>,
}
impl SshRequest {
/// Tidied at the boundary rather than stored as typed -- this came
/// from a phone keyboard, so it may have a stray space or a `~`.
fn into_config(self) -> Result<crate::config::SshConfig, ApiError> {
let address = crate::setups::tidy(&self.address)
.ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?;
Ok(crate::config::SshConfig {
address,
port: self.port,
identity_file: self
.identity_file
.as_deref()
.and_then(crate::setups::tidy)
.map(std::path::PathBuf::from),
options: self
.options
.iter()
.filter_map(|o| crate::setups::tidy(o))
.collect(),
})
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AddSetupRequest {
name: String,
/// Absent means this machine.
#[serde(default)]
ssh: Option<SshRequest>,
}
/// What a machine turned out to have, without saving anything.
///
/// The point of trying before committing: a wrong address or an
/// unauthorised key is caught while the person is still looking at the
/// form that caused it, rather than at the first spawn.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProbeRequest {
#[serde(default)]
ssh: Option<SshRequest>,
}
async fn probe_setup(
axum::Json(body): axum::Json<ProbeRequest>,
) -> Result<axum::Json<Vec<ProviderInfo>>, ApiError> {
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
let providers = probe(ssh, "this setup").await?;
Ok(axum::Json(
providers
.into_iter()
.map(|provider| ProviderInfo {
name: provider.name,
kind: provider.kind,
models: provider.models,
})
.collect(),
))
}
/// Asks the machine an `ssh` block describes -- or this one -- what it has.
///
/// `label` only ever appears in a failure message, so a probe of an
/// unsaved form can still say which machine would not answer.
async fn probe(
ssh: Option<crate::config::SshConfig>,
label: &str,
) -> Result<Vec<crate::config::ProviderConfig>, ApiError> {
let transport = match ssh {
Some(ssh) => crate::session::transport::Transport::Ssh {
name: label.to_string(),
ssh,
},
None => crate::session::transport::Transport::Here,
};
crate::setups::discover(&transport)
.await
.map_err(bad_request)
}
async fn add_setup(
State(manager): State<Arc<SessionManager>>,
axum::Json(body): axum::Json<AddSetupRequest>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
// Ask the machine being added what it has, before writing anything --
// so a bad address fails here rather than leaving a setup that can
// never spawn.
let providers = probe(ssh.clone(), &body.name).await?;
let setup = manager
.add_setup(&body.name, ssh, providers)
.map_err(bad_request)?;
Ok(axum::Json(info_for(setup)))
}
async fn read_setup(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
manager
.setups()
.into_iter()
.find(|setup| setup.id == id)
.map(|setup| axum::Json(info_for(setup)))
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateSetupRequest {
#[serde(default)]
name: Option<String>,
/// Ask the machine again what it has -- after installing something
/// there, or when a binary moved.
#[serde(default)]
rediscover: bool,
}
async fn update_setup(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<UpdateSetupRequest>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
let providers = if body.rediscover {
let existing = manager
.setups()
.into_iter()
.find(|setup| setup.id == id)
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))?;
let transport = crate::session::transport::Transport::for_setup(&existing);
Some(
crate::setups::discover(&transport)
.await
.map_err(bad_request)?,
)
} else {
None
};
let setup = manager
.update_setup(&id, body.name.as_deref(), providers)
.map_err(bad_request)?;
Ok(axum::Json(info_for(setup)))
}
async fn delete_setup(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
manager.delete_setup(&id).map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
+137 -7
View File
@@ -25,7 +25,9 @@ use anyhow::{Context, Result, bail};
use serde::Serialize;
use tokio::sync::{broadcast, mpsc};
use crate::config::{Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, TokenEntry};
use crate::config::{
Config, DriverKind, ProviderConfig, SessionConfig, SetupConfig, SshConfig, TokenEntry,
};
use claude::ClaudeDriver;
use driver::{Driver, Event, ImageRef, SessionStatus};
use echo::EchoDriver;
@@ -64,8 +66,12 @@ pub struct SpawnSpec {
pub struct SessionInfo {
pub id: String,
pub provider: String,
/// The machine it runs on.
/// Id of the machine it runs on, which is what the session stored.
pub setup: String,
/// That machine's current label, resolved when this row is built --
/// so renaming a setup renames it everywhere it appears, rather than
/// leaving old sessions showing the old name.
pub setup_name: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
@@ -167,11 +173,14 @@ impl LiveSession {
Ok(name)
}
fn info(&self) -> SessionInfo {
/// `setup_name` is passed in rather than stored: only the manager
/// holds the config, and the label can change under a running session.
fn info(&self, setup_name: &str) -> SessionInfo {
SessionInfo {
id: self.meta.id.clone(),
provider: self.meta.provider.clone(),
setup: self.meta.setup.clone(),
setup_name: setup_name.to_string(),
title: self.meta.title.clone(),
model: self.shared.model.lock().unwrap().clone(),
cwd: self.meta.cwd.clone(),
@@ -259,6 +268,119 @@ impl SessionManager {
Ok(())
}
/// The one path by which the config changes.
///
/// Clone, apply, save, and only then commit: a failed write leaves
/// what was already there and reports why, so what this server
/// believes and what is on disk cannot come apart. The ordering is
/// the whole trick -- mutating in place and then saving would leave a
/// server that had accepted a change nothing on disk records.
fn update<T>(&self, apply: impl FnOnce(&mut Config) -> Result<T>) -> Result<T> {
let mut inner = self.inner.write().unwrap();
let mut candidate = inner.config.clone();
let outcome = apply(&mut candidate)?;
candidate.save(&self.config_path)?;
inner.config = candidate;
Ok(outcome)
}
/// Adds a machine with the providers it was found to have.
///
/// `providers` comes from probing rather than from the caller (see
/// `crate::setups`), which is why this takes them as an argument: the
/// probe is async and this is not, so the route does the asking and
/// this does the writing.
pub fn add_setup(
&self,
name: &str,
ssh: Option<SshConfig>,
providers: Vec<ProviderConfig>,
) -> Result<SetupConfig> {
let name = name.trim().to_string();
if name.is_empty() {
bail!("a setup needs a name");
}
self.update(|config| {
if config.setup_named(&name).is_some() {
bail!("there is already a setup called \"{name}\"");
}
// Ids are derived once and then fixed, so a label can be
// edited later without orphaning the sessions that named it.
let mut id = crate::setups::id_from(&name);
while config.setup(&id).is_some() {
id = format!("{id}-{}", &random_hex()[..4]);
}
let setup = SetupConfig {
id,
name: name.clone(),
ssh,
providers,
};
config.setups.push(setup.clone());
Ok(setup)
})
}
/// Renames a machine, or replaces what was discovered on it.
pub fn update_setup(
&self,
id: &str,
name: Option<&str>,
providers: Option<Vec<ProviderConfig>>,
) -> Result<SetupConfig> {
self.update(|config| {
if let Some(name) = name {
let name = name.trim();
if name.is_empty() {
bail!("a setup needs a name");
}
if config.setups.iter().any(|s| s.name == name && s.id != id) {
bail!("there is already a setup called \"{name}\"");
}
}
let setup = config
.setups
.iter_mut()
.find(|setup| setup.id == id)
.with_context(|| format!("no setup with id \"{id}\""))?;
if let Some(name) = name {
setup.name = name.trim().to_string();
}
if let Some(providers) = providers {
setup.providers = providers;
}
Ok(setup.clone())
})
}
/// Removes a machine, provided nothing is still running on it.
///
/// Refused rather than cascaded: deleting a machine should not
/// silently kill conversations, and the person asking is better placed
/// to decide which of those sessions they still want.
pub fn delete_setup(&self, id: &str) -> Result<()> {
self.update(|config| {
if config.setup(id).is_none() {
bail!("no setup with id \"{id}\"");
}
let using: Vec<&str> = config
.sessions
.iter()
.filter(|session| session.setup == id)
.map(|session| session.title.as_str())
.collect();
if !using.is_empty() {
bail!(
"{} session(s) still run on it: {}. Delete them first.",
using.len(),
using.join(", "),
);
}
config.setups.retain(|setup| setup.id != id);
Ok(())
})
}
pub fn tokens(&self) -> Vec<TokenEntry> {
self.inner.read().unwrap().config.tokens.clone()
}
@@ -299,10 +421,11 @@ impl SessionManager {
.sessions
.iter()
.map(|meta| match inner.live.get(&meta.id) {
Some(session) => session.info(),
Some(session) => session.info(label_of(&inner.config, &meta.setup)),
None => SessionInfo {
id: meta.id.clone(),
setup: meta.setup.clone(),
setup_name: label_of(&inner.config, &meta.setup).to_string(),
provider: meta.provider.clone(),
title: meta.title.clone(),
model: meta.model.clone(),
@@ -357,7 +480,7 @@ impl SessionManager {
.unwrap_or_else(|| format!("{} session", provider.name));
let meta = SessionConfig {
id: id.clone(),
setup: setup.name.clone(),
setup: setup.id.clone(),
provider: provider.name.clone(),
title,
model: spec.model.or_else(|| provider.models.first().cloned()),
@@ -385,7 +508,7 @@ impl SessionManager {
return Err(err);
}
inner.config = candidate;
let info = session.info();
let info = session.info(&setup.name);
inner.live.insert(id, session);
Ok(info)
}
@@ -450,6 +573,13 @@ fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, Provid
Ok((setup.clone(), provider.clone()))
}
/// A setup's current label, or its id when the setup has been deleted --
/// which is what a session left behind by a removed machine shows, and is
/// better than an empty column or a guess at what it used to be called.
fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str {
config.setup(id).map_or(id, |setup| setup.name.as_str())
}
/// Names for a failure message: what there is, so the reader can see what
/// they meant instead of only that they were wrong.
fn names<'a>(all: impl Iterator<Item = &'a str>) -> String {
@@ -577,7 +707,7 @@ mod tests {
fn echo_spec() -> SpawnSpec {
SpawnSpec {
params: Default::default(),
setup: crate::config::LOCAL_SETUP.to_string(),
setup: crate::config::LOCAL_SETUP_ID.to_string(),
provider: crate::config::ECHO_PROVIDER.to_string(),
title: None,
model: None,
+154
View File
@@ -0,0 +1,154 @@
//! Finding out what a machine can run, rather than being told.
//!
//! The phone adds a machine by giving connection details; this asks the
//! machine itself which of the known programs it has, and the answer
//! becomes its providers. That is a security property, not a convenience:
//! **no route accepts a command from the phone.** If it did, the enrolled
//! token would be able to introduce arbitrary programs to run on every
//! machine a setup names, and the transport already reaches those over
//! ssh. Here the phone's authority is "add this machine", never "run
//! this".
//!
//! It is also the better interface. Nobody wants to type an absolute path
//! on a phone keyboard, and a machine that has moved its binaries answers
//! correctly on the next probe without anyone editing anything.
//!
//! The cost is that a program somewhere unusual is invisible. That is a
//! deliberate trade rather than an oversight: the escape hatch is editing
//! `config.ron` on the backend, which is exactly the authority the phone
//! is not being given.
use anyhow::{Context, Result};
use crate::config::{DriverKind, ProviderConfig};
use crate::session::transport::{Launch, Transport};
/// What is looked for, and what finding it makes.
///
/// Extending this is how a new driver becomes discoverable -- one row, not
/// a branch anywhere. The name is what the provider gets called, so it is
/// what the phone shows and what a session stores.
const PROBES: &[(&str, &str, DriverKind)] = &[
("claude-cli", "claude", DriverKind::ClaudeCli),
("local-llama", "llama-server", DriverKind::LlamaCpp),
];
/// Models offered for a discovered Claude CLI. A shortcut list for the
/// spawn screen, not a restriction -- the field stays free text.
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
/// Asks `transport`'s machine which of [`PROBES`] it has.
///
/// One round trip rather than one per program: over ssh each would be a
/// separate connection and handshake, and a person waiting on "test this
/// setup" notices. `command -v` is POSIX and a shell builtin, so it works
/// whatever is installed -- and `|| true` keeps a missing program from
/// ending the loop, since the caller wants the whole answer rather than
/// the first failure.
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
let script = format!(
"for p in {}; do command -v \"$p\" || true; done",
wanted.join(" ")
);
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
let found = transport.capture(&launch).await?;
let mut providers = Vec::new();
// Echo runs inside this server, so it exists exactly where this server
// does and nowhere else. Nothing to probe for, and offering it on a
// remote machine would be a choice that changes nothing.
if matches!(transport, Transport::Here) {
providers.push(ProviderConfig {
name: crate::config::ECHO_PROVIDER.to_string(),
kind: DriverKind::Echo,
command: None,
models: Vec::new(),
});
}
for (name, binary, kind) in PROBES {
let path = found
.lines()
.map(str::trim)
.find(|line| line.rsplit('/').next() == Some(*binary));
let Some(path) = path else {
continue;
};
providers.push(ProviderConfig {
name: (*name).to_string(),
kind: *kind,
// The resolved path rather than the bare name: PATH under a
// non-interactive ssh session is not the one a person sees
// when they log in, so "it is on my PATH" is not enough.
command: Some(path.to_string()),
models: match kind {
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
_ => Vec::new(),
},
});
}
Ok(providers)
}
/// A short, stable, filename-safe id derived from a label.
///
/// Derived once when a setup is added and then fixed, so the label stays
/// editable. Collisions are resolved by the caller, which is the only
/// place that knows what already exists.
pub fn id_from(label: &str) -> String {
let slug: String = label
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let slug = slug.trim_matches('-').replace("--", "-");
if slug.is_empty() {
crate::session::random_hex()
} else {
slug.chars().take(32).collect()
}
}
/// Normalises what a phone keyboard produced: trims, drops blanks, and
/// expands a leading `~` the way a shell would.
pub fn tidy(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
Some(match value.strip_prefix("~/") {
Some(rest) => match std::env::home_dir() {
Some(home) => home.join(rest).to_string_lossy().into_owned(),
None => value.to_string(),
},
None => value.to_string(),
})
}
/// Runs a launch to completion and returns its stdout.
impl Transport {
pub async fn capture(&self, launch: &Launch) -> Result<String> {
let child = self.spawn(launch)?;
let output = child
.wait_with_output()
.await
.context("waiting for the probe to finish")?;
if !output.status.success() {
// ssh's own failures land on stderr -- "Permission denied",
// "Could not resolve hostname" -- and are the useful half of
// why a setup cannot be reached, so they are what comes back.
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
anyhow::bail!(if stderr.is_empty() {
format!("couldn't reach it ({})", output.status)
} else {
stderr
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
}