Prevent cross-worktree session bleed during managed session resume/load

ROADMAP #41 was still leaving a phantom-completion class open: managed
sessions could be resumed from the wrong workspace, and the CLI/runtime
paths were split between partially isolated storage and older helper
flows. This squashes the verified team work into one deliverable that
routes managed session operations through the per-worktree SessionStore,
rejects workspace mismatches explicitly, extends lane-event taxonomy for
workspace mismatch reporting, and updates the affected CLI regression
fixtures/docs so the new contract is enforced without losing same-
workspace legacy coverage.

Constraint: Keep same-workspace legacy flat sessions readable while blocking cross-worktree misuse
Constraint: No new dependencies; stay within the ROADMAP #41 changed-file scope
Rejected: Leave team auto-checkpoint history as final branch state | noisy/non-lore history for a single roadmap fix
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve workspace_root validation on future resume/load helpers; do not reintroduce path-only fallback without equivalent mismatch checks
Tested: cargo test -p runtime session_control -- --nocapture; cargo test -p rusty-claude-cli resume -- --nocapture; cargo test -p rusty-claude-cli --test cli_flags_and_config_defaults; cargo test -p rusty-claude-cli --test output_format_contract; cargo test -p rusty-claude-cli --test resume_slash_commands; cargo test --workspace --exclude compat-harness; cargo check --workspace --all-targets; git diff --check
Not-tested: cargo clippy --workspace --all-targets -- -D warnings (pre-existing failures in unchanged rust/crates/rusty-claude-cli/build.rs)
Related: ROADMAP #41
This commit is contained in:
Yeachan-Heo
2026-04-11 16:08:01 +00:00
parent 56218d7d8a
commit 61c01ff7da
8 changed files with 464 additions and 429 deletions

View File

@@ -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<Self, Box<dyn std::error::Error>> {
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 <session-id>");
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<PathBuf, Box<dyn std::error::Error>> {
Ok(current_session_store()?.sessions_dir().to_path_buf())
}
fn current_session_store() -> Result<runtime::SessionStore, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let store = runtime::SessionStore::from_cwd(&cwd)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok(store.sessions_dir().to_path_buf())
runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
Ok(Session::new().with_workspace_root(env::current_dir()?))
}
fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
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<SessionHandle, Box<dyn std::error::Error>> {
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<dyn std::error::Error>)?;
Ok(SessionHandle {
id: handle.id,
path: handle.path,
})
}
fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
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/<id>.{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<ManagedSessionSummary>,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>)
}
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>)?
.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<ManagedSessionSummary, Box<dyn std::error::Error>> {
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<dyn std::error::Error>)?;
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<dyn std::error::Error>> {
let loaded = current_session_store()?
.load_session(reference)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok((
SessionHandle {
id: loaded.handle.id,
path: loaded.handle.path,
},
loaded.session,
))
}
fn delete_managed_session(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
@@ -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<String, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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");

View File

@@ -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");

View File

@@ -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(

View File

@@ -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);