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
+201
-17
@@ -4,6 +4,11 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /setups machines, each with what it can run
|
||||
//! POST /setups add {name, ssh?} -- providers are discovered
|
||||
//! POST /setups/probe dry run {ssh?}: what would be found there
|
||||
//! GET /setups/{id} one machine, for refetching after a change
|
||||
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
|
||||
//! DELETE /setups/{id} remove, refused while sessions use it
|
||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||
//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?}
|
||||
//! GET /sessions/{id}/events?after=N SSE: transcript replay from N, then live
|
||||
@@ -45,7 +50,12 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
|
||||
|
||||
pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
Router::new()
|
||||
.route("/setups", get(list_setups))
|
||||
.route("/setups", get(list_setups).post(add_setup))
|
||||
.route("/setups/probe", post(probe_setup))
|
||||
.route(
|
||||
"/setups/{id}",
|
||||
get(read_setup).put(update_setup).delete(delete_setup),
|
||||
)
|
||||
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||
.route("/sessions/{id}", delete(delete_session))
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
@@ -122,6 +132,9 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SetupInfo {
|
||||
/// Stable; what a session stores and what these routes address.
|
||||
id: String,
|
||||
/// The editable label.
|
||||
name: String,
|
||||
/// Where it runs, for telling two setups apart. Absent for the one
|
||||
/// that is this machine.
|
||||
@@ -139,25 +152,196 @@ struct ProviderInfo {
|
||||
}
|
||||
|
||||
async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> {
|
||||
axum::Json(
|
||||
manager
|
||||
.setups()
|
||||
axum::Json(manager.setups().into_iter().map(info_for).collect())
|
||||
}
|
||||
|
||||
fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
|
||||
SetupInfo {
|
||||
id: setup.id,
|
||||
name: setup.name,
|
||||
address: setup.ssh.map(|ssh| ssh.address),
|
||||
providers: setup
|
||||
.providers
|
||||
.into_iter()
|
||||
.map(|setup| SetupInfo {
|
||||
name: setup.name,
|
||||
address: setup.ssh.map(|ssh| ssh.address),
|
||||
providers: setup
|
||||
.providers
|
||||
.into_iter()
|
||||
.map(|provider| ProviderInfo {
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
models: provider.models,
|
||||
})
|
||||
.collect(),
|
||||
.map(|provider| ProviderInfo {
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
models: provider.models,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How to reach a machine, as the phone describes it.
|
||||
///
|
||||
/// Note what is absent: nothing here names a program. Providers are found
|
||||
/// by asking the machine (`crate::setups`), never sent, so the enrolled
|
||||
/// token cannot introduce something to run.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SshRequest {
|
||||
address: String,
|
||||
#[serde(default)]
|
||||
port: Option<u16>,
|
||||
/// A path on the *backend*, not a key itself: private keys do not
|
||||
/// travel, so this names one that must already be there.
|
||||
#[serde(default)]
|
||||
identity_file: Option<String>,
|
||||
#[serde(default)]
|
||||
options: Vec<String>,
|
||||
}
|
||||
|
||||
impl SshRequest {
|
||||
/// Tidied at the boundary rather than stored as typed -- this came
|
||||
/// from a phone keyboard, so it may have a stray space or a `~`.
|
||||
fn into_config(self) -> Result<crate::config::SshConfig, ApiError> {
|
||||
let address = crate::setups::tidy(&self.address)
|
||||
.ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?;
|
||||
Ok(crate::config::SshConfig {
|
||||
address,
|
||||
port: self.port,
|
||||
identity_file: self
|
||||
.identity_file
|
||||
.as_deref()
|
||||
.and_then(crate::setups::tidy)
|
||||
.map(std::path::PathBuf::from),
|
||||
options: self
|
||||
.options
|
||||
.iter()
|
||||
.filter_map(|o| crate::setups::tidy(o))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AddSetupRequest {
|
||||
name: String,
|
||||
/// Absent means this machine.
|
||||
#[serde(default)]
|
||||
ssh: Option<SshRequest>,
|
||||
}
|
||||
|
||||
/// What a machine turned out to have, without saving anything.
|
||||
///
|
||||
/// The point of trying before committing: a wrong address or an
|
||||
/// unauthorised key is caught while the person is still looking at the
|
||||
/// form that caused it, rather than at the first spawn.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProbeRequest {
|
||||
#[serde(default)]
|
||||
ssh: Option<SshRequest>,
|
||||
}
|
||||
|
||||
async fn probe_setup(
|
||||
axum::Json(body): axum::Json<ProbeRequest>,
|
||||
) -> Result<axum::Json<Vec<ProviderInfo>>, ApiError> {
|
||||
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
|
||||
let providers = probe(ssh, "this setup").await?;
|
||||
Ok(axum::Json(
|
||||
providers
|
||||
.into_iter()
|
||||
.map(|provider| ProviderInfo {
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
models: provider.models,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Asks the machine an `ssh` block describes -- or this one -- what it has.
|
||||
///
|
||||
/// `label` only ever appears in a failure message, so a probe of an
|
||||
/// unsaved form can still say which machine would not answer.
|
||||
async fn probe(
|
||||
ssh: Option<crate::config::SshConfig>,
|
||||
label: &str,
|
||||
) -> Result<Vec<crate::config::ProviderConfig>, ApiError> {
|
||||
let transport = match ssh {
|
||||
Some(ssh) => crate::session::transport::Transport::Ssh {
|
||||
name: label.to_string(),
|
||||
ssh,
|
||||
},
|
||||
None => crate::session::transport::Transport::Here,
|
||||
};
|
||||
crate::setups::discover(&transport)
|
||||
.await
|
||||
.map_err(bad_request)
|
||||
}
|
||||
|
||||
async fn add_setup(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
axum::Json(body): axum::Json<AddSetupRequest>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
|
||||
// Ask the machine being added what it has, before writing anything --
|
||||
// so a bad address fails here rather than leaving a setup that can
|
||||
// never spawn.
|
||||
let providers = probe(ssh.clone(), &body.name).await?;
|
||||
let setup = manager
|
||||
.add_setup(&body.name, ssh, providers)
|
||||
.map_err(bad_request)?;
|
||||
Ok(axum::Json(info_for(setup)))
|
||||
}
|
||||
|
||||
async fn read_setup(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
manager
|
||||
.setups()
|
||||
.into_iter()
|
||||
.find(|setup| setup.id == id)
|
||||
.map(|setup| axum::Json(info_for(setup)))
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateSetupRequest {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// Ask the machine again what it has -- after installing something
|
||||
/// there, or when a binary moved.
|
||||
#[serde(default)]
|
||||
rediscover: bool,
|
||||
}
|
||||
|
||||
async fn update_setup(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<UpdateSetupRequest>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
let providers = if body.rediscover {
|
||||
let existing = manager
|
||||
.setups()
|
||||
.into_iter()
|
||||
.find(|setup| setup.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&existing);
|
||||
Some(
|
||||
crate::setups::discover(&transport)
|
||||
.await
|
||||
.map_err(bad_request)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let setup = manager
|
||||
.update_setup(&id, body.name.as_deref(), providers)
|
||||
.map_err(bad_request)?;
|
||||
Ok(axum::Json(info_for(setup)))
|
||||
}
|
||||
|
||||
async fn delete_setup(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
manager.delete_setup(&id).map_err(bad_request)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
Reference in new issue
Block a user