From c12ab7f0987fcb693df70f9aa67f57416b11d829 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 03:13:36 -0400 Subject: [PATCH] Take rustfmt's defaults The code was hand-formatted -- close to rustfmt's output but not it, mostly in keeping chains and call arguments on one line where the formatter would break them. That is a per-line decision every future change has to make again, and reproducing it would mean a config whose only job is to preserve how the code already looks. So this is `cargo fmt` at its defaults, with no rustfmt.toml, which is where the sibling dev-updater checkout already sits: it is clean at the defaults today, so the two repos now agree on layout without either of them configuring it. Formatting only -- no behaviour, no renames, nothing reordered. Verified after: cargo test (35 pass), cargo clippy --all-targets clean, cargo fmt --check clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- server/src/auth.rs | 16 +- server/src/certs.rs | 45 +++-- server/src/config.rs | 31 +++- server/src/main.rs | 36 +++- server/src/media.rs | 5 +- server/src/routes.rs | 41 +++- server/src/session/claude.rs | 308 ++++++++++++++++++++++--------- server/src/session/driver.rs | 35 +++- server/src/session/echo.rs | 59 ++++-- server/src/session/mod.rs | 65 +++++-- server/src/session/transcript.rs | 57 ++++-- server/src/ssh.rs | 24 ++- server/src/usage.rs | 25 ++- 13 files changed, 557 insertions(+), 190 deletions(-) diff --git a/server/src/auth.rs b/server/src/auth.rs index 5474363..d6ddc45 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -72,8 +72,11 @@ pub async fn require_token( .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")); if let Some(token) = presented { - let hashes: Vec = - manager.tokens().into_iter().map(|entry| entry.sha256).collect(); + let hashes: Vec = manager + .tokens() + .into_iter() + .map(|entry| entry.sha256) + .collect(); if token_matches(token, &hashes) { return next.run(request).await; } @@ -105,8 +108,7 @@ mod tests { fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc { let manager = Arc::new( - SessionManager::new(dir.join("config.ron"), dir.join("sessions")) - .expect("manager"), + SessionManager::new(dir.join("config.ron"), dir.join("sessions")).expect("manager"), ); manager .set_tokens(vec![TokenEntry { @@ -195,7 +197,11 @@ mod tests { .oneshot(request(path, auth.as_deref())) .await .expect("response"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path} {auth:?}"); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{path} {auth:?}" + ); } let ok = router diff --git a/server/src/certs.rs b/server/src/certs.rs index 2ce53b1..c6e28be 100644 --- a/server/src/certs.rs +++ b/server/src/certs.rs @@ -73,14 +73,22 @@ pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result { private::write_file(&leaf_key, leaf_key_pem.as_bytes())?; private::write_file(&leaf_cert, leaf_pem.as_bytes())?; - Ok(Certificates { leaf_cert, leaf_key, ca_is_new }) + Ok(Certificates { + leaf_cert, + leaf_key, + ca_is_new, + }) } fn generate_ca() -> Result<(String, String)> { let key = KeyPair::generate().context("generate CA key")?; let mut params = CertificateParams::default(); - params.distinguished_name.push(DnType::OrganizationName, "ai-app dev"); - params.distinguished_name.push(DnType::CommonName, "ai-app dev CA"); + params + .distinguished_name + .push(DnType::OrganizationName, "ai-app dev"); + params + .distinguished_name + .push(DnType::CommonName, "ai-app dev CA"); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); // Explicit, because strict verifiers reject a CA without them -- and // that rejection surfaces as an opaque handshake failure on a phone. @@ -89,20 +97,21 @@ fn generate_ca() -> Result<(String, String)> { Ok((certificate.pem(), key.serialize_pem())) } -fn generate_leaf( - ca_pem: &str, - ca_key_pem: &str, - addresses: &[IpAddr], -) -> Result<(String, String)> { +fn generate_leaf(ca_pem: &str, ca_key_pem: &str, addresses: &[IpAddr]) -> Result<(String, String)> { let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?; let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?; let key = KeyPair::generate().context("generate leaf key")?; let mut params = CertificateParams::default(); - params.distinguished_name.push(DnType::OrganizationName, "ai-app dev"); + params + .distinguished_name + .push(DnType::OrganizationName, "ai-app dev"); params.distinguished_name.push( DnType::CommonName, - addresses.first().map(|a| a.to_string()).unwrap_or_else(|| "ai-app".to_string()), + addresses + .first() + .map(|a| a.to_string()) + .unwrap_or_else(|| "ai-app".to_string()), ); params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect(); params.is_ca = IsCa::ExplicitNoCa; @@ -135,10 +144,16 @@ mod tests { // The CA is the pinned one: replacing it would strand every // installed app, so it must survive a restart untouched. assert!(!second.ca_is_new); - assert_eq!(ca, std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca")); + assert_eq!( + ca, + std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca") + ); // The leaf is not pinned, and is reissued so a new address is just // a restart away. - assert_ne!(leaf, std::fs::read_to_string(&second.leaf_cert).expect("leaf")); + assert_ne!( + leaf, + std::fs::read_to_string(&second.leaf_cert).expect("leaf") + ); } #[test] @@ -146,7 +161,11 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let certs = ensure(dir.path(), &addresses()).expect("generate"); assert_eq!( - std::fs::metadata(dir.path()).expect("dir").permissions().mode() & 0o777, + std::fs::metadata(dir.path()) + .expect("dir") + .permissions() + .mode() + & 0o777, 0o700, ); for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] { diff --git a/server/src/config.rs b/server/src/config.rs index b1090e9..8a4391e 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -63,7 +63,10 @@ pub(crate) mod format { /// one silently mangled. `parse` round-trips either way, since a /// wrapped body parses the same as an unwrapped one re-wrapped. fn unwrap_outer(text: &str) -> String { - let Some(body) = text.strip_prefix("(\n").and_then(|rest| rest.strip_suffix("\n)")) else { + let Some(body) = text + .strip_prefix("(\n") + .and_then(|rest| rest.strip_suffix("\n)")) + else { return text.to_string(); }; let mut out: String = body @@ -305,7 +308,10 @@ mod tests { assert!(first_run.tokens.is_empty()); assert!(first_run.sessions.is_empty()); assert_eq!(first_run.providers().len(), 1); - assert_eq!(first_run.provider(ECHO_PROVIDER).expect("built-in").kind, DriverKind::Echo); + assert_eq!( + first_run.provider(ECHO_PROVIDER).expect("built-in").kind, + DriverKind::Echo + ); let config = Config { tokens: vec![TokenEntry { @@ -347,7 +353,11 @@ mod tests { // Built-in echo plus the configured one; any provider can run on // any host, so they are listed independently. assert_eq!( - loaded.providers().iter().map(|p| p.name.clone()).collect::>(), + loaded + .providers() + .iter() + .map(|p| p.name.clone()) + .collect::>(), ["echo", "claude-cli"], ); @@ -361,8 +371,14 @@ mod tests { // and only `skip_serializing_if` keeps this from writing it back. let text = std::fs::read_to_string(&path).expect("read back"); assert!(!text.trim_start().starts_with('('), "outer parens: {text}"); - assert!(text.starts_with("tokens: ["), "top level should sit at column 0: {text}"); - assert!(text.contains("port: 2222"), "optional written long-hand: {text}"); + assert!( + text.starts_with("tokens: ["), + "top level should sit at column 0: {text}" + ); + assert!( + text.contains("port: 2222"), + "optional written long-hand: {text}" + ); } #[test] @@ -378,6 +394,9 @@ mod tests { }; // One entry, not two: the built-in is skipped rather than shadowed. assert_eq!(config.providers().len(), 1); - assert_eq!(config.provider(ECHO_PROVIDER).expect("provider").kind, DriverKind::ClaudeCli); + assert_eq!( + config.provider(ECHO_PROVIDER).expect("provider").kind, + DriverKind::ClaudeCli + ); } } diff --git a/server/src/main.rs b/server/src/main.rs index 2ba2ab0..7cc59e9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -57,7 +57,9 @@ fn xdg_dir(base: Option, fallback: &str) -> PathBuf { base.map(PathBuf::from) .filter(|path| path.is_absolute()) .unwrap_or_else(|| { - std::env::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(fallback) + std::env::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(fallback) }) .join("ai-app") } @@ -171,8 +173,12 @@ async fn main() -> Result<()> { tracing_subscriber::fmt().with_env_filter("info").init(); let args = Args::parse(); - let config_path = args.config.unwrap_or_else(|| config_home().join("config.ron")); - let data_dir = args.data_dir.unwrap_or_else(|| data_home().join("sessions")); + let config_path = args + .config + .unwrap_or_else(|| config_home().join("config.ron")); + let data_dir = args + .data_dir + .unwrap_or_else(|| data_home().join("sessions")); let manager = Arc::new( SessionManager::new(config_path.clone(), data_dir) .with_context(|| format!("failed to load {}", config_path.display()))?, @@ -185,7 +191,12 @@ async fn main() -> Result<()> { tracing::info!(" host {} -> {}", host.name, host.address); } for info in manager.sessions() { - tracing::info!(" session {} ({}, {:?})", info.id, info.provider, info.status); + tracing::info!( + " session {} ({}, {:?})", + info.id, + info.provider, + info.status + ); } // Before the interface check below, deliberately: the certificates are @@ -237,11 +248,13 @@ async fn main() -> Result<()> { .await .context("failed to load TLS cert/key")?; - let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage { - credentials_path: std::env::home_dir() - .unwrap_or_else(|| PathBuf::from("/")) - .join(".claude/.credentials.json"), - })])); + let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new( + usage::ClaudeUsage { + credentials_path: std::env::home_dir() + .unwrap_or_else(|| PathBuf::from("/")) + .join(".claude/.credentials.json"), + }, + )])); // The bearer-token middleware wraps the entire router -- routes and // fallback alike -- here and only here, so a new route can't forget @@ -281,6 +294,9 @@ mod tests { // Relative values are ignored per the spec, rather than resolving // against whatever the working directory happens to be -- so a // relative setting lands on the same path as no setting at all. - assert_eq!(xdg_dir(Some("relative/path".into()), ".config"), home_fallback); + assert_eq!( + xdg_dir(Some("relative/path".into()), ".config"), + home_fallback + ); } } diff --git a/server/src/media.rs b/server/src/media.rs index 9626716..fe9d58a 100644 --- a/server/src/media.rs +++ b/server/src/media.rs @@ -45,7 +45,10 @@ mod tests { fn the_two_directions_agree() { for (media_type, extension) in IMAGE_TYPES { assert_eq!(extension_for(media_type), Some(extension)); - assert_eq!(media_type_for(&format!("abc123.{extension}")), Some(media_type)); + assert_eq!( + media_type_for(&format!("abc123.{extension}")), + Some(media_type) + ); } } diff --git a/server/src/routes.rs b/server/src/routes.rs index fab6abe..7bad22e 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -103,7 +103,9 @@ fn bad_request(err: anyhow::Error) -> ApiError { } fn lookup(manager: &SessionManager, id: &str) -> Result, ApiError> { - manager.session(id).ok_or_else(|| ApiError::NotFound(format!("no session {id}"))) + manager + .session(id) + .ok_or_else(|| ApiError::NotFound(format!("no session {id}"))) } async fn list_sessions(State(manager): State>) -> axum::Json> { @@ -154,7 +156,10 @@ async fn list_hosts(State(manager): State>) -> axum::Json) -> Router { - Router::new().route("/usage", get(usage)).with_state(monitor) + Router::new() + .route("/usage", get(usage)) + .with_state(monitor) } async fn usage( @@ -276,7 +288,9 @@ async fn set_model( UrlPath(id): UrlPath, axum::Json(body): axum::Json, ) -> Result { - manager.set_session_model(&id, &body.model).map_err(bad_request)?; + manager + .set_session_model(&id, &body.model) + .map_err(bad_request)?; Ok(StatusCode::NO_CONTENT) } @@ -306,7 +320,9 @@ async fn upload_attachment( .bytes() .await .map_err(|err| ApiError::BadRequest(format!("upload read failed: {err}")))?; - let name = session.save_attachment(&bytes, &content_type).map_err(bad_request)?; + let name = session + .save_attachment(&bytes, &content_type) + .map_err(bad_request)?; Ok(axum::Json(serde_json::json!({ "id": name }))) } @@ -323,10 +339,14 @@ async fn serve_file( return Err(ApiError::BadRequest("invalid file id".to_string())); } let session = lookup(&manager, &id)?; - let candidates = - [session.dir().join("files").join(&name), session.dir().join("attachments").join(&name)]; + let candidates = [ + session.dir().join("files").join(&name), + session.dir().join("attachments").join(&name), + ]; let Some(path) = candidates.iter().find(|path| path.is_file()) else { - return Err(ApiError::NotFound(format!("no file {name} in session {id}"))); + return Err(ApiError::NotFound(format!( + "no file {name} in session {id}" + ))); }; // A file that is there but unreadable is this server's fault, not the // request's -- Internal logs it and says nothing more to the caller. @@ -433,5 +453,6 @@ async fn send_event( entry: &SeqEvent, ) -> Result<(), mpsc::error::SendError> { let data = serde_json::to_string(entry).expect("events always serialize"); - tx.send(SseEvent::default().id(entry.seq.to_string()).data(data)).await + tx.send(SseEvent::default().id(entry.seq.to_string()).data(data)) + .await } diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index b046caa..a4b8f57 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -173,11 +173,17 @@ impl ClaudeDriver { let _ = sink.send(Event::Error { message: format!( "{label} exited with {status}{}", - if detail.is_empty() { String::new() } else { format!(": {detail}") } + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } ), }); } - let _ = sink.send(Event::Status { state: SessionStatus::Exited }); + let _ = sink.send(Event::Status { + state: SessionStatus::Exited, + }); }); } @@ -222,7 +228,9 @@ impl Driver for ClaudeDriver { } // Sent mid-turn this queues for injection at the next tool // boundary; sent while idle it starts a turn. - let _ = self.sink.send(Event::Status { state: SessionStatus::Running }); + let _ = self.sink.send(Event::Status { + state: SessionStatus::Running, + }); self.send_line( json!({"type": "user", "message": {"role": "user", "content": content}}).to_string(), ); @@ -235,7 +243,9 @@ impl Driver for ClaudeDriver { }; match response { AnswerOutcome::Respond(control_response) => { - let _ = self.sink.send(Event::Status { state: SessionStatus::Running }); + let _ = self.sink.send(Event::Status { + state: SessionStatus::Running, + }); self.send_line(control_response.to_string()); } // A multi-question AskUserQuestion still waiting on the rest. @@ -288,7 +298,10 @@ async fn read_stdout( let mut lines = BufReader::new(stdout).lines(); while let Ok(Some(line)) = lines.next_line().await { let Ok(message) = serde_json::from_str::(&line) else { - tracing::warn!("unparseable claude output line: {}", &line[..line.len().min(200)]); + tracing::warn!( + "unparseable claude output line: {}", + &line[..line.len().min(200)] + ); continue; }; let (events, new_session_id) = { @@ -329,7 +342,10 @@ fn write_resume_token(session_dir: &Path, session_id: &str) { fn attachment_block(session_dir: &Path, id: &str) -> Result { // Ids are server-generated hex (see routes::upload_attachment); the // check keeps a crafted "id" from naming an arbitrary file. - if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') { + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') + { anyhow::bail!("invalid attachment id"); } let path = session_dir.join("attachments").join(id); @@ -380,13 +396,20 @@ struct Translator { impl Translator { fn new(session_dir: PathBuf) -> Self { - Self { session_id: None, pending: HashMap::new(), session_dir } + Self { + session_id: None, + pending: HashMap::new(), + session_dir, + } } fn translate(&mut self, message: &Value) -> Vec { // Events from subagents (Task tool internals) carry a // parent_tool_use_id; the transcript shows the Task tool's own // start/end instead of every nested step. - if message.get("parent_tool_use_id").is_some_and(|id| !id.is_null()) { + if message + .get("parent_tool_use_id") + .is_some_and(|id| !id.is_null()) + { return Vec::new(); } match message.get("type").and_then(Value::as_str) { @@ -405,18 +428,33 @@ impl Translator { Some("control_response") => { let response = &message["response"]; if response.get("subtype").and_then(Value::as_str) == Some("error") { - let error = response.get("error").and_then(Value::as_str).unwrap_or("unknown"); - vec![Event::Error { message: format!("claude rejected a request: {error}") }] + let error = response + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown"); + vec![Event::Error { + message: format!("claude rejected a request: {error}"), + }] } else { Vec::new() } } Some("result") => { let usage = &message["usage"]; - let tokens = usage.get("input_tokens").and_then(Value::as_u64).unwrap_or(0) - + usage.get("output_tokens").and_then(Value::as_u64).unwrap_or(0); + let tokens = usage + .get("input_tokens") + .and_then(Value::as_u64) + .unwrap_or(0) + + usage + .get("output_tokens") + .and_then(Value::as_u64) + .unwrap_or(0); let mut events = Vec::new(); - if message.get("is_error").and_then(Value::as_bool).unwrap_or(false) { + if message + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false) + { events.push(Event::Error { message: message .get("result") @@ -428,7 +466,9 @@ impl Translator { if tokens > 0 { events.push(Event::UsageDelta { tokens }); } - events.push(Event::Status { state: SessionStatus::Idle }); + events.push(Event::Status { + state: SessionStatus::Idle, + }); events } _ => Vec::new(), @@ -444,7 +484,9 @@ impl Translator { && event["delta"].get("type").and_then(Value::as_str) == Some("text_delta") && let Some(text) = delta.as_str() { - return vec![Event::AssistantText { delta: text.to_string() }]; + return vec![Event::AssistantText { + delta: text.to_string(), + }]; } Vec::new() } @@ -457,8 +499,16 @@ impl Translator { .iter() .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) .map(|block| Event::ToolStart { - id: block.get("id").and_then(Value::as_str).unwrap_or_default().to_string(), - tool: block.get("name").and_then(Value::as_str).unwrap_or_default().to_string(), + id: block + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + tool: block + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), input: block.get("input").cloned().unwrap_or(Value::Null), }) .collect() @@ -469,9 +519,15 @@ impl Translator { if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") { return Vec::new(); } - let request_id = - message.get("request_id").and_then(Value::as_str).unwrap_or_default().to_string(); - let tool_name = request.get("tool_name").and_then(Value::as_str).unwrap_or("a tool"); + let request_id = message + .get("request_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let tool_name = request + .get("tool_name") + .and_then(Value::as_str) + .unwrap_or("a tool"); let input = request.get("input").cloned().unwrap_or(Value::Null); let mut events = Vec::new(); @@ -515,9 +571,16 @@ impl Translator { } self.pending.insert( request_id.clone(), - PendingRequest { request_id, input, questions, answers: HashMap::new() }, + PendingRequest { + request_id, + input, + questions, + answers: HashMap::new(), + }, ); - events.push(Event::Status { state: SessionStatus::AwaitingInput }); + events.push(Event::Status { + state: SessionStatus::AwaitingInput, + }); events } @@ -610,7 +673,9 @@ impl Translator { let source = part.get("source")?; let data = source.get("data")?.as_str()?; use base64::Engine; - let bytes = base64::engine::general_purpose::STANDARD.decode(data).ok()?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .ok()?; // Screenshots are the overwhelming case, and they are PNG; an // unrecognized type is more likely a dialect change than a JPEG. let extension = source @@ -620,10 +685,9 @@ impl Translator { .unwrap_or("png"); let name = format!("{}.{extension}", super::random_hex()); let dir = self.session_dir.join("files"); - if let Err(err) = - crate::private::create_dir(&dir) - .map_err(std::io::Error::other) - .and_then(|()| std::fs::write(dir.join(&name), bytes)) + if let Err(err) = crate::private::create_dir(&dir) + .map_err(std::io::Error::other) + .and_then(|()| std::fs::write(dir.join(&name), bytes)) { tracing::error!("couldn't save produced image: {err}"); return None; @@ -649,7 +713,9 @@ mod tests { let mut translator = Translator::new(dir.path().to_path_buf()); let events = translate_lines( &mut translator, - &[r#"{"type":"system","subtype":"init","cwd":"/x","session_id":"5ecf21da-d53f","tools":[],"model":"claude-haiku-4-5-20251001"}"#], + &[ + r#"{"type":"system","subtype":"init","cwd":"/x","session_id":"5ecf21da-d53f","tools":[],"model":"claude-haiku-4-5-20251001"}"#, + ], ); assert!(events.is_empty()); assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f")); @@ -660,39 +726,59 @@ mod tests { // Real lines (trimmed) from the 2.1.237 probe. let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - let events = translate_lines(&mut translator, &[ - r#"{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Done."}},"session_id":"s","parent_tool_use_id":null}"#, - r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]},"parent_tool_use_id":null,"session_id":"s"}"#, - r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}},"session_id":"s","parent_tool_use_id":null}"#, - ]); - assert_eq!(events, vec![Event::AssistantText { delta: "Done.".to_string() }]); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Done."}},"session_id":"s","parent_tool_use_id":null}"#, + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]},"parent_tool_use_id":null,"session_id":"s"}"#, + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}},"session_id":"s","parent_tool_use_id":null}"#, + ], + ); + assert_eq!( + events, + vec![Event::AssistantText { + delta: "Done.".to_string() + }] + ); } #[test] fn tool_use_and_result_become_tool_events() { let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - let events = translate_lines(&mut translator, &[ - r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"echo probe-ok"}}]},"parent_tool_use_id":null}"#, - r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"probe-ok","is_error":false}]},"parent_tool_use_id":null}"#, - ]); - assert_eq!(events, vec![ - Event::ToolStart { - id: "toolu_01".to_string(), - tool: "Bash".to_string(), - input: serde_json::json!({"command": "echo probe-ok"}), - }, - Event::ToolEnd { id: "toolu_01".to_string(), output: "probe-ok".to_string() }, - ]); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"echo probe-ok"}}]},"parent_tool_use_id":null}"#, + r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"probe-ok","is_error":false}]},"parent_tool_use_id":null}"#, + ], + ); + assert_eq!( + events, + vec![ + Event::ToolStart { + id: "toolu_01".to_string(), + tool: "Bash".to_string(), + input: serde_json::json!({"command": "echo probe-ok"}), + }, + Event::ToolEnd { + id: "toolu_01".to_string(), + output: "probe-ok".to_string() + }, + ] + ); } #[test] fn subagent_events_are_not_duplicated_into_the_transcript() { let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - let events = translate_lines(&mut translator, &[ - r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_parent"}"#, - ]); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_parent"}"#, + ], + ); assert!(events.is_empty()); } @@ -700,16 +786,29 @@ mod tests { fn a_permission_request_becomes_an_allow_deny_question() { let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - let events = translate_lines(&mut translator, &[ - r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/x"},"tool_use_id":"toolu_03"}}"#, - ]); - let Event::Question { id, prompt, options } = &events[0] else { + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/x"},"tool_use_id":"toolu_03"}}"#, + ], + ); + let Event::Question { + id, + prompt, + options, + } = &events[0] + else { panic!("expected a question, got {events:?}"); }; assert_eq!(id, "req-1"); assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x")); assert_eq!(options, &["Allow", "Deny"]); - assert_eq!(events[1], Event::Status { state: SessionStatus::AwaitingInput }); + assert_eq!( + events[1], + Event::Status { + state: SessionStatus::AwaitingInput + } + ); // Allowing echoes the input back; the request is then gone. let AnswerOutcome::Respond(response) = translator.answer("req-1", "Allow") else { @@ -721,16 +820,22 @@ mod tests { response["response"]["response"]["updatedInput"]["command"], "rm -rf /tmp/x" ); - assert!(matches!(translator.answer("req-1", "Allow"), AnswerOutcome::Unknown)); + assert!(matches!( + translator.answer("req-1", "Allow"), + AnswerOutcome::Unknown + )); } #[test] fn denying_a_permission_sends_deny() { let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - translate_lines(&mut translator, &[ - r#"{"type":"control_request","request_id":"req-2","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/etc/passwd"}}}"#, - ]); + translate_lines( + &mut translator, + &[ + r#"{"type":"control_request","request_id":"req-2","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/etc/passwd"}}}"#, + ], + ); let AnswerOutcome::Respond(response) = translator.answer("req-2", "Deny") else { panic!("expected a control response"); }; @@ -743,13 +848,20 @@ mod tests { // updatedInput, keyed by the question text. let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - let events = translate_lines(&mut translator, &[ - r#"{"type":"control_request","request_id":"req-3","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"question":"Which color?","header":"Color","options":[{"label":"Red"},{"label":"Blue"}],"multiSelect":false},{"question":"Which size?","header":"Size","options":[{"label":"S"},{"label":"L"}],"multiSelect":false}]},"tool_use_id":"toolu_04","requires_user_interaction":true}}"#, - ]); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"control_request","request_id":"req-3","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"question":"Which color?","header":"Color","options":[{"label":"Red"},{"label":"Blue"}],"multiSelect":false},{"question":"Which size?","header":"Size","options":[{"label":"S"},{"label":"L"}],"multiSelect":false}]},"tool_use_id":"toolu_04","requires_user_interaction":true}}"#, + ], + ); let questions: Vec<_> = events .iter() .filter_map(|event| match event { - Event::Question { id, prompt, options } => Some((id.clone(), prompt.clone(), options.clone())), + Event::Question { + id, + prompt, + options, + } => Some((id.clone(), prompt.clone(), options.clone())), _ => None, }) .collect(); @@ -760,7 +872,10 @@ mod tests { // First answer alone isn't enough; the response goes out when the // last sub-question is answered, with all answers aboard. - assert!(matches!(translator.answer("req-3#0", "Blue"), AnswerOutcome::Pending)); + assert!(matches!( + translator.answer("req-3#0", "Blue"), + AnswerOutcome::Pending + )); let AnswerOutcome::Respond(response) = translator.answer("req-3#1", "L") else { panic!("expected a control response"); }; @@ -787,44 +902,71 @@ mod tests { assert!(image.ends_with(".png")); let saved = dir.path().join("files").join(image); assert!(saved.is_file(), "image not saved at {}", saved.display()); - assert_eq!(events[1], Event::ToolEnd { - id: "toolu_05".to_string(), - output: "took a screenshot".to_string(), - }); + assert_eq!( + events[1], + Event::ToolEnd { + id: "toolu_05".to_string(), + output: "took a screenshot".to_string(), + } + ); } #[test] fn a_turn_result_reports_usage_and_returns_to_idle() { let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - let events = translate_lines(&mut translator, &[ - r#"{"type":"result","subtype":"success","is_error":false,"num_turns":2,"stop_reason":"end_turn","session_id":"s","total_cost_usd":0.0149,"usage":{"input_tokens":18,"output_tokens":164}}"#, - ]); - assert_eq!(events, vec![ - Event::UsageDelta { tokens: 182 }, - Event::Status { state: SessionStatus::Idle }, - ]); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"result","subtype":"success","is_error":false,"num_turns":2,"stop_reason":"end_turn","session_id":"s","total_cost_usd":0.0149,"usage":{"input_tokens":18,"output_tokens":164}}"#, + ], + ); + assert_eq!( + events, + vec![ + Event::UsageDelta { tokens: 182 }, + Event::Status { + state: SessionStatus::Idle + }, + ] + ); } #[test] fn an_error_result_surfaces_the_message() { let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - let events = translate_lines(&mut translator, &[ - r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"something broke","usage":{}}"#, - ]); - assert_eq!(events[0], Event::Error { message: "something broke".to_string() }); - assert_eq!(*events.last().unwrap(), Event::Status { state: SessionStatus::Idle }); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"something broke","usage":{}}"#, + ], + ); + assert_eq!( + events[0], + Event::Error { + message: "something broke".to_string() + } + ); + assert_eq!( + *events.last().unwrap(), + Event::Status { + state: SessionStatus::Idle + } + ); } #[test] fn replayed_and_synthetic_user_text_is_skipped() { let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); - let events = translate_lines(&mut translator, &[ - r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"isReplay":true,"parent_tool_use_id":null}"#, - r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"[continue]"}]},"isSynthetic":true,"parent_tool_use_id":null}"#, - ]); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"isReplay":true,"parent_tool_use_id":null}"#, + r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"[continue]"}]},"isSynthetic":true,"parent_tool_use_id":null}"#, + ], + ); assert!(events.is_empty()); } } diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index 1b98eda..b5c387c 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -26,17 +26,27 @@ pub enum Event { /// What the user sent, echoed into the transcript by the manager (not /// by drivers) so every device renders the full conversation from the /// one stream. - UserMessage { text: String }, + UserMessage { + text: String, + }, /// Streaming assistant text; the phone renders the concatenation as /// markdown. - AssistantText { delta: String }, + AssistantText { + delta: String, + }, ToolStart { id: String, tool: String, input: serde_json::Value, }, - ToolUpdate { id: String, output: String }, - ToolEnd { id: String, output: String }, + ToolUpdate { + id: String, + output: String, + }, + ToolEnd { + id: String, + output: String, + }, /// An image the session produced or was sent, saved under the session /// dir and referenced by id; the phone fetches it by URL. Image { @@ -53,11 +63,20 @@ pub enum Event { /// The manager's record of a question being answered, so a rendered /// question card resolves on every device, not just the one that /// answered it. - Answered { id: String, answer: String }, - Status { state: SessionStatus }, + Answered { + id: String, + answer: String, + }, + Status { + state: SessionStatus, + }, /// Per-turn token counts, where the dialect reports them. - UsageDelta { tokens: u64 }, - Error { message: String }, + UsageDelta { + tokens: u64, + }, + Error { + message: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 8d4f70c..a94e792 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -28,8 +28,13 @@ pub struct EchoDriver { impl EchoDriver { pub fn new(sink: EventSink) -> Self { - let driver = Self { sink, pending_question: Mutex::new(None) }; - driver.emit(Event::Status { state: SessionStatus::Idle }); + let driver = Self { + sink, + pending_question: Mutex::new(None), + }; + driver.emit(Event::Status { + state: SessionStatus::Idle, + }); driver } @@ -53,22 +58,30 @@ impl Driver for EchoDriver { format!("Echo asks: {}", rest.trim()) }; *self.pending_question.lock().unwrap() = Some(id.clone()); - self.emit(Event::Status { state: SessionStatus::Running }); + self.emit(Event::Status { + state: SessionStatus::Running, + }); self.emit(Event::Question { id, prompt, options: vec!["Yes".to_string(), "No".to_string()], }); - self.emit(Event::Status { state: SessionStatus::AwaitingInput }); + self.emit(Event::Status { + state: SessionStatus::AwaitingInput, + }); return; } - let run_tool = text.strip_prefix("/tool").map(|rest| rest.trim().to_string()); + let run_tool = text + .strip_prefix("/tool") + .map(|rest| rest.trim().to_string()); tokio::spawn(async move { let send = |event: Event| { let _ = sink.send(event); }; - send(Event::Status { state: SessionStatus::Running }); + send(Event::Status { + state: SessionStatus::Running, + }); if let Some(input) = run_tool { let id = format!("t-{}", super::random_hex()); @@ -78,18 +91,30 @@ impl Driver for EchoDriver { input: serde_json::json!({ "input": input }), }); tokio::time::sleep(DELTA_DELAY).await; - send(Event::ToolUpdate { id: id.clone(), output: "working...".to_string() }); + send(Event::ToolUpdate { + id: id.clone(), + output: "working...".to_string(), + }); tokio::time::sleep(DELTA_DELAY).await; - send(Event::ToolEnd { id, output: format!("echoed: {input}") }); + send(Event::ToolEnd { + id, + output: format!("echoed: {input}"), + }); } // Word-at-a-time so streaming is visibly streaming. for word in format!("You said: {text}").split_inclusive(' ') { - send(Event::AssistantText { delta: word.to_string() }); + send(Event::AssistantText { + delta: word.to_string(), + }); tokio::time::sleep(DELTA_DELAY).await; } - send(Event::UsageDelta { tokens: text.split_whitespace().count() as u64 }); - send(Event::Status { state: SessionStatus::Idle }); + send(Event::UsageDelta { + tokens: text.split_whitespace().count() as u64, + }); + send(Event::Status { + state: SessionStatus::Idle, + }); }); } @@ -101,7 +126,9 @@ impl Driver for EchoDriver { self.emit(Event::AssistantText { delta: format!("You answered: {answer}"), }); - self.emit(Event::Status { state: SessionStatus::Idle }); + self.emit(Event::Status { + state: SessionStatus::Idle, + }); } _ => self.emit(Event::Error { message: format!("no question {id} is awaiting an answer"), @@ -113,7 +140,9 @@ impl Driver for EchoDriver { // Nothing real to stop; a pending question is abandoned so the // session isn't stuck awaiting input forever. *self.pending_question.lock().unwrap() = None; - self.emit(Event::Status { state: SessionStatus::Idle }); + self.emit(Event::Status { + state: SessionStatus::Idle, + }); } fn set_model(&self, model: &str) { @@ -129,6 +158,8 @@ impl Driver for EchoDriver { } fn shutdown(&self) { - self.emit(Event::Status { state: SessionStatus::Exited }); + self.emit(Event::Status { + state: SessionStatus::Exited, + }); } } diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 98c8ae1..2160e75 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -35,7 +35,10 @@ use transcript::{SeqEvent, Transcript}; const EVENT_BUFFER: usize = 256; pub fn now() -> f64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() } /// What the phone needs to spawn a session -- the spawn screen's fields. @@ -99,7 +102,9 @@ impl LiveSession { // Attachments render in the transcript like any produced image -- // the files route serves uploads by the same ref. for image in &images { - let _ = self.sink.send(Event::Image { image: image.clone() }); + let _ = self.sink.send(Event::Image { + image: image.clone(), + }); } let _ = self.sink.send(Event::UserMessage { text: text.clone() }); self.driver.send_user_message(text, images); @@ -132,7 +137,9 @@ impl LiveSession { /// The session's directory (attachments in, produced files out live in /// `attachments/` and `files/` under it). pub fn dir(&self) -> &Path { - self.transcript_path.parent().expect("transcript lives in the session dir") + self.transcript_path + .parent() + .expect("transcript lives in the session dir") } /// Stores one uploaded attachment, returning the id `POST /message` @@ -194,9 +201,9 @@ impl SessionManager { // unreachable ssh host, a provider that was edited away -- // shows as exited rather than taking the whole server down // with it, and can still be deleted from the phone. - match resolve(&config, meta) - .and_then(|(provider, host)| launch(meta.clone(), &provider, host.as_ref(), &data_dir)) - { + match resolve(&config, meta).and_then(|(provider, host)| { + launch(meta.clone(), &provider, host.as_ref(), &data_dir) + }) { Ok(session) => { live.insert(meta.id.clone(), session); } @@ -462,12 +469,21 @@ fn launch( let driver: Box = match provider.kind { DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())), - DriverKind::ClaudeCli => { - Box::new(ClaudeDriver::spawn(&meta, provider, host, &dir, sink.clone())?) - } + DriverKind::ClaudeCli => Box::new(ClaudeDriver::spawn( + &meta, + provider, + host, + &dir, + sink.clone(), + )?), }; - tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone())); + tokio::spawn(pump( + transcript, + source, + Arc::clone(&shared), + events.clone(), + )); Ok(Arc::new(LiveSession { meta, @@ -547,7 +563,12 @@ mod tests { } fn is_idle(event: &Event) -> bool { - matches!(event, Event::Status { state: SessionStatus::Idle }) + matches!( + event, + Event::Status { + state: SessionStatus::Idle + } + ) } /// Collects one full echo turn: everything up to the idle that follows @@ -611,25 +632,33 @@ mod tests { assert!(manager.sessions().is_empty()); assert!(manager.session(&info.id).is_none()); assert!(!data_dir.join(&info.id).exists()); - assert!(Config::load(&config_path).expect("reload").sessions.is_empty()); + assert!( + Config::load(&config_path) + .expect("reload") + .sessions + .is_empty() + ); assert!(manager.delete_session(&info.id).is_err()); } #[tokio::test] async fn questions_round_trip_through_answer() { let dir = tempfile::tempdir().expect("tempdir"); - let manager = SessionManager::new( - dir.path().join("config.ron"), - dir.path().join("sessions"), - ) - .expect("manager"); + let manager = + SessionManager::new(dir.path().join("config.ron"), dir.path().join("sessions")) + .expect("manager"); let info = manager.spawn_session(echo_spec()).expect("spawn"); let session = manager.session(&info.id).expect("live session"); let mut rx = session.subscribe(); session.send_message("/question deploy?".to_string(), Vec::new()); let seen = collect_until(&mut rx, |event| { - matches!(event, Event::Status { state: SessionStatus::AwaitingInput }) + matches!( + event, + Event::Status { + state: SessionStatus::AwaitingInput + } + ) }) .await; let question_id = seen diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index 9913d4e..b1c243a 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -45,17 +45,26 @@ impl Transcript { .mode(0o600) .open(path) .with_context(|| format!("open transcript {}", path.display()))?; - Ok(Self { file, next_seq: last_seq + 1 }) + Ok(Self { + file, + next_seq: last_seq + 1, + }) } /// Appends `event`, assigning it the next sequence number. Flushed per /// event: each line is tiny, and the transcript is the source of truth /// a crash must not lose the tail of. pub fn append(&mut self, event: Event, ts: f64) -> Result { - let entry = SeqEvent { seq: self.next_seq, ts, event }; + let entry = SeqEvent { + seq: self.next_seq, + ts, + event, + }; let mut line = serde_json::to_string(&entry).context("serialize event")?; line.push('\n'); - self.file.write_all(line.as_bytes()).context("append to transcript")?; + self.file + .write_all(line.as_bytes()) + .context("append to transcript")?; self.next_seq += 1; Ok(entry) } @@ -86,7 +95,10 @@ pub fn read_after(path: &Path, after: u64) -> Result> { } fn last_seq(path: &Path) -> Result { - Ok(read_after(path, 0)?.last().map(|entry| entry.seq).unwrap_or(0)) + Ok(read_after(path, 0)? + .last() + .map(|entry| entry.seq) + .unwrap_or(0)) } #[cfg(test)] @@ -95,7 +107,9 @@ mod tests { use crate::session::driver::SessionStatus; fn text(delta: &str) -> Event { - Event::AssistantText { delta: delta.to_string() } + Event::AssistantText { + delta: delta.to_string(), + } } #[test] @@ -135,7 +149,11 @@ mod tests { #[test] fn a_missing_file_reads_as_empty() { let dir = tempfile::tempdir().expect("tempdir"); - assert!(read_after(&dir.path().join("nope.jsonl"), 0).expect("read").is_empty()); + assert!( + read_after(&dir.path().join("nope.jsonl"), 0) + .expect("read") + .is_empty() + ); } #[test] @@ -150,18 +168,33 @@ mod tests { tool: "bash".into(), input: serde_json::json!({"command": "ls"}), }, - Event::ToolUpdate { id: "t1".into(), output: "partial".into() }, - Event::ToolEnd { id: "t1".into(), output: "done".into() }, - Event::Image { image: "img1".into() }, + Event::ToolUpdate { + id: "t1".into(), + output: "partial".into(), + }, + Event::ToolEnd { + id: "t1".into(), + output: "done".into(), + }, + Event::Image { + image: "img1".into(), + }, Event::Question { id: "q1".into(), prompt: "Allow?".into(), options: vec!["Yes".into(), "No".into()], }, - Event::Answered { id: "q1".into(), answer: "Yes".into() }, - Event::Status { state: SessionStatus::Idle }, + Event::Answered { + id: "q1".into(), + answer: "Yes".into(), + }, + Event::Status { + state: SessionStatus::Idle, + }, Event::UsageDelta { tokens: 42 }, - Event::Error { message: "boom".into() }, + Event::Error { + message: "boom".into(), + }, ]; let mut transcript = Transcript::open(&path).expect("open"); diff --git a/server/src/ssh.rs b/server/src/ssh.rs index 28e7958..dc971a9 100644 --- a/server/src/ssh.rs +++ b/server/src/ssh.rs @@ -22,8 +22,11 @@ use crate::config::HostConfig; /// password prompt that nothing can answer; the keepalives turn a silently /// dropped link into a process exit, which the session reports as `exited` /// rather than appearing to hang forever. -const SSH_OPTIONS: [&str; 3] = - ["BatchMode=yes", "ServerAliveInterval=30", "ServerAliveCountMax=3"]; +const SSH_OPTIONS: [&str; 3] = [ + "BatchMode=yes", + "ServerAliveInterval=30", + "ServerAliveCountMax=3", +]; /// Builds the child process for `program args…`, run in `cwd`, either on /// this machine (`host` absent) or on `host`. @@ -128,9 +131,17 @@ mod tests { #[test] fn a_session_with_no_host_runs_the_command_directly() { - let command = command(None, "claude", &args(["-p", "--verbose"]), Some(Path::new("/tmp/x"))); + let command = command( + None, + "claude", + &args(["-p", "--verbose"]), + Some(Path::new("/tmp/x")), + ); assert_eq!(argv(&command), ["claude", "-p", "--verbose"]); - assert_eq!(command.as_std().get_current_dir(), Some(Path::new("/tmp/x"))); + assert_eq!( + command.as_std().get_current_dir(), + Some(Path::new("/tmp/x")) + ); } #[test] @@ -199,7 +210,10 @@ mod tests { let evil = Path::new("/tmp/'; touch /tmp/pwned; '"); let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil))); let script = rendered.last().unwrap(); - assert_eq!(script, r"cd '/tmp/'\''; touch /tmp/pwned; '\''' && exec 'claude'"); + assert_eq!( + script, + r"cd '/tmp/'\''; touch /tmp/pwned; '\''' && exec 'claude'" + ); assert!(!script.contains("; touch /tmp/pwned; '\" ")); } } diff --git a/server/src/usage.rs b/server/src/usage.rs index bac9e60..4c93a02 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -90,7 +90,11 @@ impl ClaudeUsage { serde_json::from_str::(&text) .ok() .and_then(|creds| { - creds.get("claudeAiOauth")?.get("accessToken")?.as_str().map(String::from) + creds + .get("claudeAiOauth")? + .get("accessToken")? + .as_str() + .map(String::from) }) .ok_or_else(|| { "credential file has no claudeAiOauth.accessToken -- log in with `claude` once" @@ -148,7 +152,10 @@ fn parse_windows(body: &Value) -> Vec { .iter() .filter_map(|limit| { let percent = limit.get("percent")?.as_f64()?; - let kind = limit.get("kind").and_then(Value::as_str).unwrap_or("unknown"); + let kind = limit + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown"); let scope_model = limit .get("scope") .and_then(|scope| scope.get("model")) @@ -168,7 +175,10 @@ fn parse_windows(body: &Value) -> Vec { .get("resets_at") .and_then(Value::as_str) .map(String::from), - active: limit.get("is_active").and_then(Value::as_bool).unwrap_or(false), + active: limit + .get("is_active") + .and_then(Value::as_bool) + .unwrap_or(false), }) }) .collect() @@ -184,7 +194,10 @@ pub struct UsageMonitor { impl UsageMonitor { pub fn new(providers: Vec>) -> Self { - Self { providers, cache: Mutex::new(Vec::new()) } + Self { + providers, + cache: Mutex::new(Vec::new()), + } } /// Blocking -- call via `spawn_blocking`. @@ -241,7 +254,9 @@ mod tests { #[test] fn missing_credentials_degrade_to_unavailable() { - let provider = ClaudeUsage { credentials_path: PathBuf::from("/nonexistent/creds.json") }; + let provider = ClaudeUsage { + credentials_path: PathBuf::from("/nonexistent/creds.json"), + }; let snapshot = provider.fetch(); assert!(!snapshot.available); assert!(snapshot.error.expect("reason").contains("logged in"));