# Conflicts: # AGENTS.md # PLAN.md # app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt # app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt # server/src/config.rs # server/src/main.rs # server/src/routes.rs # server/src/session/echo.rs # server/src/session/llama.rs # server/src/session/transport.rs # server/src/ssh.rs # server/src/usage.rs
216 lines
8.7 KiB
Rust
216 lines
8.7 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 could introduce
|
|
//! arbitrary programs to run on every machine a setup names.
|
|
//!
|
|
//! 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.
|
|
//!
|
|
//! The cost is that a program somewhere unusual is invisible. The escape hatch
|
|
//! is editing `config.ron` on the backend, which is exactly the authority the
|
|
//! phone is not being given.
|
|
|
|
use anyhow::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),
|
|
// Named for the program rather than for where it runs: it runs
|
|
// wherever the setup is, and "local" was true only while a llama
|
|
// session could not be spawned on another machine.
|
|
("llama-cpp", "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. `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.
|
|
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. 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, 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.
|
|
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 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(),
|
|
})
|
|
}
|
|
|
|
/// The inverse of [`tidy`]'s expansion: an absolute path under this machine's
|
|
/// home, written back as `~/…`, so that a working directory reads on a phone the
|
|
/// way it is written by hand.
|
|
///
|
|
/// Applied only to paths on **this** machine. `$HOME` here says nothing about
|
|
/// the home directory of a machine reached over ssh, so a remote path is stored
|
|
/// exactly as it was typed and the remote shell is what expands it.
|
|
pub fn shorten_home(path: &str) -> String {
|
|
let Some(home) = std::env::home_dir() else {
|
|
return path.to_string();
|
|
};
|
|
let home = home.to_string_lossy();
|
|
// The separator has to be part of the match, or `/home/bobby` would be read
|
|
// as a path inside `/home/bob`.
|
|
match path.strip_prefix(home.as_ref()) {
|
|
Some("") => "~".to_string(),
|
|
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
|
|
_ => path.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Runs a launch to completion and returns its stdout as text.
|
|
///
|
|
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
|
|
/// failure reported as the machine's own words (ssh's "Permission denied" is the
|
|
/// useful half of why a setup cannot be reached), and the output read as text
|
|
/// because every caller here is asking a question whose answer is words.
|
|
impl Transport {
|
|
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
|
let captured = self
|
|
.capture_with_input(launch, super::session::transport::Input::None)
|
|
.await?;
|
|
Ok(String::from_utf8_lossy(&captured.ok()?).into_owned())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The two halves of a home-relative path, which have to be inverses: what
|
|
/// is stored is what the phone draws, and what the phone sends back is what
|
|
/// a process is started in.
|
|
#[test]
|
|
fn a_home_path_shortens_and_expands_back() {
|
|
let Some(home) = std::env::home_dir() else {
|
|
return;
|
|
};
|
|
let full = home.join("repos/ai-app-2");
|
|
let full = full.to_string_lossy();
|
|
assert_eq!(shorten_home(&full), "~/repos/ai-app-2");
|
|
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
|
|
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
|
|
|
|
// Not a prefix match on the characters: a sibling directory whose name
|
|
// merely starts with the home directory's is not inside it.
|
|
let sibling = format!("{}-backup/notes", home.to_string_lossy());
|
|
assert_eq!(shorten_home(&sibling), sibling);
|
|
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
|
|
}
|
|
}
|