Rename setups and add provider reauthentication

This commit is contained in:
iris committed 2026-09-12 22:56:43 -04:00
1 parent e9a0f1b9da
commit 7d9df5d572
36 files changed
+1866 -684

No files matched your search

+78 -38
View File
@@ -28,7 +28,11 @@ pub struct Config {
/// credential. A list (of one, today) so per-device tokens with individual
/// revocation are a config entry later, not a migration.
pub tokens: Vec<TokenEntry>,
pub setups: Vec<SetupConfig>,
/// `setups` is the persisted spelling before the machine/provider boundary
/// was named correctly. Read it once so an update does not discard the
/// machines already configured; every subsequent write uses `machines`.
#[serde(alias = "setups")]
pub machines: Vec<MachineConfig>,
pub sessions: Vec<SessionConfig>,
/// What a new session's thinking level is when nothing chose one.
///
@@ -52,8 +56,8 @@ pub struct Config {
/// host and offered the whole cross-product.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupConfig {
/// Stable identifier, minted when the setup is added and never
pub struct MachineConfig {
/// Stable identifier, minted when the machine 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.
@@ -62,19 +66,19 @@ pub struct SetupConfig {
/// How to reach it, absent for this machine.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<SshConfig>,
/// What can be spawned here. Names are unique within a setup, and only
/// What can be spawned here. Names are unique within a machine, and only
/// within it: two machines may each have a `claude-cli`, which is the point.
#[serde(default)]
pub providers: Vec<ProviderConfig>,
}
impl SetupConfig {
impl MachineConfig {
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
self.providers.iter().find(|provider| provider.name == name)
}
}
/// One thing a setup can run: which driver, and how to invoke it.
/// One thing a machine can run: which driver, and how to invoke it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
@@ -100,7 +104,7 @@ impl ProviderConfig {
}
}
/// How to reach a setup that isn't this machine, with the system `ssh` client
/// How to reach a machine that isn't this machine, with the system `ssh` client
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
/// one place to configure connections. A remote session is the identical
/// command with `ssh host …` in front, and nothing downstream knows.
@@ -302,12 +306,15 @@ pub struct TokenEntry {
pub struct SessionConfig {
/// Stable identifier; names the session's directory and its routes.
pub id: String,
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
/// Id of the [`MachineConfig`] 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 takes effect on the next relaunch; a session
/// whose setup or provider is gone reports as exited and can still be
/// `setup` is accepted only as the on-disk migration from builds that used
/// that word for a machine. The API and newly written records say `machine`.
#[serde(alias = "setup")]
pub machine: String,
/// Name of the provider within that machine. Both stored by name rather than
/// resolved, so an edited machine takes effect on the next relaunch; a session
/// whose machine or provider is gone reports as exited and can still be
/// deleted.
pub provider: String,
pub title: String,
@@ -419,17 +426,17 @@ fn not_set(flag: &bool) -> bool {
!*flag
}
/// The name of the echo provider, and of the setup this machine gets on first
/// The name of the echo provider, and of the local machine created on first
/// run.
///
/// Echo is seeded into the config rather than conjured at read time. An
/// implicit provider is one a person cannot see in the file or edit from the
/// phone; if somebody deletes it, that was 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
pub const LOCAL_MACHINE: &str = "this machine";
/// The id of the machine 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";
pub const LOCAL_MACHINE_ID: &str = "local";
/// Where `ai-server --enroll-link` leaves a token for the running server to
/// adopt: beside the config, since it is config in transit.
@@ -438,15 +445,15 @@ pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
}
impl Config {
pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.id == id)
pub fn machine(&self, id: &str) -> Option<&MachineConfig> {
self.machines.iter().find(|machine| machine.id == id)
}
/// A setup by the label a person sees, for messages and for the one place a
/// A machine 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)
pub fn machine_named(&self, name: &str) -> Option<&MachineConfig> {
self.machines.iter().find(|machine| machine.name == name)
}
/// This machine, offering whatever was found on it.
@@ -455,10 +462,10 @@ impl Config {
/// be *discovered*: a hardcoded list is a claim about what is installed, and
/// this one was wrong -- every fresh install asserted a `claude-cli`
/// provider whether or not `claude` existed.
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
SetupConfig {
id: LOCAL_SETUP_ID.to_string(),
name: LOCAL_SETUP.to_string(),
pub fn seed(providers: Vec<ProviderConfig>) -> MachineConfig {
MachineConfig {
id: LOCAL_MACHINE_ID.to_string(),
name: LOCAL_MACHINE.to_string(),
ssh: None,
providers,
}
@@ -533,11 +540,11 @@ mod tests {
let path = dir.path().join("config.ron");
// A missing file is the ordinary first-run state, not an error.
// Nothing is conjured to fill it: the seed setup is written by the
// Nothing is conjured to fill it: the seed machine is written by the
// manager, so the file always says what there is.
let first_run = Config::load(&path).expect("load");
assert!(first_run.tokens.is_empty());
assert!(first_run.setups.is_empty());
assert!(first_run.machines.is_empty());
assert!(first_run.sessions.is_empty());
let config = Config {
@@ -545,7 +552,7 @@ mod tests {
name: "phone".to_string(),
sha256: "ab".repeat(32),
}],
setups: vec![
machines: vec![
Config::seed(vec![
Config::echo_provider(),
ProviderConfig {
@@ -555,7 +562,7 @@ mod tests {
models: Vec::new(),
},
]),
SetupConfig {
MachineConfig {
id: "vm".to_string(),
name: "the vm".to_string(),
ssh: Some(SshConfig {
@@ -577,7 +584,7 @@ mod tests {
default_effort: Some("low".to_string()),
sessions: vec![SessionConfig {
id: "abc123".to_string(),
setup: "vm".to_string(),
machine: "vm".to_string(),
provider: "claude-cli".to_string(),
title: "test".to_string(),
model: None,
@@ -597,14 +604,14 @@ mod tests {
let loaded = Config::load(&path).expect("reload");
assert_eq!(loaded.tokens[0].name, "phone");
assert_eq!(loaded.sessions[0].setup, "vm");
assert_eq!(loaded.sessions[0].machine, "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.machine("vm").expect("machine").name, "the vm");
assert_eq!(loaded.sessions[0].provider, "claude-cli");
assert_eq!(
loaded
.setup("vm")
.expect("setup")
.machine("vm")
.expect("machine")
.ssh
.as_ref()
.expect("ssh")
@@ -612,15 +619,21 @@ mod tests {
Some(2222),
);
// The same provider name on two machines is the point, not a
// collision: names are unique within a setup and only within one.
// collision: names are unique within a machine and only within one.
assert!(
loaded
.setup(LOCAL_SETUP_ID)
.machine(LOCAL_MACHINE_ID)
.expect("local")
.provider("claude-cli")
.is_some()
);
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
assert!(
loaded
.machine(LOCAL_MACHINE_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
@@ -642,6 +655,33 @@ mod tests {
);
}
#[test]
fn reads_setup_spelling_from_existing_configs_but_writes_machine_spelling() {
let old = r#"
setups: [(
id: "vm",
name: "the vm",
providers: [],
)],
sessions: [(
id: "abc123",
setup: "vm",
provider: "echo",
title: "old words",
created: 1234.5,
)],
"#;
let config: Config = format::parse(old).expect("old setup spelling still loads");
assert_eq!(config.machines[0].id, "vm");
assert_eq!(config.sessions[0].machine, "vm");
let written = format::render(&config).expect("render migrated config");
assert!(written.contains("machines:"), "{written}");
assert!(written.contains("machine: \"vm\""), "{written}");
assert!(!written.contains("setups:"), "{written}");
assert!(!written.contains("setup: \"vm\""), "{written}");
}
#[test]
/// The seed is this machine and nothing more: a name, no ssh, and
/// exactly the providers it was handed.
@@ -653,7 +693,7 @@ mod tests {
/// this can check is that the seed does not invent anything.
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
let seed = Config::seed(vec![Config::echo_provider()]);
assert_eq!(seed.name, LOCAL_SETUP);
assert_eq!(seed.name, LOCAL_MACHINE);
assert!(seed.ssh.is_none());
assert_eq!(
seed.provider(ECHO_PROVIDER).expect("echo").kind,
+2 -2
View File
@@ -1,7 +1,7 @@
//! Reading and changing files on the machine a setup names.
//! Reading and changing files on a configured machine.
//!
//! Every operation here is one small POSIX shell script handed to `Transport`,
//! the way `setups::discover` and `import::list` already ask a machine a
//! the way `machines::discover` and `import::list` already ask a machine a
//! question. That is what makes the local and the ssh case one implementation:
//! a second one written against `std::fs` would be the one that gets tested,
//! and the remote half -- the ordering of entries, what a symlink reports, how
@@ -4,7 +4,7 @@
//! 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.
//! arbitrary programs to run on every machine already configured here.
//!
//! 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
@@ -28,7 +28,7 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
("claude-cli", "claude", DriverKind::ClaudeCli),
("codex-cli", "codex", DriverKind::CodexCli),
// Named for the program rather than for where it runs: it runs
// wherever the setup is, and "local" was true only while a llama
// wherever the machine is, and "local" was true only while a llama
// session could not be spawned on another machine.
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
];
@@ -171,7 +171,7 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
}
/// 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
/// machine 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
@@ -233,7 +233,7 @@ pub fn shorten_home(path: &str) -> String {
///
/// 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
/// useful half of why a machine 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> {
+23 -13
View File
@@ -14,12 +14,13 @@
mod auth;
mod config;
mod files;
mod machines;
mod media;
mod models;
mod provider_auth;
mod resume;
mod routes;
mod session;
mod setups;
mod ssh;
mod usage;
@@ -143,7 +144,7 @@ async fn main() -> Result<()> {
let config_path = args
.config
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
// Before the manager exists, on purpose: constructing it and seeding setups
// Before the manager exists, on purpose: constructing it and seeding machines
// touches sessions and subprocesses this invocation has no business with
// while another instance is serving. Only the hash reaches disk, in the
// spool `auth.rs` reads; the link goes to stdout alone.
@@ -188,19 +189,19 @@ async fn main() -> Result<()> {
// After construction rather than inside it: seeding asks this machine what
// it has, and a constructor that quietly runs a subprocess is a surprise to
// every caller including the tests.
manager.seed_setup().await?;
manager.seed_machine().await?;
tracing::info!("config: {}", config_path.display());
tracing::info!("models: {}", models_dir.display());
for setup in manager.setups() {
match &setup.ssh {
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
// No parenthetical naming the local machine: the default setup is
// *called* "this machine", and the line read "setup this machine
// (this machine)".
None => tracing::info!(" setup \"{}\" runs here", setup.name),
for machine in manager.machines() {
match &machine.ssh {
Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address),
// No parenthetical naming the local machine: the default machine is
// *called* "this machine", so repeating a local qualifier read
// like a stutter.
None => tracing::info!(" machine \"{}\" runs here", machine.name),
}
for provider in &setup.providers {
for provider in &machine.providers {
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
}
}
@@ -263,11 +264,12 @@ async fn main() -> Result<()> {
.context("failed to load TLS cert/key")?;
// No providers listed here any more: which machines can be asked, and about
// what, comes from the setups at the moment the screen is opened -- so a
// what, comes from the machines at the moment the screen is opened -- so a
// machine added from the phone reports its limits without a restart.
// The fixture is the manager's, because that is where the `/usage` command
// that sets it is typed; the monitor is what serves it.
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor)));
// The one thing in here that acts without a request behind it: a session
// switched to auto-resume waits out its account's usage limit and picks
@@ -279,7 +281,14 @@ async fn main() -> Result<()> {
// The bearer-token middleware wraps the entire router -- routes and fallback
// alike -- here and only here, so a new route can't forget auth.
let app = routes::router(Arc::clone(&manager))
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
.merge(routes::usage_router(
Arc::clone(&monitor),
Arc::clone(&manager),
))
.merge(routes::provider_auth_router(
Arc::clone(&provider_logins),
Arc::clone(&manager),
))
.merge(routes::models_router(Arc::clone(&models)))
.layer(axum::middleware::from_fn_with_state(
Arc::clone(&manager),
@@ -322,6 +331,7 @@ async fn main() -> Result<()> {
// above: a throwaway session is one nobody meant to keep, and the whole point
// is that nothing has to remember to clean it up.
manager.stop_throwaway_sessions();
provider_logins.cancel_all();
manager.detach_all();
Ok(())
+4 -4
View File
@@ -523,13 +523,13 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
}
}
/// Where a machine reached over ssh keeps its models, when its setup does
/// Where a machine reached over ssh keeps its models, when its machine does
/// not say.
///
/// The same place this backend puts its own downloads, written out rather
/// than derived: `$XDG_DATA_HOME` here describes *this* machine's
/// environment, and the far machine's is the far machine's business. A
/// setup whose models are elsewhere says so (`SshConfig::models_dir`).
/// machine whose models are elsewhere says so (`SshConfig::models_dir`).
const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models";
/// Which directory holds the models on the machine `transport` reaches.
@@ -550,11 +550,11 @@ pub fn dir_on(transport: &Transport, local: &Path) -> String {
}
}
/// Every GGUF on the machine a setup names, which is the machine that
/// Every GGUF on a configured machine, which is the machine that
/// would have to serve it.
///
/// The local half of this is [`ModelStore::list`], reading the same shape
/// off this machine's disk; a caller picks by transport, since a setup
/// off this machine's disk; a caller picks by transport, since a machine
/// with no ssh *is* this machine and asking a shell about it would be a
/// slower way to the same answer. What must not happen is offering this
/// backend's downloads for a session on another machine: the file has to
+467
View File
@@ -0,0 +1,467 @@
//! Interactive provider login carried between a CLI on a configured machine
//! and the phone. The CLI remains the only credential writer: this layer keeps
//! its short-lived process and relays only the authorization URL and the code
//! a person copies back from the browser.
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::process::Stdio;
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use rand::Rng;
use serde::Serialize;
use crate::config::{MachineConfig, ProviderConfig};
use crate::session::transport::Transport;
use crate::usage::UsageMonitor;
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const AUTHORIZATION_URL_TIMEOUT: Duration = Duration::from_secs(15);
const OUTPUT_POLL: Duration = Duration::from_millis(50);
const START_WAIT: Duration = Duration::from_secs(16);
type Key = (String, String);
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum LoginState {
Starting,
WaitingForCode {
#[serde(rename = "authorizationUrl")]
authorization_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
Submitting,
Succeeded,
Failed {
detail: String,
},
Cancelled,
}
impl LoginState {
fn terminal(&self) -> bool {
matches!(
self,
Self::Succeeded | Self::Failed { .. } | Self::Cancelled
)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginInfo {
pub attempt: String,
#[serde(flatten)]
pub state: LoginState,
}
#[derive(Clone)]
struct Attempt {
id: String,
state: Arc<Mutex<LoginState>>,
input: mpsc::Sender<Input>,
}
impl Attempt {
fn info(&self) -> LoginInfo {
LoginInfo {
attempt: self.id.clone(),
state: self.state.lock().unwrap().clone(),
}
}
}
enum Input {
Code(String),
Cancel,
}
/// The active login per machine and provider. Completed attempts stay until a
/// new one replaces them, so a phone that briefly loses its connection can ask
/// how the operation ended rather than being handed an ambiguous 404.
pub struct LoginManager {
attempts: Mutex<HashMap<Key, Attempt>>,
usage: Arc<UsageMonitor>,
}
impl LoginManager {
pub fn new(usage: Arc<UsageMonitor>) -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
usage,
}
}
pub fn start(&self, machine: MachineConfig, provider: ProviderConfig) -> LoginInfo {
let provider_key = provider
.kind
.usage_provider()
.expect("a login route only accepts a metered provider")
.to_string();
let key = (machine.id.clone(), provider_key);
let mut attempts = self.attempts.lock().unwrap();
if let Some(attempt) = attempts.get(&key)
&& !attempt.state.lock().unwrap().terminal()
{
return attempt.info();
}
let id = attempt_id();
let state = Arc::new(Mutex::new(LoginState::Starting));
let (input, commands) = mpsc::channel();
let attempt = Attempt {
id: id.clone(),
state: Arc::clone(&state),
input,
};
attempts.insert(key, attempt.clone());
drop(attempts);
// Seed the cache before the worker takes the gate. A usage request in
// that small handoff window sees a fresh, truthful state and cannot
// start a second CLI against the same credential.
let gate = self.usage.claude_authentication_started(&machine);
let usage = Arc::clone(&self.usage);
std::thread::spawn(move || {
let _guard = gate.lock().unwrap();
run_login(&machine, &provider, commands, &state);
usage.claude_authentication_finished(&machine.id);
});
attempt.info()
}
pub fn wait_until_ready(&self, machine: &str, provider: &str, attempt: &str) -> LoginInfo {
let started = Instant::now();
loop {
let info = self.read(machine, provider, attempt).unwrap_or(LoginInfo {
attempt: attempt.to_string(),
state: LoginState::Failed {
detail: "the sign-in attempt disappeared".to_string(),
},
});
if !matches!(info.state, LoginState::Starting) || started.elapsed() >= START_WAIT {
return info;
}
std::thread::sleep(OUTPUT_POLL);
}
}
pub fn read(&self, machine: &str, provider: &str, attempt: &str) -> Option<LoginInfo> {
let attempts = self.attempts.lock().unwrap();
let found = attempts.get(&(machine.to_string(), provider.to_string()))?;
(found.id == attempt).then(|| found.info())
}
pub fn submit(
&self,
machine: &str,
provider: &str,
attempt: &str,
code: &str,
) -> Result<LoginInfo> {
let code = valid_code(code)?;
let attempts = self.attempts.lock().unwrap();
let found = attempts
.get(&(machine.to_string(), provider.to_string()))
.filter(|found| found.id == attempt)
.context("no such sign-in attempt")?;
if found.state.lock().unwrap().terminal() {
return Ok(found.info());
}
*found.state.lock().unwrap() = LoginState::Submitting;
if found.input.send(Input::Code(code.to_string())).is_err() {
*found.state.lock().unwrap() = LoginState::Failed {
detail: "the sign-in process has stopped".to_string(),
};
anyhow::bail!("the sign-in process has stopped");
}
Ok(found.info())
}
pub fn cancel(&self, machine: &str, provider: &str, attempt: &str) -> Result<LoginInfo> {
let attempts = self.attempts.lock().unwrap();
let found = attempts
.get(&(machine.to_string(), provider.to_string()))
.filter(|found| found.id == attempt)
.context("no such sign-in attempt")?;
if !found.state.lock().unwrap().terminal() {
let _ = found.input.send(Input::Cancel);
}
Ok(found.info())
}
/// Interactive helpers are unlike sessions: nothing adopts them after a
/// server restart. End every one while the process is still here to reap
/// the child it launched.
pub fn cancel_all(&self) {
let attempts = self.attempts.lock().unwrap();
for attempt in attempts.values() {
if !attempt.state.lock().unwrap().terminal() {
let _ = attempt.input.send(Input::Cancel);
}
}
let pending: Vec<_> = attempts
.values()
.map(|attempt| Arc::clone(&attempt.state))
.collect();
drop(attempts);
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline
&& pending
.iter()
.any(|state| !state.lock().unwrap().terminal())
{
std::thread::sleep(OUTPUT_POLL);
}
}
}
fn run_login(
machine: &MachineConfig,
provider: &ProviderConfig,
commands: mpsc::Receiver<Input>,
state: &Arc<Mutex<LoginState>>,
) {
if let Err(err) = run_login_inner(machine, provider, commands, state) {
*state.lock().unwrap() = LoginState::Failed {
detail: format!("couldn't sign in to Claude on {}: {err:#}", machine.name),
};
}
}
fn run_login_inner(
machine: &MachineConfig,
provider: &ProviderConfig,
commands: mpsc::Receiver<Input>,
state: &Arc<Mutex<LoginState>>,
) -> Result<()> {
let transport = Transport::for_machine(machine);
let args = vec![
"BROWSER=/bin/false".to_string(),
provider.program().to_string(),
"auth".to_string(),
"login".to_string(),
"--claudeai".to_string(),
];
let host = match &transport {
Transport::Here => None,
Transport::Ssh { ssh, .. } => Some(ssh),
};
let mut command = crate::ssh::command(host, "env", &args, None, None);
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.with_context(|| format!("couldn't run {} auth login", provider.program()))?;
let mut stdin = child
.stdin
.take()
.context("the login process has no stdin")?;
let stdout = child
.stdout
.take()
.context("the login process has no stdout")?;
let stderr = child
.stderr
.take()
.context("the login process has no stderr")?;
let (output, lines) = mpsc::channel();
read_lines(stdout, output.clone());
read_lines(stderr, output);
let started = Instant::now();
let mut authorization_url = None;
let mut last_line = None;
loop {
while let Ok(line) = lines.try_recv() {
if let Some(url) = authorization_url_in(&line) {
authorization_url = Some(url.to_string());
*state.lock().unwrap() = LoginState::WaitingForCode {
authorization_url: url.to_string(),
detail: None,
};
} else if line.to_ascii_lowercase().contains("invalid code") {
if let Some(url) = &authorization_url {
*state.lock().unwrap() = LoginState::WaitingForCode {
authorization_url: url.clone(),
detail: Some(
"That code was not accepted. Copy the complete code and try again."
.to_string(),
),
};
}
} else if !line.trim().is_empty() {
last_line = Some(line.trim().chars().take(500).collect::<String>());
}
}
match commands.recv_timeout(OUTPUT_POLL) {
Ok(Input::Code(code)) => {
*state.lock().unwrap() = LoginState::Submitting;
writeln!(stdin, "{code}").context("couldn't send the login code")?;
stdin.flush().context("couldn't send the login code")?;
}
Ok(Input::Cancel) => {
let _ = child.kill();
let _ = child.wait();
*state.lock().unwrap() = LoginState::Cancelled;
return Ok(());
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("the phone disconnected from the sign-in attempt");
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if let Some(status) = child
.try_wait()
.context("couldn't check the login process")?
{
*state.lock().unwrap() = if status.success() {
LoginState::Succeeded
} else {
LoginState::Failed {
detail: last_line
.unwrap_or_else(|| format!("Claude's login process exited with {status}")),
}
};
return Ok(());
}
if authorization_url.is_none() && started.elapsed() >= AUTHORIZATION_URL_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("the Claude CLI did not provide an authorization URL");
}
if started.elapsed() >= LOGIN_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("the sign-in attempt expired; start it again");
}
}
}
fn read_lines(reader: impl Read + Send + 'static, output: mpsc::Sender<String>) {
std::thread::spawn(move || {
for line in BufReader::new(reader).lines().map_while(Result::ok) {
let _ = output.send(line);
}
});
}
fn authorization_url_in(line: &str) -> Option<&str> {
let start = line.find("https://")?;
let tail = &line[start..];
let end = tail
.find(|character: char| character.is_whitespace() || character == '\u{1b}')
.unwrap_or(tail.len());
let url = &tail[..end];
(url.starts_with("https://claude.com/") || url.starts_with("https://platform.claude.com/"))
.then_some(url)
}
fn valid_code(code: &str) -> Result<&str> {
let code = code.trim();
anyhow::ensure!(!code.is_empty(), "the login code is empty");
anyhow::ensure!(code.len() <= 4096, "the login code is too long");
anyhow::ensure!(
!code.chars().any(char::is_control),
"the login code contains a line break or control character"
);
Ok(code)
}
fn attempt_id() -> String {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DriverKind;
#[test]
fn extracts_only_anthropics_https_login_url() {
assert_eq!(
authorization_url_in("visit: https://claude.com/cai/oauth/authorize?state=x"),
Some("https://claude.com/cai/oauth/authorize?state=x")
);
assert!(authorization_url_in("visit: http://claude.com/nope").is_none());
assert!(authorization_url_in("visit: https://example.com/nope").is_none());
}
#[test]
fn login_code_is_one_bounded_line() {
assert_eq!(valid_code(" abc#state ").unwrap(), "abc#state");
assert!(valid_code("\n").is_err());
assert!(valid_code("a\nb").is_err());
assert!(valid_code(&"x".repeat(4097)).is_err());
}
#[cfg(unix)]
#[test]
fn relays_a_headless_cli_login_without_taking_over_its_credentials() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let cli = dir.path().join("fake-claude");
std::fs::write(
&cli,
"#!/bin/sh\necho 'https://claude.com/cai/oauth/authorize?state=test'\nIFS= read -r code\n[ \"$code\" = 'the-code' ]\n",
)
.expect("write fake CLI");
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o700))
.expect("make fake CLI executable");
let monitor = Arc::new(UsageMonitor::new(Default::default()));
let logins = LoginManager::new(monitor);
let machine = MachineConfig {
id: "vm".to_string(),
name: "test vm".to_string(),
ssh: None,
providers: Vec::new(),
};
let provider = ProviderConfig {
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: Some(cli.display().to_string()),
models: Vec::new(),
};
let started = logins.start(machine, provider);
let ready = logins.wait_until_ready("vm", "claude", &started.attempt);
assert!(matches!(ready.state, LoginState::WaitingForCode { .. }));
let wire = serde_json::to_value(&ready).expect("serialize login state");
assert!(wire.get("authorizationUrl").is_some(), "{wire}");
assert!(wire.get("authorization_url").is_none(), "{wire}");
let submitted = logins
.submit("vm", "claude", &started.attempt, "the-code")
.expect("submit code");
assert!(matches!(submitted.state, LoginState::Submitting));
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let finished = logins
.read("vm", "claude", &started.attempt)
.expect("attempt remains readable");
if matches!(finished.state, LoginState::Succeeded) {
break;
}
assert!(
Instant::now() < deadline,
"login did not finish: {finished:?}"
);
std::thread::sleep(OUTPUT_POLL);
}
}
}
+11 -11
View File
@@ -48,7 +48,7 @@ const AT_LEAST: f64 = 60.0;
/// How long after the limit was hit to stop waiting.
///
/// Something has to bound it, or a machine that can never be asked -- an
/// unplugged laptop, a setup somebody edited away -- is retried for ever with
/// unplugged laptop, a machine somebody edited away -- is retried for ever with
/// nothing on screen saying so. A day is past the longest window Claude
/// reports, so reaching this means the wait was never going to end on its own.
const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
@@ -123,7 +123,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
Step::Send => match manager.resume_now(&owed.session_id) {
Ok(message) => tracing::info!(
"the limit on {} has lifted; sent \"{message}\" to {}",
owed.setup,
owed.machine,
owed.session_id
),
Err(err) => {
@@ -144,7 +144,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
// to read on a phone.
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
_ => format!("{} could not be asked for a day", owed.setup),
_ => format!("{} could not be asked for a day", owed.machine),
};
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
@@ -164,18 +164,18 @@ async fn snapshot_for(
manager: &SessionManager,
owed: &OwedResume,
) -> Option<UsageSnapshot> {
let setups: Vec<_> = manager
.setups()
let machines: Vec<_> = manager
.machines()
.into_iter()
.filter(|setup| setup.id == owed.setup)
.filter(|machine| machine.id == owed.machine)
.collect();
if setups.is_empty() {
if machines.is_empty() {
return None;
}
let provider = owed.provider;
tokio::task::spawn_blocking(move || {
monitor
.snapshots(&setups)
.snapshots(&machines)
.into_iter()
.find(|snapshot| snapshot.provider == provider)
})
@@ -258,7 +258,7 @@ mod tests {
fn owed(since: f64) -> OwedResume {
OwedResume {
session_id: "s1".to_string(),
setup: "local".to_string(),
machine: "local".to_string(),
provider: crate::usage::CLAUDE,
scheduled: ScheduledResume { at: since, since },
}
@@ -267,8 +267,8 @@ mod tests {
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
UsageSnapshot {
provider: crate::usage::CLAUDE.to_string(),
setup: "local".to_string(),
setup_name: "this machine".to_string(),
machine: "local".to_string(),
machine_name: "this machine".to_string(),
limit_id: None,
limit_name: None,
state,
+249 -124
View File
@@ -3,27 +3,31 @@
//! wraps the whole router in.
//!
//! ```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
//! GET /setups/{id}/models GGUFs on that machine, for a llama session
//! GET /setups/{id}/providers/{provider}/models models a CLI currently offers
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
//! GET /setups/{id}/file?path=P content of file P, or why not
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
//! GET /machines machines, each with what it can run
//! POST /machines add {name, ssh?} -- providers are discovered
//! POST /machines/probe dry run {ssh?}: what would be found there
//! GET /machines/{id} one machine, for refetching after a change
//! GET /machines/{id}/models GGUFs on that machine, for a llama session
//! GET /machines/{id}/providers/{provider}/models models a CLI currently offers
//! POST /machines/{id}/providers/{provider}/auth begin provider sign-in
//! GET /machines/{id}/providers/{provider}/auth/{attempt} sign-in state
//! POST /machines/{id}/providers/{provider}/auth/{attempt}/code submit browser code
//! DELETE /machines/{id}/providers/{provider}/auth/{attempt} cancel sign-in
//! GET /machines/{id}/dir?path=P entries of directory P, and P resolved
//! GET /machines/{id}/file?path=P content of file P, or why not
//! PUT /machines/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
//! (409 when the file no longer matches ifSha256)
//! POST /setups/{id}/file {path} create empty; refused if it exists
//! POST /setups/{id}/dir {path} create; refused if it exists
//! GET /setups/{id}/importable Claude Code sessions on it that could be continued
//! POST /setups/{id}/importable/import {sessions} -> 202; runs on the server
//! POST /setups/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts
//! GET /setups/{id}/importable/events SSE: what is in flight against them
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
//! DELETE /setups/{id} remove, refused while sessions use it
//! POST /machines/{id}/file {path} create empty; refused if it exists
//! POST /machines/{id}/dir {path} create; refused if it exists
//! GET /machines/{id}/importable Claude Code sessions on it that could be continued
//! POST /machines/{id}/importable/import {sessions} -> 202; runs on the server
//! POST /machines/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts
//! GET /machines/{id}/importable/events SSE: what is in flight against them
//! PUT /machines/{id} rename {name?} and/or re-probe {rediscover?}
//! DELETE /machines/{id} remove, refused while sessions use it
//! GET /sessions list (id, provider, title, model, status, last activity)
//! GET /sessions/{id} one session, for refetching after a change
//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?}
//! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?}
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
//! (a backlog past CATCH_UP_LIMIT arrives as a
//! `reset` frame plus the newest window)
@@ -114,30 +118,30 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
pub fn router(manager: Arc<SessionManager>) -> Router {
Router::new()
.route("/setups", get(list_setups).post(add_setup))
.route("/setups/probe", post(probe_setup))
.route("/setups/{id}/importable", get(list_importable))
.route("/machines", get(list_machines).post(add_machine))
.route("/machines/probe", post(probe_machine))
.route("/machines/{id}/importable", get(list_importable))
// A batch at a time, never a session at a time -- see
// [`delete_importable`].
.route("/setups/{id}/importable/delete", post(delete_importable))
.route("/setups/{id}/importable/import", post(start_import))
.route("/setups/{id}/importable/events", get(importable_events))
.route("/machines/{id}/importable/delete", post(delete_importable))
.route("/machines/{id}/importable/import", post(start_import))
.route("/machines/{id}/importable/events", get(importable_events))
.route(
"/setups/{id}",
get(read_setup).put(update_setup).delete(delete_setup),
"/machines/{id}",
get(read_machine).put(update_machine).delete(delete_machine),
)
// The models on the machine a setup names, for a llama session there.
.route("/setups/{id}/models", get(setup_models))
// The models on a configured machine, for a llama session there.
.route("/machines/{id}/models", get(machine_models))
.route(
"/setups/{id}/providers/{provider}/models",
"/machines/{id}/providers/{provider}/models",
get(provider_models),
)
// The filesystem of the machine a setup names. Under the setup
// The filesystem of a configured machine. Under the machine
// rather than under a session because a filesystem is a property of
// a machine; a session only says where to start looking.
.route("/setups/{id}/dir", get(list_dir).post(create_dir))
.route("/machines/{id}/dir", get(list_dir).post(create_dir))
.route(
"/setups/{id}/file",
"/machines/{id}/file",
get(read_file).put(write_file).post(create_file),
)
.route("/sessions", get(list_sessions).post(spawn_session))
@@ -255,7 +259,7 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
/// opened from a row carries that snapshot with it. Fine for what a row
/// *says* and wrong for what a control is *set to*: a switch drawn from a
/// stale row shows the position it had when the list was fetched, and the
/// person reading it cannot tell. Same reason `GET /setups/{id}` exists.
/// person reading it cannot tell. Same reason `GET /machines/{id}` exists.
async fn read_session(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -269,7 +273,7 @@ async fn read_session(
}
/// What the spawn screen needs to render itself, so the phone holds no
/// hardcoded list: a setup added to `config.ron` shows up with no app
/// hardcoded list: a machine added to `config.ron` shows up with no app
/// rebuild.
///
/// One list rather than two, because the halves are not independent. A
@@ -278,12 +282,12 @@ async fn read_session(
/// the box that hasn't got it".
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SetupInfo {
struct MachineInfo {
/// 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 this machine.
/// Where it runs, for telling two machines apart. Absent for this machine.
#[serde(skip_serializing_if = "Option::is_none")]
address: Option<String>,
providers: Vec<ProviderInfo>,
@@ -300,16 +304,16 @@ struct ProviderInfo {
default_permission_mode: Option<&'static str>,
}
async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> {
axum::Json(manager.setups().into_iter().map(info_for).collect())
async fn list_machines(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<MachineInfo>> {
axum::Json(manager.machines().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
fn info_for(machine: crate::config::MachineConfig) -> MachineInfo {
MachineInfo {
id: machine.id,
name: machine.name,
address: machine.ssh.map(|ssh| ssh.address),
providers: machine
.providers
.into_iter()
.map(|provider| ProviderInfo {
@@ -326,7 +330,7 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
/// 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
/// asking the machine (`crate::machines`), never sent, so the enrolled token
/// cannot introduce something to run.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -353,7 +357,7 @@ 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)
let address = crate::machines::tidy(&self.address)
.ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?;
Ok(crate::config::SshConfig {
address,
@@ -361,12 +365,12 @@ impl SshRequest {
identity_file: self
.identity_file
.as_deref()
.and_then(crate::setups::tidy)
.and_then(crate::machines::tidy)
.map(std::path::PathBuf::from),
options: self
.options
.iter()
.filter_map(|o| crate::setups::tidy(o))
.filter_map(|o| crate::machines::tidy(o))
.collect(),
// Not `tidy`: that expands `~` to *this* machine's home, and this
// path is on the other one. The remote shell expands it there.
@@ -391,7 +395,7 @@ impl SshRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct AddSetupRequest {
struct AddMachineRequest {
name: String,
/// Absent means this machine.
#[serde(default)]
@@ -410,11 +414,11 @@ struct ProbeRequest {
ssh: Option<SshRequest>,
}
async fn probe_setup(
async fn probe_machine(
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?;
let providers = probe(ssh, "this machine").await?;
Ok(axum::Json(
providers
.into_iter()
@@ -443,50 +447,50 @@ async fn probe(
},
None => crate::session::transport::Transport::Here,
};
crate::setups::discover(&transport)
crate::machines::discover(&transport)
.await
.map_err(bad_request)
}
async fn add_setup(
async fn add_machine(
State(manager): State<Arc<SessionManager>>,
axum::Json(body): axum::Json<AddSetupRequest>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
axum::Json(body): axum::Json<AddMachineRequest>,
) -> Result<axum::Json<MachineInfo>, 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
// bad address fails here rather than leaving a machine that can never
// spawn.
let providers = probe(ssh.clone(), &body.name).await?;
let setup = manager
.add_setup(&body.name, ssh, providers)
let machine = manager
.add_machine(&body.name, ssh, providers)
.map_err(bad_request)?;
Ok(axum::Json(info_for(setup)))
Ok(axum::Json(info_for(machine)))
}
/// One setup by id, or the 404 that says so. Three handlers ask this same
/// One machine by id, or the 404 that says so. Three handlers ask this same
/// question; the answer, and the wording of the refusal, belong in one place.
fn setup_by_id(
fn machine_by_id(
manager: &Arc<SessionManager>,
id: &str,
) -> Result<crate::config::SetupConfig, ApiError> {
) -> Result<crate::config::MachineConfig, ApiError> {
manager
.setups()
.machines()
.into_iter()
.find(|setup| setup.id == id)
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))
.find(|machine| machine.id == id)
.ok_or_else(|| ApiError::NotFound(format!("no machine {id}")))
}
async fn read_setup(
async fn read_machine(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
setup_by_id(&manager, &id).map(|setup| axum::Json(info_for(setup)))
) -> Result<axum::Json<MachineInfo>, ApiError> {
machine_by_id(&manager, &id).map(|machine| axum::Json(info_for(machine)))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct UpdateSetupRequest {
struct UpdateMachineRequest {
#[serde(default)]
name: Option<String>,
/// Ask the machine again what it has -- after installing something
@@ -495,37 +499,37 @@ struct UpdateSetupRequest {
rediscover: bool,
}
async fn update_setup(
async fn update_machine(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<UpdateSetupRequest>,
) -> Result<axum::Json<SetupInfo>, ApiError> {
axum::Json(body): axum::Json<UpdateMachineRequest>,
) -> Result<axum::Json<MachineInfo>, ApiError> {
let providers = if body.rediscover {
let existing = manager
.setups()
.machines()
.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);
.find(|machine| machine.id == id)
.ok_or_else(|| ApiError::NotFound(format!("no machine {id}")))?;
let transport = crate::session::transport::Transport::for_machine(&existing);
Some(
crate::setups::discover(&transport)
crate::machines::discover(&transport)
.await
.map_err(bad_request)?,
)
} else {
None
};
let setup = manager
.update_setup(&id, body.name.as_deref(), providers)
let machine = manager
.update_machine(&id, body.name.as_deref(), providers)
.map_err(bad_request)?;
Ok(axum::Json(info_for(setup)))
Ok(axum::Json(info_for(machine)))
}
async fn delete_setup(
async fn delete_machine(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
manager.delete_setup(&id).map_err(bad_request)?;
manager.delete_machine(&id).map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
@@ -538,10 +542,10 @@ fn files_on(
id: &str,
path: &str,
) -> Result<(crate::session::transport::Transport, String), ApiError> {
let setup = setup_by_id(manager, id)?;
let machine = machine_by_id(manager, id)?;
let path = crate::files::check_path(path).map_err(bad_request)?;
Ok((
crate::session::transport::Transport::for_setup(&setup),
crate::session::transport::Transport::for_machine(&machine),
path,
))
}
@@ -566,15 +570,15 @@ struct PathQuery {
///
/// Not `GET /models`, which is this backend's own downloads: those are on
/// the machine a session runs on only when they are the same machine. A
/// spawn screen offering this backend's list for a remote setup would be
/// spawn screen offering this backend's list for a remote machine would be
/// naming files that are not there, and the session would fail at the
/// point of loading rather than at the point of choosing.
async fn setup_models(
async fn machine_models(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<crate::models::LocalModel>>, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let machine = machine_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_machine(&machine);
let dir = crate::models::dir_on(&transport, manager.models_dir());
crate::models::on_machine(&transport, &dir)
.await
@@ -582,19 +586,19 @@ async fn setup_models(
.map_err(from_machine)
}
/// The models a CLI provider currently offers on the setup's machine.
/// The models a CLI provider currently offers on its configured machine.
/// Codex answers from its live account catalog; providers with a configured
/// shortcut list return that list.
async fn provider_models(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, provider_name)): UrlPath<(String, String)>,
) -> Result<axum::Json<Vec<String>>, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let provider = setup.provider(&provider_name).ok_or_else(|| {
ApiError::NotFound(format!("no provider {provider_name} on {}", setup.name))
let machine = machine_by_id(&manager, &id)?;
let provider = machine.provider(&provider_name).ok_or_else(|| {
ApiError::NotFound(format!("no provider {provider_name} on {}", machine.name))
})?;
let transport = crate::session::transport::Transport::for_setup(&setup);
crate::setups::provider_models(&transport, provider)
let transport = crate::session::transport::Transport::for_machine(&machine);
crate::machines::provider_models(&transport, provider)
.await
.map(axum::Json)
.map_err(from_machine)
@@ -704,7 +708,7 @@ async fn create_dir(
#[serde(deny_unknown_fields)]
struct SpawnRequest {
/// Which machine, and which of the things it offers.
setup: String,
machine: String,
provider: String,
#[serde(default)]
title: Option<String>,
@@ -721,7 +725,7 @@ struct SpawnRequest {
#[serde(default)]
params: std::collections::BTreeMap<String, String>,
/// Continue a Claude Code session the machine already has, named by the id
/// `GET /setups/{id}/importable` reported.
/// `GET /machines/{id}/importable` reported.
///
/// An id and not a path, deliberately: the server looks the path up again
/// among the sessions it enumerated, so an enrolled token cannot turn this
@@ -735,8 +739,8 @@ async fn list_importable(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<ImportableRow>>, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let machine = machine_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_machine(&machine);
let mut found = crate::session::import::list(&transport)
.await
.map_err(bad_request)?;
@@ -814,8 +818,8 @@ async fn delete_importable(
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<DeleteBatch>,
) -> Result<StatusCode, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let machine = machine_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_machine(&machine);
// Registered before anything is spawned, so the 202 is only sent once
// every row is already showing "deleting" -- a phone that refetches the
// instant it gets the reply cannot catch a row that has not started.
@@ -892,10 +896,10 @@ async fn start_import(
) -> Result<StatusCode, ApiError> {
// Checked before accepting, so an unknown machine is an error the caller
// sees rather than one it has to go and read off a row.
setup_by_id(&manager, &id)?;
machine_by_id(&manager, &id)?;
for session in body.sessions {
let request = SpawnRequest {
setup: id.clone(),
machine: id.clone(),
provider: body.provider.clone(),
// Nothing to say: `spawn` titles an import from the session it
// continues, and the cwd comes from the same place.
@@ -946,22 +950,25 @@ struct ImportRequest {
/// is the pending registry.
fn in_background<F>(
manager: &Arc<SessionManager>,
setup: String,
machine: String,
session: String,
operation: Operation,
work: F,
) where
F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
{
let running = manager.pending().begin(&setup, &session, operation);
let running = manager.pending().begin(&machine, &session, operation);
tokio::spawn(async move {
match work.await {
Ok(()) => {
tracing::info!("{} {session} on {setup}: done", operation.label());
tracing::info!("{} {session} on {machine}: done", operation.label());
running.succeeded();
}
Err(err) => {
tracing::warn!("{} {session} on {setup} failed: {err:#}", operation.label());
tracing::warn!(
"{} {session} on {machine} failed: {err:#}",
operation.label()
);
// The server's own words, the way every other failure in this
// app reaches a person.
running.failed(format!("{err:#}"));
@@ -970,7 +977,7 @@ fn in_background<F>(
});
}
/// Every change to what is in flight against one machine. Scoped to the setup
/// Every change to what is in flight against one machine. Scoped to the machine
/// the screen is showing, the same way a session's events are scoped to that
/// session.
async fn importable_events(
@@ -983,7 +990,7 @@ async fn importable_events(
// that is what the listing is for: the screen refetches on arrival and
// carries the truth whatever this stream missed.
let change = item.ok()?;
if change.setup() != id {
if change.machine() != id {
return None;
}
Some(Ok(SseEvent::default().json_data(&change).ok()?))
@@ -1010,15 +1017,15 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
// not the phone's: which file that id names, and what is in it.
let seed = match &body.import {
Some(want) => {
let setup = setup_by_id(manager, &body.setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let machine = machine_by_id(manager, &body.machine)?;
let transport = crate::session::transport::Transport::for_machine(&machine);
let chosen = crate::session::import::find(&transport, want)
.await
.map_err(bad_request)?
.ok_or_else(|| {
ApiError::NotFound(format!(
"setup \"{}\" has no Claude Code session {want} to import",
body.setup
"machine \"{}\" has no Claude Code session {want} to import",
body.machine
))
})?;
if let Some(existing) = manager.session_driving(want) {
@@ -1065,7 +1072,7 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
};
let spec = SpawnSpec {
setup: body.setup,
machine: body.machine,
provider: body.provider,
// An imported session is recognised by what it was about, so its
// opening message is the title unless one was typed. Blank normalised
@@ -1155,8 +1162,8 @@ async fn delete_session(
// everything as it was rather than a deleted session and a transcript the
// phone has already promised is gone.
if let Some(foreign) = &foreign {
let setup = setup_by_id(&manager, &foreign.setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let machine = machine_by_id(&manager, &foreign.machine)?;
let transport = crate::session::transport::Transport::for_machine(&machine);
foreign.delete(&transport).await.map_err(bad_request)?;
tracing::info!(
"deleted {} session {} with ai-app session {id}",
@@ -1307,15 +1314,133 @@ pub fn usage_router(
.with_state(UsageState { monitor, manager })
}
#[derive(Clone)]
pub struct ProviderAuthState {
logins: Arc<crate::provider_auth::LoginManager>,
manager: Arc<SessionManager>,
}
/// Separate state from the session routes because login attempts are
/// short-lived provider processes, not conversations to persist or adopt.
pub fn provider_auth_router(
logins: Arc<crate::provider_auth::LoginManager>,
manager: Arc<SessionManager>,
) -> Router {
Router::new()
.route(
"/machines/{id}/providers/{provider}/auth",
post(start_provider_auth),
)
.route(
"/machines/{id}/providers/{provider}/auth/{attempt}",
get(read_provider_auth).delete(cancel_provider_auth),
)
.route(
"/machines/{id}/providers/{provider}/auth/{attempt}/code",
post(submit_provider_auth_code),
)
.with_state(ProviderAuthState { logins, manager })
}
fn provider_auth_target(
manager: &Arc<SessionManager>,
machine_id: &str,
provider_name: &str,
) -> Result<
(
crate::config::MachineConfig,
crate::config::ProviderConfig,
&'static str,
),
ApiError,
> {
let machine = machine_by_id(manager, machine_id)?;
let provider = machine.provider(provider_name).cloned().ok_or_else(|| {
ApiError::NotFound(format!("no provider {provider_name} on {}", machine.name))
})?;
let key = provider.kind.usage_provider().ok_or_else(|| {
ApiError::BadRequest(format!(
"{} does not have an account to sign in to",
provider.name
))
})?;
if key != crate::usage::CLAUDE {
return Err(ApiError::BadRequest(format!(
"{} does not support sign-in through the app",
provider.name
)));
}
Ok((machine, provider, key))
}
async fn start_provider_auth(
State(state): State<ProviderAuthState>,
UrlPath((machine_id, provider_name)): UrlPath<(String, String)>,
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
let (machine, provider, key) =
provider_auth_target(&state.manager, &machine_id, &provider_name)?;
let info = state.logins.start(machine, provider);
let logins = Arc::clone(&state.logins);
let info = tokio::task::spawn_blocking(move || {
logins.wait_until_ready(&machine_id, key, &info.attempt)
})
.await
.context("provider sign-in worker panicked")?;
Ok(axum::Json(info))
}
async fn read_provider_auth(
State(state): State<ProviderAuthState>,
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
state
.logins
.read(&machine_id, key, &attempt)
.map(axum::Json)
.ok_or_else(|| ApiError::NotFound("no such sign-in attempt".to_string()))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ProviderAuthCode {
code: String,
}
async fn submit_provider_auth_code(
State(state): State<ProviderAuthState>,
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
axum::Json(body): axum::Json<ProviderAuthCode>,
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
state
.logins
.submit(&machine_id, key, &attempt, &body.code)
.map(axum::Json)
.map_err(bad_request)
}
async fn cancel_provider_auth(
State(state): State<ProviderAuthState>,
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
state
.logins
.cancel(&machine_id, key, &attempt)
.map(axum::Json)
.map_err(bad_request)
}
async fn usage(
State(state): State<UsageState>,
) -> Result<axum::Json<Vec<crate::usage::UsageSnapshot>>, ApiError> {
// Read here rather than inside the fetch, so the list of machines is the
// one that existed when the request arrived and cannot change under a
// fetch that takes an ssh round trip per machine.
let setups = state.manager.setups();
let machines = state.manager.machines();
// The fetch is blocking by design (see `usage`); off the workers.
let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&setups))
let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&machines))
.await
.context("usage fetch panicked")?;
Ok(axum::Json(snapshots))
@@ -1348,7 +1473,7 @@ struct CwdRequest {
/// Moves a session to a different working directory.
///
/// The directory is checked here rather than in the manager because checking
/// it is an ssh round trip on a remote setup, and the manager is not async.
/// it is an ssh round trip on a remote machine, and the manager is not async.
///
/// Checked rather than trusted, and refused rather than corrected: a mistyped
/// path that was accepted would leave a session recorded somewhere its process
@@ -1372,20 +1497,20 @@ async fn set_cwd(
// launched from, which is not something the person typing it can see. The
// same question the explorer asks of every path, asked in one place.
let cwd = crate::files::check_path(&body.cwd.to_string_lossy()).map_err(bad_request)?;
let setup = setup_by_id(&manager, &session.setup)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let machine = machine_by_id(&manager, &session.machine)?;
let transport = crate::session::transport::Transport::for_machine(&machine);
if !crate::session::import::directory_exists(&transport, &cwd).await {
return Err(ApiError::BadRequest(format!(
"{} has no directory {cwd}",
setup.name
machine.name
)));
}
// Stored in the short form, so the one path kept is the one the phone will
// draw -- rather than storing `/home/bob/…` and abbreviating it again at
// each place it is shown, which is two representations of one directory.
// Only where the setup runs here; see `setups::shorten_home`.
let stored = if setup.ssh.is_none() {
crate::setups::shorten_home(&cwd)
// Only where the machine runs here; see `machines::shorten_home`.
let stored = if machine.ssh.is_none() {
crate::machines::shorten_home(&cwd)
} else {
cwd.clone()
};
+1 -1
View File
@@ -13,7 +13,7 @@
//!
//! **The phone never names a file.** It picks an id out of what this
//! module enumerated, and the path is looked up again on the server -- the
//! same rule the setups model follows for providers, and for the same
//! same rule the machines model follows for providers, and for the same
//! reason: an enrolled token must not be able to turn into "read me this
//! arbitrary path".
+1 -1
View File
@@ -12,7 +12,7 @@
//! command carries the tunnel between them. The far `llama-server` binds
//! loopback only, so a model is never served to that machine's network.
//!
//! **The model file is the far machine's, not this one's.** A remote setup
//! **The model file is the far machine's, not this one's.** A remote machine
//! names its own models directory (`SshConfig::models_dir`, defaulting to where
//! this backend keeps its downloads), and the file is looked for *there* -- so
//! a session naming a model that machine does not have says so, instead of
+117 -115
View File
@@ -30,8 +30,8 @@ use serde::Serialize;
use tokio::sync::{broadcast, mpsc};
use crate::config::{
Config, DEFAULT_RESUME_MESSAGE, DriverKind, ProviderConfig, ScheduledResume, SessionConfig,
SetupConfig, SshConfig, TokenEntry,
Config, DEFAULT_RESUME_MESSAGE, DriverKind, MachineConfig, ProviderConfig, ScheduledResume,
SessionConfig, SshConfig, TokenEntry,
};
use claude::ClaudeDriver;
use codex::CodexDriver;
@@ -100,7 +100,7 @@ pub fn now() -> f64 {
}
pub struct SpawnSpec {
pub setup: String,
pub machine: String,
pub provider: String,
pub title: Option<String>,
pub model: Option<String>,
@@ -172,7 +172,7 @@ impl AutoResumeView {
pub struct OwedResume {
pub session_id: String,
/// The machine whose account ran out, which is the one to ask.
pub setup: String,
pub machine: String,
/// Which meter reports on it -- a `crate::usage::UsageProvider::name`, the
/// same pairing `SessionInfo::usage_provider` uses.
pub provider: &'static str,
@@ -193,11 +193,11 @@ fn resume_message(meta: &SessionConfig) -> String {
pub struct SessionInfo {
pub id: String,
pub provider: String,
pub setup: String,
pub machine: String,
/// That machine's current label, resolved when this row is built, so
/// renaming a setup renames it everywhere rather than leaving old
/// renaming a machine renames it everywhere rather than leaving old
/// sessions showing the old name.
pub setup_name: String,
pub machine_name: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
@@ -562,7 +562,7 @@ impl LiveSession {
Ok((name, path))
}
/// `setup_name` and `cwd` are passed in rather than read from the
/// `machine_name` and `cwd` are passed in rather than read from the
/// snapshot this session launched with: only the manager holds the
/// config, and both can change under a running session. Passed rather
/// than mirrored into `Shared`, so there is one answer, read where the
@@ -574,7 +574,7 @@ impl LiveSession {
/// cautious one.
fn info(
&self,
setup_name: &str,
machine_name: &str,
cwd: Option<&Path>,
effort: Option<&str>,
imported: bool,
@@ -584,8 +584,8 @@ impl LiveSession {
SessionInfo {
id: self.meta.id.clone(),
provider: self.meta.provider.clone(),
setup: self.meta.setup.clone(),
setup_name: setup_name.to_string(),
machine: self.meta.machine.clone(),
machine_name: machine_name.to_string(),
title: self.shared.title.lock().unwrap().clone(),
model: self.shared.model.lock().unwrap().clone(),
permission_mode: self.shared.permission_mode.lock().unwrap().clone(),
@@ -646,7 +646,7 @@ pub struct SessionManager {
/// The CLI-owned copy optionally removed with an ai-app session.
#[derive(Debug, PartialEq, Eq)]
pub struct ForeignTranscript {
pub setup: String,
pub machine: String,
pub id: String,
kind: DriverKind,
}
@@ -702,10 +702,10 @@ impl SessionManager {
// One unlaunchable session -- a corrupt transcript, an
// unreachable host, a provider edited away -- shows as exited
// rather than taking the server down, and can still be deleted.
match resolve(&config, meta).and_then(|(setup, provider)| {
match resolve(&config, meta).and_then(|(machine, provider)| {
launch(
meta.clone(),
&setup,
&machine,
&provider,
Env {
data_dir: &data_dir,
@@ -777,7 +777,7 @@ impl SessionManager {
self
}
/// Writes this machine into a config that has no setups, with the
/// Writes this machine into a config that has no machines, with the
/// providers actually found on it.
///
/// Discovered rather than assumed. This used to write a `claude-cli`
@@ -789,16 +789,16 @@ impl SessionManager {
/// this server runs, and says so in the log. Seeding the hardcoded list
/// would be the original bug with an extra step, and seeding nothing
/// leaves a fresh install with nothing to prove the pipe with.
pub async fn seed_setup(&self) -> Result<()> {
if !self.inner.read().unwrap().config.setups.is_empty() {
pub async fn seed_machine(&self) -> Result<()> {
if !self.inner.read().unwrap().config.machines.is_empty() {
return Ok(());
}
let providers = match crate::setups::discover(&transport::Transport::Here).await {
let providers = match crate::machines::discover(&transport::Transport::Here).await {
Ok(found) => found,
Err(err) => {
tracing::warn!(
"couldn't ask this machine what it has ({err}); seeding {} only -- \
re-probe the setup from the app once that is fixed",
re-probe the machine from the app once that is fixed",
crate::config::ECHO_PROVIDER
);
vec![Config::echo_provider()]
@@ -807,16 +807,16 @@ impl SessionManager {
let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
let mut inner = self.inner.write().unwrap();
if !inner.config.setups.is_empty() {
if !inner.config.machines.is_empty() {
return Ok(());
}
let mut candidate = inner.config.clone();
candidate.setups.push(Config::seed(providers.clone()));
candidate.machines.push(Config::seed(providers.clone()));
candidate.save(&self.config_path)?;
inner.config = candidate;
tracing::info!(
"no setups configured -- added \"{}\" with: {}",
crate::config::LOCAL_SETUP,
"no machines configured -- added \"{}\" with: {}",
crate::config::LOCAL_MACHINE,
names.join(", ")
);
Ok(())
@@ -839,80 +839,80 @@ impl SessionManager {
///
/// `providers` comes from probing rather than from the caller: the
/// probe is async and this is not, so the route asks and this writes.
pub fn add_setup(
pub fn add_machine(
&self,
name: &str,
ssh: Option<SshConfig>,
providers: Vec<ProviderConfig>,
) -> Result<SetupConfig> {
) -> Result<MachineConfig> {
let name = name.trim().to_string();
if name.is_empty() {
bail!("a setup needs a name");
bail!("a machine needs a name");
}
self.update(|config| {
if config.setup_named(&name).is_some() {
bail!("there is already a setup called \"{name}\"");
if config.machine_named(&name).is_some() {
bail!("there is already a machine called \"{name}\"");
}
// Ids are derived once and then fixed, so a label can be
// edited later without orphaning the sessions that named it.
let mut id = crate::setups::id_from(&name);
while config.setup(&id).is_some() {
let mut id = crate::machines::id_from(&name);
while config.machine(&id).is_some() {
id = format!("{id}-{}", &random_hex()[..4]);
}
let setup = SetupConfig {
let machine = MachineConfig {
id,
name: name.clone(),
ssh,
providers,
};
config.setups.push(setup.clone());
Ok(setup)
config.machines.push(machine.clone());
Ok(machine)
})
}
pub fn update_setup(
pub fn update_machine(
&self,
id: &str,
name: Option<&str>,
providers: Option<Vec<ProviderConfig>>,
) -> Result<SetupConfig> {
) -> Result<MachineConfig> {
self.update(|config| {
if let Some(name) = name {
let name = name.trim();
if name.is_empty() {
bail!("a setup needs a name");
bail!("a machine needs a name");
}
if config.setups.iter().any(|s| s.name == name && s.id != id) {
bail!("there is already a setup called \"{name}\"");
if config.machines.iter().any(|s| s.name == name && s.id != id) {
bail!("there is already a machine called \"{name}\"");
}
}
let setup = config
.setups
let machine = config
.machines
.iter_mut()
.find(|setup| setup.id == id)
.with_context(|| format!("no setup with id \"{id}\""))?;
.find(|machine| machine.id == id)
.with_context(|| format!("no machine with id \"{id}\""))?;
if let Some(name) = name {
setup.name = name.trim().to_string();
machine.name = name.trim().to_string();
}
if let Some(providers) = providers {
setup.providers = providers;
machine.providers = providers;
}
Ok(setup.clone())
Ok(machine.clone())
})
}
/// Removes a machine, provided nothing is still running on it.
/// Refused rather than cascaded: the person asking is better placed to
/// decide which of those sessions they still want.
pub fn delete_setup(&self, id: &str) -> Result<()> {
pub fn delete_machine(&self, id: &str) -> Result<()> {
self.update(|config| {
if config.setup(id).is_none() {
bail!("no setup with id \"{id}\"");
if config.machine(id).is_none() {
bail!("no machine with id \"{id}\"");
}
let using: Vec<&str> = config
.sessions
.iter()
.filter(|session| session.setup == id)
.filter(|session| session.machine == id)
.map(|session| session.title.as_str())
.collect();
if !using.is_empty() {
@@ -922,7 +922,7 @@ impl SessionManager {
using.join(", "),
);
}
config.setups.retain(|setup| setup.id != id);
config.machines.retain(|machine| machine.id != id);
Ok(())
})
}
@@ -1061,18 +1061,18 @@ impl SessionManager {
pub fn remote_of(&self, id: &str) -> Option<(crate::config::SshConfig, Option<PathBuf>)> {
let inner = self.inner.read().unwrap();
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
let setup = inner
let machine = inner
.config
.setups
.machines
.iter()
.find(|setup| setup.id == meta.setup)?;
Some((setup.ssh.clone()?, meta.cwd.clone()))
.find(|machine| machine.id == meta.machine)?;
Some((machine.ssh.clone()?, meta.cwd.clone()))
}
pub fn foreign_transcript(&self, id: &str) -> Option<ForeignTranscript> {
let inner = self.inner.read().unwrap();
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
let kind = kind_of(&inner.config, &meta.setup, &meta.provider)?;
let kind = kind_of(&inner.config, &meta.machine, &meta.provider)?;
let session_dir = self.data_dir.join(&meta.id);
let foreign = match kind {
DriverKind::ClaudeCli => {
@@ -1085,7 +1085,7 @@ impl SessionManager {
DriverKind::Echo | DriverKind::LlamaCpp => None,
}?;
Some(ForeignTranscript {
setup: meta.setup.clone(),
machine: meta.machine.clone(),
id: foreign,
kind,
})
@@ -1101,28 +1101,28 @@ impl SessionManager {
.iter()
.map(|meta| match inner.live.get(&meta.id) {
Some(session) => session.info(
label_of(&inner.config, &meta.setup),
label_of(&inner.config, &meta.machine),
meta.cwd.as_deref(),
meta.effort.as_deref(),
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
kind_of(&inner.config, &meta.setup, &meta.provider),
kind_of(&inner.config, &meta.machine, &meta.provider),
AutoResumeView::of(meta),
),
None => SessionInfo {
id: meta.id.clone(),
setup: meta.setup.clone(),
setup_name: label_of(&inner.config, &meta.setup).to_string(),
machine: meta.machine.clone(),
machine_name: label_of(&inner.config, &meta.machine).to_string(),
provider: meta.provider.clone(),
title: meta.title.clone(),
model: meta.model.clone(),
permission_mode: meta.permission_mode.clone(),
effort: meta.effort.clone(),
takes_effort: kind_of(&inner.config, &meta.setup, &meta.provider)
takes_effort: kind_of(&inner.config, &meta.machine, &meta.provider)
.is_some_and(DriverKind::takes_effort),
context_tokens: None,
max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider)
max_image_edge: kind_of(&inner.config, &meta.machine, &meta.provider)
.and_then(DriverKind::max_image_edge),
usage_provider: kind_of(&inner.config, &meta.setup, &meta.provider)
usage_provider: kind_of(&inner.config, &meta.machine, &meta.provider)
.and_then(DriverKind::usage_provider),
notify: meta.notify,
auto_resume: meta.auto_resume,
@@ -1131,10 +1131,10 @@ impl SessionManager {
imported: import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
keeps_own_transcript: keeps_own_transcript(
&inner.config,
&meta.setup,
&meta.machine,
&meta.provider,
),
own_transcript_name: kind_of(&inner.config, &meta.setup, &meta.provider)
own_transcript_name: kind_of(&inner.config, &meta.machine, &meta.provider)
.and_then(DriverKind::own_transcript_name),
cwd: meta.cwd.clone(),
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
@@ -1172,8 +1172,8 @@ impl SessionManager {
/// Every machine this server can run something on, each with what it
/// can run. One list rather than two, because the pair is the choice.
pub fn setups(&self) -> Vec<SetupConfig> {
self.inner.read().unwrap().config.setups.clone()
pub fn machines(&self) -> Vec<MachineConfig> {
self.inner.read().unwrap().config.machines.clone()
}
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
@@ -1191,28 +1191,28 @@ impl SessionManager {
fn spawn_seeded(&self, spec: SpawnSpec, seed: Option<Seed>) -> Result<SessionInfo> {
let mut inner = self.inner.write().unwrap();
let setup = inner
let machine = inner
.config
.setup(&spec.setup)
.machine(&spec.machine)
.with_context(|| {
format!(
"no setup with id \"{}\" -- configured: {}",
spec.setup,
"no machine with id \"{}\" -- configured: {}",
spec.machine,
// Ids, since that is what was looked up. Labels made
// the failure read as a contradiction: "no setup named
// the failure read as a contradiction: "no machine named
// X -- configured: X".
names(inner.config.setups.iter().map(|s| s.id.as_str())),
names(inner.config.machines.iter().map(|s| s.id.as_str())),
)
})?
.clone();
let provider = setup
let provider = machine
.provider(&spec.provider)
.with_context(|| {
format!(
"setup \"{}\" has no provider named \"{}\" -- it offers: {}",
spec.setup,
"machine \"{}\" has no provider named \"{}\" -- it offers: {}",
spec.machine,
spec.provider,
names(setup.providers.iter().map(|p| p.name.as_str())),
names(machine.providers.iter().map(|p| p.name.as_str())),
)
})?
.clone();
@@ -1223,7 +1223,7 @@ impl SessionManager {
.unwrap_or_else(|| format!("{} session", provider.name));
let meta = SessionConfig {
id: id.clone(),
setup: setup.id.clone(),
machine: machine.id.clone(),
provider: provider.name.clone(),
title,
// No model unless one was chosen. This used to fall back to the
@@ -1267,7 +1267,7 @@ impl SessionManager {
let session = launch(
meta.clone(),
&setup,
&machine,
&provider,
self.env(),
self.announce.clone(),
@@ -1286,7 +1286,7 @@ impl SessionManager {
// Whether this one was seeded, the same question the listing asks
// of the directory a moment later.
let info = session.info(
&setup.name,
&machine.name,
session.meta.cwd.as_deref(),
session.meta.effort.as_deref(),
import::read_cursor(&self.data_dir.join(&id)).is_some(),
@@ -1442,8 +1442,8 @@ impl SessionManager {
let scheduled = meta.resume?;
Some(OwedResume {
session_id: meta.id.clone(),
setup: meta.setup.clone(),
provider: kind_of(&inner.config, &meta.setup, &meta.provider)?
machine: meta.machine.clone(),
provider: kind_of(&inner.config, &meta.machine, &meta.provider)?
.usage_provider()?,
scheduled,
})
@@ -1628,7 +1628,7 @@ impl SessionManager {
/// it entirely.
///
/// Whether the directory exists is the caller's question, because
/// asking it is an ssh round trip on a remote setup; see the route.
/// asking it is an ssh round trip on a remote machine; see the route.
pub fn set_session_cwd(&self, id: &str, cwd: PathBuf) -> Result<()> {
{
let mut inner = self.inner.write().unwrap();
@@ -1889,7 +1889,7 @@ impl SessionManager {
// Fresh from the config, like every other launch: a model or a
// permission mode changed while the session was stopped is what it
// starts with.
let (setup, provider) = resolve(&inner.config, &meta)?;
let (machine, provider) = resolve(&inner.config, &meta)?;
match existing {
Some(session) => {
// Replacing the value a driver lives in does not end the
@@ -1901,7 +1901,7 @@ impl SessionManager {
}
*session.driver.lock().unwrap() = Some(make_driver(
&meta,
&setup,
&machine,
&provider,
self.env(),
session.dir(),
@@ -1915,7 +1915,7 @@ impl SessionManager {
None => {
let session = launch(
meta,
&setup,
&machine,
&provider,
self.env(),
self.announce.clone(),
@@ -2050,24 +2050,26 @@ fn adoptable(session_dir: &Path) -> bool {
/// The provider and host a session's config names, or a message saying
/// which one is missing. Both are looked up fresh at every launch, so
/// editing either takes effect on the next respawn.
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, ProviderConfig)> {
let setup = config
.setup(&meta.setup)
.with_context(|| format!("no setup named \"{}\"", meta.setup))?;
let provider = setup.provider(&meta.provider).with_context(|| {
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(MachineConfig, ProviderConfig)> {
let machine = config
.machine(&meta.machine)
.with_context(|| format!("no machine named \"{}\"", meta.machine))?;
let provider = machine.provider(&meta.provider).with_context(|| {
format!(
"setup \"{}\" has no provider named \"{}\"",
meta.setup, meta.provider
"machine \"{}\" has no provider named \"{}\"",
meta.machine, meta.provider
)
})?;
Ok((setup.clone(), provider.clone()))
Ok((machine.clone(), provider.clone()))
}
/// A setup's current label, or its id when the setup has been deleted --
/// A machine's current label, or its id when the machine has been deleted --
/// which is what a session left behind by a removed machine shows, and is
/// better than an empty column or a guess at what it used to be called.
fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str {
config.setup(id).map_or(id, |setup| setup.name.as_str())
config
.machine(id)
.map_or(id, |machine| machine.name.as_str())
}
/// The two ways a session directory can name a Claude Code conversation:
@@ -2094,21 +2096,21 @@ fn foreign_ids(dir: &Path) -> (Option<String>, Option<String>) {
/// app's delete cannot reach.
///
/// False when the provider can't be found, which is the safe way round: a
/// setup or provider removed from the config leaves sessions naming one
/// machine or provider removed from the config leaves sessions naming one
/// that is gone, and the warning that then shows is the strong one. Saying
/// "this can be brought back" on no evidence is the answer that loses
/// somebody's conversation.
fn keeps_own_transcript(config: &Config, setup: &str, provider: &str) -> bool {
kind_of(config, setup, provider).is_some_and(DriverKind::keeps_own_transcript)
fn keeps_own_transcript(config: &Config, machine: &str, provider: &str) -> bool {
kind_of(config, machine, provider).is_some_and(DriverKind::keeps_own_transcript)
}
/// What a session's provider is, for the questions answered by its *kind*
/// rather than by its name. `None` for a provider that has been edited away,
/// which is a session that cannot run at all.
fn kind_of(config: &Config, setup: &str, provider: &str) -> Option<DriverKind> {
fn kind_of(config: &Config, machine: &str, provider: &str) -> Option<DriverKind> {
config
.setup(setup)
.and_then(|setup| setup.providers.iter().find(|it| it.name == provider))
.machine(machine)
.and_then(|machine| machine.providers.iter().find(|it| it.name == provider))
.map(|provider| provider.kind)
}
@@ -2310,7 +2312,7 @@ struct Env<'a> {
/// process for it to speak to. See [`Launching`] for when that is.
fn launch(
meta: SessionConfig,
setup: &SetupConfig,
machine: &MachineConfig,
provider: &ProviderConfig,
env: Env<'_>,
announce: Announcements,
@@ -2405,7 +2407,7 @@ fn launch(
&& shared.context_tokens.lock().unwrap().is_none()
&& let Some(session_id) = claude::read_resume_token(&dir)
{
let transport = Transport::for_setup(setup);
let transport = Transport::for_machine(machine);
let shared = Arc::clone(&shared);
tokio::spawn(async move {
if let Some(context) = import::context_of(&transport, &session_id).await {
@@ -2423,7 +2425,7 @@ fn launch(
&& shared.context_tokens.lock().unwrap().is_none()
&& let Some(thread_id) = codex::read_thread(&dir)
{
let transport = Transport::for_setup(setup);
let transport = Transport::for_machine(machine);
let shared = Arc::clone(&shared);
tokio::spawn(async move {
if let Some(context) = codex::context_of(&transport, &thread_id).await {
@@ -2441,7 +2443,7 @@ fn launch(
// anybody pressing anything.
if let Some(cursor) = import::read_cursor(&dir) {
spawn_import_sync(
Transport::for_setup(setup),
Transport::for_machine(machine),
dir.clone(),
cursor,
sink.clone(),
@@ -2454,7 +2456,7 @@ fn launch(
.then(|| {
make_driver(
&meta,
setup,
machine,
provider,
env,
&dir,
@@ -2505,7 +2507,7 @@ fn launch(
#[allow(clippy::too_many_arguments)]
fn make_driver(
meta: &SessionConfig,
setup: &SetupConfig,
machine: &MachineConfig,
provider: &ProviderConfig,
env: Env<'_>,
dir: &Path,
@@ -2525,7 +2527,7 @@ fn make_driver(
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
meta,
provider,
&Transport::for_setup(setup),
&Transport::for_machine(machine),
env.models_dir,
transcript_path,
dir,
@@ -2535,7 +2537,7 @@ fn make_driver(
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
meta,
provider,
&Transport::for_setup(setup),
&Transport::for_machine(machine),
dir,
sink.clone(),
Arc::clone(subagents),
@@ -2543,7 +2545,7 @@ fn make_driver(
DriverKind::CodexCli => Arc::new(CodexDriver::launch(
meta,
provider,
Transport::for_setup(setup),
Transport::for_machine(machine),
dir,
sink.clone(),
)?),
@@ -2799,7 +2801,7 @@ mod tests {
fn echo_spec() -> SpawnSpec {
SpawnSpec {
params: Default::default(),
setup: crate::config::LOCAL_SETUP_ID.to_string(),
machine: crate::config::LOCAL_MACHINE_ID.to_string(),
provider: crate::config::ECHO_PROVIDER.to_string(),
title: None,
model: None,
@@ -2857,7 +2859,7 @@ mod tests {
/// depending on whether `claude` happens to be installed.
fn seed_echo_only(config_path: &std::path::Path) {
Config {
setups: vec![Config::seed(vec![Config::echo_provider()])],
machines: vec![Config::seed(vec![Config::echo_provider()])],
..Config::default()
}
.save(config_path)
@@ -3356,7 +3358,7 @@ mod tests {
assert_eq!(
manager.foreign_transcript(&info.id),
Some(ForeignTranscript {
setup: info.setup.clone(),
machine: info.machine.clone(),
id: "5ecf21da-d53f".to_string(),
kind: DriverKind::ClaudeCli,
})
@@ -3979,7 +3981,7 @@ mod tests {
std::fs::write(&command, "#!/bin/sh\ncat > /dev/null\n").expect("write stand-in");
std::fs::set_permissions(&command, std::fs::Permissions::from_mode(0o755)).expect("chmod");
Config {
setups: vec![Config::seed(vec![
machines: vec![Config::seed(vec![
Config::echo_provider(),
ProviderConfig {
name: "stand-in".to_string(),
+20 -20
View File
@@ -48,16 +48,16 @@ impl Operation {
#[serde(rename_all = "camelCase", tag = "state")]
pub enum Change {
Started {
setup: String,
machine: String,
session: String,
operation: Operation,
},
Finished {
setup: String,
machine: String,
session: String,
},
Failed {
setup: String,
machine: String,
session: String,
message: String,
},
@@ -66,11 +66,11 @@ pub enum Change {
impl Change {
/// Which machine this is about, so a stream scoped to one can drop the rest.
/// Every variant carries it; matching here keeps that fact in one place.
pub fn setup(&self) -> &str {
pub fn machine(&self) -> &str {
match self {
Self::Started { setup, .. }
| Self::Finished { setup, .. }
| Self::Failed { setup, .. } => setup,
Self::Started { machine, .. }
| Self::Finished { machine, .. }
| Self::Failed { machine, .. } => machine,
}
}
}
@@ -106,12 +106,12 @@ impl Registry {
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
/// a failure. Dropping without settling means the task was cancelled or
/// panicked, and a row stuck on "importing" for ever is a worse answer.
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight {
let key = (setup.to_string(), session.to_string());
pub fn begin(self: &Arc<Self>, machine: &str, session: &str, operation: Operation) -> InFlight {
let key = (machine.to_string(), session.to_string());
self.running.lock().unwrap().insert(key.clone(), operation);
self.failures.lock().unwrap().remove(&key);
let _ = self.changes.send(Change::Started {
setup: key.0.clone(),
machine: key.0.clone(),
session: key.1.clone(),
operation,
});
@@ -123,25 +123,25 @@ impl Registry {
}
/// What is happening to this session, if anything is.
pub fn running(&self, setup: &str, session: &str) -> Option<Operation> {
let key = (setup.to_string(), session.to_string());
pub fn running(&self, machine: &str, session: &str) -> Option<Operation> {
let key = (machine.to_string(), session.to_string());
self.running.lock().unwrap().get(&key).copied()
}
/// How the last operation on this session failed, if it did.
pub fn failure(&self, setup: &str, session: &str) -> Option<String> {
let key = (setup.to_string(), session.to_string());
pub fn failure(&self, machine: &str, session: &str) -> Option<String> {
let key = (machine.to_string(), session.to_string());
self.failures.lock().unwrap().get(&key).cloned()
}
/// Forgets failures against sessions the machine no longer has. Called from
/// the listing, which is the only place that knows what is still there.
pub fn prune(&self, setup: &str, present: &[String]) {
pub fn prune(&self, machine: &str, present: &[String]) {
self.failures
.lock()
.unwrap()
.retain(|(kept_setup, session), _| {
kept_setup != setup || present.iter().any(|id| id == session)
.retain(|(kept_machine, session), _| {
kept_machine != machine || present.iter().any(|id| id == session)
});
}
@@ -174,7 +174,7 @@ impl InFlight {
}
self.settled = true;
self.registry.running.lock().unwrap().remove(&self.key);
let (setup, session) = (self.key.0.clone(), self.key.1.clone());
let (machine, session) = (self.key.0.clone(), self.key.1.clone());
let change = match failure {
Some(message) => {
self.registry
@@ -183,12 +183,12 @@ impl InFlight {
.unwrap()
.insert(self.key.clone(), message.clone());
Change::Failed {
setup,
machine,
session,
message,
}
}
None => Change::Finished { setup, session },
None => Change::Finished { machine, session },
};
let _ = self.registry.changes.send(change);
}
+5 -5
View File
@@ -97,7 +97,7 @@ pub enum Transport {
Here,
/// Reached with the system `ssh` client. Owns its entry rather than
/// borrowing it, so a session keeps working against the config it was
/// spawned with even if the setup is edited afterwards.
/// spawned with even if the machine is edited afterwards.
Ssh { name: String, ssh: SshConfig },
}
@@ -179,11 +179,11 @@ impl Transport {
}
}
/// The transport a setup describes; a setup with no `ssh` is here.
pub fn for_setup(setup: &crate::config::SetupConfig) -> Self {
match &setup.ssh {
/// The transport a machine describes; a machine with no `ssh` is here.
pub fn for_machine(machine: &crate::config::MachineConfig) -> Self {
match &machine.ssh {
Some(ssh) => Self::Ssh {
name: setup.name.clone(),
name: machine.name.clone(),
ssh: ssh.clone(),
},
None => Self::Here,
+196 -74
View File
@@ -16,11 +16,11 @@
//! behind the same snapshot shape.
//!
//! **Asked of the machine that spends the tokens, not of this one.** A session
//! runs wherever its setup says, so the account being billed is that machine's.
//! runs wherever its machine says, so the account being billed is that machine's.
//! In the layout this project aims at, `ai-server` is on the host, the host has
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
//! the screen could show would be an account with no sessions. Credentials are
//! read through the session `Transport`, one snapshot per setup that offers
//! read through the session `Transport`, one snapshot per machine that offers
//! that provider.
//!
//! The token is read *to* the backend and the HTTP call is made from here, so
@@ -36,7 +36,7 @@ use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::Value;
use crate::config::SetupConfig;
use crate::config::MachineConfig;
use crate::session::transport::{Launch, Transport};
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
@@ -72,12 +72,12 @@ pub struct UsageWindow {
/// What came back when a machine was asked about its limits.
///
/// Four answers rather than a flag and a message, because the screen has to
/// Named answers rather than a flag and a message, because the screen has to
/// treat them differently. "Nobody is logged in here" is a machine working
/// exactly as configured, while "I could not reach it" is a fault worth
/// chasing, and "the endpoint refused me" says nothing about the machine at
/// all. Collapsing them into one `error` string made the first look like the
/// last, so a perfectly healthy setup read as broken.
/// last, so a perfectly healthy machine read as broken.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum UsageState {
@@ -88,6 +88,13 @@ pub enum UsageState {
NotLoggedIn,
/// The machine could not be asked at all.
Unreachable { detail: String },
/// An explicit Claude login is waiting for its browser code. Kept apart
/// from a failure because nothing is broken while somebody is completing
/// the operation on the phone.
Authenticating,
/// Credentials exist, but the CLI could not renew them. Unlike a generic
/// provider failure, this has a direct action the phone can offer.
LoginRequired { detail: String },
/// The machine is logged in, but the usage endpoint did not answer.
Failed { detail: String },
}
@@ -98,10 +105,10 @@ pub struct UsageSnapshot {
pub provider: String,
/// Which machine these are the numbers for. The point of the whole module:
/// they belong to an account on a particular box.
pub setup: String,
pub machine: String,
/// That machine's current label, resolved when the snapshot is built, so
/// renaming a setup renames it here too.
pub setup_name: String,
/// renaming a machine renames it here too.
pub machine_name: String,
/// The provider's billing pool, when it exposes more than one. Codex uses
/// this to keep regular usage separate from its Luna reserve allowance.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -149,8 +156,8 @@ pub trait UsageProvider: Send + Sync {
/// credentials that machine stores -- nothing to configure, and it reports on
/// exactly the account whose CLI runs the sessions there.
pub struct ClaudeUsage {
pub setup: String,
pub setup_name: String,
pub machine: String,
pub machine_name: String,
/// How to reach that machine. `Here` for the backend's own.
pub transport: Transport,
/// The CLI to run there, for the one thing this asks of it: refreshing its
@@ -168,8 +175,8 @@ impl ClaudeUsage {
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
UsageSnapshot {
provider: self.name().to_string(),
setup: self.setup.clone(),
setup_name: self.setup_name.clone(),
machine: self.machine.clone(),
machine_name: self.machine_name.clone(),
limit_id: None,
limit_name: None,
state,
@@ -234,8 +241,8 @@ impl UsageProvider for ClaudeUsage {
/// app-server protocol. The CLI owns authentication and token refresh; this
/// process never opens or copies its credentials.
pub struct CodexUsage {
pub setup: String,
pub setup_name: String,
pub machine: String,
pub machine_name: String,
pub transport: Transport,
pub program: String,
}
@@ -244,8 +251,8 @@ impl CodexUsage {
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
UsageSnapshot {
provider: CODEX.to_string(),
setup: self.setup.clone(),
setup_name: self.setup_name.clone(),
machine: self.machine.clone(),
machine_name: self.machine_name.clone(),
limit_id: None,
limit_name: None,
state,
@@ -283,7 +290,7 @@ impl UsageProvider for CodexUsage {
Err(err) => {
return vec![self.snapshot(
UsageState::Unreachable {
detail: format!("couldn't ask Codex on {}: {err:#}", self.setup_name),
detail: format!("couldn't ask Codex on {}: {err:#}", self.machine_name),
},
Vec::new(),
)];
@@ -446,7 +453,7 @@ impl ClaudeUsage {
return Err(UsageState::Failed {
detail: format!(
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
self.setup_name, self.program
self.machine_name, self.program
),
});
}
@@ -463,10 +470,10 @@ impl ClaudeUsage {
/// A login the CLI could not renew: the one state here somebody has to act
/// on, so it says where and what to run.
fn still_expired(&self) -> UsageState {
UsageState::Failed {
UsageState::LoginRequired {
detail: format!(
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
self.setup_name, self.program
"The Claude login on {} has expired and could not be refreshed.",
self.machine_name
),
}
}
@@ -727,6 +734,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
},
UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(),
UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"),
UsageState::Authenticating => "sign-in is in progress".to_string(),
UsageState::LoginRequired { detail } => format!("sign-in required ({detail})"),
UsageState::Failed { detail } => format!("the meter failed ({detail})"),
}
}
@@ -734,8 +743,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
/// The fixture, as a provider, so it travels the same route and the same
/// cache as a real meter rather than being spliced in at the screen.
struct EchoUsage {
setup: String,
setup_name: String,
machine: String,
machine_name: String,
fixture: Fixture,
}
@@ -759,8 +768,8 @@ impl UsageProvider for EchoUsage {
));
vec![UsageSnapshot {
provider: self.name().to_string(),
setup: self.setup.clone(),
setup_name: self.setup_name.clone(),
machine: self.machine.clone(),
machine_name: self.machine_name.clone(),
limit_id: None,
limit_name: None,
state,
@@ -778,7 +787,7 @@ impl UsageProvider for EchoUsage {
/// Which paid services a machine can be asked about.
///
/// Derived from what the setup says it can run, so a machine with no Claude
/// Derived from what the machine says it can run, so a machine with no Claude
/// provider is not asked about Claude limits -- it has none, and a row saying
/// so would be a fact about nothing.
///
@@ -787,9 +796,9 @@ impl UsageProvider for EchoUsage {
/// one of these rows by that same name: two lists that disagreed would leave a
/// session looking for a snapshot nothing produces. A second service later is a
/// name there and an impl beside [`ClaudeUsage`], not a screen.
fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
fn providers_for(machine: &MachineConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
for provider in &setup.providers {
for provider in &machine.providers {
let Some(name) = provider.kind.usage_provider() else {
continue;
};
@@ -801,23 +810,23 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
}
match name {
CLAUDE => found.push(Box::new(ClaudeUsage {
setup: setup.id.clone(),
setup_name: setup.name.clone(),
transport: Transport::for_setup(setup),
machine: machine.id.clone(),
machine_name: machine.name.clone(),
transport: Transport::for_machine(machine),
program: provider.program().to_string(),
})),
CODEX => found.push(Box::new(CodexUsage {
setup: setup.id.clone(),
setup_name: setup.name.clone(),
transport: Transport::for_setup(setup),
machine: machine.id.clone(),
machine_name: machine.name.clone(),
transport: Transport::for_machine(machine),
program: provider.program().to_string(),
})),
// Nothing at all until a test has asked for something: an
// echo session costs nothing, so the honest answer is no row
// rather than a row saying zero.
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
setup: setup.id.clone(),
setup_name: setup.name.clone(),
machine: machine.id.clone(),
machine_name: machine.name.clone(),
fixture: fixture.clone(),
})),
_ => {}
@@ -829,16 +838,22 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
/// One machine's numbers for one service, and when they were fetched.
///
/// Keyed by the machine and the service rather than by position: the set is not
/// fixed at startup -- setups are added, renamed and removed from the phone --
/// fixed at startup -- machines are added, renamed and removed from the phone --
/// and a positional cache would hand one machine's numbers to another the
/// moment the list shifted.
type Cached = HashMap<(String, &'static str), (Instant, Vec<UsageSnapshot>)>;
type ProviderGate = Arc<Mutex<()>>;
#[derive(Default)]
/// The cache in front of whatever machines exist: at most one real fetch per
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
pub struct UsageMonitor {
cache: Mutex<Cached>,
/// One gate per machine and metered provider. The cache lock cannot cover
/// a network call without making an unreachable machine stall every other
/// one, but releasing it used to let concurrent `/usage` requests launch
/// two token refreshers against the same rotating credential.
gates: Mutex<HashMap<(String, &'static str), ProviderGate>>,
/// The invented meter an echo session can put up; empty unless one
/// has. Shared with the session layer, which is where the command
/// that sets it is typed -- see [`Fixture`].
@@ -849,37 +864,116 @@ impl UsageMonitor {
pub fn new(fixture: Fixture) -> Self {
Self {
cache: Mutex::new(Cached::new()),
gates: Mutex::new(HashMap::new()),
fixture,
}
}
fn gate_for(&self, key: &(String, &'static str)) -> ProviderGate {
self.gates
.lock()
.unwrap()
.entry(key.clone())
.or_default()
.clone()
}
fn cached(
&self,
key: &(String, &'static str),
machine_name: &str,
max_age: Option<Duration>,
) -> Option<Vec<UsageSnapshot>> {
let cache = self.cache.lock().unwrap();
let (fetched, snapshots) = cache.get(key)?;
if max_age.is_some_and(|age| fetched.elapsed() >= age) {
return None;
}
let mut snapshots = snapshots.clone();
for snapshot in &mut snapshots {
snapshot.machine_name = machine_name.to_string();
}
Some(snapshots)
}
/// Prevents automatic refresh from overlapping an explicit login. The
/// fresh cache entry closes the small gap before the login worker acquires
/// the same gate, while an attempt lasting beyond the normal cache window
/// is protected by the worker holding it.
pub(crate) fn claude_authentication_started(&self, machine: &MachineConfig) -> ProviderGate {
let key = (machine.id.clone(), CLAUDE);
let gate = self.gate_for(&key);
let guard = gate.lock().unwrap();
self.cache.lock().unwrap().insert(
key,
(
Instant::now(),
vec![UsageSnapshot {
provider: CLAUDE.to_string(),
machine: machine.id.clone(),
machine_name: machine.name.clone(),
limit_id: None,
limit_name: None,
state: UsageState::Authenticating,
windows: Vec::new(),
fetched_at: crate::session::now(),
}],
),
);
drop(guard);
gate
}
/// Makes the first read after a login ask the provider instead of keeping
/// the pre-login state for the ordinary cache interval.
pub(crate) fn claude_authentication_finished(&self, machine: &str) {
self.cache
.lock()
.unwrap()
.remove(&(machine.to_string(), CLAUDE));
}
/// One or more snapshots per machine that offers a paid service, in the
/// order the machines are configured and the provider reports them.
///
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
/// Blocking -- call via `spawn_blocking`. Takes the machines rather than
/// holding the manager, so this module stays below the session layer rather
/// than reaching up into it.
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
pub fn snapshots(&self, machines: &[MachineConfig]) -> Vec<UsageSnapshot> {
let mut fresh = Vec::new();
for setup in setups {
for provider in providers_for(setup, &self.fixture) {
let key = (setup.id.clone(), provider.name());
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
&& fetched.elapsed() < provider.poll_interval()
for machine in machines {
for provider in providers_for(machine, &self.fixture) {
let key = (machine.id.clone(), provider.name());
if let Some(snapshots) =
self.cached(&key, &machine.name, Some(provider.poll_interval()))
{
fresh.extend(snapshots);
continue;
}
let gate = self.gate_for(&key);
let _guard = match gate.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::WouldBlock) => {
// A refresh already in flight can keep serving the last
// measured answer. An explicit login seeds its own
// authenticating answer before taking the gate.
if let Some(snapshots) = self.cached(&key, &machine.name, None) {
fresh.extend(snapshots);
continue;
}
// The first-ever fetch has no honest stale answer. Wait
// for its one producer, then recheck below.
gate.lock().unwrap()
}
Err(std::sync::TryLockError::Poisoned(err)) => err.into_inner(),
};
if let Some(snapshots) =
self.cached(&key, &machine.name, Some(provider.poll_interval()))
{
// Cached numbers, but the machine's *name* is read fresh: a
// rename should show immediately rather than waiting out a
// poll interval it has nothing to do with.
let mut snapshots = snapshot.clone();
for snapshot in &mut snapshots {
snapshot.setup_name = setup.name.clone();
}
fresh.extend(snapshots);
continue;
}
// Fetched without the lock held: this makes a network call per
// machine, and holding the cache across them would serialise
// every phone asking for the screen behind the slowest ssh.
let snapshots = provider.fetch();
self.cache
.lock()
@@ -890,11 +984,14 @@ impl UsageMonitor {
}
// Machines that have gone away should not keep their numbers alive.
let live: std::collections::HashSet<&str> =
setups.iter().map(|setup| setup.id.as_str()).collect();
machines.iter().map(|machine| machine.id.as_str()).collect();
self.cache
.lock()
.unwrap()
.retain(|(setup, _), _| live.contains(setup.as_str()));
.retain(|(machine, _), _| live.contains(machine.as_str()));
self.gates.lock().unwrap().retain(|(machine, _), gate| {
live.contains(machine.as_str()) || Arc::strong_count(gate) > 1
});
fresh
}
}
@@ -904,6 +1001,31 @@ mod tests {
use super::*;
use crate::config::DriverKind;
#[test]
fn refresh_gates_are_shared_per_machine_and_provider_only() {
let monitor = UsageMonitor::new(Fixture::default());
let claude_here = monitor.gate_for(&("here".to_string(), CLAUDE));
let same = monitor.gate_for(&("here".to_string(), CLAUDE));
let claude_there = monitor.gate_for(&("there".to_string(), CLAUDE));
let codex_here = monitor.gate_for(&("here".to_string(), CODEX));
let held = claude_here.lock().unwrap();
assert!(
matches!(same.try_lock(), Err(std::sync::TryLockError::WouldBlock)),
"the same machine/provider pair must have one credential writer"
);
assert!(
claude_there.try_lock().is_ok(),
"another machine must not wait"
);
assert!(
codex_here.try_lock().is_ok(),
"another provider must not wait"
);
drop(held);
assert!(same.try_lock().is_ok());
}
#[test]
fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() {
assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500"));
@@ -913,9 +1035,9 @@ mod tests {
#[test]
fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() {
let provider = ClaudeUsage {
setup: "far".to_string(),
setup_name: "somewhere else".to_string(),
transport: Transport::for_setup(&unreachable_setup()),
machine: "far".to_string(),
machine_name: "somewhere else".to_string(),
transport: Transport::for_machine(&unreachable_machine()),
program: "/opt/claude".to_string(),
};
// The machine cannot be reached, so the refresh attempt fails there
@@ -934,10 +1056,10 @@ mod tests {
"the token must never be quoted back"
);
let UsageState::Failed { detail } = provider.still_expired() else {
panic!("still expired is a fault");
let UsageState::LoginRequired { detail } = provider.still_expired() else {
panic!("still expired must offer a new login");
};
assert!(detail.contains("/opt/claude /login"), "{detail}");
assert!(detail.contains("somewhere else"), "{detail}");
assert!(!detail.contains("unreachable"), "{detail}");
}
@@ -967,10 +1089,10 @@ mod tests {
assert_eq!(windows[3].resets_at, None);
}
/// A setup naming a machine that cannot be dialled, so nothing here touches
/// A machine naming a machine that cannot be dialled, so nothing here touches
/// the network beyond ssh failing to resolve it.
fn unreachable_setup() -> SetupConfig {
SetupConfig {
fn unreachable_machine() -> MachineConfig {
MachineConfig {
id: "far".to_string(),
name: "somewhere else".to_string(),
ssh: Some(crate::config::SshConfig {
@@ -993,9 +1115,9 @@ mod tests {
#[test]
fn a_machine_that_cannot_be_asked_says_so_rather_than_looking_logged_out() {
let provider = ClaudeUsage {
setup: "far".to_string(),
setup_name: "somewhere else".to_string(),
transport: Transport::for_setup(&unreachable_setup()),
machine: "far".to_string(),
machine_name: "somewhere else".to_string(),
transport: Transport::for_machine(&unreachable_machine()),
program: "claude".to_string(),
};
let snapshot = provider.fetch().remove(0);
@@ -1007,8 +1129,8 @@ mod tests {
"{:?}",
snapshot.state
);
assert_eq!(snapshot.setup, "far");
assert_eq!(snapshot.setup_name, "somewhere else");
assert_eq!(snapshot.machine, "far");
assert_eq!(snapshot.machine_name, "somewhere else");
assert!(snapshot.windows.is_empty());
}
@@ -1043,7 +1165,7 @@ mod tests {
#[test]
fn only_machines_that_can_run_claude_are_asked_about_it() {
let mut echo_only = unreachable_setup();
let mut echo_only = unreachable_machine();
echo_only.providers = vec![crate::config::ProviderConfig {
name: "echo".to_string(),
kind: DriverKind::Echo,
@@ -1056,7 +1178,7 @@ mod tests {
// otherwise there is no meter to report.
let unset = Fixture::new();
assert!(providers_for(&echo_only, &unset).is_empty());
assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1);
assert_eq!(providers_for(&unreachable_machine(), &unset).len(), 1);
// And with one set, that machine has exactly the invented meter
// -- under the name the session's `usageProvider` will name.
@@ -1142,8 +1264,8 @@ mod tests {
#[test]
fn keeps_codex_reserve_as_a_named_pool() {
let provider = CodexUsage {
setup: "local".to_string(),
setup_name: "this machine".to_string(),
machine: "local".to_string(),
machine_name: "this machine".to_string(),
transport: Transport::Here,
program: "codex".to_string(),
};