diff --git a/ROADMAP.md b/ROADMAP.md index 8187843..6fe5c9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -344,7 +344,7 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 = - Forks inherit the parent's workspace root by default; an explicit re-bind is required to move a session to a new worktree, and that re-bind is itself recorded as a structured event so the orchestrator can audit cross-worktree handoffs. - Surface a `branch.workspace_mismatch` lane event so clawhip stops counting wrong-CWD writes as lane completions. - **Status.** A `workspace_root` field has been added to `Session` in `rust/crates/runtime/src/session.rs` (with builder, accessor, JSON + JSONL round-trip, fork inheritance, and given/when/then test coverage in `persists_workspace_root_round_trip_and_forks_inherit_it`). The CWD validation, the namespaced on-disk path, and the `branch.workspace_mismatch` lane event are still outstanding and tracked under this item. + **Status.** Done. Managed-session creation/list/latest/load/fork now route through the per-worktree `SessionStore` namespace in runtime + CLI paths, session loads/resumes reject wrong-workspace access with typed `SessionControlError::WorkspaceMismatch` details, `branch.workspace_mismatch` / `workspace_mismatch` are available on the lane-event surface, and same-workspace legacy flat sessions remain readable while mismatched legacy access is blocked. Focused runtime/CLI/tools coverage for the isolation path is green, and `cargo test --workspace --exclude compat-harness` passes. `cargo clippy --workspace --all-targets -- -D warnings` still fails on pre-existing lints in unchanged `rust/crates/rusty-claude-cli/build.rs`, so that lint cleanup remains outside this roadmap item. ## Deployment Architecture Gap (filed from dogfood 2026-04-08) diff --git a/rust/crates/runtime/src/lane_events.rs b/rust/crates/runtime/src/lane_events.rs index 96a9ac8..603a375 100644 --- a/rust/crates/runtime/src/lane_events.rs +++ b/rust/crates/runtime/src/lane_events.rs @@ -36,6 +36,8 @@ pub enum LaneEventName { Closed, #[serde(rename = "branch.stale_against_main")] BranchStaleAgainstMain, + #[serde(rename = "branch.workspace_mismatch")] + BranchWorkspaceMismatch, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -67,6 +69,7 @@ pub enum LaneFailureClass { McpHandshake, GatewayRouting, ToolRuntime, + WorkspaceMismatch, Infra, } @@ -277,6 +280,10 @@ mod tests { LaneEventName::BranchStaleAgainstMain, "branch.stale_against_main", ), + ( + LaneEventName::BranchWorkspaceMismatch, + "branch.workspace_mismatch", + ), ]; for (event, expected) in cases { @@ -300,6 +307,7 @@ mod tests { (LaneFailureClass::McpHandshake, "mcp_handshake"), (LaneFailureClass::GatewayRouting, "gateway_routing"), (LaneFailureClass::ToolRuntime, "tool_runtime"), + (LaneFailureClass::WorkspaceMismatch, "workspace_mismatch"), (LaneFailureClass::Infra, "infra"), ]; @@ -329,6 +337,38 @@ mod tests { assert_eq!(failed.detail.as_deref(), Some("broken server")); } + #[test] + fn workspace_mismatch_failure_class_round_trips_in_branch_event_payloads() { + let mismatch = LaneEvent::new( + LaneEventName::BranchWorkspaceMismatch, + LaneEventStatus::Blocked, + "2026-04-04T00:00:02Z", + ) + .with_failure_class(LaneFailureClass::WorkspaceMismatch) + .with_detail("session belongs to /tmp/repo-a but current workspace is /tmp/repo-b") + .with_data(json!({ + "expectedWorkspaceRoot": "/tmp/repo-a", + "actualWorkspaceRoot": "/tmp/repo-b", + "sessionId": "sess-123", + })); + + let mismatch_json = serde_json::to_value(&mismatch).expect("lane event should serialize"); + assert_eq!(mismatch_json["event"], "branch.workspace_mismatch"); + assert_eq!(mismatch_json["failureClass"], "workspace_mismatch"); + assert_eq!( + mismatch_json["data"]["expectedWorkspaceRoot"], + "/tmp/repo-a" + ); + + let round_trip: LaneEvent = + serde_json::from_value(mismatch_json).expect("lane event should deserialize"); + assert_eq!(round_trip.event, LaneEventName::BranchWorkspaceMismatch); + assert_eq!( + round_trip.failure_class, + Some(LaneFailureClass::WorkspaceMismatch) + ); + } + #[test] fn commit_events_can_carry_worktree_and_supersession_metadata() { let event = LaneEvent::commit_created( diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 0524519..1d86e24 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -121,6 +121,17 @@ impl SessionStore { return Ok(path); } } + if let Some(legacy_root) = self.legacy_sessions_root() { + for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] { + let path = legacy_root.join(format!("{session_id}.{extension}")); + if !path.exists() { + continue; + } + let session = Session::load_from_path(&path)?; + self.validate_loaded_session(&path, &session)?; + return Ok(path); + } + } Err(SessionControlError::Format( format_missing_session_reference(session_id), )) @@ -128,61 +139,9 @@ impl SessionStore { pub fn list_sessions(&self) -> Result, SessionControlError> { let mut sessions = Vec::new(); - let read_result = fs::read_dir(&self.sessions_root); - let entries = match read_result { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(sessions), - Err(err) => return Err(err.into()), - }; - for entry in entries { - let entry = entry?; - let path = entry.path(); - if !is_managed_session_file(&path) { - continue; - } - let metadata = entry.metadata()?; - let modified_epoch_millis = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()) - .unwrap_or_default(); - let (id, message_count, parent_session_id, branch_name) = - match Session::load_from_path(&path) { - Ok(session) => { - let parent_session_id = session - .fork - .as_ref() - .map(|fork| fork.parent_session_id.clone()); - let branch_name = session - .fork - .as_ref() - .and_then(|fork| fork.branch_name.clone()); - ( - session.session_id, - session.messages.len(), - parent_session_id, - branch_name, - ) - } - Err(_) => ( - path.file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown") - .to_string(), - 0, - None, - None, - ), - }; - sessions.push(ManagedSessionSummary { - id, - path, - modified_epoch_millis, - message_count, - parent_session_id, - branch_name, - }); + self.collect_sessions_from_dir(&self.sessions_root, &mut sessions)?; + if let Some(legacy_root) = self.legacy_sessions_root() { + self.collect_sessions_from_dir(&legacy_root, &mut sessions)?; } sessions.sort_by(|left, right| { right @@ -206,6 +165,7 @@ impl SessionStore { ) -> Result { let handle = self.resolve_reference(reference)?; let session = Session::load_from_path(&handle.path)?; + self.validate_loaded_session(&handle.path, &session)?; Ok(LoadedManagedSession { handle: SessionHandle { id: session.session_id.clone(), @@ -221,7 +181,9 @@ impl SessionStore { branch_name: Option, ) -> Result { let parent_session_id = session.session_id.clone(); - let forked = session.fork(branch_name); + let forked = session + .fork(branch_name) + .with_workspace_root(self.workspace_root.clone()); let handle = self.create_handle(&forked.session_id); let branch_name = forked .fork @@ -236,6 +198,96 @@ impl SessionStore { branch_name, }) } + + fn legacy_sessions_root(&self) -> Option { + self.sessions_root + .parent() + .filter(|parent| parent.file_name().is_some_and(|name| name == "sessions")) + .map(Path::to_path_buf) + } + + fn validate_loaded_session( + &self, + session_path: &Path, + session: &Session, + ) -> Result<(), SessionControlError> { + let Some(actual) = session.workspace_root() else { + if path_is_within_workspace(session_path, &self.workspace_root) { + return Ok(()); + } + return Err(SessionControlError::Format( + format_legacy_session_missing_workspace_root(session_path, &self.workspace_root), + )); + }; + if workspace_roots_match(actual, &self.workspace_root) { + return Ok(()); + } + Err(SessionControlError::WorkspaceMismatch { + expected: self.workspace_root.clone(), + actual: actual.to_path_buf(), + }) + } + + fn collect_sessions_from_dir( + &self, + directory: &Path, + sessions: &mut Vec, + ) -> Result<(), SessionControlError> { + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !is_managed_session_file(&path) { + continue; + } + let metadata = entry.metadata()?; + let modified_epoch_millis = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let summary = match Session::load_from_path(&path) { + Ok(session) => { + if self.validate_loaded_session(&path, &session).is_err() { + continue; + } + ManagedSessionSummary { + id: session.session_id, + path, + modified_epoch_millis, + message_count: session.messages.len(), + parent_session_id: session + .fork + .as_ref() + .map(|fork| fork.parent_session_id.clone()), + branch_name: session + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()), + } + } + Err(_) => ManagedSessionSummary { + id: path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_string(), + path, + modified_epoch_millis, + message_count: 0, + parent_session_id: None, + branch_name: None, + }, + }; + sessions.push(summary); + } + Ok(()) + } } /// Stable hex fingerprint of a workspace path. @@ -294,6 +346,7 @@ pub enum SessionControlError { Io(std::io::Error), Session(SessionError), Format(String), + WorkspaceMismatch { expected: PathBuf, actual: PathBuf }, } impl Display for SessionControlError { @@ -302,6 +355,12 @@ impl Display for SessionControlError { Self::Io(error) => write!(f, "{error}"), Self::Session(error) => write!(f, "{error}"), Self::Format(error) => write!(f, "{error}"), + Self::WorkspaceMismatch { expected, actual } => write!( + f, + "session workspace mismatch: expected {}, found {}", + expected.display(), + actual.display() + ), } } } @@ -327,9 +386,8 @@ pub fn sessions_dir() -> Result { pub fn managed_sessions_dir_for( base_dir: impl AsRef, ) -> Result { - let path = base_dir.as_ref().join(".claw").join("sessions"); - fs::create_dir_all(&path)?; - Ok(path) + let store = SessionStore::from_cwd(base_dir)?; + Ok(store.sessions_dir().to_path_buf()) } pub fn create_managed_session_handle( @@ -342,10 +400,8 @@ pub fn create_managed_session_handle_for( base_dir: impl AsRef, session_id: &str, ) -> Result { - let id = session_id.to_string(); - let path = - managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}")); - Ok(SessionHandle { id, path }) + let store = SessionStore::from_cwd(base_dir)?; + Ok(store.create_handle(session_id)) } pub fn resolve_session_reference(reference: &str) -> Result { @@ -356,36 +412,8 @@ pub fn resolve_session_reference_for( base_dir: impl AsRef, reference: &str, ) -> Result { - let base_dir = base_dir.as_ref(); - if is_session_reference_alias(reference) { - let latest = latest_managed_session_for(base_dir)?; - return Ok(SessionHandle { - id: latest.id, - path: latest.path, - }); - } - - let direct = PathBuf::from(reference); - let candidate = if direct.is_absolute() { - direct.clone() - } else { - base_dir.join(&direct) - }; - let looks_like_path = direct.extension().is_some() || direct.components().count() > 1; - let path = if candidate.exists() { - candidate - } else if looks_like_path { - return Err(SessionControlError::Format( - format_missing_session_reference(reference), - )); - } else { - resolve_managed_session_path_for(base_dir, reference)? - }; - - Ok(SessionHandle { - id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()), - path, - }) + let store = SessionStore::from_cwd(base_dir)?; + store.resolve_reference(reference) } pub fn resolve_managed_session_path(session_id: &str) -> Result { @@ -396,16 +424,8 @@ pub fn resolve_managed_session_path_for( base_dir: impl AsRef, session_id: &str, ) -> Result { - let directory = managed_sessions_dir_for(base_dir)?; - for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] { - let path = directory.join(format!("{session_id}.{extension}")); - if path.exists() { - return Ok(path); - } - } - Err(SessionControlError::Format( - format_missing_session_reference(session_id), - )) + let store = SessionStore::from_cwd(base_dir)?; + store.resolve_managed_path(session_id) } #[must_use] @@ -424,64 +444,8 @@ pub fn list_managed_sessions() -> Result, SessionCont pub fn list_managed_sessions_for( base_dir: impl AsRef, ) -> Result, SessionControlError> { - let mut sessions = Vec::new(); - for entry in fs::read_dir(managed_sessions_dir_for(base_dir)?)? { - let entry = entry?; - let path = entry.path(); - if !is_managed_session_file(&path) { - continue; - } - let metadata = entry.metadata()?; - let modified_epoch_millis = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()) - .unwrap_or_default(); - let (id, message_count, parent_session_id, branch_name) = - match Session::load_from_path(&path) { - Ok(session) => { - let parent_session_id = session - .fork - .as_ref() - .map(|fork| fork.parent_session_id.clone()); - let branch_name = session - .fork - .as_ref() - .and_then(|fork| fork.branch_name.clone()); - ( - session.session_id, - session.messages.len(), - parent_session_id, - branch_name, - ) - } - Err(_) => ( - path.file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown") - .to_string(), - 0, - None, - None, - ), - }; - sessions.push(ManagedSessionSummary { - id, - path, - modified_epoch_millis, - message_count, - parent_session_id, - branch_name, - }); - } - sessions.sort_by(|left, right| { - right - .modified_epoch_millis - .cmp(&left.modified_epoch_millis) - .then_with(|| right.id.cmp(&left.id)) - }); - Ok(sessions) + let store = SessionStore::from_cwd(base_dir)?; + store.list_sessions() } pub fn latest_managed_session() -> Result { @@ -491,10 +455,8 @@ pub fn latest_managed_session() -> Result, ) -> Result { - list_managed_sessions_for(base_dir)? - .into_iter() - .next() - .ok_or_else(|| SessionControlError::Format(format_no_managed_sessions())) + let store = SessionStore::from_cwd(base_dir)?; + store.latest_session() } pub fn load_managed_session(reference: &str) -> Result { @@ -505,15 +467,8 @@ pub fn load_managed_session_for( base_dir: impl AsRef, reference: &str, ) -> Result { - let handle = resolve_session_reference_for(base_dir, reference)?; - let session = Session::load_from_path(&handle.path)?; - Ok(LoadedManagedSession { - handle: SessionHandle { - id: session.session_id.clone(), - path: handle.path, - }, - session, - }) + let store = SessionStore::from_cwd(base_dir)?; + store.load_session(reference) } pub fn fork_managed_session( @@ -528,21 +483,8 @@ pub fn fork_managed_session_for( session: &Session, branch_name: Option, ) -> Result { - let parent_session_id = session.session_id.clone(); - let forked = session.fork(branch_name); - let handle = create_managed_session_handle_for(base_dir, &forked.session_id)?; - let branch_name = forked - .fork - .as_ref() - .and_then(|fork| fork.branch_name.clone()); - let forked = forked.with_persistence_path(handle.path.clone()); - forked.save_to_path(&handle.path)?; - Ok(ForkedManagedSession { - parent_session_id, - handle, - session: forked, - branch_name, - }) + let store = SessionStore::from_cwd(base_dir)?; + store.fork_session(session, branch_name) } #[must_use] @@ -574,12 +516,36 @@ fn format_no_managed_sessions() -> String { ) } +fn format_legacy_session_missing_workspace_root( + session_path: &Path, + workspace_root: &Path, +) -> String { + format!( + "legacy session is missing workspace binding: {}\nOpen it from its original workspace or re-save it from {}.", + session_path.display(), + workspace_root.display() + ) +} + +fn workspace_roots_match(left: &Path, right: &Path) -> bool { + canonicalize_for_compare(left) == canonicalize_for_compare(right) +} + +fn canonicalize_for_compare(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn path_is_within_workspace(path: &Path, workspace_root: &Path) -> bool { + canonicalize_for_compare(path).starts_with(canonicalize_for_compare(workspace_root)) +} + #[cfg(test)] mod tests { use super::{ create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias, list_managed_sessions_for, load_managed_session_for, resolve_session_reference_for, - workspace_fingerprint, ManagedSessionSummary, SessionStore, LATEST_SESSION_REFERENCE, + workspace_fingerprint, ManagedSessionSummary, SessionControlError, SessionStore, + LATEST_SESSION_REFERENCE, }; use crate::session::Session; use std::fs; @@ -595,7 +561,7 @@ mod tests { } fn persist_session(root: &Path, text: &str) -> Session { - let mut session = Session::new(); + let mut session = Session::new().with_workspace_root(root.to_path_buf()); session .push_user_text(text) .expect("session message should save"); @@ -708,7 +674,7 @@ mod tests { // ------------------------------------------------------------------ fn persist_session_via_store(store: &SessionStore, text: &str) -> Session { - let mut session = Session::new(); + let mut session = Session::new().with_workspace_root(store.workspace_root().to_path_buf()); session .push_user_text(text) .expect("session message should save"); @@ -820,6 +786,95 @@ mod tests { fs::remove_dir_all(base).expect("temp dir should clean up"); } + #[test] + fn session_store_rejects_legacy_session_from_other_workspace() { + // given + let base = temp_dir(); + let workspace_a = base.join("repo-alpha"); + let workspace_b = base.join("repo-beta"); + fs::create_dir_all(&workspace_a).expect("workspace a should exist"); + fs::create_dir_all(&workspace_b).expect("workspace b should exist"); + + let store_b = SessionStore::from_cwd(&workspace_b).expect("store b should build"); + let legacy_root = workspace_b.join(".claw").join("sessions"); + fs::create_dir_all(&legacy_root).expect("legacy root should exist"); + let legacy_path = legacy_root.join("legacy-cross.jsonl"); + let session = Session::new() + .with_workspace_root(workspace_a.clone()) + .with_persistence_path(legacy_path.clone()); + session + .save_to_path(&legacy_path) + .expect("legacy session should persist"); + + // when + let err = store_b + .load_session("legacy-cross") + .expect_err("workspace mismatch should be rejected"); + + // then + match err { + SessionControlError::WorkspaceMismatch { expected, actual } => { + assert_eq!(expected, workspace_b); + assert_eq!(actual, workspace_a); + } + other => panic!("expected workspace mismatch, got {other:?}"), + } + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn session_store_loads_safe_legacy_session_from_same_workspace() { + // given + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + let legacy_root = base.join(".claw").join("sessions"); + let legacy_path = legacy_root.join("legacy-safe.jsonl"); + fs::create_dir_all(&legacy_root).expect("legacy root should exist"); + let session = Session::new() + .with_workspace_root(base.clone()) + .with_persistence_path(legacy_path.clone()); + session + .save_to_path(&legacy_path) + .expect("legacy session should persist"); + + // when + let loaded = store + .load_session("legacy-safe") + .expect("same-workspace legacy session should load"); + + // then + assert_eq!(loaded.handle.id, session.session_id); + assert_eq!(loaded.handle.path, legacy_path); + assert_eq!(loaded.session.workspace_root(), Some(base.as_path())); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + + #[test] + fn session_store_loads_unbound_legacy_session_from_same_workspace() { + // given + let base = temp_dir(); + fs::create_dir_all(&base).expect("base dir should exist"); + let store = SessionStore::from_cwd(&base).expect("store should build"); + let legacy_root = base.join(".claw").join("sessions"); + let legacy_path = legacy_root.join("legacy-unbound.json"); + fs::create_dir_all(&legacy_root).expect("legacy root should exist"); + let session = Session::new().with_persistence_path(legacy_path.clone()); + session + .save_to_path(&legacy_path) + .expect("legacy session should persist"); + + // when + let loaded = store + .load_session("legacy-unbound") + .expect("same-workspace legacy session without workspace binding should load"); + + // then + assert_eq!(loaded.handle.path, legacy_path); + assert_eq!(loaded.session.workspace_root(), None); + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + #[test] fn session_store_latest_and_resolve_reference() { // given diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7caef9d..ce26eab 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2215,30 +2215,9 @@ fn version_json_value() -> serde_json::Value { } fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { - let resolved_path = if session_path.exists() { - session_path.to_path_buf() - } else { - match resolve_session_reference(&session_path.display().to_string()) { - Ok(handle) => handle.path, - Err(error) => { - if output_format == CliOutputFormat::Json { - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": format!("failed to restore session: {error}"), - }) - ); - } else { - eprintln!("failed to restore session: {error}"); - } - std::process::exit(1); - } - } - }; - - let session = match Session::load_from_path(&resolved_path) { - Ok(session) => session, + let session_reference = session_path.display().to_string(); + let (handle, session) = match load_session_reference(&session_reference) { + Ok(loaded) => loaded, Err(error) => { if output_format == CliOutputFormat::Json { eprintln!( @@ -2254,6 +2233,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu std::process::exit(1); } }; + let resolved_path = handle.path.clone(); if commands.is_empty() { if output_format == CliOutputFormat::Json { @@ -2262,14 +2242,14 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu serde_json::json!({ "kind": "restored", "session_id": session.session_id, - "path": resolved_path.display().to_string(), + "path": handle.path.display().to_string(), "message_count": session.messages.len(), }) ); } else { println!( "Restored session from {} ({} messages).", - resolved_path.display(), + handle.path.display(), session.messages.len() ); } @@ -2762,7 +2742,7 @@ fn run_resume_command( } let backup_path = write_session_clear_backup(session, session_path)?; let previous_session_id = session.session_id.clone(); - let cleared = Session::new(); + let cleared = new_cli_session()?; let new_session_id = cleared.session_id.clone(); cleared.save_to_path(session_path)?; Ok(ResumeCommandOutcome { @@ -3729,7 +3709,7 @@ impl LiveCli { permission_mode: PermissionMode, ) -> Result> { let system_prompt = build_system_prompt()?; - let session_state = Session::new(); + let session_state = new_cli_session()?; let session = create_managed_session_handle(&session_state.session_id)?; let runtime = build_runtime( session_state.with_persistence_path(session.path.clone()), @@ -4314,7 +4294,7 @@ impl LiveCli { } let previous_session = self.session.clone(); - let session_state = Session::new(); + let session_state = new_cli_session()?; self.session = create_managed_session_handle(&session_state.session_id)?; let runtime = build_runtime( session_state.with_persistence_path(self.session.path.clone()), @@ -4354,8 +4334,7 @@ impl LiveCli { return Ok(false); }; - let handle = resolve_session_reference(&session_ref)?; - let session = Session::load_from_path(&handle.path)?; + let (handle, session) = load_session_reference(&session_ref)?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); let runtime = build_runtime( @@ -4510,8 +4489,7 @@ impl LiveCli { println!("Usage: /session switch "); return Ok(false); }; - let handle = resolve_session_reference(target)?; - let session = Session::load_from_path(&handle.path)?; + let (handle, session) = load_session_reference(target)?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); let runtime = build_runtime( @@ -4772,177 +4750,88 @@ impl LiveCli { } fn sessions_dir() -> Result> { + Ok(current_session_store()?.sessions_dir().to_path_buf()) +} + +fn current_session_store() -> Result> { let cwd = env::current_dir()?; - let store = runtime::SessionStore::from_cwd(&cwd) - .map_err(|e| Box::new(e) as Box)?; - Ok(store.sessions_dir().to_path_buf()) + runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) +} + +fn new_cli_session() -> Result> { + Ok(Session::new().with_workspace_root(env::current_dir()?)) } fn create_managed_session_handle( session_id: &str, ) -> Result> { - let id = session_id.to_string(); - let path = sessions_dir()?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}")); - Ok(SessionHandle { id, path }) + let handle = current_session_store()? + .create_handle(session_id); + Ok(SessionHandle { + id: handle.id, + path: handle.path, + }) } fn resolve_session_reference(reference: &str) -> Result> { - if SESSION_REFERENCE_ALIASES - .iter() - .any(|alias| reference.eq_ignore_ascii_case(alias)) - { - let latest = latest_managed_session()?; - return Ok(SessionHandle { - id: latest.id, - path: latest.path, - }); - } - - let direct = PathBuf::from(reference); - let looks_like_path = direct.extension().is_some() || direct.components().count() > 1; - let path = if direct.exists() { - direct - } else if looks_like_path { - return Err(format_missing_session_reference(reference).into()); - } else { - resolve_managed_session_path(reference)? - }; - let id = path - .file_name() - .and_then(|value| value.to_str()) - .and_then(|name| { - name.strip_suffix(&format!(".{PRIMARY_SESSION_EXTENSION}")) - .or_else(|| name.strip_suffix(&format!(".{LEGACY_SESSION_EXTENSION}"))) - }) - .unwrap_or(reference) - .to_string(); - Ok(SessionHandle { id, path }) + let handle = current_session_store()? + .resolve_reference(reference) + .map_err(|e| Box::new(e) as Box)?; + Ok(SessionHandle { + id: handle.id, + path: handle.path, + }) } fn resolve_managed_session_path(session_id: &str) -> Result> { - let directory = sessions_dir()?; - for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] { - let path = directory.join(format!("{session_id}.{extension}")); - if path.exists() { - return Ok(path); - } - } - // Backward compatibility: pre-isolation sessions were stored at - // `.claw/sessions/.{jsonl,json}` without the per-workspace hash - // subdirectory. Walk up from `directory` to the `.claw/sessions/` root - // and try the flat layout as a fallback so users do not lose access - // to their pre-upgrade managed sessions. - if let Some(legacy_root) = directory - .parent() - .filter(|parent| parent.file_name().is_some_and(|name| name == "sessions")) - { - for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] { - let path = legacy_root.join(format!("{session_id}.{extension}")); - if path.exists() { - return Ok(path); - } - } - } - Err(format_missing_session_reference(session_id).into()) -} - -fn is_managed_session_file(path: &Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|extension| { - extension == PRIMARY_SESSION_EXTENSION || extension == LEGACY_SESSION_EXTENSION - }) -} - -fn collect_sessions_from_dir( - directory: &Path, - sessions: &mut Vec, -) -> Result<(), Box> { - if !directory.exists() { - return Ok(()); - } - for entry in fs::read_dir(directory)? { - let entry = entry?; - let path = entry.path(); - if !is_managed_session_file(&path) { - continue; - } - let metadata = entry.metadata()?; - let modified_epoch_millis = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()) - .unwrap_or_default(); - let (id, message_count, parent_session_id, branch_name) = - match Session::load_from_path(&path) { - Ok(session) => { - let parent_session_id = session - .fork - .as_ref() - .map(|fork| fork.parent_session_id.clone()); - let branch_name = session - .fork - .as_ref() - .and_then(|fork| fork.branch_name.clone()); - ( - session.session_id, - session.messages.len(), - parent_session_id, - branch_name, - ) - } - Err(_) => ( - path.file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown") - .to_string(), - 0, - None, - None, - ), - }; - sessions.push(ManagedSessionSummary { - id, - path, - modified_epoch_millis, - message_count, - parent_session_id, - branch_name, - }); - } - Ok(()) + current_session_store()? + .resolve_managed_path(session_id) + .map_err(|e| Box::new(e) as Box) } fn list_managed_sessions() -> Result, Box> { - let mut sessions = Vec::new(); - let primary_dir = sessions_dir()?; - collect_sessions_from_dir(&primary_dir, &mut sessions)?; - - // Backward compatibility: include sessions stored in the pre-isolation - // flat `.claw/sessions/` root so users do not lose access to existing - // managed sessions after the workspace-hashed subdirectory rollout. - if let Some(legacy_root) = primary_dir - .parent() - .filter(|parent| parent.file_name().is_some_and(|name| name == "sessions")) - { - collect_sessions_from_dir(legacy_root, &mut sessions)?; - } - - sessions.sort_by(|left, right| { - right - .modified_epoch_millis - .cmp(&left.modified_epoch_millis) - .then_with(|| right.id.cmp(&left.id)) - }); - Ok(sessions) + Ok(current_session_store()? + .list_sessions() + .map_err(|e| Box::new(e) as Box)? + .into_iter() + .map(|session| ManagedSessionSummary { + id: session.id, + path: session.path, + modified_epoch_millis: session.modified_epoch_millis, + message_count: session.message_count, + parent_session_id: session.parent_session_id, + branch_name: session.branch_name, + }) + .collect()) } fn latest_managed_session() -> Result> { - list_managed_sessions()? - .into_iter() - .next() - .ok_or_else(|| format_no_managed_sessions().into()) + let session = current_session_store()? + .latest_session() + .map_err(|e| Box::new(e) as Box)?; + Ok(ManagedSessionSummary { + id: session.id, + path: session.path, + modified_epoch_millis: session.modified_epoch_millis, + message_count: session.message_count, + parent_session_id: session.parent_session_id, + branch_name: session.branch_name, + }) +} + +fn load_session_reference( + reference: &str, +) -> Result<(SessionHandle, Session), Box> { + let loaded = current_session_store()? + .load_session(reference) + .map_err(|e| Box::new(e) as Box)?; + Ok(( + SessionHandle { + id: loaded.handle.id, + path: loaded.handle.path, + }, + loaded.session, + )) } fn delete_managed_session(path: &Path) -> Result<(), Box> { @@ -4963,18 +4852,6 @@ fn confirm_session_deletion(session_id: &str) -> bool { matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") } -fn format_missing_session_reference(reference: &str) -> String { - format!( - "session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL." - ) -} - -fn format_no_managed_sessions() -> String { - format!( - "no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`." - ) -} - fn render_session_list(active_session_id: &str) -> Result> { let sessions = list_managed_sessions()?; let mut lines = vec![ @@ -6161,8 +6038,7 @@ fn run_export( output_path: Option<&Path>, output_format: CliOutputFormat, ) -> Result<(), Box> { - let handle = resolve_session_reference(session_reference)?; - let session = Session::load_from_path(&handle.path)?; + let (handle, session) = load_session_reference(session_reference)?; let markdown = render_session_markdown(&session, &handle.id, &handle.path); if let Some(path) = output_path { @@ -10760,6 +10636,7 @@ UU conflicted.rs", ) .expect("session dir should exist"); Session::new() + .with_workspace_root(workspace.clone()) .with_persistence_path(legacy_path.clone()) .save_to_path(&legacy_path) .expect("legacy session should save"); @@ -10812,6 +10689,53 @@ UU conflicted.rs", std::fs::remove_dir_all(workspace).expect("workspace should clean up"); } + #[test] + fn load_session_reference_rejects_workspace_mismatch() { + let _guard = cwd_lock().lock().expect("cwd lock"); + let workspace_a = temp_workspace("session-mismatch-a"); + let workspace_b = temp_workspace("session-mismatch-b"); + std::fs::create_dir_all(&workspace_a).expect("workspace a should create"); + std::fs::create_dir_all(&workspace_b).expect("workspace b should create"); + let previous = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&workspace_b).expect("switch cwd"); + + let session_path = workspace_a.join(".claw/sessions/legacy-cross.jsonl"); + std::fs::create_dir_all( + session_path + .parent() + .expect("session path should have parent directory"), + ) + .expect("session dir should exist"); + Session::new() + .with_workspace_root(workspace_a.clone()) + .with_persistence_path(session_path.clone()) + .save_to_path(&session_path) + .expect("session should save"); + + let error = crate::load_session_reference(&session_path.display().to_string()) + .expect_err("mismatched workspace should fail"); + assert!( + error.to_string().contains("session workspace mismatch"), + "unexpected error: {error}" + ); + assert!( + error + .to_string() + .contains(&workspace_b.display().to_string()), + "expected current workspace in error: {error}" + ); + assert!( + error + .to_string() + .contains(&workspace_a.display().to_string()), + "expected originating workspace in error: {error}" + ); + + std::env::set_current_dir(previous).expect("restore cwd"); + std::fs::remove_dir_all(workspace_a).expect("workspace a should clean up"); + std::fs::remove_dir_all(workspace_b).expect("workspace b should clean up"); + } + #[test] fn unknown_slash_command_guidance_suggests_nearby_commands() { let message = format_unknown_slash_command("stats"); diff --git a/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs b/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs index 21a93e2..bc4fbe2 100644 --- a/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs +++ b/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs @@ -266,7 +266,7 @@ fn command_in(cwd: &Path) -> Command { fn write_session(root: &Path, label: &str) -> PathBuf { let session_path = root.join(format!("{label}.jsonl")); - let mut session = Session::new(); + let mut session = Session::new().with_workspace_root(root.to_path_buf()); session .push_user_text(format!("session fixture for {label}")) .expect("session write should succeed"); diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 7d28330..9710b92 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -4,6 +4,7 @@ use std::process::{Command, Output}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use runtime::Session; use serde_json::Value; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -236,12 +237,7 @@ fn doctor_and_resume_status_emit_json_when_requested() { assert!(sandbox["enabled"].is_boolean()); assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string()); - let session_path = root.join("session.jsonl"); - fs::write( - &session_path, - "{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n", - ) - .expect("session should write"); + let session_path = write_session_fixture(&root, "resume-json", Some("hello")); let resumed = assert_json_command( &root, &[ @@ -268,12 +264,7 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() { fs::create_dir_all(&config_home).expect("config home should exist"); fs::create_dir_all(&home).expect("home should exist"); - let session_path = root.join("session.jsonl"); - fs::write( - &session_path, - "{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n", - ) - .expect("session should write"); + let session_path = write_session_fixture(&root, "resume-inventory-json", Some("inventory")); let mcp = assert_json_command_with_env( &root, @@ -324,12 +315,7 @@ fn resumed_version_and_init_emit_structured_json_when_requested() { let root = unique_temp_dir("resume-version-init-json"); fs::create_dir_all(&root).expect("temp dir should exist"); - let session_path = root.join("session.jsonl"); - fs::write( - &session_path, - "{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n", - ) - .expect("session should write"); + let session_path = write_session_fixture(&root, "resume-version-init-json", None); let version = assert_json_command( &root, @@ -405,6 +391,24 @@ fn write_upstream_fixture(root: &Path) -> PathBuf { upstream } +fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf { + let session_path = root.join("session.jsonl"); + let mut session = Session::new() + .with_workspace_root(root.to_path_buf()) + .with_persistence_path(session_path.clone()); + session.session_id = session_id.to_string(); + if let Some(text) = user_text { + session + .push_user_text(text) + .expect("session fixture message should persist"); + } else { + session + .save_to_path(&session_path) + .expect("session fixture should persist"); + } + session_path +} + fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) { fs::create_dir_all(root).expect("agent root should exist"); fs::write( diff --git a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs index 556cbdb..b620449 100644 --- a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs +++ b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs @@ -20,7 +20,7 @@ fn resumed_binary_accepts_slash_commands_with_arguments() { let session_path = temp_dir.join("session.jsonl"); let export_path = temp_dir.join("notes.txt"); - let mut session = Session::new(); + let mut session = workspace_session(&temp_dir); session .push_user_text("ship the slash command harness") .expect("session write should succeed"); @@ -122,7 +122,7 @@ fn resumed_config_command_loads_settings_files_end_to_end() { fs::create_dir_all(&config_home).expect("config home should exist"); let session_path = project_dir.join("session.jsonl"); - Session::new() + workspace_session(&project_dir) .with_persistence_path(&session_path) .save_to_path(&session_path) .expect("session should persist"); @@ -180,13 +180,11 @@ fn resume_latest_restores_the_most_recent_managed_session() { // given let temp_dir = unique_temp_dir("resume-latest"); let project_dir = temp_dir.join("project"); - let sessions_dir = project_dir.join(".claw").join("sessions"); - fs::create_dir_all(&sessions_dir).expect("sessions dir should exist"); + let store = runtime::SessionStore::from_cwd(&project_dir).expect("session store should build"); + let older_path = store.create_handle("session-older").path; + let newer_path = store.create_handle("session-newer").path; - let older_path = sessions_dir.join("session-older.jsonl"); - let newer_path = sessions_dir.join("session-newer.jsonl"); - - let mut older = Session::new().with_persistence_path(&older_path); + let mut older = workspace_session(&project_dir).with_persistence_path(&older_path); older .push_user_text("older session") .expect("older session write should succeed"); @@ -194,7 +192,7 @@ fn resume_latest_restores_the_most_recent_managed_session() { .save_to_path(&older_path) .expect("older session should persist"); - let mut newer = Session::new().with_persistence_path(&newer_path); + let mut newer = workspace_session(&project_dir).with_persistence_path(&newer_path); newer .push_user_text("newer session") .expect("newer session write should succeed"); @@ -229,7 +227,7 @@ fn resumed_status_command_emits_structured_json_when_requested() { fs::create_dir_all(&temp_dir).expect("temp dir should exist"); let session_path = temp_dir.join("session.jsonl"); - let mut session = Session::new(); + let mut session = workspace_session(&temp_dir); session .push_user_text("resume status json fixture") .expect("session write should succeed"); @@ -283,7 +281,7 @@ fn resumed_status_surfaces_persisted_model() { fs::create_dir_all(&temp_dir).expect("temp dir should exist"); let session_path = temp_dir.join("session.jsonl"); - let mut session = Session::new(); + let mut session = workspace_session(&temp_dir); session.model = Some("claude-sonnet-4-6".to_string()); session .push_user_text("model persistence fixture") @@ -324,7 +322,7 @@ fn resumed_sandbox_command_emits_structured_json_when_requested() { fs::create_dir_all(&temp_dir).expect("temp dir should exist"); let session_path = temp_dir.join("session.jsonl"); - Session::new() + workspace_session(&temp_dir) .save_to_path(&session_path) .expect("session should persist"); @@ -365,7 +363,7 @@ fn resumed_version_command_emits_structured_json() { let temp_dir = unique_temp_dir("resume-version-json"); fs::create_dir_all(&temp_dir).expect("temp dir should exist"); let session_path = temp_dir.join("session.jsonl"); - Session::new() + workspace_session(&temp_dir) .save_to_path(&session_path) .expect("session should persist"); @@ -398,7 +396,7 @@ fn resumed_export_command_emits_structured_json() { let temp_dir = unique_temp_dir("resume-export-json"); fs::create_dir_all(&temp_dir).expect("temp dir should exist"); let session_path = temp_dir.join("session.jsonl"); - let mut session = Session::new(); + let mut session = workspace_session(&temp_dir); session .push_user_text("export json fixture") .expect("write ok"); @@ -432,7 +430,7 @@ fn resumed_help_command_emits_structured_json() { let temp_dir = unique_temp_dir("resume-help-json"); fs::create_dir_all(&temp_dir).expect("temp dir should exist"); let session_path = temp_dir.join("session.jsonl"); - Session::new() + workspace_session(&temp_dir) .save_to_path(&session_path) .expect("persist ok"); @@ -465,7 +463,7 @@ fn resumed_no_command_emits_restored_json() { let temp_dir = unique_temp_dir("resume-no-cmd-json"); fs::create_dir_all(&temp_dir).expect("temp dir should exist"); let session_path = temp_dir.join("session.jsonl"); - let mut session = Session::new(); + let mut session = workspace_session(&temp_dir); session .push_user_text("restored json fixture") .expect("write ok"); @@ -499,7 +497,7 @@ fn resumed_stub_command_emits_not_implemented_json() { let temp_dir = unique_temp_dir("resume-stub-json"); fs::create_dir_all(&temp_dir).expect("temp dir should exist"); let session_path = temp_dir.join("session.jsonl"); - Session::new() + workspace_session(&temp_dir) .save_to_path(&session_path) .expect("persist ok"); @@ -533,6 +531,10 @@ fn run_claw(current_dir: &Path, args: &[&str]) -> Output { run_claw_with_env(current_dir, args, &[]) } +fn workspace_session(root: &Path) -> Session { + Session::new().with_workspace_root(root.to_path_buf()) +} + fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_claw")); command.current_dir(current_dir).args(args); diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index de3c758..aa2de6e 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -3734,6 +3734,8 @@ fn classify_lane_failure(error: &str) -> LaneFailureClass { || normalized.contains("tool runtime") { LaneFailureClass::ToolRuntime + } else if normalized.contains("workspace") && normalized.contains("mismatch") { + LaneFailureClass::WorkspaceMismatch } else if normalized.contains("plugin") { LaneFailureClass::PluginStartup } else if normalized.contains("mcp") && normalized.contains("handshake") { @@ -7253,6 +7255,10 @@ mod tests { "tool failed: denied tool execution from hook", LaneFailureClass::ToolRuntime, ), + ( + "workspace mismatch while resuming the managed session", + LaneFailureClass::WorkspaceMismatch, + ), ("thread creation failed", LaneFailureClass::Infra), ]; @@ -7279,6 +7285,10 @@ mod tests { LaneEventName::BranchStaleAgainstMain, "branch.stale_against_main", ), + ( + LaneEventName::BranchWorkspaceMismatch, + "branch.workspace_mismatch", + ), ]; for (event, expected) in cases {