The markdown had accumulated a lot that was stale rather than wrong. PLAN.md still described pi as the llama.cpp harness, a refcounted LlamaServerManager, and a providers-by-hosts cross-product, all of which were superseded or never built; it also carried a second copy of the HTTP table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held implementation checklists for work that has since landed. AGENTS.md restated most of PLAN.md's design instead of being the working-notes layer it says it is. 3225 lines of markdown to 2180, with the stale sections gone rather than reworded. On the server, comments explaining what the code already says are out and the ones recording a constraint, a measurement or an incident are kept but cut to a few lines each: 5504 comment lines to 4586. Four doc comments in session/mod.rs, and one each in process.rs and usage.rs, had drifted onto the item above the one they describe -- functions were reordered without them, so `stop_session`'s doc sat on `set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on `type Cached`. Each is back on its own item. routes.rs's module table also claimed later phases would add `/hosts`, which setups replaced. cargo test (127 passed), clippy --all-targets and fmt are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
8.6 KiB
Rust
213 lines
8.6 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),
|
|
("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. `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");
|
|
}
|
|
}
|