generated from nhcarrigan/template
feat: Claude CLI 2.1.50–2.1.53 audit (#171)
## Summary This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review. ### New Features - **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly - **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active - **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead) - **Org UUID in account info** — exposes the org UUID from Claude auth status ### Bug Fixes - **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164) - **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166) - **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165) - **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169) ### Maintenance - Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162) - Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163) - Expose org UUID from `claude auth status` (Closes #160) - Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import) - Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147) - Run `cargo update` to bring Cargo.lock up to date ### Closes Closes #160 Closes #162 Closes #163 Closes #164 Closes #165 Closes #166 Closes #167 Closes #168 Closes #169 Closes #81 Closes #82 Closes #83 Closes #84 Closes #85 Closes #86 Closes #87 Closes #90 Closes #91 Closes #93 Closes #94 Closes #95 Closes #96 Closes #97 Closes #98 Closes #99 Closes #101 Closes #141 Closes #142 Closes #143 Closes #145 Closes #146 Closes #147 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #171 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #171.
This commit is contained in:
+108
-26
@@ -1,17 +1,17 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use parking_lot::Mutex;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||
use crate::commands::record_cost;
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::process_ext::HideWindow;
|
||||
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||
use crate::types::{
|
||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||
@@ -89,7 +89,7 @@ fn find_claude_binary() -> Option<String> {
|
||||
|
||||
// Use a login shell to resolve claude via the user's PATH - GUI apps don't
|
||||
// inherit shell PATH, so bare `which` may miss ~/.local/bin entries
|
||||
if let Ok(output) = Command::new("bash").args(["-lc", "which claude"]).output() {
|
||||
if let Ok(output) = Command::new("bash").hide_window().args(["-lc", "which claude"]).output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
@@ -102,52 +102,58 @@ fn find_claude_binary() -> Option<String> {
|
||||
}
|
||||
|
||||
pub struct WslBridge {
|
||||
process: Option<Child>,
|
||||
process: Arc<Mutex<Option<Child>>>,
|
||||
stdin: Option<ChildStdin>,
|
||||
working_directory: String,
|
||||
session_id: Option<String>,
|
||||
mcp_config_file: Option<NamedTempFile>,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
/// Set to true once the `system:init` message arrives, false at the start of every new session.
|
||||
received_init: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WslBridge {
|
||||
pub fn new() -> Self {
|
||||
WslBridge {
|
||||
process: None,
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
stdin: None,
|
||||
working_directory: String::new(),
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: None,
|
||||
received_init: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_conversation_id(conversation_id: String) -> Self {
|
||||
WslBridge {
|
||||
process: None,
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
stdin: None,
|
||||
working_directory: String::new(),
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: Some(conversation_id),
|
||||
received_init: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||
// If a process handle exists but the process has already exited (e.g. due to a
|
||||
// failed working directory), clean up the stale handle so we can restart cleanly.
|
||||
if let Some(ref mut process) = self.process {
|
||||
if process.try_wait().map(|s| s.is_some()).unwrap_or(false) {
|
||||
self.process = None;
|
||||
self.stdin = None;
|
||||
{
|
||||
let mut proc_guard = self.process.lock();
|
||||
if let Some(ref mut proc) = *proc_guard {
|
||||
if proc.try_wait().map(|s| s.is_some()).unwrap_or(false) {
|
||||
*proc_guard = None;
|
||||
self.stdin = None;
|
||||
}
|
||||
}
|
||||
if proc_guard.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if self.process.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
|
||||
// Load saved achievements and stats when starting a new session
|
||||
@@ -224,6 +230,7 @@ impl WslBridge {
|
||||
tracing::debug!("Working dir: {}", working_dir);
|
||||
|
||||
let mut cmd = Command::new(&claude_path);
|
||||
cmd.hide_window();
|
||||
cmd.args([
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
@@ -291,6 +298,7 @@ impl WslBridge {
|
||||
|
||||
// Check if Claude binary is installed inside WSL
|
||||
let binary_check = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "bash", "-lc", "which claude"])
|
||||
.output();
|
||||
if let Ok(output) = binary_check {
|
||||
@@ -301,6 +309,7 @@ impl WslBridge {
|
||||
|
||||
// Validate the working directory exists inside WSL before spawning
|
||||
let dir_check = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-d", working_dir])
|
||||
.output();
|
||||
if let Ok(output) = dir_check {
|
||||
@@ -375,8 +384,7 @@ impl WslBridge {
|
||||
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
|
||||
|
||||
// Hide the console window on Windows
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
cmd.hide_window();
|
||||
|
||||
cmd
|
||||
};
|
||||
@@ -396,7 +404,10 @@ impl WslBridge {
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
self.stdin = stdin;
|
||||
self.process = Some(child);
|
||||
*self.process.lock() = Some(child);
|
||||
|
||||
// Reset the init flag so the watchdog and stdout handler start fresh.
|
||||
self.received_init.store(false, Ordering::SeqCst);
|
||||
|
||||
// Note: We no longer reset stats here - stats persist across reconnects
|
||||
// Stats are only reset when explicitly disconnecting via stop()
|
||||
@@ -413,8 +424,9 @@ impl WslBridge {
|
||||
let app_clone = app.clone();
|
||||
let stats_clone = self.stats.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
let received_init_clone = self.received_init.clone();
|
||||
thread::spawn(move || {
|
||||
handle_stdout(stdout, app_clone, stats_clone, conv_id);
|
||||
handle_stdout(stdout, app_clone, stats_clone, conv_id, received_init_clone);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -426,12 +438,31 @@ impl WslBridge {
|
||||
});
|
||||
}
|
||||
|
||||
// Emit Connected immediately so the frontend can send the greeting message.
|
||||
// This is intentionally optimistic — Claude Code buffers stdout until stdin receives
|
||||
// data on Windows/WSL, so we must send something to stdin first or system:init never
|
||||
// arrives. The received_init flag below tracks whether init actually arrived.
|
||||
emit_connection_status(
|
||||
&app,
|
||||
ConnectionStatus::Connected,
|
||||
self.conversation_id.clone(),
|
||||
);
|
||||
|
||||
// Watchdog: if system:init never arrives the process is truly hung (e.g. a silent crash
|
||||
// after spawning). After 5 minutes we kill it so the user isn't stuck forever.
|
||||
// handle_stdout will surface the error when stdout closes after the kill.
|
||||
let process_watchdog = self.process.clone();
|
||||
let received_init_watchdog = self.received_init.clone();
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_secs(60));
|
||||
if !received_init_watchdog.load(Ordering::SeqCst) {
|
||||
if let Some(mut proc) = process_watchdog.lock().take() {
|
||||
let _ = proc.kill();
|
||||
let _ = proc.wait();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -510,7 +541,10 @@ impl WslBridge {
|
||||
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
|
||||
// we have to kill the process. This is the only reliable way to stop it.
|
||||
// See: https://github.com/anthropics/claude-code/issues/3455
|
||||
if let Some(mut process) = self.process.take() {
|
||||
// Extract the process first so the MutexGuard is dropped before we mutably
|
||||
// borrow `self` again via estimate_interrupted_request_cost.
|
||||
let maybe_process = self.process.lock().take();
|
||||
if let Some(mut process) = maybe_process {
|
||||
// Estimate cost for interrupted request before killing
|
||||
self.estimate_interrupted_request_cost(app);
|
||||
|
||||
@@ -640,7 +674,7 @@ impl WslBridge {
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, app: &AppHandle) {
|
||||
if let Some(mut process) = self.process.take() {
|
||||
if let Some(mut process) = self.process.lock().take() {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
}
|
||||
@@ -671,7 +705,7 @@ impl WslBridge {
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.process.is_some()
|
||||
self.process.lock().is_some()
|
||||
}
|
||||
|
||||
pub fn get_working_directory(&self) -> &str {
|
||||
@@ -694,13 +728,16 @@ fn handle_stdout(
|
||||
app: AppHandle,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
received_init: Arc<AtomicBool>,
|
||||
) {
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line) if !line.is_empty() => {
|
||||
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
|
||||
if let Err(e) =
|
||||
process_json_line(&line, &app, &stats, &conversation_id, &received_init)
|
||||
{
|
||||
tracing::error!("Error processing line: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -712,6 +749,22 @@ fn handle_stdout(
|
||||
}
|
||||
}
|
||||
|
||||
// If stdout closed before system:init arrived the process exited without initialising.
|
||||
// Emit an error line so the user understands why the connection failed.
|
||||
if !received_init.load(Ordering::SeqCst) {
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: "Claude Code exited before initialising. Check the working directory and Claude Code installation, then try connecting again.".to_string(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||
}
|
||||
|
||||
@@ -916,6 +969,7 @@ fn process_json_line(
|
||||
app: &AppHandle,
|
||||
stats: &Arc<RwLock<UsageStats>>,
|
||||
conversation_id: &Option<String>,
|
||||
received_init: &Arc<AtomicBool>,
|
||||
) -> Result<(), String> {
|
||||
let message: ClaudeMessage = serde_json::from_str(line)
|
||||
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
||||
@@ -928,6 +982,9 @@ fn process_json_line(
|
||||
..
|
||||
} => {
|
||||
if subtype == "init" {
|
||||
// Mark as initialised so the watchdog knows the process is healthy.
|
||||
received_init.store(true, Ordering::SeqCst);
|
||||
|
||||
if let Some(id) = session_id {
|
||||
let _ = app.emit(
|
||||
"claude:session",
|
||||
@@ -2059,7 +2116,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_stale_process_detection_with_try_wait() {
|
||||
// Spawn a real process that exits immediately so we can verify try_wait detects it
|
||||
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
|
||||
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
|
||||
|
||||
// Wait for it to exit
|
||||
let _ = child.wait();
|
||||
@@ -2078,7 +2135,7 @@ mod tests {
|
||||
fn test_stale_process_is_some_after_exit() {
|
||||
// Verify the logic used in start(): a process that has exited is detected
|
||||
// and the handle is cleaned up so start() can proceed
|
||||
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
|
||||
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
|
||||
|
||||
// Let it exit
|
||||
let _ = child.wait();
|
||||
@@ -2355,4 +2412,29 @@ mod tests {
|
||||
let content = serde_json::json!([]);
|
||||
assert_eq!(extract_tool_result_text(&content), None);
|
||||
}
|
||||
|
||||
// Verify the 50K tool result persistence threshold (CLI v2.1.51+).
|
||||
// Results > 50K chars are now persisted to disk; the stream may send null
|
||||
// or a large inline string. Both must be handled without panicking.
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_large_content_above_50k_threshold() {
|
||||
let large_text = "x".repeat(60_000);
|
||||
let content = serde_json::Value::String(large_text.clone());
|
||||
assert_eq!(extract_tool_result_text(&content), Some(large_text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_result_deserializes_with_null_content() {
|
||||
let json = r#"{"type":"tool_result","tool_use_id":"toolu_abc","content":null}"#;
|
||||
let block: ContentBlock = serde_json::from_str(json).unwrap();
|
||||
if let ContentBlock::ToolResult { tool_use_id, content, is_error } = block {
|
||||
assert_eq!(tool_use_id, "toolu_abc");
|
||||
assert!(content.is_null());
|
||||
assert_eq!(is_error, None);
|
||||
// Persisted-to-disk results produce null content → no preview shown
|
||||
assert_eq!(extract_tool_result_text(&content), None);
|
||||
} else {
|
||||
panic!("Expected ToolResult variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user