Merge branch 'main' of git.arirex.me:iris/ai-app
# 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
This commit is contained in:
commit
3c0214ece8
94 files changed
+8299
-9454
No files matched your search
+53
-70
@@ -1,33 +1,28 @@
|
||||
//! 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".
|
||||
//! 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 without anyone editing anything.
|
||||
//! 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. 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.
|
||||
//! 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.
|
||||
/// 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
|
||||
@@ -36,18 +31,16 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||
("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.
|
||||
/// 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.
|
||||
/// 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!(
|
||||
@@ -58,9 +51,9 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
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.
|
||||
// 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(),
|
||||
@@ -81,8 +74,8 @@ pub async fn discover(transport: &Transport) -> Result<Vec<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.
|
||||
// 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(),
|
||||
@@ -95,16 +88,14 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
|
||||
/// 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.
|
||||
/// 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, rather than
|
||||
/// something this app quietly accepts on their behalf.
|
||||
/// 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") {
|
||||
@@ -123,11 +114,9 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
|
||||
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.
|
||||
/// 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()
|
||||
@@ -163,25 +152,20 @@ pub fn tidy(value: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The inverse of [`tidy`]'s expansion: an absolute path under this
|
||||
/// machine's home, written back as `~/…`.
|
||||
/// 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.
|
||||
///
|
||||
/// So that a working directory reads on a phone the way it is written by
|
||||
/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and
|
||||
/// almost all of it is the part nobody is reading.
|
||||
///
|
||||
/// 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 -- where a `~` somebody wrote
|
||||
/// stays a `~`, and the remote shell is what expands it
|
||||
/// (`ssh::quote_path`).
|
||||
/// 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`.
|
||||
// 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}"),
|
||||
@@ -191,11 +175,10 @@ pub fn shorten_home(path: &str) -> 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" or "Could not resolve hostname" 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.
|
||||
/// 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
|
||||
@@ -209,9 +192,9 @@ impl Transport {
|
||||
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.
|
||||
/// 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 {
|
||||
@@ -223,8 +206,8 @@ mod tests {
|
||||
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.
|
||||
// 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");
|
||||
|
||||
Reference in new issue
Block a user