//! Claude Code as an engine: a chat whose model is "claude-code/" is not run by our own //! agent loop but by the `claude` CLI (`-p`, stream-json). Its events become normal chat //! messages; its tool approvals (`can_use_tool`) go through our permission system, so cards, //! push actions, "allow for this project" and the sandbox work as for the native agent. //! //! Login is done in the web UI: `claude auth login` runs in a PTY, the sign-in URL is shown //! and the code the Anthropic page displays is pasted back (see the `login_*` functions). use crate::app::{App, PermissionReply}; use crate::config::ModelConfig; use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command}; use crate::provider::{ChatRequest, Provider, StreamEvent}; use crate::store::UsageRecord; use crate::types::{Event, ImageData, Message, Part, Role, Session, Usage, new_id, now_ms}; use anyhow::{Result, anyhow, bail}; use async_trait::async_trait; use serde_json::{Value, json}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; pub const PROVIDER: &str = "claude-code"; /// The `claude` binary of the server user (outside the sandbox); None = not installed. /// Where updates from the web UI are installed (npm prefix in the persistent home); it is first in /// `binary()`'s search and is made readable inside the sandbox, so one update serves all users. fn managed_dir() -> Option { std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".npm-global")) } fn managed_binary() -> Option { managed_dir().map(|d| d.join("bin/claude")).filter(|p| p.is_file()) } pub fn binary() -> Option { if let Some(m) = managed_binary() { return Some(m); } let mut dirs: Vec = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect(); if let Some(h) = std::env::var_os("HOME") { dirs.push(PathBuf::from(&h).join(".local/bin")); dirs.push(PathBuf::from(&h).join(".npm-global/bin")); } dirs.into_iter().map(|d| d.join("claude")).find(|p| p.is_file()) } /// Registered like a provider so the model picker, validation and statistics need no special cases; /// requests never reach it (the runner hands such chats to `run`). pub struct Marker; #[async_trait] impl Provider for Marker { async fn stream(&self, _req: ChatRequest, _tx: mpsc::Sender, _cancel: CancellationToken) -> Result<()> { bail!("this model runs through Claude Code and cannot be used for background tasks – set a small_model in the configuration") } async fn list_models(&self) -> Result> { Ok(models()) } } pub fn models() -> Vec { [("default", "Claude Code (default model)"), ("opus", "Claude Code · Opus"), ("sonnet", "Claude Code · Sonnet"), ("haiku", "Claude Code · Haiku")] .into_iter() .map(|(id, name)| ModelConfig { id: id.into(), name: Some(name.into()), vision: true, reasoning: true, ..Default::default() }) .collect() } pub fn is_claude_code(model_spec: &str) -> bool { model_spec.starts_with("claude-code/") } /// A command running `claude` for this user: in the user's sandbox, or directly with a minimal /// environment (no server secrets; the CLI uses its own sign-in from HOME). fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Result { let refs: Vec<&str> = args.iter().map(String::as_str).collect(); if let Some(spec) = crate::sandbox::spec_for(app, owner) { if !crate::sandbox::available() { bail!("Claude Code is disabled for this account: the sandbox (bubblewrap) is not available"); } // An update installed from the web UI lives outside the image: mounted read-only, called by path. if let (Some(bin), Some(dir)) = (managed_binary(), managed_dir()) { let program = bin.display().to_string(); return Ok(crate::sandbox::command_with(&spec, cwd, &[dir.as_path()], &program, &refs)); } return Ok(crate::sandbox::command(&spec, cwd, "claude", &refs)); } let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?; let mut c = tokio::process::Command::new(bin); c.current_dir(cwd).env_clear(); for k in ["PATH", "HOME", "LANG", "USER", "SHELL"] { if let Some(v) = std::env::var_os(k) { c.env(k, v); } } c.env("TERM", "xterm-256color").env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").args(args); // The user's git credentials and identity (the CLI runs git for the agent). if let Some(user) = owner.and_then(|id| app.store.user(id).ok().flatten()) { if let Some(cfg) = crate::gitcreds::global_config(app, &user) { c.env("GIT_CONFIG_GLOBAL", cfg); } c.envs(crate::gitcreds::identity_env(app, &user.id)); c.envs(crate::sshkeys::env(app, &user)); } Ok(c) } // --- tool mapping ----------------------------------------------------------------------- /// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours. fn map_tool(name: &str, input: &Value) -> (String, Value) { let mut v = input.clone(); let rename = |v: &mut Value, from: &str, to: &str| { if let Some(x) = v.get(from).cloned() { v[to] = x; if let Some(o) = v.as_object_mut() { o.remove(from); } } }; let ours = match name { "Bash" => "bash", "Read" => "read", "Write" => "write", "Edit" | "MultiEdit" | "NotebookEdit" => "edit", "Glob" => "glob", "Grep" => "grep", "WebFetch" => "web_fetch", "WebSearch" => "web_search", "Task" | "Agent" => "task", other => return (other.to_lowercase(), v), }; rename(&mut v, "file_path", "path"); rename(&mut v, "notebook_path", "path"); (ours.to_string(), v) } struct CcTool { name: String, } impl ToolInfo for CcTool { fn name(&self) -> &str { &self.name } fn read_only(&self) -> bool { matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list") } fn is_edit(&self) -> bool { matches!(self.name.as_str(), "write" | "edit") } } fn unified(path: &str, old: &str, new: &str) -> String { similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(path, path).to_string() } /// Diff of a pending write/edit (shown on the approval card and kept as the result's diff). fn preview_diff(name: &str, input: &Value) -> Option { let path = input["path"].as_str()?; let old = std::fs::read_to_string(path).unwrap_or_default(); match name { "write" => Some(unified(path, &old, input["content"].as_str()?)), "edit" => { let (from, to) = (input["old_string"].as_str()?, input["new_string"].as_str()?); let new = if input["replace_all"].as_bool() == Some(true) { old.replace(from, to) } else { old.replacen(from, to, 1) }; Some(unified(path, &old, &new)) } _ => None, } } fn title_of(name: &str, input: &Value, claude_input: &Value) -> String { match name { "bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()), "write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(), _ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()), } } /// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection). async fn decide(app: &Arc, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option) { let (name, input) = map_tool(claude_name, claude_input); if claude_name == "AskUserQuestion" { return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into())); } let info = CcTool { name: name.clone() }; let cfg = app.config(); let root = app.root_session_id(&session.id); let (mut decision, approved) = app.with_runtime(&root, |r| { (Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(&info, &input), r.approved.clone()) }); if decision == Decision::Ask { let project = app.store.project_approvals(&session.cwd).unwrap_or_default(); if !project.is_empty() { let mut all = approved; all.extend(project); decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(&info, &input); } } match decision { Decision::Deny => return (false, Some("Denied by the permission rules in the configuration.".into())), Decision::Allow => {} Decision::Ask => { let diff = preview_diff(&name, &input); let (reply, message, _) = app.request_permission(&session.id, &name, title_of(&name, &input, claude_input), input.clone(), diff, cancel.clone()).await; match reply { PermissionReply::Reject => { let mut text = "The user rejected this tool call.".to_string(); if let Some(m) = message.filter(|m| !m.trim().is_empty()) { text.push_str(&format!(" User feedback: {m}")); } return (false, Some(text)); } PermissionReply::Always | PermissionReply::Project => { let mut keys: Vec<(String, bool)> = Vec::new(); if name == "bash" { for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() { keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part))); } } else { keys.push((name.clone(), true)); } app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone()))); if reply == PermissionReply::Project { for (k, ok) in &keys { if *ok { let _ = app.store.add_project_approval(&session.cwd, k); } } } } PermissionReply::Once => {} } } } // The state before the agent's change (turn changes, rewind). if matches!(name.as_str(), "write" | "edit") { if let Some(path) = input["path"].as_str() { let before = std::fs::read(path).ok(); let _ = app.store.add_checkpoint(&session.id, path, before.as_deref()); } } (true, None) } // --- the turn --------------------------------------------------------------------------- struct Cur { id: String, api_id: String, parts: Vec, usage: Usage, model: Option, started: i64, } fn tokens(u: &Value) -> Usage { Usage { input_tokens: u["input_tokens"].as_u64().unwrap_or(0), output_tokens: u["output_tokens"].as_u64().unwrap_or(0), cache_read_tokens: u["cache_read_input_tokens"].as_u64().unwrap_or(0), cache_write_tokens: u["cache_creation_input_tokens"].as_u64().unwrap_or(0), cost: None, allowance: None, } } fn finalize(app: &App, session_id: &str, cur: &mut Option, model_id: &str) { let Some(c) = cur.take() else { return }; if c.parts.is_empty() { return; } let mut m = Message::new(Role::Assistant, c.parts); m.id = c.id; m.created_at = c.started; m.model = Some(format!("{PROVIDER}/{}", c.model.as_deref().unwrap_or(model_id))); m.usage = Some(c.usage.clone()); if app.store.add_message(session_id, &m).is_ok() { app.emit(Event::MessageAdded { session_id: session_id.into(), message: m }); } } /// Text of a tool result: a string, or a list of blocks. fn result_text(content: &Value) -> String { match content { Value::String(s) => s.clone(), Value::Array(a) => a.iter().filter_map(|b| b["text"].as_str()).collect::>().join("\n"), other => other.to_string(), } } /// Images inside a tool result (the CLI's Read of a picture): base64 blocks. fn result_images(content: &Value) -> Vec { content .as_array() .into_iter() .flatten() .filter(|b| b["type"] == "image" && b["source"]["type"] == "base64") .filter_map(|b| { let data = b["source"]["data"].as_str().filter(|d| !d.is_empty())?; Some(ImageData { media_type: b["source"]["media_type"].as_str().unwrap_or("image/png").to_string(), data: data.to_string(), blob: None, size: None }) }) .collect() } /// Images of the prompt as files the CLI can read (it has no inline image input in this mode). fn save_images(cwd: &str, parts: &[Part]) -> Vec { use base64::Engine; let dir = Path::new(cwd).join(".codecraft/uploads"); let mut out = Vec::new(); for (i, p) in parts.iter().enumerate() { let Part::Image { image } = p else { continue }; let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue }; let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png"); let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms())); if std::fs::create_dir_all(&dir).is_ok() { let _ = std::fs::write(dir.join(".gitignore"), "*\n"); if std::fs::write(&path, bytes).is_ok() { out.push(path.display().to_string()); } } } out } /// Run one turn of a "claude-code/…" chat. Returns the final text. pub async fn run(app: Arc, session_id: &str, cancel: CancellationToken) -> Result { let session = app.session(session_id)?; let owner = session.owner_id.clone(); let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string(); let history = crate::runner::active_history(app.store.messages(session_id)?); let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else { bail!("nothing to do: no user message"); }; let mut prompt = history[last_user].parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::>().join("\n\n"); let images = save_images(&session.cwd, &history[last_user].parts); if !images.is_empty() { prompt.push_str(&format!("\n\nAttached images (read them with the Read tool):\n{}", images.iter().map(|p| format!("- {p}")).collect::>().join("\n"))); } // Continue the CLI's own conversation; without one (new chat, fork, model switch) give it the history as context. let key = format!("claude_sid:{session_id}"); let resume: Option = owner.as_deref().and_then(|u| app.store.user_setting::>(u, &key).ok().flatten()); if resume.is_none() && last_user > 0 { let earlier = crate::runner::transcript_of(&history[..last_user]); prompt = format!("\n{}\n\n\n{prompt}", crate::runner::fit(&earlier, 20_000)); } let mode = match (session.agent.as_str(), session.permission_mode.as_str()) { ("plan", _) => "plan", _ => "default", }; let mut args: Vec = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio", "--permission-mode", mode] .iter() .map(|s| s.to_string()) .collect(); if model_id != "default" { args.extend(["--model".into(), model_id.clone()]); } if let Some(r) = &resume { args.extend(["--resume".into(), r.clone()]); } let mut appended = session.instructions.trim().to_string(); for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() { appended = format!("{appended}\n{extra}").trim().to_string(); } if !appended.is_empty() { args.extend(["--append-system-prompt".into(), appended]); } let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?; let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true).spawn().map_err(|e| anyhow!("could not start Claude Code: {e}"))?; let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?; let stderr_tail = Arc::new(Mutex::new(String::new())); { let tail = stderr_tail.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(l)) = lines.next_line().await { let mut t = tail.lock().unwrap(); t.push_str(&l); t.push('\n'); if t.len() > 4000 { let cut = t.len() - 3000; let cut = (cut..t.len()).find(|i| t.is_char_boundary(*i)).unwrap_or(t.len()); t.drain(..cut); } } }); } let first = json!({"type": "user", "message": {"role": "user", "content": prompt}}); stdin.write_all(format!("{first}\n").as_bytes()).await?; let mut lines = BufReader::new(child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?).lines(); let started = now_ms(); let mut cur: Option = None; let mut calls: HashMap = HashMap::new(); let mut diffs: HashMap = HashMap::new(); let mut final_text = String::new(); let mut got_result = false; let mut failure: Option = None; loop { let line = tokio::select! { l = lines.next_line() => l?, _ = cancel.cancelled() => { let _ = child.start_kill(); break; } }; let Some(line) = line else { break }; let Ok(ev) = serde_json::from_str::(&line) else { continue }; let sub = ev["parent_tool_use_id"].as_str().is_some(); match ev["type"].as_str().unwrap_or("") { "system" if ev["subtype"] == "init" => { if let (Some(sid), Some(u)) = (ev["session_id"].as_str(), owner.as_deref()) { let _ = app.store.set_user_setting(u, &key, &Some(sid.to_string())); } } "stream_event" if !sub => { let e = &ev["event"]; match e["type"].as_str().unwrap_or("") { "message_start" => { finalize(&app, session_id, &mut cur, &model_id); let u = tokens(&e["message"]["usage"]); let id = new_id(); app.emit(Event::StepStarted { session_id: session_id.into(), message_id: id.clone(), started_at: now_ms(), prompt_tokens: u.input_tokens + u.cache_read_tokens + u.cache_write_tokens, estimated: false }); cur = Some(Cur { id, api_id: e["message"]["id"].as_str().unwrap_or("").into(), parts: vec![], usage: u, model: e["message"]["model"].as_str().map(String::from), started: now_ms() }); } "content_block_delta" => { if let Some(c) = &cur { match e["delta"]["type"].as_str().unwrap_or("") { "text_delta" => app.emit(Event::TextDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["text"].as_str().unwrap_or("").into() }), "thinking_delta" => app.emit(Event::ReasoningDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["thinking"].as_str().unwrap_or("").into() }), _ => {} } } } "message_delta" => { if let Some(c) = &mut cur { if let Some(o) = e["usage"]["output_tokens"].as_u64() { c.usage.output_tokens = o; } } } "message_stop" => finalize(&app, session_id, &mut cur, &model_id), _ => {} } } "assistant" if !sub => { let api_id = ev["message"]["id"].as_str().unwrap_or("").to_string(); if cur.as_ref().is_none_or(|c| c.api_id != api_id) { finalize(&app, session_id, &mut cur, &model_id); cur = Some(Cur { id: new_id(), api_id, parts: vec![], usage: tokens(&ev["message"]["usage"]), model: ev["message"]["model"].as_str().map(String::from), started: now_ms() }); } let c = cur.as_mut().unwrap(); for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() { let part = match b["type"].as_str().unwrap_or("") { "text" => Part::Text { text: b["text"].as_str().unwrap_or("").to_string() }, "thinking" => Part::Reasoning { text: b["thinking"].as_str().unwrap_or("").to_string(), signature: b["signature"].as_str().map(String::from) }, "tool_use" => { let (name, input) = map_tool(b["name"].as_str().unwrap_or(""), &b["input"]); let id = b["id"].as_str().unwrap_or("").to_string(); calls.insert(id.clone(), name.clone()); Part::ToolCall { id, name, input, extra: None } } _ => continue, }; if !c.parts.contains(&part) && !matches!(&part, Part::ToolCall { id, .. } if c.parts.iter().any(|p| matches!(p, Part::ToolCall { id: i, .. } if i == id))) { c.parts.push(part); } } } "user" if !sub => { finalize(&app, session_id, &mut cur, &model_id); let mut parts = Vec::new(); for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() { if b["type"] != "tool_result" { continue; } let id = b["tool_use_id"].as_str().unwrap_or("").to_string(); let is_error = b["is_error"].as_bool().unwrap_or(false); let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) }; parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta }); } if !parts.is_empty() { let m = Message::new(Role::User, parts); if app.store.add_message(session_id, &m).is_ok() { app.emit(Event::MessageAdded { session_id: session_id.into(), message: m }); } } } "control_request" if ev["request"]["subtype"] == "can_use_tool" => { finalize(&app, session_id, &mut cur, &model_id); let req = &ev["request"]; let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await; if allow { let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]); if let (Some(d), Some(id)) = (preview_diff(&name, &input), req["tool_use_id"].as_str()) { diffs.insert(id.to_string(), d); } } let response = if allow { json!({"behavior": "allow", "updatedInput": req["input"]}) } else { json!({"behavior": "deny", "message": message.unwrap_or_default()}) }; let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}}); if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() { break; } } "result" => { finalize(&app, session_id, &mut cur, &model_id); got_result = true; final_text = ev["result"].as_str().unwrap_or("").to_string(); if ev["is_error"].as_bool().unwrap_or(false) || ev["subtype"].as_str().is_some_and(|s| s != "success") { failure = Some(if final_text.trim().is_empty() { format!("Claude Code stopped: {}", ev["subtype"].as_str().unwrap_or("error")) } else { final_text.clone() }); } let u = tokens(&ev["usage"]); let (p, m) = (PROVIDER, model_id.as_str()); let _ = app.store.add_usage(&UsageRecord { ts: now_ms(), session_id: session_id.into(), root_session_id: app.root_session_id(session_id), provider: p.into(), model: m.into(), agent: "claude-code".into(), input_tokens: u.input_tokens, output_tokens: u.output_tokens, cache_read_tokens: u.cache_read_tokens, cache_write_tokens: u.cache_write_tokens, ttft_ms: 0, duration_ms: (now_ms() - started).max(0) as u64, cost: ev["total_cost_usd"].as_f64().unwrap_or(0.0), error: failure.is_some(), user_id: None, allowance: None, }); break; } _ => {} } } finalize(&app, session_id, &mut cur, &model_id); drop(stdin); let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; let _ = child.start_kill(); if cancel.is_cancelled() { return Ok(final_text); } if let Some(f) = failure { // An expired sign-in is the common case: say where to fix it. if f.to_lowercase().contains("login") || f.to_lowercase().contains("authenticat") || f.contains("401") { bail!("Claude Code is not signed in (or the sign-in expired): Settings → Claude Code → Sign in. ({f})"); } bail!("{f}"); } if !got_result { let tail = stderr_tail.lock().unwrap().trim().to_string(); if tail.to_lowercase().contains("login") || tail.to_lowercase().contains("authenticat") { bail!("Claude Code is not signed in: Settings → Claude Code → Sign in."); } bail!("Claude Code ended without an answer{}", if tail.is_empty() { String::new() } else { format!(": {tail}") }); } Ok(final_text) } // --- sign-in from the web UI ---------------------------------------------------------------- /// `claude auth login` running in a PTY for one user: its output (with the sign-in URL) and stdin. struct Login { writer: tokio::sync::Mutex, out: Arc>, child: Mutex>, exited: Arc>>, started: Instant, } static LOGINS: OnceLock>>> = OnceLock::new(); fn logins() -> &'static Mutex>> { LOGINS.get_or_init(Default::default) } /// Terminal escape sequences removed. fn strip_ansi(s: &str) -> String { let mut out = String::new(); let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { if c == '\x1b' { match chars.peek() { Some('[') => { chars.next(); for n in chars.by_ref() { if ('@'..='~').contains(&n) { break; } } } Some(']') => { chars.next(); for n in chars.by_ref() { if n == '\x07' { break; } } } _ => { chars.next(); } } } else if c != '\r' { out.push(c); } } out } fn find_url(text: &str) -> Option { let t = strip_ansi(text); let start = t.find("https://")?; let url: String = t[start..].chars().take_while(|c| !c.is_whitespace()).collect(); // Complete once whitespace follows it (the output arrives in chunks); the wide PTY avoids wrapping. let complete = t.len() > start + url.len(); (complete && (url.contains("oauth") || url.contains("authorize"))).then_some(url) } fn stop_login(user_id: &str) { if let Some(l) = logins().lock().unwrap().remove(user_id) { if let Some(mut c) = l.child.lock().unwrap().take() { let _ = c.start_kill(); } } } /// Start `claude auth login` for the user and return the sign-in URL to open in any browser. /// `console` = Anthropic Console (API billing) instead of the Claude subscription. pub async fn login_start(app: &Arc, user_id: &str, console: bool) -> Result { stop_login(user_id); let (pty, pts) = pty_process::open()?; pty.resize(pty_process::Size::new(24, 500))?; let mode = if console { "--console" } else { "--claudeai" }; let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/")); let cmd = match crate::sandbox::spec_for(app, Some(user_id)) { Some(spec) => { if !crate::sandbox::available() { bail!("the sandbox (bubblewrap) is not available"); } pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, &cwd, &[], false, "env", &["BROWSER=/bin/false", "claude", "auth", "login", mode])) } None => { let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?; let mut c = pty_process::Command::new(bin).args(["auth", "login", mode]).current_dir(&cwd).env_clear().env("BROWSER", "/bin/false").env("TERM", "xterm-256color"); for k in ["PATH", "HOME", "LANG", "USER"] { if let Some(v) = std::env::var_os(k) { c = c.env(k, v); } } c } }; let child = cmd.kill_on_drop(true).spawn(pts).map_err(|e| anyhow!("could not start `claude auth login`: {e}"))?; let (mut reader, writer) = pty.into_split(); let login = Arc::new(Login { writer: tokio::sync::Mutex::new(writer), out: Arc::default(), child: Mutex::new(Some(child)), exited: Arc::default(), started: Instant::now() }); { let (out, exited, l) = (login.out.clone(), login.exited.clone(), login.clone()); tokio::spawn(async move { let mut buf = vec![0u8; 8192]; loop { match reader.read(&mut buf).await { Ok(0) | Err(_) => break, Ok(n) => { let mut o = out.lock().unwrap(); o.push_str(&String::from_utf8_lossy(&buf[..n])); if o.len() > 64_000 { let cut = o.len() - 32_000; let cut = (cut..o.len()).find(|i| o.is_char_boundary(*i)).unwrap_or(o.len()); o.drain(..cut); } } } } let code = l.child.lock().unwrap().as_mut().and_then(|c| c.try_wait().ok().flatten()).and_then(|s| s.code()).unwrap_or(0); *exited.lock().unwrap() = Some(code); }); } logins().lock().unwrap().insert(user_id.to_string(), login.clone()); // The URL shows up within a few seconds. for _ in 0..60 { if let Some(u) = find_url(&login.out.lock().unwrap()) { return Ok(u); } if login.exited.lock().unwrap().is_some() { break; } tokio::time::sleep(Duration::from_millis(250)).await; } let out = strip_ansi(&login.out.lock().unwrap()); stop_login(user_id); bail!("no sign-in URL received from `claude auth login`{}", if out.trim().is_empty() { String::new() } else { format!(": {}", out.trim().chars().take(300).collect::()) }) } /// Hand the code from the sign-in page to the waiting CLI; true once it reports success. pub async fn login_code(user_id: &str, code: &str) -> Result { let login = logins().lock().unwrap().get(user_id).cloned().ok_or_else(|| anyhow!("no sign-in in progress – start it again"))?; if login.started.elapsed() > Duration::from_secs(15 * 60) { stop_login(user_id); bail!("the sign-in expired – start it again"); } let code: String = code.chars().filter(|c| !c.is_control()).collect::().trim().to_string(); if code.is_empty() || code.len() > 2000 { bail!("empty or invalid code"); } login.writer.lock().await.write_all(format!("{code}\r").as_bytes()).await?; for _ in 0..80 { if login.exited.lock().unwrap().is_some() { break; } tokio::time::sleep(Duration::from_millis(250)).await; } let exited = *login.exited.lock().unwrap(); let out = strip_ansi(&login.out.lock().unwrap()); stop_login(user_id); match exited { Some(0) => Ok(true), _ => bail!("sign-in failed: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("no answer from the CLI").trim()), } } pub fn login_cancel(user_id: &str) { stop_login(user_id); } /// `claude auth status` for the user (their sandbox home or the server's): the CLI's JSON. pub async fn status(app: &App, user_id: &str) -> Value { let mut v = status_inner(app, user_id).await; if v["installed"] == true { if let (Some(o), Some(i)) = (v.as_object_mut(), version_info(false).await.as_object()) { o.extend(i.clone()); } } v } async fn status_inner(app: &App, user_id: &str) -> Value { let Some(_) = binary().or_else(|| crate::sandbox::spec_for(app, Some(user_id)).map(|_| PathBuf::from("claude"))) else { return json!({"installed": false}); }; let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/")); let mut cmd = match command(app, Some(user_id), &cwd, &["auth".into(), "status".into(), "--json".into()]) { Ok(c) => c, Err(e) => return json!({"installed": true, "error": format!("{e:#}")}), }; let out = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await; let in_progress = logins().lock().unwrap().contains_key(user_id); match out { Ok(Ok(o)) => { let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null); json!({ "installed": true, "logged_in": v["loggedIn"].as_bool().unwrap_or(false), "method": v["authMethod"], "email": v["email"], "org": v["orgName"], "expires": v["loginExpiresAt"], "login_in_progress": in_progress, }) } Ok(Err(e)) => json!({"installed": false, "error": e.to_string()}), Err(_) => json!({"installed": true, "error": "timeout", "login_in_progress": in_progress}), } } pub async fn logout(app: &App, user_id: &str) -> Result<()> { let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/")); let mut cmd = command(app, Some(user_id), &cwd, &["auth".into(), "logout".into()])?; let _ = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await; Ok(()) } // --- version and update ------------------------------------------------------------------------- /// "2.1.325" or "2.1.325 (Claude Code)" → [2, 1, 325]. fn version_key(v: &str) -> Vec { v.split_whitespace().next().unwrap_or("").trim_start_matches('v').split('.').map(|p| p.parse().unwrap_or(0)).collect() } fn newer_version(latest: &str, current: &str) -> bool { version_key(latest) > version_key(current) } /// Version of the installed CLI (what `claude --version` prints). async fn installed_version() -> Option { let bin = binary()?; let out = tokio::time::timeout(Duration::from_secs(10), tokio::process::Command::new(bin).arg("--version").stdin(Stdio::null()).kill_on_drop(true).output()).await.ok()?.ok()?; let text = String::from_utf8_lossy(&out.stdout); let v = text.split_whitespace().next()?.to_string(); (!v.is_empty()).then_some(v) } static LATEST: OnceLock>> = OnceLock::new(); /// Latest release on npm (cached for 15 minutes; `force` asks again). async fn latest_version(force: bool) -> Option { let cache = LATEST.get_or_init(Default::default); if !force { if let Some((t, v)) = cache.lock().unwrap().as_ref() { if t.elapsed() < Duration::from_secs(15 * 60) { return Some(v.clone()); } } } let v: Value = crate::provider::http_client().get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest").timeout(Duration::from_secs(10)).send().await.ok()?.json().await.ok()?; let version = v["version"].as_str()?.to_string(); *cache.lock().unwrap() = Some((Instant::now(), version.clone())); Some(version) } /// Ask the registry for the latest version now (the "check" button). pub async fn refresh_latest() { let _ = latest_version(true).await; } /// {version, latest, update_available} of the CLI on the server. pub async fn version_info(force: bool) -> Value { let (current, latest) = tokio::join!(installed_version(), latest_version(force)); let update = match (¤t, &latest) { (Some(c), Some(l)) => newer_version(l, c), _ => false, }; json!({"version": current, "latest": latest, "update_available": update}) } static UPDATING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Install the latest version: a native install updates itself (`claude update`), otherwise npm installs /// into the persistent home (`~/.npm-global`, ahead of the image's copy). Returns the tool output. pub async fn update() -> Result { use std::sync::atomic::Ordering; if UPDATING.swap(true, Ordering::SeqCst) { bail!("an update is already running"); } struct Reset; impl Drop for Reset { fn drop(&mut self) { UPDATING.store(false, std::sync::atomic::Ordering::SeqCst); } } let _reset = Reset; let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server"))?; let native = std::fs::canonicalize(&bin).is_ok_and(|p| p.to_string_lossy().contains("/.local/share/claude/")); let mut cmd = if native { let mut c = tokio::process::Command::new(&bin); c.arg("update"); c } else { let prefix = managed_dir().ok_or_else(|| anyhow!("no HOME"))?; let mut c = tokio::process::Command::new("npm"); c.args(["install", "-g", "--no-audit", "--no-fund", "@anthropic-ai/claude-code@latest"]).env("NPM_CONFIG_PREFIX", prefix); c }; cmd.stdin(Stdio::null()).kill_on_drop(true); for k in ["PATH", "HOME", "LANG", "USER"] { if let Some(v) = std::env::var_os(k) { cmd.env(k, v); } } let out = tokio::time::timeout(Duration::from_secs(300), cmd.output()).await.map_err(|_| anyhow!("the update timed out after 5 minutes"))?.map_err(|e| anyhow!("could not run the updater: {e}"))?; let mut text = String::from_utf8_lossy(&out.stdout).to_string(); text.push_str(&String::from_utf8_lossy(&out.stderr)); let tail: String = { let t = text.trim(); t.chars().rev().take(1500).collect::>().into_iter().rev().collect() }; if !out.status.success() { bail!("the update failed: {tail}"); } // Ask the registry again next time. if let Some(c) = LATEST.get() { *c.lock().unwrap() = None; } Ok(tail) } #[cfg(test)] mod tests { #[test] fn tool_result_images_are_kept() { let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]); let imgs = result_images(&content); assert_eq!(imgs.len(), 1); assert_eq!((imgs[0].media_type.as_str(), imgs[0].data.as_str()), ("image/jpeg", "QUJD")); assert!(result_images(&json!("plain text")).is_empty()); } use super::*; #[test] fn finds_the_login_url_only_when_complete() { let partial = "Opening browser…\r\nIf the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&cli"; assert_eq!(find_url(partial), None); let full = format!("{partial}ent_id=1&state=x\r\nPaste code here if prompted > "); assert_eq!(find_url(&full).as_deref(), Some("https://claude.com/cai/oauth/authorize?code=true&client_id=1&state=x")); assert_eq!(strip_ansi("\x1b[2Khello\x1b[0m\r\n"), "hello\n"); } #[test] fn compares_cli_versions() { assert!(newer_version("2.1.325", "2.1.311")); assert!(newer_version("2.2.0", "2.1.999")); assert!(!newer_version("2.1.325", "2.1.325 (Claude Code)")); assert!(!newer_version("2.1.9", "2.1.10")); } #[test] fn maps_claude_tools_to_ours() { let (n, i) = map_tool("Write", &json!({"file_path": "/a/b.txt", "content": "x"})); assert_eq!((n.as_str(), i["path"].as_str()), ("write", Some("/a/b.txt"))); assert!(i.get("file_path").is_none()); assert_eq!(map_tool("Bash", &json!({"command": "ls"})).0, "bash"); assert_eq!(map_tool("mcp__x__y", &json!({})).0, "mcp__x__y"); assert!(CcTool { name: "edit".into() }.is_edit() && CcTool { name: "read".into() }.read_only()); } }