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:
irisandClaude Opus 5 committed 2026-08-28 13:08:44 -04:00
1 parent ef019a7aea
commit 19e3531c5d
5 files changed
+519 -31

No files matched your search

+26 -7
View File
@@ -104,8 +104,12 @@ pub struct Config {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupConfig {
/// What the spawn screen shows and sessions store. Unique; renaming
/// one orphans the sessions that reference it.
/// Stable identifier, minted when the setup is added and never
/// changed. Sessions reference this rather than the label, so
/// renaming a machine on the phone does not orphan its sessions --
/// which is the whole reason the two are separate fields.
pub id: String,
/// The label a person reads and may edit.
pub name: String,
/// How to reach it, absent for this machine. A setup with no `ssh` is
/// where the server itself runs.
@@ -202,7 +206,8 @@ pub struct TokenEntry {
pub struct SessionConfig {
/// Stable identifier; names the session's directory and its routes.
pub id: String,
/// Name of the [`SetupConfig`] this session runs on.
/// Id of the [`SetupConfig`] this session runs on -- the id, not the
/// label, so the machine can be renamed without losing its sessions.
pub setup: String,
/// Name of the provider within that setup. Both stored by name rather
/// than resolved, so an edited setup (a new command path, another
@@ -247,9 +252,19 @@ pub struct SessionConfig {
/// a choice.
pub const ECHO_PROVIDER: &str = "echo";
pub const LOCAL_SETUP: &str = "this machine";
/// The id of the setup a fresh install seeds. Fixed rather than random so
/// a hand-written config can name it without looking one up.
pub const LOCAL_SETUP_ID: &str = "local";
impl Config {
pub fn setup(&self, name: &str) -> Option<&SetupConfig> {
pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.id == id)
}
/// A setup by the label a person sees, for messages and for the one
/// place a name still arrives from outside: nothing else should look
/// one up this way, since labels are editable and ids are not.
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.name == name)
}
@@ -261,6 +276,7 @@ impl Config {
/// remote machine would be a choice that changes nothing.
pub fn seed() -> SetupConfig {
SetupConfig {
id: LOCAL_SETUP_ID.to_string(),
name: LOCAL_SETUP.to_string(),
ssh: None,
providers: vec![
@@ -380,7 +396,8 @@ mod tests {
setups: vec![
Config::seed(),
SetupConfig {
name: "vm".to_string(),
id: "vm".to_string(),
name: "the vm".to_string(),
ssh: Some(SshConfig {
address: "bob@10.0.2.15".to_string(),
port: Some(2222),
@@ -412,6 +429,8 @@ mod tests {
let loaded = Config::load(&path).expect("reload");
assert_eq!(loaded.tokens[0].name, "phone");
assert_eq!(loaded.sessions[0].setup, "vm");
// The label and the id are separate, and the session holds the id.
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm");
assert_eq!(loaded.sessions[0].provider, "claude-cli");
assert_eq!(
loaded
@@ -427,12 +446,12 @@ mod tests {
// collision: names are unique within a setup and only within one.
assert!(
loaded
.setup(LOCAL_SETUP)
.setup(LOCAL_SETUP_ID)
.expect("local")
.provider("claude-cli")
.is_some()
);
assert!(loaded.setup(LOCAL_SETUP).expect("local").ssh.is_none());
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
// The house rule both halves of `format` depend on: what is written
// is the *body* of the struct, with no outer parentheses and