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:
1 parent
ef019a7aea
commit
19e3531c5d
5 files changed
+519
-31
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user