Files
ai-app/server/src/setups.rs
T
irisandClaude Sonnet 5 927b638feb Cap concurrent ssh connections a batch opens at once
Deleting or importing several Claude Code sessions fired one ssh
process per item, all in the same tick. A large enough batch opened
more connections than the remote sshd's default MaxStartups tolerates
before it starts randomly refusing, so some rows failed with
"Connection closed by ... port 2222" -- not a real delete failure,
just too many handshakes landing at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 00:23:23 -04:00

192 lines
7.8 KiB
Rust

//! 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.map_err(explain)?;
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)
}
/// Adds what to do to failures whose own wording does not say.
///
/// ssh's messages are written for someone at a terminal on the backend,
/// which is exactly who is not reading this one. Host key verification is
/// the case that matters: **every** machine fails it the first time,
/// because its key is not in `known_hosts` yet -- so without this, adding
/// a machine from the phone looks broken rather than unfinished.
///
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking`
/// stays at its default, so a first connection is a decision somebody
/// makes on the backend with the key in front of them, rather than
/// something this app quietly accepts on their behalf.
fn explain(err: anyhow::Error) -> anyhow::Error {
let message = format!("{err:#}");
if message.contains("Host key verification failed") {
return anyhow::anyhow!(
"{message} This machine has not been connected to before, so its key is not \
trusted yet. Ssh to it once from the backend -- that is where the decision to \
trust a key belongs -- and try again.",
);
}
if message.contains("Permission denied") {
return anyhow::anyhow!(
"{message} The key named here has to be authorized on that machine, and the path \
is read on the backend rather than on the phone.",
);
}
err
}
/// 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> {
// See `CAPTURE_PERMITS`: caps how many of these run their ssh
// connection at once, so a batch doesn't open more than the remote
// sshd tolerates before it starts dropping them.
let _permit = super::session::transport::CAPTURE_PERMITS
.acquire()
.await
.expect("capture semaphore is never closed");
let child = self.spawn(launch, super::session::transport::Streams::Piped)?;
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())
}
}