Files
codecraft/server/src/claude_code.rs
T

934 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Claude Code as an engine: a chat whose model is "claude-code/<alias>" 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<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".npm-global"))
}
fn managed_binary() -> Option<PathBuf> {
managed_dir().map(|d| d.join("bin/claude")).filter(|p| p.is_file())
}
pub fn binary() -> Option<PathBuf> {
if let Some(m) = managed_binary() {
return Some(m);
}
let mut dirs: Vec<PathBuf> = 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<StreamEvent>, _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<Vec<ModelConfig>> {
Ok(models())
}
}
pub fn models() -> Vec<ModelConfig> {
[("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<tokio::process::Command> {
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) {
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
return (ours.to_string(), input.clone());
}
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")
// One of our tools (MCP bridge): as it declares itself.
|| crate::tools::builtin_tools().iter().any(|t| t.name() == self.name && t.read_only())
}
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<String> {
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(),
_ => match crate::tools::builtin_tools().iter().find(|t| t.name() == name) {
Some(t) => t.title(input),
None => 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<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
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<Part>,
usage: Usage,
model: Option<String>,
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<Cur>, 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::<Vec<_>>().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<ImageData> {
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<String> {
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<App>, session_id: &str, cancel: CancellationToken) -> Result<String> {
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::<Vec<_>>().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::<Vec<_>>().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<String> = owner.as_deref().and_then(|u| app.store.user_setting::<Option<String>>(u, &key).ok().flatten());
if resume.is_none() && last_user > 0 {
let earlier = crate::runner::transcript_of(&history[..last_user]);
prompt = format!("<previous-conversation>\n{}\n</previous-conversation>\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<String> = ["-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()]);
}
// CodeCraft's own tools for the CLI (MCP, per-run token).
let bridge = crate::mcp_bridge::register(session_id);
args.extend(["--mcp-config".into(), crate::mcp_bridge::config_json(&app, &bridge.token)]);
let mut appended = session.instructions.trim().to_string();
if let Some(note) = crate::mcp_bridge::prompt_section(&crate::mcp_bridge::tools_for(&app, &session)) {
appended = format!("{appended}\n{note}").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<Cur> = None;
let mut calls: HashMap<String, String> = HashMap::new();
let mut diffs: HashMap<String, String> = HashMap::new();
let mut final_text = String::new();
let mut got_result = false;
let mut failure: Option<String> = 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::<Value>(&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<pty_process::OwnedWritePty>,
out: Arc<Mutex<String>>,
child: Mutex<Option<tokio::process::Child>>,
exited: Arc<Mutex<Option<i32>>>,
started: Instant,
}
static LOGINS: OnceLock<Mutex<HashMap<String, Arc<Login>>>> = OnceLock::new();
fn logins() -> &'static Mutex<HashMap<String, Arc<Login>>> {
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<String> {
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<App>, user_id: &str, console: bool) -> Result<String> {
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::<String>()) })
}
/// 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<bool> {
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::<String>().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<u64> {
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<String> {
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<Mutex<Option<(Instant, String)>>> = OnceLock::new();
/// Latest release on npm (cached for 15 minutes; `force` asks again).
async fn latest_version(force: bool) -> Option<String> {
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 (&current, &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<String> {
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::<Vec<_>>().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());
}
}