diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index a6f8ba07a3..f9c134539f 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -142,6 +142,11 @@ struct ToolCallRequest { call: ToolCall, #[serde(default)] cwd: Option, + /// Session root set for the exec-policy context: attached-root ask and + /// deny rules only fire when the caller declares the set it recorded at + /// thread/start (review round-9, must-fix 1). Empty = single-root. + #[serde(default)] + workspace_roots: Vec, } #[derive(Debug, Deserialize)] @@ -198,6 +203,13 @@ struct RuntimeBridge { auth_token: Option, child: Option, thread_map: HashMap, + /// The workspace root set each mapped runtime thread was created with, + /// keyed by the stdio thread id. A roots-bearing resume updates the + /// record and the hint without touching the mapped thread, so the bridge + /// must notice the disagreement and re-map — otherwise every later + /// bridged turn keeps running the set the old thread was built with + /// while `thread/read` reports the new one. + thread_roots: HashMap>, last_seq_by_thread: HashMap, } @@ -205,6 +217,39 @@ struct RuntimeBridge { struct RuntimeThreadHint { model: Option, workspace: Option, + /// Accessible roots declared on the stdio thread record. The hint is the + /// only carrier between that record and the runtime thread every bridged + /// turn actually executes on. + workspace_roots: Vec, +} + +/// Convert a stored cwd-first root set (primary included) into the DECLARED +/// additional entries the runtime's create/PATCH intakes expect: entries +/// equal to `primary` are the row's own head, not declared additions, so +/// they must not count against the runtime's intake cap. The strip is the +/// shared core primitive (round-38 M38-3; canonical fallback legs since +/// round-41): a `..`-spelled or aliased copy of the head drops with it. +/// A `None` workspace has no head to strip and passes the set through +/// untouched. +fn declared_roots_without_primary(primary: Option<&Path>, stored: &[PathBuf]) -> Vec { + match primary { + Some(primary) => codewhale_core::strip_workspace_roots_head(primary, stored), + None => stored.to_vec(), + } +} + +/// The runtime create/PATCH wire body for a stored root set: the head is +/// stripped (see [`declared_roots_without_primary`]) because the runtime's +/// intakes validate `workspace_roots` as DECLARED additional entries under +/// the intake cap — re-declaring a legal full-load row's own head (64 +/// additions + head) would fail creation with a 400 and leave the bridge +/// answering `runtime_unavailable` for every later turn with no eviction +/// path for the poisoned hint (review #484/CodeWhale round-37 B37-3). An +/// empty remainder stays off the wire: it degenerates to the workspace +/// root on the runtime side. +fn runtime_thread_roots_wire(workspace: Option<&Path>, stored: &[PathBuf]) -> Option> { + let declared = declared_roots_without_primary(workspace, stored); + (!declared.is_empty()).then_some(declared) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -654,27 +699,68 @@ async fn thread_handler(State(state): State, Json(req): Json http_error_from_jsonrpc(err).into_response(), }; } + // The HTTP face feeds the same hint map the stdio dispatch feeds: a + // start/resume/fork that declares `workspace_roots` must land in the + // hint cache, or the turn-executing runtime thread is created + // single-root and the attached-root ask/deny rules never fire + // (review round-9, must-fix 1). The `missing` guard mirrors the stdio + // named arms: a not-found resume/fork answers 404 and never touches + // the cache, or its null fields would clobber the cached hint. + let should_record_hint = matches!( + &req, + ThreadRequest::Create { .. } + | ThreadRequest::Start(_) + | ThreadRequest::Resume(_) + | ThreadRequest::Fork(_) + ); let mut runtime = state.runtime.write().await; match runtime.handle_thread(req).await { - Ok(res) => (StatusCode::OK, Json(res)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ThreadResponse { - thread_id: "error".to_string(), - status: format!("error:{err}"), - thread: None, - threads: Vec::new(), - goal: None, - model: None, - model_provider: None, - cwd: None, - approval_policy: None, - sandbox: None, - events: Vec::new(), - data: json!({}), - }), - ) - .into_response(), + Ok(res) => { + if should_record_hint { + if let Err(err) = ensure_thread_found(&res) { + return http_error_from_jsonrpc(err).into_response(); + } + record_stdio_thread_hint(&state, &res).await; + } + (StatusCode::OK, Json(res)).into_response() + } + Err(err) => { + // Intake-validation rejections are the caller's mistake, not a + // server fault: a declared root set that fails + // `validate_workspace_roots` (or an empty explicit cwd) answers + // 400 with the reason, where the unconditional 500 misclassified + // a client error and leaked absolute paths through a `status` + // field (review #484/CodeWhale round-22 SF22-4). The stdio + // lane's -32603 mapping is disclosed and stays. + if err + .downcast_ref::() + .is_some() + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": err.to_string() })), + ) + .into_response(); + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ThreadResponse { + thread_id: "error".to_string(), + status: format!("error:{err}"), + thread: None, + threads: Vec::new(), + goal: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + events: Vec::new(), + data: json!({}), + }), + ) + .into_response() + } } } @@ -714,12 +800,30 @@ async fn tool_handler( // read guard: they run concurrently with each other and with status // reads instead of serializing every request behind one Mutex. let runtime = state.runtime.read().await; - match runtime.invoke_tool(req.call, approval_mode, &cwd).await { + match runtime + .invoke_tool(req.call, approval_mode, &cwd, &req.workspace_roots) + .await + { Ok(value) => (StatusCode::OK, Json(value)), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "ok": false, "error": err.to_string() })), - ), + Err(err) => { + // The /tool face is a roots intake since round-24 B24-3, so an + // over-cap, filesystem-root, primary-ancestor, non-absolute, or + // empty/relative-cwd declaration is the caller's mistake: answer + // 400 with the reason, matching the sibling lane above, instead + // of misclassifying it as a server fault. + let status = if err + .downcast_ref::() + .is_some() + { + StatusCode::BAD_REQUEST + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + ( + status, + Json(json!({ "ok": false, "error": err.to_string() })), + ) + } } } @@ -1333,6 +1437,15 @@ async fn record_stdio_thread_hint(state: &AppState, response: &ThreadResponse) { RuntimeThreadHint { model: response.model.clone(), workspace: response.cwd.clone(), + // A `thread/start` declaring `workspace_roots` stores the full set + // on the parent record; the turn-executing runtime thread is + // created from this hint, so dropping the set here would deny or + // prompt writes under attached roots while `thread/read` still + // reports them. + workspace_roots: response + .thread + .as_ref() + .map_or_else(Vec::new, |thread| thread.workspace_roots.clone()), }, ); } @@ -1490,6 +1603,7 @@ impl RuntimeBridge { auth_token: Some(auth_token), child: Some(child), thread_map: HashMap::new(), + thread_roots: HashMap::new(), last_seq_by_thread: HashMap::new(), }; bridge.wait_until_ready().await?; @@ -1587,14 +1701,54 @@ impl RuntimeBridge { hint: Option, ) -> Result { if let Some(runtime_thread_id) = self.thread_map.get(stdio_thread_id) { + let roots_changed = hint.as_ref().is_some_and(|hint| { + self.thread_roots + .get(stdio_thread_id) + .is_some_and(|mapped| *mapped != hint.workspace_roots) + }); + if !roots_changed { + return Ok(runtime_thread_id.clone()); + } + // The hint now declares a different root set than the mapped + // runtime thread was created with (a roots-bearing resume after + // the first bridged turn). Re-shape the SAME runtime thread via + // the PATCH primitive instead of creating a fresh one: PATCH + // preserves session_id and the accumulated turns (the model's + // context), evicts the cached engine, and re-normalizes the set + // with the thread's workspace as the primary root. Re-creating + // the thread would run the next turn with correct roots but a + // blank memory, and strand the old thread unarchived in the + // store (review round-9, must-fix 2). + let hint = hint.expect("a root-set change implies a hint"); + let body = json!({ + "workspace": hint.workspace, + "workspace_roots": runtime_thread_roots_wire( + hint.workspace.as_deref(), + &hint.workspace_roots, + ) + .unwrap_or_default(), + }); + let _record = self + .request_json( + self.authed( + self.client + .patch(format!("{}/v1/threads/{runtime_thread_id}", self.base_url)), + ) + .json(&body), + ) + .await?; + self.thread_roots + .insert(stdio_thread_id.to_string(), hint.workspace_roots.clone()); return Ok(runtime_thread_id.clone()); } let hint = hint.unwrap_or_default(); + let roots = hint.workspace_roots.clone(); let runtime_thread_id = self - .create_runtime_thread(hint.model, hint.workspace) + .create_runtime_thread(hint.model, hint.workspace, hint.workspace_roots) .await?; self.thread_map .insert(stdio_thread_id.to_string(), runtime_thread_id.clone()); + self.thread_roots.insert(stdio_thread_id.to_string(), roots); Ok(runtime_thread_id) } @@ -1604,22 +1758,28 @@ impl RuntimeBridge { if let Some(runtime_thread_id) = self.thread_map.remove(stdio_thread_id) { self.last_seq_by_thread.remove(&runtime_thread_id); } + self.thread_roots.remove(stdio_thread_id); } async fn create_runtime_thread( &mut self, model: Option, workspace: Option, + workspace_roots: Vec, ) -> Result { + let mut body = json!({ + "model": model, + "workspace": workspace, + "mode": "agent", + "archived": false, + }); + if let Some(declared) = runtime_thread_roots_wire(workspace.as_deref(), &workspace_roots) { + body["workspace_roots"] = json!(declared); + } let record = self .request_json( self.authed(self.client.post(format!("{}/v1/threads", self.base_url))) - .json(&json!({ - "model": model, - "workspace": workspace, - "mode": "agent", - "archived": false, - })), + .json(&body), ) .await?; let thread_id = extract_runtime_thread_id(&record)?.to_string(); @@ -1972,6 +2132,7 @@ impl RuntimeBridge { auth_token: None, child: None, thread_map: HashMap::new(), + thread_roots: HashMap::new(), last_seq_by_thread: HashMap::new(), } } @@ -2192,7 +2353,12 @@ async fn dispatch_stdio_request_with_writer( | ThreadRequest::Fork(_) ); let response = handle_thread_request(state, request).await?; + // The envelope arm shares the named arms' guard: a `missing` + // resume/fork must fail and must not record — recording its null + // model/workspace would clobber the cached hint, and the next + // bridged turn would PATCH the live thread down to single-root. if should_record_hint { + ensure_thread_found(&response)?; record_stdio_thread_hint(state, &response).await; } StdioDispatchResult { @@ -2681,6 +2847,7 @@ mod tests { use axum::body::{Body, to_bytes}; use axum::extract::{Path as AxumPath, Query}; use axum::http::header; + use axum::routing::patch; use codewhale_protocol::AppRequest; use std::collections::HashMap; use std::fs; @@ -2847,6 +3014,7 @@ mod tests { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .expect("policy check"); @@ -2883,6 +3051,7 @@ mod tests { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .expect("policy check"); assert!(decision.matched_rule.is_none()); @@ -2929,6 +3098,7 @@ mod tests { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .expect("policy check"); assert!(decision.allow); @@ -2978,6 +3148,7 @@ mod tests { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .expect("policy check"); assert!(decision.matched_rule.is_none()); @@ -2996,6 +3167,7 @@ mod tests { auth_token: None, child: None, thread_map: HashMap::from([("stdio-1".to_string(), "runtime-1".to_string())]), + thread_roots: HashMap::new(), last_seq_by_thread: HashMap::new(), })) } @@ -3361,6 +3533,74 @@ mod tests { assert_eq!(cleared.result["data"]["cleared"], true); } + #[tokio::test] + async fn stdio_thread_start_records_the_declared_roots_in_the_hint() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write(&config_path, "").expect("write config"); + let state = build_state(Some(config_path), None).expect("state"); + + let started = dispatch_stdio_request( + &state, + "thread/start", + json!({ + "cwd": "/tmp/codewhale-primary", + "workspace_roots": ["/tmp/codewhale-shared"] + }), + ) + .await + .expect("start thread"); + let thread_id = started.result["thread_id"] + .as_str() + .expect("thread id") + .to_string(); + + let hints = state.stdio_thread_hints.lock().await; + let hint = hints.get(&thread_id).expect("hint recorded"); + assert_eq!( + hint.workspace_roots, + vec![ + PathBuf::from("/tmp/codewhale-primary"), + PathBuf::from("/tmp/codewhale-shared") + ], + "the hint is the only way the declared set reaches the runtime thread" + ); + assert_eq!( + hint.workspace.as_deref(), + Some(Path::new("/tmp/codewhale-primary")) + ); + } + + #[test] + fn bridge_wire_strips_a_stored_full_load_row_head() { + // Round-37 B37-3 pin: the hint stores the parent record's cwd-first + // full form (primary included), while the runtime's create/PATCH + // intakes validate `workspace_roots` as DECLARED additional entries + // under the intake cap. A legal full-load row (64 additions + head) + // must go on the wire as 64 declarations or the runtime 400s the + // create and the bridge answers `runtime_unavailable` forever. + let primary = PathBuf::from("/repo"); + let mut stored = vec![primary.clone()]; + stored.extend((0..64).map(|index| PathBuf::from(format!("/repo/r{index}")))); + let wire = runtime_thread_roots_wire(Some(primary.as_path()), &stored).expect("wire set"); + assert_eq!(wire.len(), 64, "the head is not a declaration"); + assert!(!wire.contains(&primary), "the head is stripped"); + + // A single-root row (head only) stays off the wire entirely: the + // empty remainder degenerates to the workspace root on the runtime + // side. + assert!( + runtime_thread_roots_wire(Some(primary.as_path()), std::slice::from_ref(&primary)) + .is_none(), + "a head-only set must not be declared" + ); + + // Without a workspace there is no head to strip. + let passthrough = + runtime_thread_roots_wire(None, &stored).expect("no-head set still declared"); + assert_eq!(passthrough.len(), 65); + } + #[tokio::test] async fn stdio_resume_of_missing_thread_fails_without_clobbering_the_hint() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -3378,6 +3618,7 @@ mod tests { RuntimeThreadHint { model: Some("deepseek-v4-pro".to_string()), workspace: Some(workspace.clone()), + ..RuntimeThreadHint::default() }, ); } @@ -3407,6 +3648,118 @@ mod tests { assert_eq!(hint.workspace.as_deref(), Some(workspace.as_path())); } + /// The generic `thread/request` envelope and the HTTP `/thread` face share + /// the named arms' guard: a `missing` resume/fork must fail with the named + /// not-found error on every surface, and none of them may record the + /// response's null fields over the cached hint (same clobber chain as + /// #5171 — the next bridged turn would PATCH the live thread down to + /// single-root). + #[tokio::test] + async fn missing_resume_never_clobbers_the_hint_on_any_face() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write(&config_path, "").expect("write config"); + let state = build_state(Some(config_path), None).expect("state"); + + let workspace = tmp.path().join("ws"); + { + let mut hints = state.stdio_thread_hints.lock().await; + hints.insert( + "ghost-thread".to_string(), + RuntimeThreadHint { + model: Some("deepseek-v4-pro".to_string()), + workspace: Some(workspace.clone()), + workspace_roots: vec![workspace.clone(), tmp.path().join("attached")], + }, + ); + } + + // Generic stdio envelope arm. + let err = dispatch_stdio_request( + &state, + "thread/request", + json!({ "kind": "resume", "thread_id": "ghost-thread" }), + ) + .await + .expect_err("an envelope resume of a missing thread must fail like the named arm"); + assert_eq!(err.code, -32004); + + // HTTP face: 404 with the named error, hint untouched. + let app = app_router(state.clone(), &[]); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/thread") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec( + &json!({ "kind": "resume", "thread_id": "ghost-thread" }), + ) + .expect("request json"), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = response_body_json(response).await; + assert!( + format!("{body}").contains("thread_not_found"), + "the HTTP error names the not-found cause: {body}" + ); + + let hints = state.stdio_thread_hints.lock().await; + let hint = hints + .get("ghost-thread") + .expect("cached hint survives both faces"); + assert_eq!(hint.model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!(hint.workspace.as_deref(), Some(workspace.as_path())); + assert_eq!( + hint.workspace_roots, + vec![workspace.clone(), tmp.path().join("attached")], + "the attached root set survives — a wipe here is what collapses the next turn to single-root" + ); + } + + #[tokio::test] + async fn http_thread_face_answers_400_on_intake_validation_errors() { + // Round-22 SF22-4: a caller-side intake rejection (here, an explicit + // empty cwd; the same class as a declared root set failing + // `validate_workspace_roots`) is the client's mistake and answers + // 400 with the reason — the unconditional 500 misclassified it as a + // server fault and carried the paths in a `status` field. The stdio + // lane keeps its disclosed -32603 mapping. + let (app, _tmp) = app_with_config(None); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/thread") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ "kind": "start", "cwd": "" })) + .expect("request json"), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response_body_json(response).await; + // Round-29/30/31 B1: production's rejection text moved to "cwd must + // be a non-empty absolute path"; the stable fragment "cwd must be" + // matches the current text and stays sensitive to a regression to + // the old "must not be empty" family. + assert!( + body["error"] + .as_str() + .is_some_and(|message| message.contains("cwd must be")), + "the rejection reason must be in the body: {body}" + ); + } + fn sse_frame(event: &str, payload: Value) -> String { format!("event: {event}\ndata: {payload}\n\n") } @@ -4078,6 +4431,14 @@ mod tests { async fn create_thread(Json(body): Json) -> Json { assert_eq!(body["model"], "deepseek-v4"); assert_eq!(body["workspace"], "/tmp/codewhale-stdio"); + // A multi-root stdio thread must create a multi-root runtime + // thread: this is the only place the declared set can reach the + // thread every bridged turn executes on. Round-37 B37-3: the + // wire carries the DECLARED additional entries — the stored + // head is the row's primary slot (re-anchored server-side from + // `workspace`) and must not count against the runtime's intake + // cap. + assert_eq!(body["workspace_roots"], json!(["/tmp/codewhale-shared"])); Json(json!({ "id": "thr_runtime", "model": body["model"].clone(), @@ -4104,6 +4465,10 @@ mod tests { Some(RuntimeThreadHint { model: Some("deepseek-v4".to_string()), workspace: Some(PathBuf::from("/tmp/codewhale-stdio")), + workspace_roots: vec![ + PathBuf::from("/tmp/codewhale-stdio"), + PathBuf::from("/tmp/codewhale-shared"), + ], }), ) .await @@ -4118,6 +4483,184 @@ mod tests { ); } + #[tokio::test] + async fn stdio_runtime_bridge_omits_an_empty_root_set() { + async fn create_thread(Json(body): Json) -> Json { + assert!( + body.get("workspace_roots").is_none(), + "a single-root hint must keep the historical frame: {body}" + ); + Json(json!({ "id": "thr_single" })) + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("listener addr"); + let app = Router::new().route("/v1/threads", post(create_thread)); + + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve test runtime"); + }); + + let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}")); + let runtime_id = bridge + .ensure_runtime_thread( + "single_root_thread", + Some(RuntimeThreadHint { + model: Some("deepseek-v4".to_string()), + workspace: Some(PathBuf::from("/tmp/codewhale-single")), + workspace_roots: Vec::new(), + }), + ) + .await + .expect("runtime thread"); + server.abort(); + let _ = server.await; + + assert_eq!(runtime_id, "thr_single"); + } + + /// Round-9 M-B: a roots-bearing `thread/resume` after the first bridged + /// turn updates the stdio record and the hint, but the already-mapped + /// runtime thread was created with the old set. The bridge must reshape + /// the SAME runtime thread in place via PATCH (session id and turns + /// survive) so later turns execute the declared set; an unchanged hint + /// must keep the existing mapping. + #[tokio::test] + async fn stdio_runtime_bridge_remaps_when_the_hint_root_set_changes() { + let bodies = Arc::new(Mutex::new(Vec::::new())); + let captured = Arc::clone(&bodies); + async fn create_thread( + axum::extract::State(captured): axum::extract::State>>>, + Json(body): Json, + ) -> Json { + let ordinal = { + let mut bodies = captured.lock().await; + bodies.push(body.clone()); + bodies.len() + }; + Json(json!({ "id": format!("thr_runtime_{ordinal}") })) + } + // Patches land in the same capture, tagged: the roots-changed resume + // must re-shape the SAME runtime thread instead of creating a fresh + // one. + async fn patch_thread( + axum::extract::State(captured): axum::extract::State>>>, + axum::extract::Path(id): axum::extract::Path, + Json(body): Json, + ) -> Json { + let mut bodies = captured.lock().await; + let mut record = json!({ "patch": true }); + record["id"] = json!(id); + // A live PATCH preserves the thread's accumulated turns — that is + // the context-continuity guarantee under test. + record["turns"] = json!([{ "input": "earlier", "output": "earlier" }]); + record["workspace_roots"] = body["workspace_roots"].clone(); + bodies.push(record.clone()); + Json(record) + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("listener addr"); + let app = Router::new() + .route("/v1/threads", post(create_thread)) + .route("/v1/threads/{id}", patch(patch_thread)) + .with_state(captured); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve test runtime"); + }); + + let hint = |roots: &[&str]| RuntimeThreadHint { + model: None, + workspace: Some(PathBuf::from("/tmp/codewhale-primary")), + workspace_roots: roots.iter().map(PathBuf::from).collect(), + }; + let mut bridge = RuntimeBridge::from_base_url_for_test(format!("http://{addr}")); + let first = bridge + .ensure_runtime_thread( + "thr_stdio", + Some(hint(&["/tmp/codewhale-primary", "/tmp/codewhale-r1"])), + ) + .await + .expect("initial runtime thread"); + assert_eq!(first, "thr_runtime_1"); + + // An unchanged hint keeps the mapped thread: no second create. + let same = bridge + .ensure_runtime_thread( + "thr_stdio", + Some(hint(&["/tmp/codewhale-primary", "/tmp/codewhale-r1"])), + ) + .await + .expect("unchanged hint keeps the mapping"); + assert_eq!(same, "thr_runtime_1"); + assert_eq!( + bridge.thread_map.get("thr_stdio").map(String::as_str), + Some("thr_runtime_1") + ); + + // A roots-bearing resume changed the declared set: the bridge must + // re-shape the SAME runtime thread via PATCH (context continuity) + // instead of creating a fresh one. + let remapped = bridge + .ensure_runtime_thread( + "thr_stdio", + Some(hint(&["/tmp/codewhale-primary", "/tmp/codewhale-r2"])), + ) + .await + .expect("changed roots re-shape the runtime thread"); + assert_eq!(remapped, "thr_runtime_1", "the runtime thread is kept"); + assert_eq!( + bridge.thread_map.get("thr_stdio").map(String::as_str), + Some("thr_runtime_1") + ); + assert_eq!( + bridge.thread_roots.get("thr_stdio"), + Some(&vec![ + PathBuf::from("/tmp/codewhale-primary"), + PathBuf::from("/tmp/codewhale-r2") + ]), + "the mapped root set carries the resumed roots" + ); + server.abort(); + let _ = server.await; + + let bodies = bodies.lock().await; + assert_eq!(bodies.len(), 2, "one create plus one PATCH"); + assert_eq!( + bodies[0]["workspace_roots"], + json!(["/tmp/codewhale-r1"]), + "round-37 B37-3: the create wire carries the declared additions, head stripped" + ); + assert_eq!( + bodies[1]["patch"], + json!(true), + "the roots change re-shapes via PATCH" + ); + assert_eq!( + bodies[1]["id"], + json!("thr_runtime_1"), + "the PATCH lands on the same runtime thread" + ); + assert_eq!( + bodies[1]["workspace_roots"], + json!(["/tmp/codewhale-r2"]), + "round-37 B37-3: the PATCH carries the resumed declared additions, head stripped" + ); + assert_eq!( + bodies[1]["turns"].as_array().map(Vec::len), + Some(1), + "context continuity: the patched thread keeps its turns" + ); + } + // ── prompt routing runs a real turn ──────────────────────────────── // // `/prompt`, `prompt/request` and `prompt/run` used to return HTTP 200 diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 6ace21dd07..f00ab3c188 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -4991,6 +4991,7 @@ fn run_sandbox_command(command: SandboxCommand) -> Result<()> { path: None, ask_for_approval: ask.into(), sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), })?; println!("{}", serde_json::to_string_pretty(&decision)?); Ok(()) diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index 137ad27b28..7842a61a35 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -937,6 +937,29 @@ pub trait CommandSkillGroupContext { /// `/restore `: host restores by snapshot id; handler composes the /// exact success message from its list entry. fn restore_snapshot(&mut self, id: &str) -> Result<(), String>; + /// `/restore` rollback scope: true when the session root set has entries + /// beyond the primary workspace. Snapshots are primary-bound, so the + /// handler must name the boundary instead of implying every accessible + /// root was reverted. + fn restore_covers_primary_only(&self) -> bool; /// `/restore` trust gate posture (yolo / trust_mode). fn approval_state(&self) -> CommandApprovalState; + /// `/restore` rollback-boundary disclosure text. The default is the + /// stable English sentence shared verbatim with the model/API lanes + /// ([`ATTACHED_ROOTS_NOT_REVERTED_NOTE`]); hosts with a localized + /// human-facing surface (the TUI) override it with their translated + /// form so the same disclosure is not English-only on one face + /// (round-45 R45-3). + fn attached_roots_not_reverted_note(&self) -> String { + ATTACHED_ROOTS_NOT_REVERTED_NOTE.to_string() + } } + +/// Honesty clause for revert/undo/restore reports when the session root set +/// extends beyond the primary workspace: the snapshot side-repo is rooted at +/// the primary, so a restore rolls back only the primary while attached-root +/// writes persist. Owned here (single source) so the portable `/restore` +/// handler and the host's model/API lanes can never drift; the TUI re-exports +/// it from `crate::snapshot` and layers the localized form on top. +pub const ATTACHED_ROOTS_NOT_REVERTED_NOTE: &str = + "Only the primary workspace was reverted; attached workspace roots were not rolled back."; diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index 76a7142338..bbf95c57d4 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -1492,6 +1492,10 @@ impl CommandSkillGroupContext for FakeSkillGroup { } } + fn restore_covers_primary_only(&self) -> bool { + false + } + fn approval_state(&self) -> CommandApprovalState { self.approval } diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 076293665b..f43182bb97 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -722,6 +722,7 @@ fn config_store_exec_policy_engine_uses_sibling_permissions() { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .expect("policy check"); @@ -854,6 +855,7 @@ fn config_store_appends_exact_workspace_allow_rules() { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .expect("check exact grant"); assert_eq!( @@ -871,6 +873,7 @@ fn config_store_appends_exact_workspace_allow_rules() { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .expect("check extra args"); assert!(extra_args.requires_approval); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 39a6d93eae..6bddbe14c1 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -79,6 +79,704 @@ pub enum InitialHistory { }, } +/// Normalizes a workspace root set: `cwd` is the primary root at position 0, +/// followed by `roots` in their original order with duplicates removed. An +/// empty `roots` degenerates to `[cwd]`. +/// +/// Entries that are empty or relative are dropped, not normalized: every +/// containment check downstream is `Path::starts_with`-shaped, where an empty +/// root contains *every* path and a relative root is meaningless against the +/// absolute candidates the boundary checks resolve. The `cwd` argument is +/// filtered by the same rule, and there the filter fails closed: an empty or +/// relative cwd cannot head a root set (its normalized form is the vacuous +/// root), so the whole set collapses to empty and the thread has no writable +/// roots at all. Intake surfaces reject an empty cwd/workspace outright; this +/// filter is the last line for values that bypass a surface (legacy or +/// hand-edited records). This is the single chokepoint every consumer routes +/// through, so intake validation lives here rather than at each protocol +/// surface. +/// +/// Deduplication is lexical, not filesystem-aware: two spellings of the same +/// directory (a symlinked `/var/x` beside its `/private/var/x` target) both +/// survive here. Callers that enumerate writable roots canonicalize per root +/// for exactly that reason; a future canonicalizing intake would remove the +/// residue, at the cost of filesystem access on every normalization. +/// +/// This function stays a *shape* normalizer, not a validator, because it also +/// runs on sets that never passed an intake surface (restored sessions, legacy +/// or hand-edited records): those degrade by dropping the meaningless entries +/// rather than failing the whole load. Intake surfaces that receive a +/// caller-declared set route through [`validate_workspace_roots`] instead, +/// which rejects rather than drops. +/// +/// The additional entries are capped at [`MAX_WORKSPACE_ROOTS`], earliest +/// declared wins (review #484/CodeWhale round-24 B24-4). Validating intakes +/// reject an over-cap declaration with an error, but the load faces (the +/// state reader's resume resolution, engine init, `Op::SyncSession`, +/// `exec --resume`, ACP `session/load`) consume sets that never passed one — +/// an unbounded hand-edited row turned every write-tool call into O(n²) dedup +/// plus per-root canonicalization, a permanent stall the cap exists to bound. +/// A declaration that passed intake can never be truncated here: the intake +/// cap counts declared roots and this cap counts the same entries, so a +/// full 64-root declaration survives whole behind the primary. +pub fn normalize_workspace_roots(cwd: &Path, roots: &[PathBuf]) -> Vec { + if cwd.as_os_str().is_empty() || !cwd.is_absolute() { + return Vec::new(); + } + let mut normalized = vec![cwd.to_path_buf()]; + for root in roots { + if normalized.len() > MAX_WORKSPACE_ROOTS { + break; + } + if root.as_os_str().is_empty() || !root.is_absolute() { + continue; + } + if !normalized.contains(root) { + normalized.push(root.clone()); + } + } + normalized +} + +/// Lexically collapse CurDir and ParentDir components of `path` into the +/// landing path the tool boundary resolves: a `..` at the filesystem root +/// (or a Windows drive root) CLAMPS, it does not fail, and a `..` a relative +/// spelling cannot pop is KEPT. This is the single implementation behind the +/// tools layer's landing normalizer (`tools::spec::normalize_path`), shared +/// so the judgment lanes can never drift from the gate +/// (review #484/CodeWhale round-22 B22-5). +pub fn normalize_path_lexically(path: &Path) -> PathBuf { + let mut prefix: Option = None; + let mut is_root = false; + let mut stack: Vec = Vec::new(); + + for component in path.components() { + match component { + std::path::Component::Prefix(prefix_component) => { + prefix = Some(prefix_component.as_os_str().to_owned()); + } + std::path::Component::RootDir => { + is_root = true; + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + let parent = std::path::Component::ParentDir.as_os_str(); + if let Some(last) = stack.pop() { + if last == parent { + stack.push(last); + stack.push(parent.to_owned()); + } + } else if !is_root { + stack.push(parent.to_owned()); + } + } + std::path::Component::Normal(part) => { + stack.push(part.to_owned()); + } + } + } + + let mut normalized = PathBuf::new(); + if let Some(prefix) = prefix { + normalized.push(prefix); + } + if is_root { + normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR)); + } + for part in stack { + normalized.push(part); + } + normalized +} + +/// Outcome of the deepest-existing-ancestor walk (round-45 R45-4): the one +/// core implementation every security-adjacent comparison lane delegates +/// to. `ancestor` is the deepest existing prefix, canonicalized through +/// symlinks when possible (spelling per the caller's +/// [`DeepestExistingFallback`] choice when even it cannot canonicalize); +/// `existing` is that same ancestor's RAW pre-canonicalization spelling +/// (callers whose leniency gates compare declared spellings — the tools' +/// follow-symlinks fast path — consume it instead); `tail` holds the +/// nonexistent components in deepest-first order — re-append with +/// [`DeepestExistingWalk::compose`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeepestExistingWalk { + pub ancestor: PathBuf, + pub existing: PathBuf, + pub tail: Vec, +} + +impl DeepestExistingWalk { + /// The composed resolution: canonical ancestor plus the re-appended + /// nonexistent tail. + pub fn compose(&self) -> PathBuf { + let mut composed = self.ancestor.clone(); + for part in self.tail.iter().rev() { + composed.push(part); + } + composed + } +} + +/// What spelling [`walk_deepest_existing`] keeps when even the deepest +/// walked-to ancestor cannot canonicalize (e.g. an EACCES wall above it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeepestExistingFallback { + /// Keep the ORIGINAL input spelling and drop the tail — the round-44 + /// R44-1 comparison shape, where a wrong-but-existing ancestor must + /// never substitute for the declared ghost path. + OriginalPath, + /// Keep the deepest walked-to ANCESTOR spelling plus the tail — the + /// operand/spec shapes, where the walk's own result is the closest + /// statable truth and must survive. + DeepestAncestor, +} + +/// Walk `path` up to its deepest EXISTING ancestor and canonicalize that +/// prefix through symlinks (round-45 R45-4). This is the single source for +/// the deepest-existing walk — `resolve_operand_cwd`'s Err branch, the +/// ghost-primary comparison (`resolve_deepest_existing_canonical`), the +/// TUI's carve-out `resolve_deepest_existing`, and the tools' +/// `resolve_nonexistent_path` all delegate here so their fallback semantics +/// can never drift apart again (the round-45 finding was four copies with +/// two independent divergence axes: the fallback target and whether the +/// composed result is lexically normalized — both are now explicit at each +/// call site). +pub fn walk_deepest_existing( + path: &Path, + fallback: DeepestExistingFallback, +) -> DeepestExistingWalk { + if let Ok(canonical) = path.canonicalize() { + return DeepestExistingWalk { + ancestor: canonical, + existing: path.to_path_buf(), + tail: Vec::new(), + }; + } + let mut existing = path.to_path_buf(); + let mut tail: Vec = Vec::new(); + while !existing.exists() { + // A `..` (or `.`) final component yields no `file_name`: the engine + // lane's pinned behavior (round-20 B20-1, moved verbatim) pops it + // WITHOUT recording it and keeps walking — the walk terminates via + // the existing check or the root, never here. + if let Some(name) = existing.file_name() { + tail.push(name.to_owned()); + } + match existing.parent() { + Some(parent) if !parent.as_os_str().is_empty() => existing = parent.to_path_buf(), + _ => break, + } + } + let raw_existing = existing.clone(); + match existing.canonicalize() { + Ok(canonical) => DeepestExistingWalk { + ancestor: canonical, + existing: raw_existing, + tail, + }, + Err(_) => match fallback { + DeepestExistingFallback::OriginalPath => DeepestExistingWalk { + ancestor: path.to_path_buf(), + existing: raw_existing, + tail: Vec::new(), + }, + DeepestExistingFallback::DeepestAncestor => DeepestExistingWalk { + existing: raw_existing.clone(), + ancestor: raw_existing, + tail, + }, + }, + } +} + +/// Resolve a `cwd:`/`working_dir:`-style operand the way the exec lane does, +/// so a policy judgment sees the directory execution actually runs in +/// (review #484/CodeWhale round-22 B22-5): a relative spelling joins onto +/// `workspace`; an existing result canonicalizes through any symlink; a +/// genuinely-missing operand walks to the deepest existing ancestor, +/// canonicalizes it through any symlink, re-appends the popped tail, and +/// normalizes — the same walk the engine's judged-cwd applies (round-20 +/// B20-1, round-21 B21-3). Both the headless `Runtime::invoke_tool` lane and +/// the TUI engine lane call this one implementation. Round-45 P2-10: only +/// `ErrorKind::NotFound` walks — an EACCES/ELOOP operand exists but cannot +/// be canonicalized, and walking it up to the deepest statable ancestor +/// would judge policy against the WRONG directory; such operands keep their +/// own lexically normalized spelling so containment fails closed. +pub fn resolve_operand_cwd(workspace: &Path, raw: &str) -> PathBuf { + let raw_path = Path::new(raw); + let joined = if raw_path.is_absolute() { + raw_path.to_path_buf() + } else { + workspace.join(raw_path) + }; + match joined.canonicalize() { + Ok(canonical) => canonical, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let walked = walk_deepest_existing(&joined, DeepestExistingFallback::DeepestAncestor); + normalize_path_lexically(&walked.compose()) + } + Err(_) => normalize_path_lexically(&joined), + } +} + +/// A caller-supplied value failed an intake validation rule: a declared +/// workspace root set (or a workspace/cwd that cannot head one) was refused. +/// +/// The distinct type (not just an `anyhow!` string) lets HTTP lanes classify +/// the rejection as the caller's mistake — HTTP 400 — instead of a server +/// fault (review #484/CodeWhale round-22 SF22-4). The `Display` text is the +/// rejection reason, unchanged from the plain `anyhow!` strings these sites +/// used before, so message-based classifiers keep working. +#[derive(Debug)] +pub struct IntakeValidationError { + message: String, +} + +impl std::fmt::Display for IntakeValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for IntakeValidationError {} + +impl IntakeValidationError { + /// Build the rejection as an `anyhow::Error` so existing `anyhow::Result` + /// signatures carry it transparently. + fn err(message: impl Into) -> anyhow::Error { + anyhow::Error::new(Self { + message: message.into(), + }) + } +} + +/// Ceiling on one declared root set (review #484/CodeWhale round-22 SF22-8). +/// Every boundary consumer scales with the set length — the intake dedup, +/// per-turn sandbox materialization, per-operand `boundary_roots` +/// canonicalization, and repo law's per-root constitution loads — so an +/// unbounded declaration from a hostile or buggy client converts per-turn +/// work into a permanent stall. 64 is far above any legitimate multi-repo +/// session and bounds that work to a constant. +pub const MAX_WORKSPACE_ROOTS: usize = 64; + +/// Comparison-local spelling normalization (round-39 B39-2): on Windows, +/// `std::fs::canonicalize` returns verbatim `\\?\`-prefixed paths while +/// lexical spellings keep the DOS form, and the two prefix components never +/// compare equal — so a comparison mixing one canonicalized side with one +/// lexical side silently skips the ancestor/filesystem-root guard (the +/// fail-open). Strip the verbatim prefix (mapping `\\?\UNC\` to `\\`) and +/// case-fold, mirroring the repo's existing +/// `normalize_windows_path_for_comparison` precedent in codewhale-config. +/// A no-op on Unix, where case is significant and no verbatim prefix +/// exists. +/// Ancestor test for the intake/sanitizer guards, split by platform +/// (round-39 B39-2, its P2 correction, rounds 42/44): Unix resolves each +/// side through its deepest EXISTING ancestor before the native component +/// comparison — the walk closes the ghost-primary hole (a +/// not-yet-materialized primary keeps a lexical spelling whose canonical +/// prefix diverges on macOS `/var` ↔ `/private/var` shapes) while +/// component semantics keep legal `:`/`\`-bearing filenames distinct (a +/// sibling named `data:` is never the ancestor of `data:archive/ws`). +/// Windows compares through the spelling normalizer, where the +/// verbatim-vs-DOS divergence is real. +#[cfg(not(target_os = "windows"))] +fn comparison_is_ancestor_of(primary: &Path, judged: &Path) -> bool { + // Round-44 R44-1: a NOT-YET-MATERIALIZED primary cannot canonicalize, + // so a plain canonicalize-with-fallback mixes the primary's LEXICAL + // spelling against the ancestor's CANONICAL one — on macOS-default + // shapes (/var vs /private/var) the comparison silently skips the + // ancestor guard and arms the primary's parent as writable. Resolve + // the primary through its deepest EXISTING ancestor (the + // resolve_operand_cwd walk), canonicalize that, and re-append the + // nonexistent tail — both sides then carry canonical spellings for + // every component that exists. Component semantics stay native: a + // sibling named `data:` never contains `data:archive`. + let primary_real = resolve_deepest_existing_canonical(primary); + let judged_real = judged + .canonicalize() + .unwrap_or_else(|_| judged.to_path_buf()); + primary_real.starts_with(&judged_real) +} + +/// Canonical spelling of `path` with its nonexistent tail resolved through +/// the deepest EXISTING ancestor (round-44 R44-1): the existing prefix +/// canonicalizes (through symlinks and platform aliases like macOS's +/// /var → /private/var) and the nonexistent tail re-appends verbatim, so a +/// ghost primary and an existing ancestor compare on canonical spellings +/// for every component that exists. Round-45 R45-4: the walk itself is the +/// shared [`walk_deepest_existing`] (OriginalPath fallback — a failed +/// ancestor canonicalization keeps the declared spelling instead of +/// substituting any walked-to ancestor). +#[cfg(not(target_os = "windows"))] +fn resolve_deepest_existing_canonical(path: &Path) -> PathBuf { + walk_deepest_existing(path, DeepestExistingFallback::OriginalPath).compose() +} + +#[cfg(target_os = "windows")] +fn comparison_is_ancestor_of(primary: &Path, judged: &Path) -> bool { + let haystack = comparison_spelling(primary); + let needle = comparison_spelling(judged); + haystack.starts_with(&needle) && comparison_contains(&haystack, &needle) +} + +/// Windows-only comparison-local spelling normalization (round-39 B39-2): +/// `std::fs::canonicalize` returns verbatim `\\?\`-prefixed paths while +/// lexical spellings keep the DOS form, and the two prefix components never +/// compare equal — so a comparison mixing one canonicalized side with one +/// lexical side silently skips the ancestor/filesystem-root guard (the +/// fail-open). Strip the verbatim prefix (mapping `\\?\UNC\` to `\\`) and +/// case-fold, mirroring the repo's existing +/// `normalize_windows_path_for_comparison` precedent in codewhale-config. +#[cfg(target_os = "windows")] +fn comparison_spelling(path: &Path) -> String { + let spelling = path.to_string_lossy().replace('/', "\\"); + let folded = spelling.to_ascii_lowercase(); + if let Some(rest) = folded.strip_prefix(r"\\?\unc\") { + format!(r"\\{rest}") + } else if let Some(rest) = folded.strip_prefix(r"\\?\") { + rest.to_string() + } else { + folded + } +} + +/// Windows-only separator-boundary prefix test over +/// [`comparison_spelling`] output: true when `haystack` equals `needle` or +/// extends it by one path component (a root-ending needle — `/`, a drive +/// root like `d:`, a UNC share root — contains everything below it). +#[cfg(target_os = "windows")] +fn comparison_contains(haystack: &str, needle: &str) -> bool { + if needle.is_empty() || !haystack.starts_with(needle) { + return false; + } + if haystack.len() == needle.len() { + return true; + } + if needle.ends_with('/') || needle.ends_with('\\') || needle.ends_with(':') { + return true; + } + matches!( + haystack.as_bytes().get(needle.len()), + Some(b'/') | Some(b'\\') + ) +} + +/// Intake validation for a caller-declared root set: admit it whole or reject +/// it with an error, never silently reshape it. +/// +/// CANONICAL+LEXICAL (round-37 M37-3 promoted the round-20 B20-4 shape): +/// an EXISTING declared root canonicalizes before the filesystem-root and +/// ancestor comparisons, so a symlink spelling — and, on case-insensitive +/// filesystems, a case-folded spelling — of the primary's ancestor is +/// rejected here instead of widening the boundary at the canonicalizing +/// enforcement (`get_writable_roots`, seatbelt, bwrap). A declared root +/// that does not exist yet keeps the lexical judgment: materialization +/// stays the delivery lane's job, and the same-disk TOCTOU between this +/// check and enforcement is the disclosed inherent residual. A not-yet- +/// materialized PRIMARY resolves through its deepest EXISTING ancestor +/// (`walk_deepest_existing`, round-44 R44-1): every component that exists +/// compares on canonical spellings, so the macOS `/tmp` ↔ `/private/tmp` +/// ghost-primary shape is rejected like any other ancestor — the former +/// existence-dependent residual is closed (pinned by +/// `intake_rejects_a_canonical_ancestor_under_a_ghost_primary`). +/// +/// The per-turn sandbox copies this set verbatim into +/// `WorkspaceWrite.writable_roots`, so three classes of declared entry widen +/// the boundary past anything the caller saw, and each is rejected here: +/// +/// - a non-absolute root (`~/shared`, `relative/dir`) is meaningless against +/// the absolute candidates every containment check resolves — +/// [`normalize_workspace_roots`] would silently drop it, shrinking the +/// declared set without telling the caller, so intake refuses it instead; +/// - a root that normalizes to the filesystem root (`/`, `/..`) makes the +/// sandboxed exec lane filesystem-writable in one attachment; +/// - a root that is a proper ancestor of the primary root (the primary's +/// parent) grants the same reach one spelling at a time. +/// +/// `..` spellings are caught by the same lexical normalization the landing +/// checks apply, so `/shared/..` rejects exactly where `/` does. A root that +/// merely lives *under* the primary is fine — it is already writable through +/// the primary — and a root equal to the primary dedups in +/// [`normalize_workspace_roots`]. +/// +/// The degenerate primary keeps the normalizer's fail-closed collapse rather +/// than an error: an empty or relative primary cannot head a root set (its +/// normalized form is the vacuous root), which is the round-17 decision the +/// intake surfaces already pin — an empty workspace is rejected outright +/// there, and this collapse is the last line for values that bypass a +/// surface. +pub fn validate_workspace_roots(cwd: &Path, roots: &[PathBuf]) -> Result> { + if cwd.as_os_str().is_empty() || !cwd.is_absolute() { + return Ok(Vec::new()); + } + if roots.len() > MAX_WORKSPACE_ROOTS { + return Err(IntakeValidationError::err(format!( + "workspace root set declares {} roots; the intake cap is {MAX_WORKSPACE_ROOTS}, \ + and an oversized declaration stalls every per-turn boundary computation", + roots.len() + ))); + } + let primary = normalize_lexical_components(cwd); + // Round-37 M37-3: judge against the paths the FILESYSTEM actually + // resolves, not only the lexical spellings. A declared ancestor that + // differs by case (case-insensitive filesystems) or rides a symlink + // canonicalizes INTO the primary's real parent chain, and the + // canonicalizing boundaries (get_writable_roots, seatbelt, bwrap) arm + // the widened root as-is — the rejection must fire here, at the one + // intake every declared set crosses. A declared root that does not + // exist yet keeps the lexical judgment (the soft-keep/delivery lane + // owns materialization; the same-disk TOCTOU is the disclosed + // inherent one). + let primary_real = cwd.canonicalize().unwrap_or_else(|_| primary.clone()); + for root in roots { + if root.as_os_str().is_empty() || !root.is_absolute() { + return Err(IntakeValidationError::err(format!( + "workspace root must be an absolute path; got {root:?}" + ))); + } + let normalized = normalize_lexical_components(root); + let judged = root.canonicalize().unwrap_or_else(|_| normalized.clone()); + if is_filesystem_root(&judged) { + return Err(IntakeValidationError::err(format!( + "workspace root {root:?} normalizes to the filesystem root; \ + attaching it would make the whole filesystem writable" + ))); + } + // Round-39 B39-2 + P2: the platform-split ancestor predicate — + // native component comparison on Unix, spelling-normalized on + // Windows (the verbatim fail-open). + if primary_real != judged && comparison_is_ancestor_of(&primary_real, &judged) { + return Err(IntakeValidationError::err(format!( + "workspace root {root:?} contains the primary root {cwd:?}; \ + attaching an ancestor of the primary would widen the sandbox \ + past the primary" + ))); + } + } + Ok(normalize_workspace_roots(cwd, roots)) +} + +/// Strip a stored row's own head: entries whose lexical collapse equals +/// `primary` (the cwd-first head itself, INCLUDING a `..`-spelled copy the +/// raw-equality dedup in [`normalize_workspace_roots`] admits) are the +/// row's primary slot, not declared additions. Every face that converts a +/// stored row into a declaration — a re-declaring resume/fork/load, a +/// primary move's carried set, a bridged wire body — goes through this one +/// strip, or a `..`-spelled copy of the OLD primary survives the move and +/// keeps the abandoned directory writable (review #484/CodeWhale round-38 +/// M38-3). +pub fn strip_workspace_roots_head(primary: &Path, stored: &[PathBuf]) -> Vec { + let head = normalize_lexical_components(primary); + // Round-41 M41-2 + the zhuowp Windows follow-up: the head comparison + // runs CANONICALLY (existing paths canonicalize with a lexical + // fallback; the strip runs on the cold move/load faces AND the + // per-turn turn_meta/preview paths — the canonicalize legs are + // bounded by the 64-root cap, round-42 minor 1's ledger), and when + // BOTH sides fall back to lexical spellings — the old directory has + // disappeared — the comparison goes through the platform's comparison + // spelling (case-fold + verbatim strip on Windows, identity on Unix), + // so a stored case alias of the vanished old primary still drops + // instead of riding the move. + let head_real = primary.canonicalize().unwrap_or_else(|_| head.clone()); + stored + .iter() + .filter(|root| { + let lexical = normalize_lexical_components(root.as_path()); + if lexical != head { + let real = root.canonicalize().unwrap_or_else(|_| lexical.clone()); + return !strip_head_equivalent(&real, &head_real); + } + false + }) + .cloned() + .collect() +} + +/// Head-equivalence for the strip's fallback legs: the Windows comparison +/// spelling (case-fold, verbatim-prefix strip) on Windows — where a case +/// alias and the primary are the same path whether or not either still +/// exists — and the native comparison on Unix, where distinct spellings of +/// a vanished directory remain distinct paths. +#[cfg(target_os = "windows")] +fn strip_head_equivalent(real: &Path, head_real: &Path) -> bool { + comparison_spelling(real) == comparison_spelling(head_real) +} + +#[cfg(not(target_os = "windows"))] +fn strip_head_equivalent(real: &Path, head_real: &Path) -> bool { + real == head_real +} + +/// Validate a STORED root row — the cwd-first full form +/// [`normalize_workspace_roots`] persists (primary first, additional entries +/// after) — for re-declaration through [`validate_workspace_roots`]. +/// +/// The stored head is the row's own primary slot, not a declared addition, +/// so it must not count against the intake cap when the row is fed back +/// through a validating resume/fork/load face: a legal full-load row (64 +/// additional entries plus the stored head, minted by this build's own save +/// path) would otherwise be miscounted as an over-cap declaration and +/// permanently rejected as "corrupt or hand-edited" by exactly the gate +/// that admits its creation (review #484/CodeWhale round-37 B37-2/B37-3). +/// The head strip is [`strip_workspace_roots_head`] — lexical plus the +/// canonical fallback legs. +pub fn validate_stored_workspace_roots(primary: &Path, stored: &[PathBuf]) -> Result> { + validate_workspace_roots(primary, &strip_workspace_roots_head(primary, stored)) +} + +/// Tolerant-load companion to [`validate_workspace_roots`] (round-38 +/// M38-2): instead of rejecting a legacy or hand-edited row, DROP the +/// entries the validating intake would refuse — a filesystem root or an +/// ancestor of the primary, judged with the same canonical+lexical +/// comparison — and return the clean set plus how many entries dropped. +/// The load faces use this to strip-and-warn explicitly instead of arming +/// the poison: an in-process file tool's containment boundary IS its root +/// set, and a live row carrying `/` contained every path on disk. +pub fn sanitize_workspace_roots(cwd: &Path, stored: &[PathBuf]) -> (Vec, usize) { + let normalized = normalize_workspace_roots(cwd, stored); + if cwd.as_os_str().is_empty() || !cwd.is_absolute() { + return (Vec::new(), stored.len()); + } + // Round-39 minors 2+3: the count is the difference between the stored + // candidates (every non-head entry) and what the tolerant load actually + // arms — covering the shape normalizer's drops (relative/empty + // spellings), its cap truncation, and the poison loop below in one + // number, or the strip-and-warn receipt stays silent for part of its + // input (a relative entry stripped with dropped == 0; a hand-edited + // 70-root row silently truncating to 65). + let additional_in = stored.iter().filter(|root| root.as_path() != cwd).count(); + let primary_real = cwd + .canonicalize() + .unwrap_or_else(|_| normalize_lexical_components(cwd)); + let mut clean = Vec::with_capacity(normalized.len()); + for (index, root) in normalized.into_iter().enumerate() { + // The cwd-first head is the primary slot itself. + if index == 0 { + clean.push(root); + continue; + } + let judged = normalize_lexical_components(&root); + let real = root.canonicalize().unwrap_or_else(|_| judged.clone()); + if is_filesystem_root(&real) + || (primary_real != real && comparison_is_ancestor_of(&primary_real, &real)) + { + continue; + } + clean.push(root); + } + let dropped = additional_in.saturating_sub(clean.len().saturating_sub(1)); + (clean, dropped) +} + +/// Lexically collapse CurDir and ParentDir components of `path`, clamping a +/// `..` at the filesystem root rather than failing: `/a/..` judges as `/`, +/// the path the filesystem would actually resolve. Comparison-local, so a +/// self-consistent collapse of the two sides is exactly what the root-vs-root +/// checks need; the landing-path normalizer execution uses lives behind the +/// tool boundary and stays there. +fn normalize_lexical_components(path: &Path) -> PathBuf { + use std::path::Component; + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + } + } + normalized +} + +/// True when `path` is a filesystem root — no named component left after +/// normalization (`/`, a Windows drive root, or a `..`-clamped spelling of +/// either). +fn is_filesystem_root(path: &Path) -> bool { + !path + .components() + .any(|component| matches!(component, std::path::Component::Normal(_))) +} + +/// Resolves the cwd and workspace roots for a resume request, aligned with +/// codex semantics: an explicit root set replaces the whole set (`Some([])` +/// clears back to the bare cwd); an explicit cwd alone takes over the +/// primary slot while additional roots are preserved; neither falls back to +/// the persisted values. An explicit empty cwd is rejected, not defaulted: +/// persisting it would null containment on every later resume, the same +/// poison the intake filter drops from the roots list. +/// +/// A caller-declared replacement set goes through +/// [`validate_workspace_roots`], so a super-root or an ancestor of the +/// primary is rejected here rather than admitted into the persisted row. +/// The same holds for the cwd-only move: moving the primary is a caller +/// decision, and re-basing the persisted additional roots onto it must not +/// durably mint a set whose entries are ancestors of (or super-roots for) +/// the new primary — the semantically identical PATCH workspace-only move +/// rejects, and a widened row would also strand a later bare fork, which +/// validates the inherited set. Only the pure-load branch (no overrides) +/// keeps the tolerant normalizer: a legacy or hand-edited row must stay +/// loadable, not strand its owner at resume time. +fn resolve_resume_roots( + persisted_cwd: &Path, + persisted_roots: &[PathBuf], + params_cwd: Option<&PathBuf>, + params_roots: Option<&[PathBuf]>, +) -> Result<(PathBuf, Vec)> { + // A28-1: a RELATIVE cwd is rejected like the empty spelling — the + // validator's degenerate collapse would otherwise silently destroy the + // whole declared root set AND persist the relative primary, bricking + // every later resume of the row (the empty guard's own rationale, + // verbatim, covers this spelling). + if let Some(cwd) = params_cwd + && (cwd.as_os_str().is_empty() || !cwd.is_absolute()) + { + return Err(IntakeValidationError::err( + "cwd must be a non-empty absolute path", + )); + } + if let Some(roots) = params_roots { + let cwd = params_cwd + .cloned() + .unwrap_or_else(|| persisted_cwd.to_path_buf()); + // Round-37 minor: the same A28-1 destruction applies when the + // replacement set anchors on the row's own persisted primary — a + // relative persisted primary collapses the validator to the empty + // set and silently destroys the caller's declared replacement + // instead of erroring. + if cwd.as_os_str().is_empty() || !cwd.is_absolute() { + return Err(IntakeValidationError::err( + "cwd must be a non-empty absolute path", + )); + } + // An explicit set replaces the persisted one wholesale — including + // the explicit empty set, which clears back to the bare cwd. The + // replacement is a caller decision, so it validates instead of + // degrading: a super-root must not slip in through the resume lane. + let roots = validate_workspace_roots(&cwd, roots)?; + return Ok((cwd, roots)); + } + if let Some(new_cwd) = params_cwd { + // The head comparison is lexical (the stored-row strip's rule): a + // `..`-spelled copy of the OLD primary must drop with the primary + // itself, or it survives the move as a phantom additional root the + // re-validation then judges as a widening entry. + // Round-39 minor 8: the one shared strip — the primitive's + // "one implementation" doc holds for the move arm too. + let additional: Vec = strip_workspace_roots_head(persisted_cwd, persisted_roots); + // The persisted entries were admitted under the OLD primary; after + // the caller moves it they must satisfy the same intake rules the + // replacement set does, or the persisted row would grow a widening + // entry this topic's intake exists to refuse. + let roots = validate_workspace_roots(new_cwd, &additional)?; + return Ok((new_cwd.clone(), roots)); + } + let roots = normalize_workspace_roots(persisted_cwd, persisted_roots); + Ok((persisted_cwd.to_path_buf(), roots)) +} + /// Result of spawning or resuming a thread. #[derive(Debug, Clone)] pub struct NewThread { @@ -497,12 +1195,17 @@ impl ThreadManager { &mut self, model_provider: String, cwd: PathBuf, + workspace_roots: &[PathBuf], initial_history: InitialHistory, persist_extended_history: bool, ) -> Result { let id = format!("thread-{}", Uuid::new_v4()); let now = chrono::Utc::now().timestamp(); let preview = preview_from_initial_history(&initial_history); + // Caller-declared set: validate, don't degrade. A super-root, an + // ancestor of the primary, or a non-absolute entry is an error, not + // a silent reshape of what the caller asked for. + let workspace_roots = validate_workspace_roots(&cwd, workspace_roots)?; let source = match initial_history { InitialHistory::New => SessionSource::Interactive, InitialHistory::Forked(_) => SessionSource::Fork, @@ -518,6 +1221,7 @@ impl ThreadManager { status: ThreadStatus::Running, path: None, cwd: cwd.clone(), + workspace_roots, cli_version: self.cli_version.clone(), source: match source { SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive, @@ -568,16 +1272,50 @@ impl ThreadManager { pub fn resume_thread_with_history( &mut self, params: &ThreadResumeParams, - fallback_cwd: &Path, model_provider: String, ) -> Result> { if params.history.is_none() - && let Some(thread) = self.running_threads.get(¶ms.thread_id).cloned() + && let Some(mut thread) = self.running_threads.get(¶ms.thread_id).cloned() { + let (cwd, workspace_roots) = resolve_resume_roots( + &thread.cwd, + &thread.workspace_roots, + params.cwd.as_ref(), + params.workspace_roots.as_deref(), + )?; + thread.cwd = cwd; + thread.workspace_roots = workspace_roots; + // Write the override back to both the cache and the persisted row. + // The cache alone is not enough in-process: a later resume that + // carries history bypasses this branch entirely, re-reads the + // stored row, and would silently reinstate the pre-override set. + // Only an override pays that write: base neither persisted nor + // bumped `updated_at` on a parameterless cached resume, and doing + // it unconditionally reordered recency listings, refreshed + // archived threads to active timestamps, and taxed every resume + // with a read+write for no state change. + if params.cwd.is_some() || params.workspace_roots.is_some() { + thread.updated_at = chrono::Utc::now().timestamp(); + // Targeted write, not persist_thread: the cache snapshot is + // stale in every column another process may have written, and + // the upsert's preserving arms cover only policy and the + // archive stamp — a full upsert would let the stale snapshot + // revert concurrent updates to the passthrough columns (even + // resurrecting a concurrently archived thread). The override + // owns exactly cwd, the root set, and the recency stamp. + self.store.update_thread_root_set( + &thread.id, + &thread.cwd, + &thread.workspace_roots, + thread.updated_at, + )?; + } + self.running_threads + .insert(params.thread_id.clone(), thread.clone()); return Ok(Some(NewThread { model: params.model.clone().unwrap_or_else(|| "auto".to_string()), model_provider: params.model_provider.clone().unwrap_or(model_provider), - cwd: params.cwd.clone().unwrap_or_else(|| thread.cwd.clone()), + cwd: thread.cwd.clone(), approval_policy: params.approval_policy.clone(), sandbox: params.sandbox.clone(), thread, @@ -591,10 +1329,14 @@ impl ThreadManager { let mut thread = to_protocol_thread(metadata); thread.status = ThreadStatus::Running; thread.updated_at = chrono::Utc::now().timestamp(); - thread.cwd = params - .cwd - .clone() - .unwrap_or_else(|| fallback_cwd.to_path_buf()); + let (cwd, workspace_roots) = resolve_resume_roots( + &thread.cwd, + &thread.workspace_roots, + params.cwd.as_ref(), + params.workspace_roots.as_deref(), + )?; + thread.cwd = cwd; + thread.workspace_roots = workspace_roots; self.persist_thread(&thread, None)?; self.running_threads .insert(thread.id.clone(), thread.clone()); @@ -640,26 +1382,56 @@ impl ThreadManager { })) } - /// Forks an existing thread into a new one, inheriting the parent's provider. - pub fn fork_thread( - &mut self, - params: &ThreadForkParams, - fallback_cwd: &Path, - ) -> Result> { + /// Forks an existing thread into a new one, inheriting the parent's + /// provider and — when the request does not carry a root set — its + /// accessible roots. A fork without a `cwd` stays anchored at the + /// parent's cwd. + pub fn fork_thread(&mut self, params: &ThreadForkParams) -> Result> { + // An explicit empty cwd would persist a vacuous primary root + // (`starts_with("")` is true for every path); reject it like the + // resume lane does instead of poisoning the fork durably. A28-1: a + // relative cwd is rejected for the same reason — the collapse would + // destroy the carried root set and persist the broken primary. + if let Some(cwd) = params.cwd.as_ref() + && (cwd.as_os_str().is_empty() || !cwd.is_absolute()) + { + return Err(IntakeValidationError::err( + "cwd must be a non-empty absolute path", + )); + } let parent = self.store.get_thread(¶ms.thread_id)?; let Some(parent) = parent else { return Ok(None); }; let parent_thread = to_protocol_thread(parent); + // `None` inherits the parent's set: the fork's cwd takes the primary + // slot and the parent's additional roots survive, the same + // primary-swap rule a cwd-only resume applies. A bare `thread/fork` + // is the historical shape, so reading an absent field as "no roots" + // would silently degrade a multi-root parent to `[cwd]`. `Some([])` + // stays an explicit clear. + let workspace_roots = match params.workspace_roots.as_deref() { + Some(roots) => roots.to_vec(), + // Round-39 B39-1: the shared LEXICAL head strip, like every + // other face converting a stored row into a declaration — a + // `..`-spelled copy of the parent's primary must drop with the + // head, or the abandoned directory stays writable in the + // fork's inherited set. + None => strip_workspace_roots_head(&parent_thread.cwd, &parent_thread.workspace_roots), + }; let new = self.spawn_thread_with_history( params .model_provider .clone() .unwrap_or_else(|| parent_thread.model_provider.clone()), + // A bare `thread/fork` carries no cwd: anchor the fork at the + // parent's cwd, not the process cwd, so the parent's main + // directory stays the primary root of the set it inherits. params .cwd .clone() - .unwrap_or_else(|| fallback_cwd.to_path_buf()), + .unwrap_or_else(|| parent_thread.cwd.clone()), + &workspace_roots, InitialHistory::Forked(vec![json!({ "type": "fork", "from_thread_id": parent_thread.id @@ -816,38 +1588,40 @@ impl ThreadManager { } fn persist_thread(&self, thread: &Thread, rollout_path: Option) -> Result<()> { - // This update payload carries no per-thread policy, so preserve any - // policy already stored for the thread rather than erasing it with - // NULLs on every persist/resume. - let existing = self.store.get_thread(&thread.id)?; - self.store.upsert_thread(&ThreadMetadata { - id: thread.id.clone(), - rollout_path, - preview: thread.preview.clone(), - ephemeral: thread.ephemeral, - model_provider: thread.model_provider.clone(), - created_at: thread.created_at, - updated_at: thread.updated_at, - status: to_persisted_status(&thread.status), - path: thread.path.clone(), - cwd: thread.cwd.clone(), - cli_version: thread.cli_version.clone(), - source: to_persisted_source(&thread.source), - name: thread.name.clone(), - sandbox_policy: existing - .as_ref() - .and_then(|metadata| metadata.sandbox_policy.clone()), - approval_mode: existing - .as_ref() - .and_then(|metadata| metadata.approval_mode.clone()), - archived: matches!(thread.status, ThreadStatus::Archived), - archived_at: None, - git_sha: None, - git_branch: None, - git_origin_url: None, - memory_mode: None, - current_leaf_id: None, - }) + // This update payload carries no per-thread policy or archive + // timestamp, and the preserved values must survive concurrently + // applied clears: the state layer keeps them inside the upsert + // statement itself (policy fields while the payload carries none, + // the stamp only while the thread stays archived). A get-then-upsert + // here would resurrect a concurrently unarchived or detached record + // from a stale snapshot, and forced-Running resumes would ghost + // `archived=0` rows with a stamp set. + self.store + .upsert_thread_preserving_policy_and_archive(&ThreadMetadata { + id: thread.id.clone(), + rollout_path, + preview: thread.preview.clone(), + ephemeral: thread.ephemeral, + model_provider: thread.model_provider.clone(), + created_at: thread.created_at, + updated_at: thread.updated_at, + status: to_persisted_status(&thread.status), + path: thread.path.clone(), + cwd: thread.cwd.clone(), + workspace_roots: thread.workspace_roots.clone(), + cli_version: thread.cli_version.clone(), + source: to_persisted_source(&thread.source), + name: thread.name.clone(), + sandbox_policy: None, + approval_mode: None, + archived: matches!(thread.status, ThreadStatus::Archived), + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + memory_mode: None, + current_leaf_id: None, + }) } } @@ -988,6 +1762,7 @@ impl Runtime { let new = self.thread_manager.spawn_thread_with_history( "deepseek".to_string(), cwd, + &[], InitialHistory::New, false, )?; @@ -996,6 +1771,17 @@ impl Runtime { Ok(response) } ThreadRequest::Start(params) => { + // Same empty-cwd rejection as resume/fork: an explicit `""` + // would otherwise persist a vacuous primary root. A28-1: a + // relative cwd is the same destruction class (the collapse + // drops the declared roots and persists the broken primary). + if let Some(cwd) = params.cwd.as_ref() + && (cwd.as_os_str().is_empty() || !cwd.is_absolute()) + { + return Err(IntakeValidationError::err( + "cwd must be a non-empty absolute path", + )); + } let cwd = params.cwd.clone().unwrap_or_else(|| { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); @@ -1005,6 +1791,7 @@ impl Runtime { .clone() .unwrap_or_else(|| "deepseek".to_string()), cwd, + ¶ms.workspace_roots, InitialHistory::New, params.persist_extended_history, )?; @@ -1013,12 +1800,10 @@ impl Runtime { Ok(response) } ThreadRequest::Resume(params) => { - let fallback_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - if let Some(new) = self.thread_manager.resume_thread_with_history( - ¶ms, - &fallback_cwd, - "deepseek".to_string(), - )? { + if let Some(new) = self + .thread_manager + .resume_thread_with_history(¶ms, "deepseek".to_string())? + { let mut response = thread_response_from_new("resumed", new); response.data = self.persisted_thread_data(&response.thread_id)?; Ok(response) @@ -1040,8 +1825,7 @@ impl Runtime { } } ThreadRequest::Fork(params) => { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - if let Some(new) = self.thread_manager.fork_thread(¶ms, &cwd)? { + if let Some(new) = self.thread_manager.fork_thread(¶ms)? { let mut response = thread_response_from_new("forked", new); response.data = self.persisted_thread_data(&response.thread_id)?; Ok(response) @@ -1267,14 +2051,60 @@ impl Runtime { } /// Evaluates execution policy and dispatches a tool call. + /// + /// This lane is a roots intake like any other (review #484/CodeWhale + /// round-24 B24-3): a caller-declared set is admitted whole or rejected + /// with an error, never silently reshaped — the old `take(64)` + /// truncated the tail, and `/` or a primary-ancestor root reached the + /// policy context unvalidated. The cwd slot gets the same doctrine: the + /// `null → current_dir() → "."` fallback chain upstream can hand a + /// relative or empty spelling here, and a relative workspace cannot + /// head a root set. pub async fn invoke_tool( &self, call: ToolCall, approval_mode: AskForApproval, cwd: &Path, + workspace_roots: &[PathBuf], ) -> Result { + if cwd.as_os_str().is_empty() || !cwd.is_absolute() { + // M27-4: typed as an intake error so the app-server maps it to + // HTTP 400 like every other IntakeValidationError (the bare + // anyhow made it a 500, contradicting the lane's own comment). + return Err(IntakeValidationError::err( + "invoke_tool workspace slot must be an absolute directory; \ + got an empty or relative cwd", + )); + } + let workspace_roots = validate_workspace_roots(cwd, workspace_roots)?; let fallback_cwd = cwd.display().to_string(); - let (command, policy_cwd, execution_kind) = call.execution_subject(&fallback_cwd); + // `raw_policy_cwd` is superseded by the operand-aware resolution + // below; `command` and the kind label still come from the subject. + let (command, _raw_policy_cwd, execution_kind) = call.execution_subject(&fallback_cwd); + // Judge the same effective cwd execution resolves (review + // #484/CodeWhale round-22 B22-5): this lane used to hand the RAW + // `params.cwd` operand to the policy check while execution resolved + // the same operand roots-aware and canonically — a symlink-spelled + // operand evaded a deny scoped to the canonical target, and any + // relative/`..` operand made `normalize_workspace_scope` reject the + // judgment side, silently disarming every scoped rule for the call. + // Round-24 P3 accuracy fix: canonical resolution applies ONLY to + // operand-carrying calls, matching the engine lane — a no-operand + // call executes at the declared workspace spelling, so judging its + // canonical form (the old unconditional resolve) made every + // declared-spelling scoped rule inert lane-wide on symlinked + // systems (macOS `/tmp` sessions), the opposite divergence from the + // one B22-5 fixed. + let operand_cwd = match &call.payload { + ToolPayload::LocalShell { params } => params.cwd.clone(), + _ => None, + }; + let policy_cwd = match operand_cwd { + Some(dir) => resolve_operand_cwd(cwd, &dir) + .to_string_lossy() + .into_owned(), + None => fallback_cwd.clone(), + }; let policy_tool = match &call.payload { ToolPayload::LocalShell { .. } => "exec_shell", _ => call.name.as_str(), @@ -1287,6 +2117,13 @@ impl Runtime { path: policy_path.as_deref(), ask_for_approval: approval_mode, sandbox_mode: None, + // Validated intake (round-24 B24-3): over-cap, filesystem-root, + // primary-ancestor, and non-absolute declarations are rejected + // with an error before any policy work; the surviving set comes + // back normalized — primary prepended, deduped, capped exactly + // like every other consumer. An empty slice still keeps the + // byte-identical single-root posture for callers that have none. + workspace_roots, })?; let precheck = policy_precheck_payload(&decision, &command, &policy_cwd, execution_kind); let response_id = format!("tool-{}", Uuid::new_v4()); @@ -1777,19 +2614,26 @@ fn preview_from_initial_history(initial_history: &InitialHistory) -> String { } fn permission_path_for_call(call: &ToolCall) -> Option { + // Round-23 SF23-5: the alias set mirrors the tools layer's + // `PATH_ALIASES` (file_path / filePath fold onto `path`) so a + // camelCase-spelled file call cannot enter exec policy with `path: + // None` — path-scoped deny/ask would be silently blind on this lane. + // DEPENDENCY: if the tools layer grows another alias, this set must + // move with it (the lane currently dispatches against an empty + // registry, which is the only mitigation keeping this latent). + fn path_from(value: &Value) -> Option { + ["path", "file_path", "filePath"] + .iter() + .find_map(|name| value.get(name).and_then(Value::as_str)) + .map(str::to_string) + } match &call.payload { ToolPayload::Function { arguments } => serde_json::from_str::(arguments) .ok() - .and_then(|value| { - value - .get("path") - .and_then(Value::as_str) - .map(str::to_string) - }), - ToolPayload::Mcp { raw_arguments, .. } => raw_arguments - .get("path") - .and_then(Value::as_str) - .map(str::to_string), + .and_then(|value| path_from(&value)), + ToolPayload::Mcp { raw_arguments, .. } => { + path_from(raw_arguments).as_deref().map(str::to_string) + } ToolPayload::Custom { .. } | ToolPayload::LocalShell { .. } => None, } } @@ -1816,6 +2660,7 @@ fn to_protocol_thread(thread: ThreadMetadata) -> Thread { }, path: thread.path, cwd: thread.cwd, + workspace_roots: thread.workspace_roots, cli_version: thread.cli_version, source: match thread.source { SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive, @@ -2162,6 +3007,22 @@ fn job_state_status_to_runtime(status: JobStateStatus) -> JobStatus { #[cfg(test)] mod tests { + /// Round-43 P2-3: fixtures spell absolute paths the way each platform + /// accepts them — a POSIX `/tmp/...` spelling is NOT absolute on + /// Windows, and the intake guards now (correctly) refuse it, so every + /// hand-written fixture root goes through this helper instead. + fn abs_fixture(raw: &str) -> std::path::PathBuf { + #[cfg(target_os = "windows")] + { + let trimmed = raw.trim_start_matches('/'); + std::path::PathBuf::from(format!(r"C:\{}", trimmed.replace('/', r"\"))) + } + #[cfg(not(target_os = "windows"))] + { + std::path::PathBuf::from(raw) + } + } + use super::*; use codewhale_protocol::ThreadResumeParams; use codewhale_tools::ToolCallSource; @@ -2184,7 +3045,8 @@ mod tests { updated_at: 10, status: PersistedThreadStatus::Running, path: None, - cwd: PathBuf::from("/tmp/codewhale"), + cwd: abs_fixture("/tmp/codewhale"), + workspace_roots: Vec::new(), cli_version: "0.0.0-test".to_string(), source: SessionSource::Interactive, name: None, @@ -2720,7 +3582,7 @@ mod tests { let preview = preview_from_initial_history(&InitialHistory::Resumed { conversation_id: "test".to_string(), history: vec![json!("world")], - rollout_path: PathBuf::from("/tmp/test"), + rollout_path: abs_fixture("/tmp/test"), }); assert!(preview.contains("world")); } @@ -2820,7 +3682,8 @@ mod tests { let spawned = manager .spawn_thread_with_history( "deepseek".to_string(), - PathBuf::from("/tmp/codewhale"), + abs_fixture("/tmp/codewhale"), + &[], InitialHistory::New, true, ) @@ -2839,16 +3702,13 @@ mod tests { base_instructions: None, developer_instructions: None, personality: None, + workspace_roots: None, persist_extended_history: false, }; manager.archive_thread(&thread_id).expect("archive thread"); let archived = manager - .resume_thread_with_history( - &resume_params, - Path::new("/tmp/codewhale"), - "deepseek".to_string(), - ) + .resume_thread_with_history(&resume_params, "deepseek".to_string()) .expect("resume archived thread") .expect("thread in cache"); assert_eq!(archived.thread.status, ThreadStatus::Archived); @@ -2857,113 +3717,1186 @@ mod tests { .unarchive_thread(&thread_id) .expect("unarchive thread"); let restored = manager - .resume_thread_with_history( - &resume_params, - Path::new("/tmp/codewhale"), - "deepseek".to_string(), - ) + .resume_thread_with_history(&resume_params, "deepseek".to_string()) .expect("resume unarchived thread") .expect("thread in cache"); assert_eq!(restored.thread.status, ThreadStatus::Idle); } + /// The cached-resume persist must not null the archive timestamp the + /// store recorded, the same way it already preserves the per-thread + /// policy fields. #[test] - fn resume_with_history_does_not_reappend_persisted_messages() { - // A read→resume flow hands the thread's own history back to - // `thread/resume`; appending it verbatim doubled the conversation on - // every resume, compounding. - let store = temp_core_state("resume-history-dedup"); + fn cached_resume_preserves_the_persisted_archive_timestamp() { + let store = temp_core_state("cached-resume-archived-at"); let mut manager = ThreadManager::new(store); - let history = vec![ - json!({"type": "user_message", "message": "hello"}), - json!({"type": "assistant_message", "message": "hi there"}), - ]; let spawned = manager .spawn_thread_with_history( "deepseek".to_string(), - PathBuf::from("/tmp/codewhale"), - InitialHistory::Forked(history.clone()), + abs_fixture("/tmp/codewhale"), + &[], + InitialHistory::New, true, ) .expect("spawn thread"); let thread_id = spawned.thread.id.clone(); - let message_count = |manager: &ThreadManager| { - manager - .state_store() - .list_messages(&thread_id, None) - .expect("list messages") - .len() - }; - assert_eq!(message_count(&manager), 2); - - let resume_params = ThreadResumeParams { - thread_id: thread_id.clone(), - history: Some(history.clone()), - path: None, - model: None, - model_provider: None, - cwd: None, - approval_policy: None, - sandbox: None, - config: None, - base_instructions: None, - developer_instructions: None, - personality: None, - persist_extended_history: false, - }; - - // Resuming twice with the same history must be idempotent. - for _ in 0..2 { - manager - .resume_thread_with_history( - &resume_params, - Path::new("/tmp/codewhale"), - "deepseek".to_string(), - ) - .expect("resume thread") - .expect("thread found"); - } - assert_eq!( - message_count(&manager), - 2, - "resume re-appended messages already on the persisted chain" - ); + manager.archive_thread(&thread_id).expect("archive thread"); + let archived_at = manager + .state_store() + .get_thread(&thread_id) + .expect("read thread") + .expect("thread persisted") + .archived_at; + assert!(archived_at.is_some(), "archiving stamps archived_at"); - // A genuinely new history item is still appended, exactly once. - let mut extended = history.clone(); - extended.push(json!({"type": "user_message", "message": "something new"})); - let resume_params = ThreadResumeParams { - history: Some(extended), - ..resume_params - }; - manager + let resumed = manager .resume_thread_with_history( - &resume_params, - Path::new("/tmp/codewhale"), + &ThreadResumeParams { + thread_id: thread_id.clone(), + history: None, + path: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + config: None, + base_instructions: None, + developer_instructions: None, + personality: None, + workspace_roots: None, + persist_extended_history: false, + }, "deepseek".to_string(), ) - .expect("resume thread") - .expect("thread found"); - assert_eq!(message_count(&manager), 3); + .expect("resume archived thread") + .expect("thread in cache"); + assert_eq!(resumed.thread.status, ThreadStatus::Archived); + let persisted = manager + .state_store() + .get_thread(&thread_id) + .expect("read thread") + .expect("thread persisted"); + assert_eq!( + persisted.archived_at, archived_at, + "a parameterless cached resume must not null the archive timestamp" + ); } #[test] - fn persist_thread_preserves_stored_policy() { - // persist_thread's update payload carries no per-thread policy; - // writing NULLs unconditionally erased any policy stored earlier - // (e.g. on every resume). - let store = temp_core_state("persist-policy"); - let mut metadata = test_thread_metadata("thread-policy"); - metadata.sandbox_policy = Some("workspace-write".to_string()); - metadata.approval_mode = Some("on-request".to_string()); - store.upsert_thread(&metadata).expect("seed thread"); + fn parameterless_cached_resume_does_not_rewrite_the_row() { + // Base neither persisted nor bumped `updated_at` on a parameterless + // cached resume; the override writeback is gated the same way. Without + // the gate every parameterless resume reordered recency listings, + // refreshed archived rows to active timestamps, and paid a read+write + // for no state change. + let store = temp_core_state("cached-resume-no-writeback"); + let mut manager = ThreadManager::new(store); + let spawned = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/tmp/codewhale"), + &[], + InitialHistory::New, + true, + ) + .expect("spawn thread"); + let thread_id = spawned.thread.id.clone(); + let mut aged = manager + .state_store() + .get_thread(&thread_id) + .expect("read thread") + .expect("thread persisted"); + aged.updated_at = 1_000; + manager + .state_store() + .upsert_thread(&aged) + .expect("age the stored row"); + + manager + .resume_thread_with_history( + &ThreadResumeParams { + thread_id: thread_id.clone(), + history: None, + path: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + config: None, + base_instructions: None, + developer_instructions: None, + personality: None, + workspace_roots: None, + persist_extended_history: false, + }, + "deepseek".to_string(), + ) + .expect("resume thread") + .expect("thread in cache"); + + let row = manager + .state_store() + .get_thread(&thread_id) + .expect("read thread") + .expect("thread persisted"); + assert_eq!( + row.updated_at, 1_000, + "a parameterless cached resume must not rewrite the stored row" + ); + } + + #[test] + fn resumed_archived_thread_does_not_ghost_an_archive_stamp() { + // Resuming a persisted archived thread forces it back to Running. + // Persisting that reactivation must produce `archived=0` with no + // stamp — base never produced the `archived=0` + stamp-set pair, and + // unconditionally preserving the stored stamp recreated exactly that + // ghost. + let store = temp_core_state("resume-archived-no-ghost"); + let mut manager = ThreadManager::new(store); + let spawned = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/tmp/codewhale"), + &[], + InitialHistory::New, + true, + ) + .expect("spawn thread"); + let thread_id = spawned.thread.id.clone(); + manager.archive_thread(&thread_id).expect("archive thread"); + assert!( + manager + .state_store() + .get_thread(&thread_id) + .expect("read thread") + .expect("thread persisted") + .archived_at + .is_some(), + "archiving stamps archived_at" + ); + + manager + .resume_thread_with_history( + &ThreadResumeParams { + thread_id: thread_id.clone(), + history: Some(Vec::new()), + path: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + config: None, + base_instructions: None, + developer_instructions: None, + personality: None, + workspace_roots: None, + persist_extended_history: false, + }, + "deepseek".to_string(), + ) + .expect("resume thread") + .expect("thread resumed"); + + let row = manager + .state_store() + .get_thread(&thread_id) + .expect("read thread") + .expect("thread persisted"); + assert!(!row.archived, "the resume reactivates the thread"); + assert_eq!( + row.archived_at, None, + "reactivation must not ghost an archive stamp" + ); + } + + #[test] + fn resume_with_history_does_not_reappend_persisted_messages() { + // A read→resume flow hands the thread's own history back to + // `thread/resume`; appending it verbatim doubled the conversation on + // every resume, compounding. + let store = temp_core_state("resume-history-dedup"); + let mut manager = ThreadManager::new(store); + let history = vec![ + json!({"type": "user_message", "message": "hello"}), + json!({"type": "assistant_message", "message": "hi there"}), + ]; + let spawned = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/tmp/codewhale"), + &[], + InitialHistory::Forked(history.clone()), + true, + ) + .expect("spawn thread"); + let thread_id = spawned.thread.id.clone(); + let message_count = |manager: &ThreadManager| { + manager + .state_store() + .list_messages(&thread_id, None) + .expect("list messages") + .len() + }; + assert_eq!(message_count(&manager), 2); + + let resume_params = ThreadResumeParams { + thread_id: thread_id.clone(), + history: Some(history.clone()), + path: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + config: None, + base_instructions: None, + developer_instructions: None, + personality: None, + workspace_roots: None, + persist_extended_history: false, + }; + + // Resuming twice with the same history must be idempotent. + for _ in 0..2 { + manager + .resume_thread_with_history(&resume_params, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + } + assert_eq!( + message_count(&manager), + 2, + "resume re-appended messages already on the persisted chain" + ); + + // A genuinely new history item is still appended, exactly once. + let mut extended = history.clone(); + extended.push(json!({"type": "user_message", "message": "something new"})); + let resume_params = ThreadResumeParams { + history: Some(extended), + ..resume_params + }; + manager + .resume_thread_with_history(&resume_params, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!(message_count(&manager), 3); + } + + #[test] + fn persist_thread_preserves_stored_policy() { + // persist_thread's update payload carries no per-thread policy; + // writing NULLs unconditionally erased any policy stored earlier + // (e.g. on every resume). + let store = temp_core_state("persist-policy"); + let mut metadata = test_thread_metadata("thread-policy"); + metadata.sandbox_policy = Some("workspace-write".to_string()); + metadata.approval_mode = Some("on-request".to_string()); + store.upsert_thread(&metadata).expect("seed thread"); + + // A fresh manager has an empty running-thread cache, so resume goes + // through the persisted path, which calls persist_thread. + let mut manager = ThreadManager::new(store); + let resume_params = ThreadResumeParams { + thread_id: "thread-policy".to_string(), + history: None, + path: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + config: None, + base_instructions: None, + developer_instructions: None, + personality: None, + workspace_roots: None, + persist_extended_history: false, + }; + manager + .resume_thread_with_history(&resume_params, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + + let persisted = manager + .state_store() + .get_thread("thread-policy") + .expect("read thread") + .expect("thread persisted"); + assert_eq!(persisted.sandbox_policy.as_deref(), Some("workspace-write")); + assert_eq!(persisted.approval_mode.as_deref(), Some("on-request")); + } + + // ── workspace roots ──────────────────────────────────────────────── + + fn resume_params(thread_id: &str) -> ThreadResumeParams { + ThreadResumeParams { + thread_id: thread_id.to_string(), + history: None, + path: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + config: None, + base_instructions: None, + developer_instructions: None, + personality: None, + workspace_roots: None, + persist_extended_history: false, + } + } + + #[test] + fn normalize_workspace_roots_puts_cwd_first_and_dedups() { + let cwd = abs_fixture("/repo/main"); + let cwd = cwd.as_path(); + assert_eq!(normalize_workspace_roots(cwd, &[]), vec![cwd.to_path_buf()]); + assert_eq!( + normalize_workspace_roots( + cwd, + &[ + abs_fixture("/repo/lib"), + abs_fixture("/repo/main"), + abs_fixture("/repo/lib"), + abs_fixture("/repo/docs"), + ], + ), + vec![ + abs_fixture("/repo/main"), + abs_fixture("/repo/lib"), + abs_fixture("/repo/docs"), + ], + "cwd moves to the front and later roots dedup in original order" + ); + } + + #[test] + fn normalize_workspace_roots_drops_empty_and_relative_entries() { + let cwd = abs_fixture("/repo/main"); + let cwd = cwd.as_path(); + assert_eq!( + normalize_workspace_roots( + cwd, + &[ + PathBuf::from(""), + PathBuf::from("relative/dir"), + PathBuf::from("~/home-dir"), + abs_fixture("/repo/lib"), + ], + ), + vec![abs_fixture("/repo/main"), abs_fixture("/repo/lib")], + "an empty entry would contain every path under starts_with, and a \ + relative entry is meaningless against absolute candidates" + ); + // The cwd argument is filtered by the same rule, and there the + // filter fails closed: an empty or relative cwd cannot head a root + // set (its normalized form is the vacuous root `starts_with("")` + // accepts every path under), so the whole set collapses to empty + // rather than passing the poison through to boundary_roots(). + assert!(normalize_workspace_roots(Path::new(""), &[]).is_empty()); + assert!(normalize_workspace_roots(Path::new(""), &[abs_fixture("/repo/lib")]).is_empty()); + assert!(normalize_workspace_roots(Path::new("relative/dir"), &[]).is_empty()); + } + + #[test] + fn normalize_workspace_roots_caps_additional_entries_earliest_declared_wins() { + // Round-24 B24-4: the load faces consume sets that never passed an + // intake, so the shape normalizer itself must bound them — an + // unbounded hand-edited row made every write-tool call an O(n²) + // dedup plus per-root canonicalization. + let cwd = abs_fixture("/repo/main"); + let cwd = cwd.as_path(); + let roots: Vec = (0..MAX_WORKSPACE_ROOTS + 10) + .map(|index| abs_fixture(format!("/repo/r{index}").as_str())) + .collect(); + let normalized = normalize_workspace_roots(cwd, &roots); + assert_eq!( + normalized.len(), + MAX_WORKSPACE_ROOTS + 1, + "the primary plus at most MAX_WORKSPACE_ROOTS additional entries survive" + ); + assert_eq!(normalized[0], cwd.to_path_buf()); + assert_eq!(normalized[1], abs_fixture("/repo/r0")); + assert_eq!( + normalized[MAX_WORKSPACE_ROOTS], + abs_fixture(format!("/repo/r{}", MAX_WORKSPACE_ROOTS - 1).as_str()), + "earliest declared wins; the tail past the cap is dropped" + ); + } + + #[test] + fn a_full_validated_declaration_is_never_truncated_by_the_normalizer() { + // Round-24 B24-4: the intake cap counts declared roots, the + // normalizer cap counts the same entries — a declaration that + // passed intake must survive the shape normalizer byte-identical. + let cwd = abs_fixture("/repo/main"); + let cwd = cwd.as_path(); + let roots: Vec = (0..MAX_WORKSPACE_ROOTS) + .map(|index| abs_fixture(format!("/repo/r{index}").as_str())) + .collect(); + let validated = validate_workspace_roots(cwd, &roots) + .expect("a full MAX_WORKSPACE_ROOTS declaration is admitted whole"); + assert_eq!(validated.len(), MAX_WORKSPACE_ROOTS + 1); + assert_eq!( + normalize_workspace_roots(cwd, &roots), + validated, + "the intake's returned set and the raw normalizer must agree at the cap" + ); + } + + #[test] + #[cfg(unix)] + fn sanitize_drops_a_symlink_spelled_ancestor_via_the_canonical_leg() { + // Round-39 minor 11: the canonical leg of the sanitizer — every + // other fixture uses nonexistent paths (lexical fallback); here the + // stored ancestor EXISTS and reaches the comparison as a canonical + // spelling. + let temp = tempfile::tempdir().expect("tempdir"); + let real = temp.path().join("real"); + let workspace = real.join("ws"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + let link = temp.path().join("ancestor-link"); + std::os::unix::fs::symlink(&real, &link).expect("symlink ancestor"); + let keep = temp.path().join("keep"); + std::fs::create_dir_all(&keep).expect("keep dir"); + + let (clean, dropped) = + sanitize_workspace_roots(&workspace, &[workspace.clone(), link, keep.clone()]); + assert_eq!(clean, vec![workspace.clone(), keep]); + assert_eq!(dropped, 1, "the canonical ancestor drop is counted"); + } + + #[test] + #[cfg(target_os = "windows")] + fn comparison_contains_respects_component_boundaries() { + // Round-39 B39-2: the Windows boundary rule — a needle is contained + // only at a path-component edge, never mid-component (/repo must + // not contain /rep.ro or /repository); the boundary helpers are + // Windows-only after the round-39 P2 correction. + assert!(comparison_contains("/repo/main", "/repo")); + assert!(comparison_contains("/repo", "/repo")); + assert!(comparison_contains("/repo", "/")); + assert!(!comparison_contains("/repository", "/repo")); + assert!(!comparison_contains("/repo", "/repo/main")); + assert!(!comparison_contains("/repo", "")); + assert!(comparison_contains("d:\\work\\proj", "d:\\work")); + assert!(!comparison_contains("d:\\worksheet", "d:\\work")); + } + + #[test] + #[cfg(unix)] + fn unix_filenames_with_colons_or_backslashes_stay_unrelated_roots() { + // Round-39 P2 (reviewer-reproduced regression): `:` and `\` are + // legal Unix filename characters — a string boundary heuristic + // made validate_workspace_roots REJECT and sanitize_workspace_roots + // DROP legal sibling attachments (data: vs data:archive/ws; + // data vs a sibling literally named data\archive). The guards keep + // the native component comparison on Unix. + let temp = tempfile::tempdir().expect("tempdir"); + + let colon_sibling = temp.path().join("data:"); + let colon_ws = temp.path().join("data:archive").join("ws"); + std::fs::create_dir_all(&colon_sibling).expect("colon sibling"); + std::fs::create_dir_all(&colon_ws).expect("colon workspace"); + validate_workspace_roots(&colon_ws, std::slice::from_ref(&colon_sibling)) + .expect("a colon-named sibling is not the workspace's ancestor"); + let (clean, dropped) = + sanitize_workspace_roots(&colon_ws, &[colon_ws.clone(), colon_sibling.clone()]); + assert_eq!(clean, vec![colon_ws.clone(), colon_sibling]); + assert_eq!(dropped, 0); + + let plain = temp.path().join("data"); + let backslash_ws = temp.path().join("data\\archive").join("ws"); + std::fs::create_dir_all(&plain).expect("plain sibling"); + std::fs::create_dir_all(&backslash_ws).expect("backslash workspace"); + validate_workspace_roots(&backslash_ws, std::slice::from_ref(&plain)) + .expect("a backslash-named sibling is not the workspace's ancestor"); + let (clean, dropped) = + sanitize_workspace_roots(&backslash_ws, &[backslash_ws.clone(), plain.clone()]); + assert_eq!(clean, vec![backslash_ws.clone(), plain]); + assert_eq!(dropped, 0); + } + + #[cfg(target_os = "windows")] + #[test] + fn intake_and_sanitize_reject_mixed_spelling_ancestors_on_windows() { + // Round-41 M41-6 rework (review-corrected): the REALIZABLE + // mixed-existence shape is a nonexistent primary (lexical DOS + // fallback) under an existing ancestor (canonical verbatim) — + // the pre-fix comparison skipped the guard there. An existing + // primary plus a not-yet-created SIBLING is a legal declaration, + // not an ancestor, and the first draft wrongly expected its + // rejection. + let temp = tempfile::tempdir().expect("tempdir"); + + // The realizable mixed-existence shape (round-41 review): a + // NONEXISTENT primary keeps its lexical DOS spelling while its + // EXISTING ancestor canonicalizes to the verbatim form — the + // pre-fix verbatim-vs-DOS comparison silently skipped the guard + // and admitted the ancestor. + let ghost_primary = temp.path().join("ghost").join("ws"); + let real_ancestor = temp.path().to_path_buf(); + let err = validate_workspace_roots(&ghost_primary, std::slice::from_ref(&real_ancestor)) + .expect_err("the mixed-existence ancestor must be rejected on Windows"); + assert!( + err.to_string().contains("contains the primary root"), + "the rejection names the widening: {err}" + ); + + // The sanitize leg on the same mixed shape: the poison entry drops + // and the count names it (the round-39 sanitizer silently kept it). + let (clean, dropped) = + sanitize_workspace_roots(&ghost_primary, &[ghost_primary.clone(), real_ancestor]); + assert_eq!(clean, vec![ghost_primary.clone()]); + assert_eq!(dropped, 1, "the drop is counted, not silent"); + } + + #[test] + fn stored_row_revalidation_round_trips_a_legal_full_load_row() { + // Round-37 B37-2: the stored row persists the cwd-first full form — + // the `validated` set above IS the stored shape. Feeding it back + // through the plain declared-count validator miscounts its own head + // as an over-cap declaration; the stored-row strip must revalidate + // the row whole, or every resume/fork/load face using it would + // brick the row its own save path minted. + let cwd = abs_fixture("/repo/main"); + let cwd = cwd.as_path(); + let roots: Vec = (0..MAX_WORKSPACE_ROOTS) + .map(|index| abs_fixture(format!("/repo/r{index}").as_str())) + .collect(); + let stored = + validate_workspace_roots(cwd, &roots).expect("a full declaration is admitted whole"); + assert_eq!(stored.len(), MAX_WORKSPACE_ROOTS + 1); + let revalidated = validate_stored_workspace_roots(cwd, &stored) + .expect("a legal full-load row revalidates whole"); + assert_eq!(revalidated, stored, "the round trip is lossless"); + assert!( + validate_workspace_roots(cwd, &stored).is_err(), + "the plain validator is the miscounting control this primitive exists to bypass" + ); + } + + #[test] + fn stored_row_revalidation_strips_a_dot_dot_spelled_head_copy() { + // Round-37 minor: the strip comparison is lexical, so a `..`-spelled + // copy of the primary drops with the head instead of surviving as a + // phantom additional root (the raw-equality dedup above lets the + // spelling through; re-validation would then judge it a widening + // entry and brick the row). + let cwd = abs_fixture("/repo"); + let cwd = cwd.as_path(); + let stored = vec![ + abs_fixture("/repo"), + abs_fixture("/repo/sub/.."), + abs_fixture("/shared"), + ]; + let revalidated = validate_stored_workspace_roots(cwd, &stored) + .expect("the lexical head strip keeps the row loadable"); + assert_eq!( + revalidated, + vec![abs_fixture("/repo"), abs_fixture("/shared")] + ); + } + + #[test] + fn stored_row_revalidation_still_refuses_a_poisoned_entry() { + // The strip must not launder: a row carrying the filesystem root + // under any spelling is refused exactly like a fresh declaration. + let cwd = abs_fixture("/repo"); + let cwd = cwd.as_path(); + let stored = vec![abs_fixture("/repo"), abs_fixture("/")]; + let err = validate_stored_workspace_roots(cwd, &stored) + .expect_err("the head strip is not an amnesty for poisoned entries"); + assert!( + err.to_string().contains("filesystem root"), + "the refusal names the poison: {err}" + ); + } + + #[test] + #[cfg(unix)] + fn intake_rejects_a_canonical_ancestor_under_a_ghost_primary() { + // Round-44 R44-1: the primary does not EXIST yet, so a plain + // canonicalize-with-fallback mixed its lexical spelling against the + // ancestor's canonical one — on macOS-default shapes (/var vs + // /private/var) the guard silently skipped. On Linux the same class + // reproduces with a symlink-alias ancestor: the alias canonicalizes + // onto the real parent, the ghost primary keeps its spelling under + // the ALIAS, and the pre-fix comparison judged them unrelated. + let temp = tempfile::tempdir().expect("tempdir"); + let real_parent = temp.path().join("real"); + std::fs::create_dir_all(&real_parent).expect("real parent"); + let alias_parent = temp.path().join("alias"); + std::os::unix::fs::symlink(&real_parent, &alias_parent).expect("alias parent"); + // Ghost primary: spelled through the ALIAS, never materialized. + let ghost_primary = alias_parent.join("ws"); + + let err = validate_workspace_roots(&ghost_primary, std::slice::from_ref(&real_parent)) + .expect_err("an existing canonical ancestor must be rejected under a ghost primary"); + assert!( + err.to_string().contains("contains the primary root"), + "the rejection names the widening: {err}" + ); + } + + #[test] + #[cfg(unix)] + fn intake_rejects_a_symlink_spelled_ancestor_that_canonicalizes_into_the_primary() { + // Round-37 M37-3: the ancestor rejection judges against the path + // the filesystem resolves. A declared root that differs lexically + // but canonicalizes into the primary's real parent chain — here via + // a symlink, on case-insensitive filesystems via case-fold — used + // to pass intake and then widen the sandbox at the canonicalizing + // boundaries (get_writable_roots, seatbelt, bwrap arm the + // canonicalized root as-is). + let temp = tempfile::tempdir().expect("tempdir"); + let real = temp.path().join("real"); + let workspace = real.join("ws"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + let link = temp.path().join("link-ancestor"); + std::os::unix::fs::symlink(&real, &link).expect("symlink to the real ancestor"); + + let err = validate_workspace_roots(&workspace, &[link]) + .expect_err("a symlink spelling of the primary's ancestor must be rejected"); + assert!( + err.to_string().contains("contains the primary root"), + "the rejection names the widening: {err}" + ); + + // A symlink pointing OUTSIDE the primary's chain stays admissible: + // only ancestors are rejected, not symlinks per se. + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside).expect("outside dir"); + let outside_link = temp.path().join("link-outside"); + std::os::unix::fs::symlink(&outside, &outside_link).expect("symlink outside"); + let validated = validate_workspace_roots(&workspace, &[outside_link]) + .expect("a non-ancestor symlink is admissible"); + assert_eq!(validated.len(), 2); + } + + #[test] + fn spawn_thread_rejects_a_non_absolute_root_instead_of_dropping_it() { + // Regression pin, round-17: an empty-string root accepted at intake + // used to reach the persisted set and then boundary_roots(), where + // Path::starts_with("") is true for every path — read_file's + // containment check passed for arbitrary filesystem reads. Round-19 + // tightened the decision from "silently drop the entry" to "reject + // the declared set": a silent drop shrinks the set the caller asked + // for without telling it, and `~/shared` dies by the same rule. + let store = temp_core_state("spawn-empty-root"); + let mut manager = ThreadManager::new(store); + for root in ["", "~/shared", "relative/dir"] { + let err = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/repo/main"), + &[PathBuf::from(root)], + InitialHistory::New, + true, + ) + .expect_err("a non-absolute root must be rejected at intake"); + assert!( + err.to_string().contains("absolute path"), + "unexpected error for {root:?}: {err}" + ); + } + } + + #[test] + fn spawn_thread_rejects_a_super_root_and_the_primarys_ancestor() { + // Round-19: the per-turn sandbox copies the set verbatim into + // WorkspaceWrite.writable_roots, so attaching `/` (or `/..`, which + // normalizes to it) made the sandboxed exec lane filesystem-writable, + // and the primary's parent granted the same reach one spelling at a + // time. Both are rejected at intake now; a sibling still attaches. + let store = temp_core_state("spawn-super-root"); + let mut manager = ThreadManager::new(store); + for root in ["/", "/..", "/shared/..", "/repo"] { + let err = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/repo/main"), + &[PathBuf::from(root)], + InitialHistory::New, + true, + ) + .expect_err("a super-root or ancestor must be rejected at intake"); + assert!( + err.to_string().contains("filesystem root") + || err.to_string().contains("ancestor of the primary"), + "unexpected error for {root:?}: {err}" + ); + } + let spawned = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/repo/main"), + &[abs_fixture("/repo/lib"), abs_fixture("/repo/main/crates")], + InitialHistory::New, + true, + ) + .expect("a sibling and a subdirectory of the primary are fine"); + assert_eq!( + spawned.thread.workspace_roots, + vec![ + abs_fixture("/repo/main"), + abs_fixture("/repo/lib"), + abs_fixture("/repo/main/crates"), + ] + ); + } + + #[test] + fn validate_workspace_roots_keeps_the_degenerate_primary_collapse() { + // Round-17 decision, unchanged: an empty or relative primary cannot + // head a root set, so the set collapses to empty — the intake + // surfaces reject an empty workspace outright; this is the last line + // for values that bypass a surface. + assert!( + validate_workspace_roots(Path::new(""), &[abs_fixture("/")]) + .unwrap() + .is_empty() + ); + assert!( + validate_workspace_roots(Path::new("relative/dir"), &[]) + .unwrap() + .is_empty() + ); + } + + #[test] + fn resume_with_a_super_root_set_is_rejected_at_intake() { + let store = temp_core_state("resume-super-root"); + let mut metadata = test_thread_metadata("thread-super-root"); + metadata.cwd = abs_fixture("/old"); + metadata.workspace_roots = vec![abs_fixture("/old")]; + store.upsert_thread(&metadata).expect("seed thread"); + let mut manager = ThreadManager::new(store); + + let mut params = resume_params("thread-super-root"); + params.workspace_roots = Some(vec![abs_fixture("/..")]); + let err = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect_err("an explicit super-root replacement must be rejected"); + assert!( + err.to_string().contains("filesystem root"), + "unexpected error: {err}" + ); + + // The persisted row is untouched: the caller can retry with a real + // set, and a parameterless resume still loads the stored one. + let mut params = resume_params("thread-super-root"); + params.workspace_roots = Some(vec![abs_fixture("/new-a")]); + let resumed = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!( + resumed.thread.workspace_roots, + vec![abs_fixture("/old"), abs_fixture("/new-a")] + ); + } + + #[test] + fn spawn_thread_with_workspace_roots_persists_normalized_set() { + let store = temp_core_state("spawn-roots"); + let mut manager = ThreadManager::new(store); + let spawned = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/repo/main"), + &[abs_fixture("/repo/lib"), abs_fixture("/repo/main")], + InitialHistory::New, + true, + ) + .expect("spawn thread"); + let expected = vec![abs_fixture("/repo/main"), abs_fixture("/repo/lib")]; + assert_eq!(spawned.thread.workspace_roots, expected); + assert_eq!(spawned.thread.cwd, spawned.thread.workspace_roots[0]); + + let persisted = manager + .state_store() + .get_thread(&spawned.thread.id) + .expect("read thread") + .expect("thread persisted"); + assert_eq!(persisted.workspace_roots, expected); + } + + #[test] + fn resume_with_workspace_roots_replaces_root_set() { + let store = temp_core_state("resume-roots-replace"); + let mut metadata = test_thread_metadata("thread-roots"); + metadata.cwd = abs_fixture("/old"); + metadata.workspace_roots = vec![abs_fixture("/old"), abs_fixture("/keep")]; + store.upsert_thread(&metadata).expect("seed thread"); + + // A fresh manager forces the persisted path. + let mut manager = ThreadManager::new(store); + let mut params = resume_params("thread-roots"); + params.workspace_roots = Some(vec![abs_fixture("/new-a"), abs_fixture("/new-b")]); + let resumed = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + + // Explicit roots replace the whole set; the persisted cwd stays primary. + assert_eq!(resumed.thread.cwd, abs_fixture("/old")); + assert_eq!( + resumed.thread.workspace_roots, + vec![ + abs_fixture("/old"), + abs_fixture("/new-a"), + abs_fixture("/new-b"), + ] + ); + let persisted = manager + .state_store() + .get_thread("thread-roots") + .expect("read thread") + .expect("thread persisted"); + assert_eq!(persisted.workspace_roots, resumed.thread.workspace_roots); + } + + #[test] + fn resume_roots_override_writes_back_to_running_cache() { + let store = temp_core_state("resume-roots-writeback"); + let mut manager = ThreadManager::new(store); + let spawned = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/repo/main"), + &[], + InitialHistory::New, + true, + ) + .expect("spawn thread"); + let thread_id = spawned.thread.id.clone(); + + // Resume with an explicit set: the override lands on the returned + // thread and must be written back to the running-thread cache. + let mut params = resume_params(&thread_id); + params.workspace_roots = Some(vec![abs_fixture("/repo/shared")]); + let first = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!( + first.thread.workspace_roots, + vec![abs_fixture("/repo/main"), abs_fixture("/repo/shared")] + ); + + // A later parameterless resume reads the same cache entry: without + // the writeback it would resurrect the stale pre-override set. + let second = manager + .resume_thread_with_history(&resume_params(&thread_id), "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!( + second.thread.workspace_roots, + vec![abs_fixture("/repo/main"), abs_fixture("/repo/shared")], + "the roots override must stick in the running-thread cache" + ); + } + + #[test] + fn resume_override_survives_a_later_history_carrying_resume() { + let store = temp_core_state("resume-roots-history-bypass"); + let mut manager = ThreadManager::new(store); + let spawned = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/repo/main"), + &[], + InitialHistory::New, + true, + ) + .expect("spawn thread"); + let thread_id = spawned.thread.id.clone(); + + // Prime the running cache, then take the cached history-free branch + // with an explicit roots override: that branch used to update the + // cache only. + let primed = manager + .resume_thread_with_history(&resume_params(&thread_id), "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!( + primed.thread.workspace_roots, + vec![abs_fixture("/repo/main")] + ); + let mut params = resume_params(&thread_id); + params.workspace_roots = Some(vec![abs_fixture("/repo/shared")]); + let overridden = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!( + overridden.thread.workspace_roots, + vec![abs_fixture("/repo/main"), abs_fixture("/repo/shared")] + ); + + // A history-carrying resume bypasses the running cache and reads the + // stored row, so a cache-only override is silently undone here. + let mut params = resume_params(&thread_id); + params.history = Some(vec![json!({"type": "message", "role": "user"})]); + let second = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!( + second.thread.workspace_roots, + vec![abs_fixture("/repo/main"), abs_fixture("/repo/shared")], + "a history-carrying resume must not reinstate the pre-override set" + ); + } + + #[test] + fn cached_resume_override_writeback_cannot_clobber_concurrent_row_updates() { + // Cross-process race pin: the cached-resume override writeback used + // to route through the full upsert, whose preserving arms cover only + // policy and the archive stamp — the stale cache snapshot reverted + // every other passthrough column, even resurrecting a concurrently + // archived thread. + let store = temp_core_state("resume-roots-writeback-stale"); + let mut manager = ThreadManager::new(store); + let spawned = manager + .spawn_thread_with_history( + "deepseek".to_string(), + abs_fixture("/repo/main"), + &[], + InitialHistory::New, + true, + ) + .expect("spawn thread"); + let thread_id = spawned.thread.id.clone(); + + // A concurrent process attaches a policy and archives the thread; + // the in-process cache still carries the pre-archive snapshot. + manager + .state_store() + .upsert_thread(&ThreadMetadata { + sandbox_policy: Some("workspace-write".to_string()), + approval_mode: Some("on-request".to_string()), + ..manager + .state_store() + .get_thread(&thread_id) + .expect("read thread") + .expect("thread persisted") + }) + .expect("attach policy"); + manager + .state_store() + .mark_archived(&thread_id) + .expect("archive thread"); + + // The override writeback must land the roots without touching the + // concurrently written columns. + let mut params = resume_params(&thread_id); + params.workspace_roots = Some(vec![abs_fixture("/repo/shared")]); + let resumed = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!( + resumed.thread.workspace_roots, + vec![abs_fixture("/repo/main"), abs_fixture("/repo/shared")] + ); + + let persisted = manager + .state_store() + .get_thread(&thread_id) + .expect("read thread") + .expect("thread persisted"); + assert_eq!( + persisted.workspace_roots, + vec![abs_fixture("/repo/main"), abs_fixture("/repo/shared")], + "the override still owns the root set" + ); + assert!( + persisted.archived, + "a concurrently archived thread must not be resurrected" + ); + assert!(persisted.archived_at.is_some()); + assert_eq!(persisted.sandbox_policy.as_deref(), Some("workspace-write")); + assert_eq!(persisted.approval_mode.as_deref(), Some("on-request")); + } + + #[test] + fn resume_with_empty_roots_clears_back_to_bare_cwd() { + let store = temp_core_state("resume-roots-clear"); + let mut metadata = test_thread_metadata("thread-roots"); + metadata.cwd = abs_fixture("/old"); + metadata.workspace_roots = vec![abs_fixture("/old"), abs_fixture("/keep")]; + store.upsert_thread(&metadata).expect("seed thread"); + + let mut manager = ThreadManager::new(store); + let mut params = resume_params("thread-roots"); + params.workspace_roots = Some(Vec::new()); + let resumed = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!(resumed.thread.cwd, abs_fixture("/old")); + assert_eq!( + resumed.thread.workspace_roots, + vec![abs_fixture("/old")], + "Some([]) is an explicit clear, distinct from None (inherit)" + ); + } + + #[test] + fn resume_with_cwd_only_keeps_additional_roots() { + let store = temp_core_state("resume-roots-cwd-slot"); + let mut metadata = test_thread_metadata("thread-roots"); + metadata.cwd = abs_fixture("/old"); + metadata.workspace_roots = vec![ + abs_fixture("/old"), + abs_fixture("/keep"), + abs_fixture("/also"), + ]; + store.upsert_thread(&metadata).expect("seed thread"); + + let mut manager = ThreadManager::new(store); + let mut params = resume_params("thread-roots"); + params.cwd = Some(abs_fixture("/new")); + let resumed = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + + // The new cwd takes over the primary slot; the old cwd leaves the set + // while additional roots survive in order. + assert_eq!(resumed.thread.cwd, abs_fixture("/new")); + assert_eq!( + resumed.thread.workspace_roots, + vec![ + abs_fixture("/new"), + abs_fixture("/keep"), + abs_fixture("/also") + ] + ); + } + + #[test] + fn resume_cwd_move_strips_a_dot_dot_spelled_old_primary_copy() { + // Round-37 minor: the move-arm strip is lexical — a `..`-spelled + // copy of the OLD primary must drop with the primary itself, or it + // survives the move as a phantom additional root and the + // re-validation then judges it a widening entry against the new + // primary, bricking the move the plain-spelling copy survives. + let store = temp_core_state("resume-roots-cwd-dotdot"); + let mut metadata = test_thread_metadata("thread-roots-dotdot"); + metadata.cwd = abs_fixture("/old"); + metadata.workspace_roots = vec![ + abs_fixture("/old"), + abs_fixture("/old/sub/.."), + abs_fixture("/keep"), + ]; + store.upsert_thread(&metadata).expect("seed thread"); + + let mut manager = ThreadManager::new(store); + let mut params = resume_params("thread-roots-dotdot"); + params.cwd = Some(abs_fixture("/new")); + let resumed = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect("the lexical strip keeps the move loadable") + .expect("thread found"); + + assert_eq!(resumed.thread.cwd, abs_fixture("/new")); + assert_eq!( + resumed.thread.workspace_roots, + vec![abs_fixture("/new"), abs_fixture("/keep")], + "the dot-dot-spelled old primary drops with the head" + ); + } + + #[test] + fn resume_cwd_only_rejects_a_rebased_ancestor_root() { + // Moving the primary is a caller decision, so the re-based set meets + // the same intake rules as a replacement set: a persisted additional + // root that becomes an ancestor of (or a super-root for) the new + // primary must error, not durably widen the persisted row — a + // widened row would also strand a later bare fork, which validates + // the inherited set. + let store = temp_core_state("resume-roots-cwd-ancestor"); + let mut metadata = test_thread_metadata("thread-roots"); + metadata.cwd = abs_fixture("/p/x"); + metadata.workspace_roots = vec![abs_fixture("/p/x"), abs_fixture("/p")]; + store.upsert_thread(&metadata).expect("seed thread"); + + let mut manager = ThreadManager::new(store); + let mut params = resume_params("thread-roots"); + params.cwd = Some(abs_fixture("/p/x/deep")); + let err = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect_err("an additional root that turns ancestor must be rejected"); + assert!( + err.to_string().contains("ancestor of the primary"), + "unexpected error: {err}" + ); + + // The persisted row is untouched by the failed attempt. + let persisted = manager + .state_store() + .get_thread("thread-roots") + .expect("read thread") + .expect("thread persisted"); + assert_eq!(persisted.cwd, abs_fixture("/p/x")); + assert_eq!( + persisted.workspace_roots, + vec![abs_fixture("/p/x"), abs_fixture("/p")] + ); + } + + #[test] + fn resume_without_overrides_restores_persisted_cwd_and_roots() { + let store = temp_core_state("resume-roots-restore"); + let mut metadata = test_thread_metadata("thread-roots"); + metadata.cwd = abs_fixture("/persisted"); + metadata.workspace_roots = vec![abs_fixture("/persisted"), abs_fixture("/keep")]; + store.upsert_thread(&metadata).expect("seed thread"); - // A fresh manager has an empty running-thread cache, so resume goes - // through the persisted path, which calls persist_thread. let mut manager = ThreadManager::new(store); - let resume_params = ThreadResumeParams { - thread_id: "thread-policy".to_string(), - history: None, + let resumed = manager + .resume_thread_with_history(&resume_params("thread-roots"), "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + + // Regression: the persisted-path resume used to overwrite the cwd read + // back from the database with a current_dir fallback. + assert_eq!(resumed.thread.cwd, abs_fixture("/persisted")); + assert_eq!( + resumed.thread.workspace_roots, + vec![abs_fixture("/persisted"), abs_fixture("/keep")] + ); + + // Legacy rows with no stored roots degenerate to [cwd]. + let store = temp_core_state("resume-roots-legacy"); + let mut metadata = test_thread_metadata("thread-legacy"); + metadata.cwd = abs_fixture("/persisted"); + metadata.workspace_roots = Vec::new(); + store.upsert_thread(&metadata).expect("seed legacy thread"); + let mut manager = ThreadManager::new(store); + let resumed = manager + .resume_thread_with_history(&resume_params("thread-legacy"), "deepseek".to_string()) + .expect("resume thread") + .expect("thread found"); + assert_eq!(resumed.thread.cwd, abs_fixture("/persisted")); + assert_eq!( + resumed.thread.workspace_roots, + vec![abs_fixture("/persisted")] + ); + } + + fn fork_params(thread_id: &str) -> ThreadForkParams { + ThreadForkParams { + thread_id: thread_id.to_string(), path: None, model: None, model_provider: None, @@ -2973,25 +4906,281 @@ mod tests { config: None, base_instructions: None, developer_instructions: None, - personality: None, + workspace_roots: None, persist_extended_history: false, - }; - manager - .resume_thread_with_history( - &resume_params, - Path::new("/tmp/codewhale"), + } + } + + fn seed_multi_root_parent(name: &str) -> ThreadManager { + let store = temp_core_state(name); + let mut metadata = test_thread_metadata("thread-parent"); + metadata.cwd = abs_fixture("/repo/main"); + metadata.workspace_roots = vec![abs_fixture("/repo/main"), abs_fixture("/repo/lib")]; + store.upsert_thread(&metadata).expect("seed thread"); + ThreadManager::new(store) + } + + #[test] + fn fork_without_roots_inherits_the_parent_set() { + let mut manager = seed_multi_root_parent("fork-roots-inherit"); + let forked = manager + .fork_thread(&fork_params("thread-parent")) + .expect("fork thread") + .expect("parent found"); + + // The historical bare `thread/fork` shape: the parent record is the + // only source of the set, so an absent field must inherit it — and an + // absent cwd keeps the parent's cwd as primary rather than + // re-anchoring the set under the process cwd. + assert_eq!(forked.thread.cwd, abs_fixture("/repo/main")); + assert_eq!( + forked.thread.workspace_roots, + vec![abs_fixture("/repo/main"), abs_fixture("/repo/lib")], + ); + let persisted = manager + .state_store() + .get_thread(&forked.thread.id) + .expect("read fork") + .expect("fork persisted"); + assert_eq!(persisted.workspace_roots, forked.thread.workspace_roots); + } + + #[test] + #[cfg(target_os = "windows")] + fn workspace_move_strips_a_case_alias_of_a_vanished_old_primary() { + // Round-41 zhuowp follow-up: the old directory has DISAPPEARED, so + // both canonicalize calls fail and the comparison falls back to the + // lexical spellings — a stored case alias of the old primary is + // still the same path on a case-insensitive filesystem and must + // drop with the head, not ride the move into the new primary's set. + let temp = tempfile::tempdir().expect("tempdir"); + let old_ws = temp.path().join("Repo"); + let alias = temp.path().join("rEPO"); + let new_ws = temp.path().join("moved"); + std::fs::create_dir_all(&new_ws).expect("new workspace"); + let keep = temp.path().join("keep"); + std::fs::create_dir_all(&keep).expect("keep root"); + // Neither old_ws nor alias is created: the vanished-directory leg. + + let stripped = strip_workspace_roots_head(&old_ws, &[old_ws.clone(), alias, keep.clone()]); + assert_eq!( + stripped, + vec![keep.clone()], + "a case alias of the vanished old primary drops with the head" + ); + + let moved = validate_workspace_roots(&new_ws, &stripped).expect("re-validate"); + assert_eq!(moved, vec![new_ws.clone(), keep]); + } + + #[test] + #[cfg(unix)] + fn workspace_move_strips_a_symlink_alias_of_the_old_primary() { + // Round-41 M41-2: intake admits a root canonicalizing EQUAL to the + // primary (the ancestor rejection exempts equality; the normalizer + // dedups raw only) — a symlink alias of the primary rides the stored + // set, and a pure lexical strip let it survive the move, re-arming + // the abandoned directory. The canonical leg drops it. + let temp = tempfile::tempdir().expect("tempdir"); + let old_ws = temp.path().join("repo"); + std::fs::create_dir_all(&old_ws).expect("old workspace"); + let alias = temp.path().join("repo-alias"); + std::os::unix::fs::symlink(&old_ws, &alias).expect("primary alias"); + let new_ws = temp.path().join("moved"); + std::fs::create_dir_all(&new_ws).expect("new workspace"); + let keep = temp.path().join("keep"); + std::fs::create_dir_all(&keep).expect("keep root"); + + let stripped = strip_workspace_roots_head(&old_ws, &[old_ws.clone(), alias, keep.clone()]); + assert_eq!( + stripped, + vec![keep.clone()], + "the symlink alias of the old primary drops with the head" + ); + + // And through a real move face: the re-validated set on the new + // primary carries keep, never the abandoned directory. + let moved = validate_workspace_roots(&new_ws, &stripped).expect("re-validate"); + assert_eq!(moved, vec![new_ws.clone(), keep]); + assert!( + !moved.contains(&old_ws), + "the abandoned primary is not re-armed through any spelling" + ); + } + + #[test] + fn fork_without_roots_strips_a_dot_dot_spelled_parent_head_copy() { + // Round-39 B39-1: the None-inherit arm is the sixth conversion + // face — a `..`-spelled copy of the parent's primary (admitted by + // intake, deduped raw-only by the normalizer) must drop with the + // head, or the abandoned directory stays writable in the fork's + // inherited set. + let store = temp_core_state("fork-roots-dotdot"); + let mut metadata = test_thread_metadata("thread-parent"); + metadata.cwd = abs_fixture("/w/repo"); + metadata.workspace_roots = vec![ + abs_fixture("/w/repo"), + abs_fixture("/w/repo/sub/.."), + abs_fixture("/repo/lib"), + ]; + store.upsert_thread(&metadata).expect("seed thread"); + let mut manager = ThreadManager::new(store); + + let forked = manager + .fork_thread(&fork_params("thread-parent")) + .expect("fork thread") + .expect("parent found"); + + assert_eq!( + forked.thread.workspace_roots, + vec![abs_fixture("/w/repo"), abs_fixture("/repo/lib")], + "the dot-dot-spelled head copy drops with the head on inheritance" + ); + } + + #[test] + fn fork_with_cwd_only_swaps_primary_and_keeps_additional_roots() { + let mut manager = seed_multi_root_parent("fork-roots-cwd"); + let mut params = fork_params("thread-parent"); + params.cwd = Some(abs_fixture("/repo/topic")); + + let forked = manager + .fork_thread(¶ms) + .expect("fork thread") + .expect("parent found"); + + // Primary-swap semantics, matching a cwd-only resume: the parent's + // cwd leaves the set and its additional roots survive. + assert_eq!(forked.thread.cwd, abs_fixture("/repo/topic")); + assert_eq!( + forked.thread.workspace_roots, + vec![abs_fixture("/repo/topic"), abs_fixture("/repo/lib")], + ); + } + + #[test] + fn fork_with_explicit_empty_roots_clears_to_the_bare_cwd() { + let mut manager = seed_multi_root_parent("fork-roots-clear"); + let mut params = fork_params("thread-parent"); + params.workspace_roots = Some(Vec::new()); + + let forked = manager + .fork_thread(¶ms) + .expect("fork thread") + .expect("parent found"); + + assert_eq!( + forked.thread.workspace_roots, + vec![abs_fixture("/repo/main")], + "Some([]) is an explicit clear, distinct from None (inherit)" + ); + } + + #[test] + fn resume_with_empty_cwd_is_rejected() { + // Regression pin: a stdio resume carrying `cwd: ""` used to persist + // the empty string into the primary slot, where boundary_roots() + // turns it into the vacuous containment root on every later resume. + let store = temp_core_state("resume-empty-cwd"); + let mut metadata = test_thread_metadata("thread-empty-cwd"); + metadata.cwd = abs_fixture("/persisted"); + metadata.workspace_roots = vec![abs_fixture("/persisted")]; + store.upsert_thread(&metadata).expect("seed thread"); + let mut manager = ThreadManager::new(store); + let mut params = resume_params("thread-empty-cwd"); + params.cwd = Some(PathBuf::from("")); + + let err = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect_err("empty cwd must be rejected"); + assert!( + format!("{err:#}").contains("non-empty absolute"), + "unexpected error: {err:#}" + ); + let persisted = manager + .state_store() + .get_thread("thread-empty-cwd") + .expect("read thread") + .expect("thread persisted"); + assert_eq!( + persisted.cwd, + abs_fixture("/persisted"), + "a rejected resume must not touch the persisted root set" + ); + } + + #[test] + fn fork_with_empty_cwd_is_rejected() { + let mut manager = seed_multi_root_parent("fork-empty-cwd"); + let mut params = fork_params("thread-parent"); + params.cwd = Some(PathBuf::from("")); + + let err = manager + .fork_thread(¶ms) + .expect_err("empty cwd must be rejected"); + assert!( + format!("{err:#}").contains("non-empty absolute"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn resume_with_a_relative_cwd_is_rejected_like_the_empty_spelling() { + // A28-1: a relative cwd used to pass the guard and hit the + // validator's degenerate collapse — the declared root set was + // silently destroyed, the relative primary persisted, and every + // later resume of the row failed (invoke_tool hard-rejects a + // relative workspace). The empty guard's own rationale applies + // verbatim; both spellings are now refused at the same gate. + let mut manager = seed_multi_root_parent("resume-relative-cwd"); + let mut params = resume_params("thread-parent"); + params.cwd = Some(PathBuf::from(".")); + params.workspace_roots = Some(vec![abs_fixture("/a"), abs_fixture("/b")]); + let err = manager + .resume_thread_with_history(¶ms, "deepseek".to_string()) + .expect_err("a relative cwd must be rejected"); + assert!( + format!("{err:#}").contains("non-empty absolute"), + "unexpected error: {err:#}" + ); + // The persisted row is untouched — no destroyed root set, no + // relative primary minted. + let persisted = manager + .state_store() + .get_thread("thread-parent") + .expect("read thread") + .expect("thread persisted"); + assert_eq!( + persisted.cwd, + abs_fixture("/repo/main"), + "a rejected resume must not mint the relative primary" + ); + } + + #[test] + fn spawn_thread_with_empty_cwd_persists_no_roots() { + // Fail closed at the chokepoint: a thread whose cwd slot is empty + // gets an empty root set, so boundary_roots() holds nothing and + // every containment check denies — never the vacuous `[""]`. + let store = temp_core_state("spawn-empty-cwd"); + let mut manager = ThreadManager::new(store); + let spawned = manager + .spawn_thread_with_history( "deepseek".to_string(), + PathBuf::from(""), + &[], + InitialHistory::New, + true, ) - .expect("resume thread") - .expect("thread found"); + .expect("spawn thread"); + assert!(spawned.thread.workspace_roots.is_empty()); let persisted = manager .state_store() - .get_thread("thread-policy") + .get_thread(&spawned.thread.id) .expect("read thread") .expect("thread persisted"); - assert_eq!(persisted.sandbox_policy.as_deref(), Some("workspace-write")); - assert_eq!(persisted.approval_mode.as_deref(), Some("on-request")); + assert!(persisted.workspace_roots.is_empty()); } #[tokio::test] @@ -3059,7 +5248,8 @@ mod tests { raw_tool_call_id: None, }, AskForApproval::Never, - Path::new("/tmp/codewhale"), + abs_fixture("/tmp/codewhale").as_path(), + &[], ) .await .expect("invoke tool"); @@ -3088,7 +5278,8 @@ mod tests { .thread_manager .spawn_thread_with_history( "deepseek".to_string(), - PathBuf::from("/tmp/codewhale"), + abs_fixture("/tmp/codewhale"), + &[], InitialHistory::New, true, ) @@ -3118,4 +5309,369 @@ mod tests { "a refused message must leave no history rows: {history:?}" ); } + + /// Round-22 B22-5: the headless `/tool` + stdio tool-call lane must judge + /// exec policy on the same resolved effective cwd execution uses. + fn local_shell_call(command: &str, cwd: Option<&str>) -> ToolCall { + ToolCall { + name: "shell".to_string(), + payload: ToolPayload::LocalShell { + params: codewhale_protocol::LocalShellParams { + command: command.to_string(), + cwd: cwd.map(str::to_string), + timeout_ms: None, + }, + }, + source: ToolCallSource::Direct, + raw_tool_call_id: None, + } + } + + fn runtime_with_exec_rules(rules: Vec) -> Runtime { + Runtime::new( + ConfigToml::default(), + ModelRegistry::default(), + temp_core_state("invoke-tool-judged-cwd"), + Arc::new(ToolRegistry::default()), + Arc::new(McpManager::default()), + ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(rules), + ]), + HookDispatcher::default(), + ) + } + + fn exec_deny_scoped_to(workspace: &Path) -> codewhale_execpolicy::ToolAskRule { + codewhale_execpolicy::ToolAskRule { + tool: "exec_shell".into(), + command: Some("git push".into()), + command_exact: false, + path: None, + workspace: Some(workspace.to_string_lossy().into_owned()), + action: codewhale_execpolicy::PermissionAction::Deny, + } + } + + #[tokio::test] + async fn invoke_tool_judges_a_relative_operand_cwd_like_execution() { + // The raw relative operand made `normalize_workspace_scope` reject + // the judgment side, so every scope matched against the judged cwd + // was silently inert for the call — including a deny scoped to the + // very directory execution lands in. The deny here is scoped to the + // subdirectory the operands resolve to (it is deliberately NOT in + // the declared root set, so the root-set spanning cannot mask the + // judged-cwd leg). + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("ws"); + std::fs::create_dir_all(workspace.join("sub/x")).expect("fixture dirs"); + let workspace_canonical = workspace.canonicalize().expect("canonical workspace"); + let sub_canonical = workspace.join("sub").canonicalize().expect("canonical sub"); + + let runtime = runtime_with_exec_rules(vec![exec_deny_scoped_to(&sub_canonical)]); + + for operand in ["sub/.", "./sub", "sub/x/.."] { + let result = runtime + .invoke_tool( + local_shell_call("git push origin main", Some(operand)), + AskForApproval::OnRequest, + &workspace_canonical, + // Single-root posture: an empty declared set. + &[], + ) + .await + .expect("invoke tool"); + assert_eq!( + result["status"], "denied", + "operand {operand:?} resolves into the denied subdirectory, so the deny \ + must fire instead of every scoped rule being inert: {result}" + ); + } + + // Control: an operand resolving outside the denied scope keeps the + // ordinary approval gate — the deny is exact, not blanket. + let result = runtime + .invoke_tool( + local_shell_call( + "git push origin main", + Some(workspace_canonical.to_string_lossy().as_ref()), + ), + AskForApproval::OnRequest, + &workspace_canonical, + &[], + ) + .await + .expect("invoke tool"); + assert_eq!(result["status"], "approval_required", "{result}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn invoke_tool_judges_a_no_operand_call_at_the_declared_spelling() { + // Round-24 P3 accuracy fix: canonical resolution used to apply to + // EVERY call on this lane, so a symlink-spelled session workspace + // (macOS `/tmp`) judged its canonical form and a deny scoped to the + // DECLARED spelling went inert lane-wide — the opposite divergence + // from the operand one B22-5 fixed, and wider (every call, not just + // redirected ones). A no-operand call executes at the declared + // spelling, so it must judge there; an operand-carrying call keeps + // the canonical resolution (the B22-5 pin above covers that leg). + let temp = tempfile::tempdir().expect("tempdir"); + let real = temp.path().join("real"); + std::fs::create_dir_all(&real).expect("real dir"); + let link = temp.path().join("link"); + std::os::unix::fs::symlink(&real, &link).expect("symlink"); + let declared = link.to_string_lossy().into_owned(); + let canonical = real.canonicalize().expect("canonical real"); + + // Deny scoped to the DECLARED spelling fires for a no-operand call. + let runtime = runtime_with_exec_rules(vec![exec_deny_scoped_to(Path::new(&declared))]); + let result = runtime + .invoke_tool( + local_shell_call("git push origin main", None), + AskForApproval::OnRequest, + &link, + &[], + ) + .await + .expect("invoke tool"); + assert_eq!( + result["status"], "denied", + "a no-operand call judges at the declared spelling, so the declared-scoped \ + deny must fire: {result}" + ); + + // The canonical-spelling scope no longer matches a no-operand call + // (judgment sits at the declared spelling); it keeps firing for an + // operand-carrying call that resolves there. + let runtime = runtime_with_exec_rules(vec![exec_deny_scoped_to(&canonical)]); + let result = runtime + .invoke_tool( + local_shell_call("git push origin main", None), + AskForApproval::OnRequest, + &link, + &[], + ) + .await + .expect("invoke tool"); + assert_eq!( + result["status"], "approval_required", + "the canonical-spelling scope stays inert for a no-operand call judged at \ + the declared spelling: {result}" + ); + let result = runtime + .invoke_tool( + local_shell_call("git push origin main", Some(&declared)), + AskForApproval::OnRequest, + &link, + &[], + ) + .await + .expect("invoke tool"); + assert_eq!( + result["status"], "denied", + "an operand-carrying call still judges canonically (B22-5): {result}" + ); + } + + #[tokio::test] + async fn invoke_tool_rejects_degenerate_root_intake_declarations() { + // Round-24 B24-3: this face was the one roots intake with no + // validator in front of it — an over-cap declaration was silently + // truncated and `/` (or a primary ancestor) reached the policy + // context; a relative cwd slot fell back to the process directory + // spelling. All three classes now fail loud before any policy work. + let runtime = runtime_with_exec_rules(vec![]); + let workspace = tempfile::tempdir().expect("tempdir"); + let cwd = workspace.path().join("ws"); + std::fs::create_dir_all(&cwd).expect("workspace dir"); + let cwd = cwd.canonicalize().expect("canonical cwd"); + + let err = runtime + .invoke_tool( + local_shell_call("echo hi", None), + AskForApproval::Never, + Path::new("."), + &[], + ) + .await + .expect_err("a relative cwd slot must be rejected"); + assert!( + err.to_string().contains("absolute"), + "the rejection names the absolute-cwd requirement: {err}" + ); + + let err = runtime + .invoke_tool( + local_shell_call("echo hi", None), + AskForApproval::Never, + &cwd, + &[abs_fixture("/")], + ) + .await + .expect_err("a filesystem-root declaration must be rejected"); + assert!( + err.to_string().contains("filesystem"), + "the rejection names the filesystem-root hazard: {err}" + ); + + let over_cap: Vec = (0..MAX_WORKSPACE_ROOTS + 1) + .map(|index| cwd.join(format!("r{index}"))) + .collect(); + let err = runtime + .invoke_tool( + local_shell_call("echo hi", None), + AskForApproval::Never, + &cwd, + &over_cap, + ) + .await + .expect_err("an over-cap declaration must be rejected"); + assert!( + err.to_string().contains("cap"), + "the rejection names the intake cap: {err}" + ); + + // A full validated declaration survives: the same set at exactly + // the cap goes through and reaches the ordinary approval gate. + let at_cap: Vec = (0..MAX_WORKSPACE_ROOTS) + .map(|index| cwd.join(format!("r{index}"))) + .collect(); + let result = runtime + .invoke_tool( + local_shell_call("echo hi", None), + AskForApproval::OnRequest, + &cwd, + &at_cap, + ) + .await + .expect("a full validated declaration is admitted whole"); + assert_eq!(result["status"], "approval_required", "{result}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn invoke_tool_judges_a_symlink_spelled_operand_like_execution() { + // A deny scoped to the canonical spelling of the directory execution + // lands in was evaded by a symlink-spelled operand: judgment compared + // the raw lexical spelling while execution canonicalized through the + // link. The denied scope is deliberately absent from the declared + // root set so the root-set spanning cannot mask the judged-cwd leg. + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("ws"); + let real = temp.path().join("real"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + std::fs::create_dir_all(&real).expect("real dir"); + let real_canonical = real.canonicalize().expect("canonical real"); + std::os::unix::fs::symlink(&real_canonical, workspace.join("link")).expect("symlink"); + + let runtime = runtime_with_exec_rules(vec![exec_deny_scoped_to(&real_canonical)]); + let link = workspace.join("link"); + let result = runtime + .invoke_tool( + local_shell_call( + "git push origin main", + Some(link.to_string_lossy().as_ref()), + ), + AskForApproval::OnRequest, + &workspace.canonicalize().expect("canonical workspace"), + &[], + ) + .await + .expect("invoke tool"); + assert_eq!( + result["status"], "denied", + "the symlink resolves into the denied root, so the deny must fire: {result}" + ); + } + + #[test] + fn normalize_path_lexically_clamps_and_keeps_like_the_tool_boundary() { + // The shared normalizer behind the tools boundary's landing + // normalize: an overshoot `..` clamps at the filesystem root; a `..` + // a relative spelling cannot pop is kept. + assert_eq!( + normalize_path_lexically(abs_fixture("/w/x/../../../att/vendor/lib.rs").as_path()), + abs_fixture("/att/vendor/lib.rs") + ); + assert_eq!( + normalize_path_lexically(abs_fixture("/a/..").as_path()), + abs_fixture("/") + ); + assert_eq!( + normalize_path_lexically(Path::new("a/../../b")), + PathBuf::from("../b") + ); + } + + #[cfg(unix)] + #[test] + fn resolve_operand_cwd_walks_symlinks_like_execution() { + // Existing operands canonicalize through the link; a nonexistent + // operand resolves through the deepest existing ancestor and + // re-appends the popped tail — the walk the engine's judged-cwd + // applies (round-20 B20-1 / round-21 B21-3), now shared. + let temp = tempfile::tempdir().expect("tempdir"); + let ws = temp.path().join("ws"); + let attached = temp.path().join("att/repo"); + std::fs::create_dir_all(&ws).expect("ws"); + std::fs::create_dir_all(&attached).expect("attached"); + std::os::unix::fs::symlink(&attached, ws.join("link")).expect("symlink"); + let ws_canonical = ws.canonicalize().expect("canonical ws"); + let attached_canonical = attached.canonicalize().expect("canonical attached"); + + assert_eq!( + resolve_operand_cwd(&ws_canonical, "link"), + attached_canonical, + "an existing operand canonicalizes through the symlink" + ); + assert_eq!( + resolve_operand_cwd(&ws_canonical, "link/absent"), + attached_canonical.join("absent"), + "a nonexistent tail resolves through the deepest existing ancestor" + ); + assert_eq!( + resolve_operand_cwd(&ws_canonical, "link/absent/../.."), + attached_canonical.join("absent"), + "`..` is not a Normal component, so `file_name` skips it and the walk \ + keeps the last named segment — the engine lane's exact behavior, \ + moved verbatim" + ); + } + + #[test] + fn validate_workspace_roots_caps_the_declared_set_size() { + // Round-22 SF22-8: every boundary consumer scales with the set + // length, so an oversized declaration must be refused at intake + // rather than stalling every turn. + let cwd = abs_fixture("/repo"); + let root = |i: usize| abs_fixture(format!("/att{i}").as_str()); + let at_cap: Vec = (0..MAX_WORKSPACE_ROOTS).map(root).collect(); + assert!(validate_workspace_roots(&cwd, &at_cap).is_ok()); + let over_cap: Vec = (0..=MAX_WORKSPACE_ROOTS).map(root).collect(); + let err = validate_workspace_roots(&cwd, &over_cap).expect_err("over-cap must reject"); + assert!( + err.downcast_ref::().is_some(), + "the cap is an intake rejection: {err}" + ); + } + + #[test] + fn intake_rejections_carry_the_typed_validation_error() { + // The distinct type is what lets HTTP lanes answer 400 instead of a + // server-fault 500 (SF22-4); the Display text is unchanged. + let cwd = abs_fixture("/repo"); + for roots in [ + vec![PathBuf::from("relative/dir")], + vec![abs_fixture("/..")], + vec![cwd.parent().expect("repo parent").to_path_buf()], + ] { + let err = + validate_workspace_roots(&cwd, &roots).expect_err("intake rejection expected"); + assert!( + err.downcast_ref::().is_some(), + "rejection for {roots:?} must be typed: {err}" + ); + } + assert!(validate_workspace_roots(&cwd, &[abs_fixture("/shared")]).is_ok()); + } } diff --git a/crates/core/tests/tool_success.rs b/crates/core/tests/tool_success.rs index 11f0c90fe6..65812e9247 100644 --- a/crates/core/tests/tool_success.rs +++ b/crates/core/tests/tool_success.rs @@ -1,7 +1,4 @@ -use std::{ - path::Path, - sync::{Arc, Mutex}, -}; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use codewhale_agent::ModelRegistry; @@ -55,6 +52,13 @@ async fn invoke_fixture( payload: ToolPayload, output: ToolOutput, ) -> (serde_json::Value, Vec) { + // Round-43 P2-3: the fixture workspace must be absolute on every + // platform — a POSIX /tmp spelling is not absolute on Windows and the + // intake guards (correctly) refuse it. + #[cfg(target_os = "windows")] + let workspace_root = std::path::PathBuf::from(r"C:\tmp\codewhale"); + #[cfg(not(target_os = "windows"))] + let workspace_root = std::path::PathBuf::from("/tmp/codewhale"); let mut registry = ToolRegistry::default(); registry .register( @@ -94,7 +98,8 @@ async fn invoke_fixture( raw_tool_call_id: None, }, AskForApproval::Never, - Path::new("/tmp/codewhale"), + workspace_root.as_path(), + &[], ) .await .expect("application failure remains a transport-successful tool result"); diff --git a/crates/execpolicy/src/lib.rs b/crates/execpolicy/src/lib.rs index ccc69b8c1e..dc8979144f 100644 --- a/crates/execpolicy/src/lib.rs +++ b/crates/execpolicy/src/lib.rs @@ -313,6 +313,18 @@ pub struct ExecPolicyContext<'a> { pub ask_for_approval: AskForApproval, /// The sandbox mode in effect, if any (e.g. `"workspace-write"`). pub sandbox_mode: Option<&'a str>, + /// Additional workspace roots for ask/deny path and scope matching; + /// `cwd` remains the primary root. Ask/deny rules may span every root + /// (they only add prompts or blocks), while Allow rules narrow to the + /// judged-cwd slot only, so an attached root never widens + /// auto-approval. Caveat recorded for accuracy (round-24 P3): the + /// judged-cwd slot is `cwd` — where the call RUNS — so a `cwd:`/ + /// working-dir redirect that lands the call inside an attached root + /// judges an unscoped Allow there ("judged where it runs", the opposite + /// policy of the session-grant re-key, which keeps grants pinned to the + /// spelling the user approved). Empty preserves the historical + /// single-root matching. + pub workspace_roots: Vec, } #[derive(Debug, Clone, Default)] @@ -428,9 +440,24 @@ impl ExecPolicyEngine { fn matching_ask_rule(&self, ctx: &ExecPolicyContext<'_>) -> Option { let tool = ctx.tool.unwrap_or("exec_shell"); - let normalized_path = ctx - .path - .and_then(|path| normalize_workspace_relative_path(path, ctx.cwd)); + // Boundary roots for path/scope matching: the primary cwd plus any + // additional roots. An ask/deny rule path or scope that resolves + // against any single root matches — that only adds prompts or blocks; + // an empty root set keeps single-root behavior. + let mut roots: Vec = vec![ctx.cwd.to_string()]; + for root in &ctx.workspace_roots { + let root = root.to_string_lossy().into_owned(); + if !roots.contains(&root) { + roots.push(root); + } + } + let normalized_paths: Vec> = roots + .iter() + .map(|root| { + ctx.path + .and_then(|path| normalize_workspace_relative_path(path, root)) + }) + .collect(); let rulesets = self.read_rulesets(); let matched = rulesets @@ -443,36 +470,92 @@ impl ExecPolicyEngine { }) .filter(|(_, rule)| rule.tool == tool) .filter(|(_, rule)| { - rule.workspace - .as_deref() - .is_none_or(|workspace| workspace_scope_matches(workspace, ctx.cwd)) - }) - .filter(|(_, rule)| match rule.command.as_deref() { - Some(command) if rule.command_exact => command.trim() == ctx.command.trim(), - Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command), - None => true, - }) - .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) { - (Some(pattern), Some(call_path)) => { - let ws_rule = normalize_workspace_relative_path(pattern, ctx.cwd); - match (ws_rule, normalized_path.as_deref()) { - // Workspace-relative normalization fails for a call - // outside the workspace or a rule that names one, and - // on a POSIX host a Windows-spelled rule/call pair - // parses as unrelated relative forms. A rule spelling - // an ABSOLUTE path must still be able to match such a - // call exactly, or pinned locations (a real home, - // `/root`, a Windows profile) are unmatchable. The - // helper only fires for rooted rules, so relative - // semantics are unchanged. - (Some(ws_rule), Some(ws_call)) => { - ws_rule == ws_call || absolute_path_rule_matches(pattern, call_path) + // Which roots is this rule eligible against? A workspace- + // scoped rule is evaluated only against roots inside its + // scope; an Allow rule additionally keeps the primary-only + // narrowing on every filter (it widens auto-approval, so it + // may never reach into an attached root - exec is judged + // where it runs: the session cwd, or the resolved + // cwd:/working_dir: operand when the call redirects). + // Round-24 P3 perf: the dominant shapes (every Allow rule, + // and unscoped ask/deny on the single-root sessions that + // are the norm) borrow a static candidate slice instead of + // allocating a Vec per rule per call; only scoped + // ask/deny over a multi-root set allocates. + let scoped_candidates: Vec; + let candidate_idx: &[usize] = + if rule.action == PermissionAction::Allow { + // An Allow rule keeps the primary-only narrowing on + // every filter regardless of whether it carries a + // workspace: its rooted path and scope may never reach + // into an attached root. Round-38 M38-1: the scope + // check here is EXACT — an allow scoped to the primary + // must not auto-approve a `cwd:`-operand call judged + // inside the primary's subtree (the subtree matcher + // exists for ask/deny, where it closes a fail-open; + // widening auto-approval is the wrong direction). + if rule.workspace.as_deref().is_none_or(|workspace| { + workspace_scope_matches_exact(workspace, ctx.cwd) + }) { + &[0] + } else { + &[] } - _ => absolute_path_rule_matches(pattern, call_path), + } else if rule.workspace.is_none() && roots.len() == 1 { + &[0] + } else { + scoped_candidates = match rule.workspace.as_deref() { + None => (0..roots.len()).collect(), + Some(workspace) => roots + .iter() + .enumerate() + .filter(|(_, root)| workspace_scope_matches(workspace, root)) + .map(|(idx, _)| idx) + .collect(), + }; + &scoped_candidates + }; + if candidate_idx.is_empty() { + return false; + } + + let command_ok = match rule.command.as_deref() { + Some(command) if rule.command_exact => command.trim() == ctx.command.trim(), + Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command), + None => true, + }; + if !command_ok { + return false; + } + // The path filter runs against the SAME scoped candidate + // roots, not independently over every root: a rule scoped to + // repo R with a relative path fires only on a call that + // normalizes under R, never on the same relative path + // materialized under an unrelated root. Workspace-relative + // normalization fails for a call outside every candidate + // root, for a rule that names none, and on a POSIX host for + // a Windows-spelled rule/call pair. A rule spelling an + // ABSOLUTE path must still be able to match such a call + // exactly, or pinned locations (a real home, `/root`, a + // Windows profile) are unmatchable - the fallback runs on + // the original call path regardless of the per-root outcome, + // so relative semantics and single-root behavior are + // unchanged. + match (rule.path.as_deref(), ctx.path) { + (Some(pattern), Some(call_path)) => { + candidate_idx.iter().any(|&idx| { + match ( + normalize_workspace_relative_path(pattern, &roots[idx]), + normalized_paths[idx].as_deref(), + ) { + (Some(ws_rule), Some(ws_call)) => ws_rule == ws_call, + _ => false, + } + }) || absolute_path_rule_matches(pattern, call_path) } + (Some(_), None) => false, + (None, _) => true, } - (Some(_), None) => false, - (None, _) => true, }) .max_by_key(|(layer, rule)| (*layer, rule.action, ask_rule_specificity(rule))) .map(|(_, rule)| (*rule).clone()); @@ -1058,7 +1141,7 @@ pub fn normalize_workspace_scope(value: &str) -> Option { )) } -fn workspace_scope_matches(rule_workspace: &str, cwd: &str) -> bool { +fn workspace_scope_matches_exact(rule_workspace: &str, cwd: &str) -> bool { match ( normalize_workspace_scope(rule_workspace), normalize_workspace_scope(cwd), @@ -1068,6 +1151,27 @@ fn workspace_scope_matches(rule_workspace: &str, cwd: &str) -> bool { } } +fn workspace_scope_matches(rule_workspace: &str, cwd: &str) -> bool { + match ( + normalize_workspace_scope(rule_workspace), + normalize_workspace_scope(cwd), + ) { + (Some(rule_workspace), Some(cwd)) => { + // Round-37 M37-10 + round-38 M38-1: ask/deny candidate roots + // own the whole scope SUBTREE — the exact-equality form let a + // scoped deny be filtered out for a call judged inside its + // scope (a `cwd: "sub"` operand judges as /repo/sub while the + // rule's scope is /repo), so the denied command ran. ALLOW + // eligibility deliberately keeps the exact matcher: subtree + // reach there would auto-approve operand redirects inside the + // scope, a widening the standing body disclosures promise + // never happens. + rule_workspace == cwd || cwd.starts_with(&format!("{rule_workspace}/")) + } + _ => false, + } +} + #[derive(Debug)] struct PathForMatching { root: Option, @@ -1196,6 +1300,7 @@ mod tests { path: None, ask_for_approval, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), } } @@ -2158,6 +2263,7 @@ mod tests { path: Some("tmp/project"), ask_for_approval: AskForApproval::Never, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); @@ -2182,6 +2288,7 @@ mod tests { path: Some("/workspace/src/a.rs"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert!(absolute_path.requires_approval); @@ -2198,6 +2305,7 @@ mod tests { path: Some("src/a.rs"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert!(relative_path.requires_approval); @@ -2224,6 +2332,7 @@ mod tests { path: Some(path), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert_eq!( @@ -2248,6 +2357,7 @@ mod tests { path: Some(r"C:\workspace\src\a.rs"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); @@ -2278,6 +2388,7 @@ mod tests { path: Some("/root/.ssh/config"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert_eq!(decision.matched_action, Some(PermissionAction::Deny)); @@ -2291,6 +2402,7 @@ mod tests { path: Some("/root/.ssh/known_hosts"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert_eq!(decision.matched_rule, None); @@ -2307,6 +2419,7 @@ mod tests { path: Some("/root/../root/.ssh/config"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert_eq!(decision.matched_rule, None); @@ -2334,6 +2447,7 @@ mod tests { path: Some("~/.ssh/config"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert_eq!(decision.matched_action, Some(PermissionAction::Deny)); @@ -2348,6 +2462,7 @@ mod tests { path: Some("~/.ssh/../ssh/config"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert_eq!(decision.matched_rule, None); @@ -2371,11 +2486,51 @@ mod tests { path: Some("/src/a.rs"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert_eq!(decision.matched_rule, None); } + #[test] + fn typed_ask_absolute_path_rule_matches_a_call_outside_every_root() { + // Round-24 P3 pin bounding the absolute-path fallback: a rule + // spelling an ABSOLUTE location no root can normalize (`/root`, + // a real home) stays matchable for a call whose path sits outside + // every candidate root — the fallback compares the original call + // spelling regardless of the per-root outcome. The sibling test + // above pins the other side (a RELATIVE rule never reaches an + // absolute call through the same fallback). + let engine = + ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules( + vec![ToolAskRule { + tool: "edit_file".into(), + command: None, + command_exact: false, + path: Some("/root/.ssh/authorized_keys".into()), + workspace: None, + action: PermissionAction::Deny, + }], + )]); + + let decision = engine + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/root/.ssh/authorized_keys"), + ask_for_approval: AskForApproval::OnFailure, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/other/root")], + }) + .unwrap(); + assert!( + decision.matched_rule.is_some(), + "the deny on the pinned absolute location must fire even though the \ + call path normalizes under no candidate root" + ); + } + #[test] fn typed_ask_absolute_path_rule_folds_separators_and_case_on_windows() { let engine = @@ -2398,6 +2553,7 @@ mod tests { path: Some(r"C:\Users\U\.AWS\credentials"), ask_for_approval: AskForApproval::OnFailure, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); // The rule folds `C:/Users/u/...` and the call folds `C:\Users\U\...` @@ -2410,6 +2566,253 @@ mod tests { } } + #[test] + fn typed_ask_path_matching_spans_additional_workspace_roots() { + // A relative rule path matches an invocation path under any root. + let engine = ExecPolicyEngine::with_rulesets(vec![ + Ruleset::user(vec![], vec![]) + .with_ask_rules(vec![ToolAskRule::file_path("edit_file", "src/a.rs")]), + ]); + let decision = engine + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/shared/src/a.rs"), + ask_for_approval: AskForApproval::OnFailure, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/shared")], + }) + .unwrap(); + assert!( + decision.requires_approval, + "relative rule path must match under the additional root: {decision:?}" + ); + + // The same path with no additional root configured must not match. + let decision = engine + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/shared/src/a.rs"), + ask_for_approval: AskForApproval::OnFailure, + sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), + }) + .unwrap(); + assert_eq!(decision.matched_rule, None); + + // An absolute rule anchored at the additional root matches there but + // does not leak onto the same relative path under the primary root. + let anchored = + ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules( + vec![ToolAskRule::file_path("edit_file", "/shared/src/a.rs")], + )]); + let under_shared = anchored + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/shared/src/a.rs"), + ask_for_approval: AskForApproval::OnFailure, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/shared")], + }) + .unwrap(); + assert!(under_shared.requires_approval); + let under_primary = anchored + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/workspace/src/a.rs"), + ask_for_approval: AskForApproval::OnFailure, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/shared")], + }) + .unwrap(); + assert_eq!(under_primary.matched_rule, None); + } + + #[test] + fn workspace_scoped_allow_rule_stays_primary_root_scoped() { + let rule = ToolAskRule::exec_shell("git push").into_exact_workspace_allow("/shared"); + let engine = ExecPolicyEngine::with_rulesets(vec![ + Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]), + ]); + + // Inside the scope the rule auto-approves as before. + let scoped = engine + .check(ExecPolicyContext { + command: "git push", + cwd: "/shared", + tool: Some("exec_shell"), + path: None, + ask_for_approval: AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), + }) + .unwrap(); + assert!( + scoped.allow && !scoped.requires_approval, + "rule scoped to the primary root must apply: {scoped:?}" + ); + + // An attached root must not widen the auto-approval to a session + // running against a different root: exec always runs in the session + // cwd, so the same command there may target a different repository. + let unscoped = engine + .check(ExecPolicyContext { + command: "git push", + cwd: "/workspace", + tool: Some("exec_shell"), + path: None, + ask_for_approval: AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/shared")], + }) + .unwrap(); + assert_eq!(unscoped.matched_rule, None); + } + + #[test] + fn scoped_relative_path_rule_does_not_fire_under_unrelated_root() { + // Scope and path must pair per root: a rule scoped to /shared with + // a relative path fires only on a call under /shared, never on the + // same relative path materialized under an unrelated root. With two + // independent filters this matched via scope=/shared + path=/shared + // under the primary - over-blocking outside the rule's repo. + let rule = ToolAskRule { + tool: "edit_file".into(), + command: None, + command_exact: false, + path: Some("deploy/config.yaml".into()), + workspace: Some("/shared".into()), + action: PermissionAction::Deny, + }; + let engine = ExecPolicyEngine::with_rulesets(vec![ + Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]), + ]); + + // Negative: the same relative path under the unrelated primary root + // must not fire the /shared-scoped rule. + let decision = engine + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/workspace/deploy/config.yaml"), + ask_for_approval: AskForApproval::OnFailure, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/shared")], + }) + .unwrap(); + assert_eq!( + decision.matched_rule, None, + "scope + relative path must pair per root, not match independently: {decision:?}" + ); + + // Control: the same call under the scoped root fires the rule. + let decision = engine + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/shared/deploy/config.yaml"), + ask_for_approval: AskForApproval::OnFailure, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/shared")], + }) + .unwrap(); + assert_eq!(decision.matched_action, Some(PermissionAction::Deny)); + } + + #[test] + fn allow_relative_path_rule_does_not_auto_approve_under_attached_root() { + // The Allow narrowing covers rooted path matching, not just the + // workspace scope: an allow rule whose relative path resolves only + // under an attached root must not auto-approve a call landing + // there. Without this, a refactor could silently regress the path + // direction while the scope direction stays pinned. + let engine = + ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules( + vec![ToolAskRule { + tool: "edit_file".into(), + command: None, + command_exact: false, + path: Some("src/a.rs".into()), + workspace: None, + action: PermissionAction::Allow, + }], + )]); + + // Control: the same relative rule auto-approves under the primary. + let under_primary = engine + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/workspace/src/a.rs"), + ask_for_approval: AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), + }) + .unwrap(); + assert!(under_primary.allow && !under_primary.requires_approval); + + // Negative: attached root must not widen the auto-approval. + let under_attached = engine + .check(ExecPolicyContext { + command: "", + cwd: "/workspace", + tool: Some("edit_file"), + path: Some("/shared/src/a.rs"), + ask_for_approval: AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/shared")], + }) + .unwrap(); + assert_eq!( + under_attached.matched_rule, None, + "the Allow rule must not match via the attached root" + ); + } + + #[test] + fn workspace_scoped_ask_rule_matches_any_workspace_root() { + // Unlike Allow, a prompting rule scoped to an attached root still + // fires there: multi-root matching for ask/deny only ever adds a + // prompt or a block, never an auto-approval. + let rule = ToolAskRule { + tool: "exec_shell".into(), + command: Some("git push".into()), + command_exact: true, + path: None, + workspace: Some("/shared".into()), + action: PermissionAction::Ask, + }; + let engine = ExecPolicyEngine::with_rulesets(vec![ + Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]), + ]); + let decision = engine + .check(ExecPolicyContext { + command: "git push", + cwd: "/workspace", + tool: Some("exec_shell"), + path: None, + ask_for_approval: AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + workspace_roots: vec![std::path::PathBuf::from("/shared")], + }) + .unwrap(); + assert_eq!(decision.matched_action, Some(PermissionAction::Ask)); + assert!( + decision.requires_approval, + "ask rule scoped to an attached root must prompt: {decision:?}" + ); + } + // ── deny / allow action tests ────────────────────────────────────────── #[test] @@ -2434,6 +2837,7 @@ mod tests { path: None, ask_for_approval: AskForApproval::UnlessTrusted, sandbox_mode: None, + workspace_roots: Vec::new(), }) .unwrap(); @@ -2448,6 +2852,114 @@ mod tests { ); } + #[test] + fn workspace_scoped_deny_fires_for_a_call_judged_inside_its_scope() { + // Round-37 M37-10: a `cwd: "sub"` operand judges as /repo/sub (the + // canonical operand judging this topic introduced), while the + // persisted rule's scope names the repository root. The exact- + // equality eligibility filtered the deny out for the subdirectory + // call — the denied command ran (fail-open, undisclosed). The scope + // must own its whole subtree. + let engine = + ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules( + vec![ToolAskRule { + tool: "exec_shell".into(), + command: Some("sed".into()), + path: None, + action: PermissionAction::Deny, + workspace: Some("/repo".into()), + ..ToolAskRule::new("") + }], + )]); + + let inside = engine + .check(ExecPolicyContext { + command: "sed -i 's/foo/bar/' file.txt", + cwd: "/repo/sub", + tool: Some("exec_shell"), + path: None, + ask_for_approval: AskForApproval::OnRequest, + sandbox_mode: None, + workspace_roots: Vec::new(), + }) + .unwrap(); + assert!( + !inside.allow, + "a deny scoped to /repo must still fire for a call judged at /repo/sub: {:?}", + inside.reason() + ); + assert_eq!(inside.matched_action, Some(PermissionAction::Deny)); + + // A call OUTSIDE the scope keeps the scope's meaning: the rule is + // not global. + let outside = engine + .check(ExecPolicyContext { + command: "sed -i 's/foo/bar/' file.txt", + cwd: "/elsewhere", + tool: Some("exec_shell"), + path: None, + ask_for_approval: AskForApproval::OnRequest, + sandbox_mode: None, + workspace_roots: Vec::new(), + }) + .unwrap(); + assert_ne!(outside.matched_action, Some(PermissionAction::Deny)); + } + + #[test] + fn workspace_scoped_allow_stays_exact_for_subdirectory_operands() { + // Round-38 M38-1: the subtree matcher widened ALLOW eligibility — + // an allow scoped to the primary auto-approved any cwd-operand call + // judged inside the primary's subtree, contradicting the body's + // standing fail-closed disclosure. Allow keeps the exact matcher; + // only ask/deny own the subtree (their fail-open was the bug). + let engine = + ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules( + vec![ToolAskRule { + tool: "exec_shell".into(), + command: Some("git status".into()), + path: None, + action: PermissionAction::Allow, + workspace: Some("/repo".into()), + ..ToolAskRule::new("") + }], + )]); + + let at_root = engine + .check(ExecPolicyContext { + command: "git status", + cwd: "/repo", + tool: Some("exec_shell"), + path: None, + ask_for_approval: AskForApproval::OnRequest, + sandbox_mode: None, + workspace_roots: Vec::new(), + }) + .unwrap(); + assert_eq!( + at_root.matched_action, + Some(PermissionAction::Allow), + "an allow scoped to /repo fires at /repo itself" + ); + + let in_subdir = engine + .check(ExecPolicyContext { + command: "git status", + cwd: "/repo/sub", + tool: Some("exec_shell"), + path: None, + ask_for_approval: AskForApproval::OnRequest, + sandbox_mode: None, + workspace_roots: Vec::new(), + }) + .unwrap(); + assert_ne!( + in_subdir.matched_action, + Some(PermissionAction::Allow), + "a scoped allow must NOT auto-approve a subdirectory operand" + ); + } + #[test] fn allow_action_skips_approval_regardless_of_mode() { let engine = @@ -2470,6 +2982,7 @@ mod tests { path: None, ask_for_approval: AskForApproval::OnRequest, sandbox_mode: None, + workspace_roots: Vec::new(), }) .unwrap(); @@ -2495,6 +3008,7 @@ mod tests { path: None, ask_for_approval: AskForApproval::UnlessTrusted, sandbox_mode: None, + workspace_roots: Vec::new(), }) .unwrap(); @@ -2529,6 +3043,7 @@ mod tests { path: None, ask_for_approval: AskForApproval::OnRequest, sandbox_mode: None, + workspace_roots: Vec::new(), }) .unwrap(); @@ -2790,6 +3305,7 @@ mod tests { path: Some("/workspace/src/secrets.rs"), ask_for_approval: UnlessTrusted, sandbox_mode: None, + workspace_roots: Vec::new(), }) .unwrap(); @@ -3124,6 +3640,7 @@ mod tests { path: Some("/workspace/src/main.rs"), ask_for_approval: UnlessTrusted, sandbox_mode: None, + workspace_roots: Vec::new(), }) .unwrap(); // write_file should not be affected by exec_shell deny @@ -3186,6 +3703,7 @@ mod tests { path: None, ask_for_approval: OnRequest, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); assert!( @@ -3259,6 +3777,7 @@ mod tests { path: None, ask_for_approval: OnRequest, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap(); @@ -3327,6 +3846,7 @@ mod tests { path: Some(path), ask_for_approval, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), } } } diff --git a/crates/execpolicy/tests/authorization_order.rs b/crates/execpolicy/tests/authorization_order.rs index 8f35b83a06..5bc6fbc91c 100644 --- a/crates/execpolicy/tests/authorization_order.rs +++ b/crates/execpolicy/tests/authorization_order.rs @@ -149,6 +149,7 @@ fn authorization_order_contract_matches_documented_precedence() { path: None, ask_for_approval: case.approval, sandbox_mode: Some("workspace-write"), + workspace_roots: Vec::new(), }) .unwrap_or_else(|error| panic!("{}: policy check failed: {error}", case.name)); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 3bf853d3c2..a4f19bfb06 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -91,6 +91,13 @@ pub struct Thread { #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, pub cwd: PathBuf, + // Omitted only when the set is genuinely empty: legacy rows and the + // protocol-parity fixture hit this, but both spawn paths persist at + // least the cwd, so a thread created by this build always carries the + // key (an additive field legacy decoders tolerate). Decode still + // defaults via `default`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, pub cli_version: String, pub source: SessionSource, #[serde(skip_serializing_if = "Option::is_none")] @@ -143,6 +150,13 @@ pub struct ThreadStartParams { pub model_provider: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, + /// The full accessible root set with the cwd as the primary; a start has + /// no parent to inherit from, so this is a plain `Vec` (empty ≡ the bare + /// cwd), omitted from the wire when empty. Senders carry the full set + /// (cwd first) even where the field name reads "additional" — both + /// spellings are accepted and normalized to cwd-first on intake. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, #[serde(default)] pub persist_extended_history: bool, } @@ -172,6 +186,11 @@ pub struct ThreadResumeParams { pub developer_instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, + /// `None` (or absent) inherits the persisted set; `Some(roots)` replaces + /// it wholesale — `Some([])` is an explicit clear back to the bare cwd, + /// matching upstream codex's `Option` semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_roots: Option>, #[serde(default)] pub persist_extended_history: bool, } @@ -197,6 +216,13 @@ pub struct ThreadForkParams { pub base_instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub developer_instructions: Option, + /// `None` (or absent) inherits the parent thread's set, with the fork's + /// cwd taking the primary slot; `Some(roots)` replaces it wholesale — + /// `Some([])` is an explicit clear back to the bare cwd. The field is + /// new, so a bare `thread/fork` (the historical shape) must inherit + /// rather than silently degrade a multi-root parent to the fallback cwd. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_roots: Option>, #[serde(default)] pub persist_extended_history: bool, } diff --git a/crates/protocol/src/op.rs b/crates/protocol/src/op.rs index ec323c82b7..e8e8ae95be 100644 --- a/crates/protocol/src/op.rs +++ b/crates/protocol/src/op.rs @@ -358,6 +358,12 @@ pub enum Op { system_prompt_override: bool, model: String, workspace: PathBuf, + /// The FULL cwd-first root set synced with the session (primary + /// first, additional roots after — every sender carries the + /// normalized full form, not the additions alone). Absent on + /// legacy payloads (single root). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + workspace_roots: Vec, #[serde(default = "default_mode")] mode: String, }, @@ -654,6 +660,7 @@ mod tests { system_prompt_override: false, model: "m".into(), workspace: PathBuf::from("/ws"), + workspace_roots: Vec::new(), mode: "agent".into(), }, Op::CompactContext { @@ -707,6 +714,52 @@ mod tests { } } + #[test] + fn sync_session_workspace_roots_ride_the_wire_only_when_present() { + let op = |roots: Vec| Op::SyncSession { + engine_session_id: Some("s".into()), + messages: vec![], + system_prompt: None, + system_prompt_override: false, + model: "m".into(), + workspace: PathBuf::from("/ws"), + workspace_roots: roots, + mode: "agent".into(), + }; + + // An empty set stays off the wire: a single-root host's frame is the + // legacy shape, and a `skip_serializing_if` regression would leak the + // key into every sync. + let empty = serde_json::to_value(op(Vec::new())).unwrap(); + assert!( + empty.get("workspace_roots").is_none(), + "empty roots must stay off the wire: {empty}" + ); + + // A non-empty set is carried and round-trips. + let multi_roots = vec![PathBuf::from("/ws"), PathBuf::from("/shared")]; + let multi = serde_json::to_value(op(multi_roots.clone())).unwrap(); + assert_eq!(multi["workspace_roots"], json!(["/ws", "/shared"])); + let back: Op = serde_json::from_value(multi).unwrap(); + assert_eq!(back, op(multi_roots)); + + // A legacy payload without the key decodes to an empty set. + let legacy: Op = serde_json::from_value(json!({ + "kind": "sync_session", + "messages": [], + "model": "m", + "workspace": "/ws", + "mode": "agent" + })) + .unwrap(); + match legacy { + Op::SyncSession { + workspace_roots, .. + } => assert!(workspace_roots.is_empty()), + other => panic!("expected SyncSession, got {other:?}"), + } + } + #[test] fn every_variant_round_trips_and_tags_by_kind() { for op in every_variant() { diff --git a/crates/protocol/tests/parity_protocol.rs b/crates/protocol/tests/parity_protocol.rs index e637453391..c3ab73310c 100644 --- a/crates/protocol/tests/parity_protocol.rs +++ b/crates/protocol/tests/parity_protocol.rs @@ -1,10 +1,12 @@ use codewhale_protocol::{ - AppRequest, EventFrame, ThreadGoal, ThreadGoalProgressParams, ThreadGoalSetParams, - ThreadGoalStatus, ThreadListParams, ThreadRequest, ThreadResumeParams, ToolOutput, - UserInputAnswerEvent, UserInputOptionEvent, UserInputQuestionEvent, UserInputRequestEvent, + AppRequest, EventFrame, Thread, ThreadGoal, ThreadGoalProgressParams, ThreadGoalSetParams, + ThreadGoalStatus, ThreadListParams, ThreadRequest, ThreadResumeParams, ThreadStartParams, + ThreadStatus, ToolOutput, UserInputAnswerEvent, UserInputOptionEvent, UserInputQuestionEvent, + UserInputRequestEvent, runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope}, }; use serde_json::{Value, json}; +use std::path::PathBuf; #[test] fn mcp_tool_output_public_shape_remains_source_compatible() { @@ -109,6 +111,10 @@ fn thread_resume_params_round_trip() { base_instructions: Some("base".to_string()), developer_instructions: Some("dev".to_string()), personality: Some("default".to_string()), + workspace_roots: Some(vec![ + PathBuf::from("/repo/main"), + PathBuf::from("/repo/shared"), + ]), persist_extended_history: true, }); @@ -118,12 +124,172 @@ fn thread_resume_params_round_trip() { ThreadRequest::Resume(params) => { assert_eq!(params.thread_id, "thread-123"); assert_eq!(params.model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!( + params.workspace_roots, + Some(vec![ + PathBuf::from("/repo/main"), + PathBuf::from("/repo/shared"), + ]), + "an explicit root set round-trips" + ); + assert!( + encoded.contains(r#""workspace_roots":["/repo/main","/repo/shared"]"#), + "the encoded frame must actually carry the set: {encoded}" + ); assert!(params.persist_extended_history); } other => panic!("unexpected request: {other:?}"), } } +#[test] +fn thread_start_params_workspace_roots_round_trip() { + let request = ThreadRequest::Start(ThreadStartParams { + model: None, + model_provider: None, + cwd: Some(PathBuf::from("/repo/main")), + workspace_roots: vec![PathBuf::from("/repo/lib"), PathBuf::from("/repo/docs")], + persist_extended_history: false, + }); + + let encoded = serde_json::to_string(&request).expect("serialize request"); + assert!(encoded.contains(r#""workspace_roots":["/repo/lib","/repo/docs"]"#)); + let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request"); + match decoded { + ThreadRequest::Start(params) => { + assert_eq!( + params.workspace_roots, + vec![PathBuf::from("/repo/lib"), PathBuf::from("/repo/docs")] + ); + } + other => panic!("unexpected request: {other:?}"), + } +} + +#[test] +fn thread_resume_params_explicit_empty_set_survives_the_wire() { + // The three-state resume contract rides on the wire distinguishing + // three shapes: absent key = None = inherit the persisted set; + // `[]` = Some(vec![]) = an explicit clear back to the bare cwd; + // non-empty = replace. A skip-attr or deserialize-adapter refactor + // that folds `[]` into `None` would silently degrade explicit-clear + // back to inherit - this pins the distinction on the wire itself. + let payload = r#"{"kind":"resume","thread_id":"t","workspace_roots":[]}"#; + let decoded: ThreadRequest = serde_json::from_str(payload).expect("deserialize explicit clear"); + match &decoded { + ThreadRequest::Resume(params) => { + assert_eq!(params.workspace_roots, Some(Vec::new())); + let encoded = serde_json::to_string(&decoded).expect("re-encode"); + assert!( + encoded.contains(r#""workspace_roots":[]"#), + "an explicit clear must re-encode with the key present: {encoded}" + ); + } + other => panic!("unexpected request: {other:?}"), + } +} + +#[test] +fn thread_params_without_workspace_roots_deserialize_to_empty() { + // Payloads written before the field existed carry no workspace_roots key; + // they must still decode, yielding an empty root set. + let legacy_start = r#"{"kind":"start","cwd":"/repo"}"#; + let decoded: ThreadRequest = + serde_json::from_str(legacy_start).expect("deserialize legacy start request"); + match decoded { + ThreadRequest::Start(params) => { + assert!(params.workspace_roots.is_empty()); + assert!( + !serde_json::to_string(¶ms) + .expect("serialize start params") + .contains("workspace_roots"), + "empty root set must stay off the wire" + ); + } + other => panic!("unexpected request: {other:?}"), + } + + let legacy_resume = r#"{"kind":"resume","thread_id":"thread-1"}"#; + let decoded: ThreadRequest = + serde_json::from_str(legacy_resume).expect("deserialize legacy resume request"); + match decoded { + ThreadRequest::Resume(params) => assert!(params.workspace_roots.is_none()), + other => panic!("unexpected request: {other:?}"), + } + + let legacy_fork = r#"{"kind":"fork","thread_id":"thread-1"}"#; + let decoded: ThreadRequest = + serde_json::from_str(legacy_fork).expect("deserialize legacy fork request"); + match decoded { + // Absent must mean "inherit the parent's set": an empty `Some` would + // be read as an explicit clear and degrade a multi-root parent to + // the fork cwd. + ThreadRequest::Fork(params) => assert!(params.workspace_roots.is_none()), + other => panic!("unexpected request: {other:?}"), + } +} + +#[test] +fn thread_fork_params_explicit_empty_set_survives_the_wire() { + // Same three-state contract as resume: absent key = None = inherit, + // `[]` = Some(vec![]) = explicit clear, non-empty = replace. + let payload = r#"{"kind":"fork","thread_id":"t","workspace_roots":[]}"#; + let decoded: ThreadRequest = serde_json::from_str(payload).expect("deserialize explicit clear"); + match &decoded { + ThreadRequest::Fork(params) => { + assert_eq!(params.workspace_roots, Some(Vec::new())); + let encoded = serde_json::to_string(&decoded).expect("re-encode"); + assert!( + encoded.contains(r#""workspace_roots":[]"#), + "an explicit clear must re-encode with the key present: {encoded}" + ); + } + other => panic!("unexpected request: {other:?}"), + } +} + +#[test] +fn thread_dto_workspace_roots_default_for_legacy_payloads() { + let legacy = r#"{ + "id": "thread-1", + "preview": "", + "ephemeral": false, + "model_provider": "deepseek", + "created_at": 1, + "updated_at": 2, + "status": "idle", + "cwd": "/repo", + "cli_version": "0.0.0", + "source": "interactive" + }"#; + let decoded: Thread = serde_json::from_str(legacy).expect("deserialize legacy thread"); + assert!(decoded.workspace_roots.is_empty()); + assert!(matches!(decoded.status, ThreadStatus::Idle)); + + // An empty root set is omitted from the wire frame, matching the params' + // skip-if-empty convention: a thread decoded from a legacy payload keeps + // the historical frame byte-identical. (A thread CREATED by this build + // always carries at least [cwd] — intake normalization never yields an + // empty set — so omission happens only for pre-field rows.) + let encoded_single_root = + serde_json::to_string(&decoded).expect("serialize single-root thread"); + let frame: serde_json::Value = + serde_json::from_str(&encoded_single_root).expect("single-root frame parses"); + assert!( + frame.get("workspace_roots").is_none(), + "an empty root set must stay off the wire frame: {frame}" + ); + + let mut current = decoded; + current.workspace_roots = vec![PathBuf::from("/repo"), PathBuf::from("/repo/lib")]; + let encoded = serde_json::to_string(¤t).expect("serialize thread"); + let round_tripped: Thread = serde_json::from_str(&encoded).expect("round-trip thread"); + assert_eq!( + round_tripped.workspace_roots, + vec![PathBuf::from("/repo"), PathBuf::from("/repo/lib")] + ); +} + #[test] fn thread_list_params_defaults_are_serializable() { let request = ThreadRequest::List(ThreadListParams { diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 24bcbee55d..1554e99fc6 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -75,6 +75,9 @@ pub struct ThreadMetadata { pub path: Option, /// Working directory that was active when the thread was created. pub cwd: PathBuf, + /// Workspace roots attached to the thread; `cwd` is always the primary root. + #[serde(default)] + pub workspace_roots: Vec, /// Version of the CLI that created this thread. pub cli_version: String, /// How this session was initiated. @@ -636,6 +639,54 @@ impl StateStore { )) .context("failed to initialize thread goal continuation schema")?; } + if user_version < 5 { + // Two processes first-opening the same store race here: deciding + // the column's presence outside the transaction (the v0/v4 shape) + // lets both see it missing and makes the loser's ALTER fail with + // a duplicate-column error. `BEGIN IMMEDIATE` alone is not + // enough — the decision would still precede the lock — so the + // presence check runs inside the write transaction, where the + // loser observes the winner's committed column and becomes a + // no-op version bump. + conn.execute_batch("BEGIN IMMEDIATE;") + .context("failed to begin the workspace roots migration")?; + // Any failure after the BEGIN must leave no open transaction + // behind; the connection would roll back on drop, but the + // explicit ROLLBACK keeps the same connection usable for the + // error report. + let migration = match column_exists(conn, "threads", "workspace_roots") { + Ok(exists) => { + if exists { + String::new() + } else { + "ALTER TABLE threads\n ADD COLUMN workspace_roots TEXT NOT NULL DEFAULT '[]';\n" + .to_string() + } + } + Err(error) => { + let _ = conn.execute_batch("ROLLBACK;"); + return Err(error).context("failed to inspect the threads table"); + } + }; + let committed = conn.execute_batch(&format!( + r#"{migration} + PRAGMA user_version = 5; + COMMIT; + "# + )); + if committed.is_err() { + // Leave no open transaction behind on a failed migration; + // the open error propagates below. + let _ = conn.execute_batch("ROLLBACK;"); + committed.context("failed to initialize thread workspace roots schema")?; + } + // Deliberately no local `user_version` mirror here: this is the + // terminal migration step, so the stale pre-migration value is + // never read again (an unused assignment fails -D warnings). + // A future v6 step re-reads PRAGMA user_version first, and the + // in-transaction column_exists guard keeps re-entry into this + // block harmless. + } Ok(()) } @@ -645,16 +696,75 @@ impl StateStore { /// or [`set_current_leaf_id`](Self::set_current_leaf_id) for that. pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> { let conn = self.conn()?; + Self::upsert_thread_row(&conn, thread, false)?; + self.append_thread_name( + &thread.id, + thread.name.clone(), + thread.updated_at, + thread.rollout_path.clone(), + ) + } + + /// Insert or update thread metadata while keeping, atomically inside the + /// upsert statement, the columns whose authority the caller's snapshot + /// does not carry: the per-thread policy fields (kept when the payload + /// carries `None`) and the archive timestamp (kept only while the payload + /// still says archived, so a resume that reactivates the thread clears + /// the stamp instead of ghosting `archived=0` + stamp set). + /// + /// A get-then-upsert pair cannot express this: a row cleared concurrently + /// (unarchive, policy detach) between the read and the write would be + /// resurrected from the stale snapshot. The conditions here run on the + /// in-transaction row, so the read-modify-write collapses into one + /// statement. + pub fn upsert_thread_preserving_policy_and_archive( + &self, + thread: &ThreadMetadata, + ) -> Result<()> { + let conn = self.conn()?; + Self::upsert_thread_row(&conn, thread, true)?; + self.append_thread_name( + &thread.id, + thread.name.clone(), + thread.updated_at, + thread.rollout_path.clone(), + ) + } + + fn upsert_thread_row( + conn: &rusqlite::Connection, + thread: &ThreadMetadata, + preserve_policy_and_archive: bool, + ) -> Result<()> { + let (sandbox_policy_arm, approval_mode_arm, archived_at_arm) = + if preserve_policy_and_archive { + ( + "COALESCE(excluded.sandbox_policy, threads.sandbox_policy)", + "COALESCE(excluded.approval_mode, threads.approval_mode)", + // Payload-blind while archived: the row's stamp is the + // authority, so a stale payload carrying its own old + // stamp cannot resurrect a concurrently cleared one. + "CASE WHEN excluded.archived = 0 THEN NULL \ + ELSE threads.archived_at END", + ) + } else { + ( + "excluded.sandbox_policy", + "excluded.approval_mode", + "excluded.archived_at", + ) + }; conn.execute( - r#" + &format!( + r#" INSERT INTO threads ( id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, - git_sha, git_branch, git_origin_url, memory_mode + git_sha, git_branch, git_origin_url, memory_mode, workspace_roots ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, - ?18, ?19, ?20, ?21 + ?18, ?19, ?20, ?21, ?22 ) ON CONFLICT(id) DO UPDATE SET rollout_path=excluded.rollout_path, @@ -669,15 +779,16 @@ impl StateStore { cli_version=excluded.cli_version, source=excluded.source, title=excluded.title, - sandbox_policy=excluded.sandbox_policy, - approval_mode=excluded.approval_mode, + sandbox_policy={sandbox_policy_arm}, + approval_mode={approval_mode_arm}, archived=excluded.archived, - archived_at=excluded.archived_at, + archived_at={archived_at_arm}, git_sha=excluded.git_sha, git_branch=excluded.git_branch, git_origin_url=excluded.git_origin_url, - memory_mode=excluded.memory_mode - "#, + memory_mode=excluded.memory_mode, + workspace_roots=excluded.workspace_roots + "#), params![ thread.id, path_to_opt_string(thread.rollout_path.as_deref()), @@ -700,16 +811,59 @@ impl StateStore { thread.git_branch, thread.git_origin_url, thread.memory_mode, + workspace_roots_to_json(&thread.workspace_roots), ], ) .context("failed to upsert thread metadata")?; + Ok(()) + } - self.append_thread_name( - &thread.id, - thread.name.clone(), - thread.updated_at, - thread.rollout_path.clone(), - )?; + /// Update only the location columns of a thread row: `cwd`, + /// `workspace_roots`, and the `updated_at` recency stamp. + /// + /// This is the cached-resume override writeback's write path. That + /// caller's payload comes from the in-process running-thread cache, which + /// is stale in every column another process may have written since the + /// cache entry was stamped. Routing the override through the full upsert + /// let that snapshot revert concurrent updates far beyond the override's + /// ownership — the upsert's preserving arms cover only the policy fields + /// and the archive stamp, while `archived`, `status`, `preview`, `title`, + /// and `rollout_path` are plain `excluded.*` passthrough (a stale cache + /// could even resurrect a concurrently archived thread). A narrow UPDATE + /// writes exactly the columns the override owns and nothing else. + /// + /// Scope caveat (recorded for accuracy, round-24 P3): "the columns the + /// override owns" is over-broad for the roots-only leg in one direction + /// — `cwd` is written UNCONDITIONALLY, including when the caller only + /// meant to refresh the root set, so a concurrently moved cwd the cache + /// never saw is still reverted by that spelling. The typed caller API + /// always passes the pair. A row deleted concurrently with this UPDATE + /// fails loud instead of a silent no-op success (round-24 P3): the + /// writeback's ownership claim only holds while the row exists. + pub fn update_thread_root_set( + &self, + id: &str, + cwd: &Path, + workspace_roots: &[PathBuf], + updated_at: i64, + ) -> Result<()> { + let conn = self.conn()?; + let rows = conn + .execute( + "UPDATE threads SET cwd = ?2, workspace_roots = ?3, updated_at = ?4 WHERE id = ?1", + params![ + id, + cwd.display().to_string(), + workspace_roots_to_json(workspace_roots), + updated_at, + ], + ) + .context("failed to update thread root set")?; + if rows == 0 { + return Err(anyhow::anyhow!( + "thread '{id}' vanished before the root-set writeback landed" + )); + } Ok(()) } @@ -722,7 +876,7 @@ impl StateStore { r#" SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, - git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id + git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id, workspace_roots FROM threads WHERE id = ?1 "#, @@ -740,9 +894,9 @@ impl StateStore { pub fn list_threads(&self, filters: ThreadListFilters) -> Result> { let conn = self.conn()?; let sql = if filters.include_archived { - "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads ORDER BY updated_at DESC LIMIT ?1" + "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id, workspace_roots FROM threads ORDER BY updated_at DESC LIMIT ?1" } else { - "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1" + "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id, workspace_roots FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1" }; let mut stmt = conn.prepare(sql).context("failed to prepare list query")?; @@ -2019,9 +2173,73 @@ fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result { git_origin_url: row.get(19)?, memory_mode: row.get(20)?, current_leaf_id: row.get(21)?, + workspace_roots: workspace_roots_from_json(row.get::<_, Option>(22)?), }) } +fn workspace_roots_from_json(raw: Option) -> Vec { + let Some(value) = raw else { + return Vec::new(); + }; + // The fallback direction is safe (empty = single-root legacy), but a + // writer-side serialization bug must not present as silent degradation; + // tolerance with visibility, as for the other legacy-shaped columns. The + // warning surfaces only on hosts that installed a `tracing` subscriber + // (the TUI does); headless hosts without one still get the safe fallback + // but no log line. + match serde_json::from_str::>(&value) { + Ok(roots) => { + // The reader stays tolerant (normalization at intake and at seed + // drops these), but a poisoned row — written by an older build or + // hand-edited — must not be invisible: an empty entry contains + // every path under starts_with, a relative one is dead weight. + if roots + .iter() + .any(|root| root.as_os_str().is_empty() || !root.is_absolute()) + { + tracing::warn!( + target: "codewhale_state", + "workspace_roots on threads row holds an empty or relative entry; normalization drops it" + ); + } + roots + } + Err(error) => { + tracing::warn!( + target: "codewhale_state", + %error, + "invalid workspace_roots JSON on threads row; treating as empty" + ); + Vec::new() + } + } +} + +/// Serialize the root set for the `threads` column. +/// +/// A path that cannot be represented in the JSON column (non-UTF-8 on Unix) +/// is the one direction that can lose the set. Degrading to an empty list +/// keeps the thread writable — an upsert failure would cost the whole record +/// — but it must not be silent: the reader only warns on malformed JSON, so +/// without this the loss is invisible. Visibility note: the warning surfaces +/// only on hosts that installed a `tracing` subscriber before this call (the +/// TUI does; a headless host with no subscriber sees nothing on stderr), so +/// the tolerant empty-set fallback remains the load-bearing guarantee, not +/// the log line. +fn workspace_roots_to_json(roots: &[PathBuf]) -> String { + match serde_json::to_string(roots) { + Ok(json) => json, + Err(error) => { + tracing::warn!( + target: "codewhale_state", + %error, + "workspace_roots could not be serialized; persisting an empty set" + ); + "[]".to_string() + } + } +} + fn row_to_thread_goal(row: &rusqlite::Row<'_>) -> rusqlite::Result { let status_raw: String = row.get(3)?; Ok(ThreadGoalRecord { @@ -2076,6 +2294,7 @@ mod tests { status: ThreadStatus::Running, path: None, cwd: PathBuf::from("/tmp/codewhale"), + workspace_roots: Vec::new(), cli_version: "0.0.0-test".to_string(), source: SessionSource::Interactive, name: None, @@ -2207,6 +2426,173 @@ mod tests { } } + #[test] + fn preserving_upsert_keeps_policy_and_archive_stamp_the_payload_does_not_carry() { + let store = temp_state_store("preserving-upsert-keeps"); + let mut seeded = test_thread("thread-1"); + seeded.sandbox_policy = Some("workspace-write".to_string()); + seeded.approval_mode = Some("suggest".to_string()); + seeded.archived = true; + seeded.archived_at = Some(1_234); + store.upsert_thread(&seeded).expect("seed thread"); + + // The resume/persist payload carries no policy or stamp but still + // says archived (a cached resume keeps the cached status): the row's + // own values rule, inside the one statement. + let mut payload = test_thread("thread-1"); + payload.archived = true; + store + .upsert_thread_preserving_policy_and_archive(&payload) + .expect("preserving upsert"); + let row = store + .get_thread("thread-1") + .expect("read thread") + .expect("thread exists"); + assert_eq!(row.sandbox_policy, seeded.sandbox_policy); + assert_eq!(row.approval_mode, seeded.approval_mode); + assert!(row.archived, "payload still says archived"); + assert_eq!( + row.archived_at, + Some(1_234), + "stamp survives while archived" + ); + + // The plain upsert keeps its passthrough semantics: a None payload + // value really does clear the column. + let mut plain = test_thread("thread-1"); + plain.archived = true; + store.upsert_thread(&plain).expect("plain upsert"); + let row = store + .get_thread("thread-1") + .expect("read thread") + .expect("thread exists"); + assert_eq!(row.sandbox_policy, None); + assert_eq!(row.archived_at, None); + } + + #[test] + fn preserving_upsert_clears_the_stamp_when_the_payload_reactivates_the_thread() { + let store = temp_state_store("preserving-upsert-reactivates"); + let mut seeded = test_thread("thread-1"); + seeded.archived = true; + seeded.archived_at = Some(1_234); + store.upsert_thread(&seeded).expect("seed thread"); + + // A resume forces the thread back to Running: persisting it must not + // ghost `archived=0` with a stamp set — base never produced that pair. + let payload = test_thread("thread-1"); + store + .upsert_thread_preserving_policy_and_archive(&payload) + .expect("preserving upsert"); + let row = store + .get_thread("thread-1") + .expect("read thread") + .expect("thread exists"); + assert!(!row.archived); + assert_eq!(row.archived_at, None, "reactivation clears the stamp"); + } + + #[test] + fn preserving_upsert_does_not_resurrect_a_concurrently_cleared_stamp() { + let store = temp_state_store("preserving-upsert-no-resurrection"); + let mut seeded = test_thread("thread-1"); + seeded.archived = true; + seeded.archived_at = Some(1_234); + store.upsert_thread(&seeded).expect("seed thread"); + // Another process unarchives between the stale snapshot and this + // write: mark_unarchived clears flag and stamp on the row. + store.mark_unarchived("thread-1").expect("unarchive"); + + // A stale payload whose in-memory snapshot still says archived and + // still carries the pre-clear stamp must not resurrect it: while the + // payload says archived, the row's stamp is the only authority, and + // the row no longer carries one. (With a payload stamp of None this + // pin would be vacuous — the passthrough arm writes NULL too — so the + // payload deliberately carries the stale value.) + let mut stale = test_thread("thread-1"); + stale.archived = true; + stale.archived_at = Some(1_234); + store + .upsert_thread_preserving_policy_and_archive(&stale) + .expect("preserving upsert"); + let row = store + .get_thread("thread-1") + .expect("read thread") + .expect("thread exists"); + assert_eq!( + row.archived_at, None, + "a concurrently cleared stamp stays cleared" + ); + } + + #[test] + fn update_thread_root_set_touches_only_cwd_roots_and_updated_at() { + // The cached-resume override writeback's write path: its payload is a + // stale in-process snapshot, so the targeted UPDATE must move exactly + // the columns the override owns and leave every other column — a + // newer policy, the archive flag and stamp, preview, status — to the + // row's own authority. + let store = temp_state_store("root-set-targeted-update"); + let mut seeded = test_thread("thread-1"); + seeded.sandbox_policy = Some("workspace-write".to_string()); + seeded.approval_mode = Some("on-request".to_string()); + seeded.archived = true; + seeded.archived_at = Some(1_234); + seeded.preview = "concurrent preview".to_string(); + store.upsert_thread(&seeded).expect("seed thread"); + + store + .update_thread_root_set( + "thread-1", + Path::new("/repo/main"), + &[PathBuf::from("/repo/main"), PathBuf::from("/repo/shared")], + 9_999, + ) + .expect("targeted update"); + + let row = store + .get_thread("thread-1") + .expect("read thread") + .expect("thread exists"); + assert_eq!(row.cwd, PathBuf::from("/repo/main")); + assert_eq!( + row.workspace_roots, + vec![PathBuf::from("/repo/main"), PathBuf::from("/repo/shared")] + ); + assert_eq!(row.updated_at, 9_999); + assert_eq!(row.sandbox_policy, seeded.sandbox_policy); + assert_eq!(row.approval_mode, seeded.approval_mode); + assert!( + row.archived, + "the archive flag is not the override's column" + ); + assert_eq!(row.archived_at, Some(1_234)); + assert_eq!(row.preview, "concurrent preview"); + assert_eq!(row.status, seeded.status); + assert_eq!(row.created_at, seeded.created_at); + } + + #[test] + fn update_thread_root_set_fails_loud_when_the_row_vanished() { + // Round-24 P3 (behavior residual closed): a concurrently deleted row + // used to turn the cached-resume writeback into a silent no-op + // success — the caller claimed ownership of columns it never wrote. + // Zero rows affected must surface as an error instead. + let store = temp_state_store("root-set-vanished"); + let err = store + .update_thread_root_set( + "ghost-thread", + Path::new("/repo/main"), + &[PathBuf::from("/repo/main")], + 9_999, + ) + .expect_err("an absent row must not report success"); + assert!( + err.to_string().contains("vanished"), + "unexpected error: {err:#}" + ); + } + #[test] fn state_store_reuses_one_connection_across_operations_and_clones() { let store = temp_state_store("conn-reuse"); @@ -2388,6 +2774,108 @@ mod tests { let _ = fs::remove_dir_all(dir); } + #[test] + fn migration_to_workspace_roots_is_idempotent() { + // A v4 database must gain the workspace_roots column exactly once; + // reopening with a stale header re-runs the guarded ALTER without + // aborting on "duplicate column name". + let dir = temp_state_dir("migration-v5-idempotent"); + let db_path = dir.join("state.db"); + drop(StateStore::open(Some(db_path.clone())).expect("initial open")); + { + let conn = Connection::open(&db_path).expect("raw connection"); + conn.pragma_update(None, "user_version", 4) + .expect("reset user_version to v4"); + } + + let store = StateStore::open(Some(db_path.clone())).expect("reopen with v4 header"); + let mut thread = test_thread("thread-v5"); + thread.workspace_roots = vec![PathBuf::from("/tmp/codewhale")]; + store + .upsert_thread(&thread) + .expect("write after guarded v5 migration"); + + drop(store); + let store = StateStore::open(Some(db_path)).expect("third open"); + let persisted = store + .get_thread("thread-v5") + .expect("read after reopen") + .expect("thread persisted"); + assert_eq!( + persisted.workspace_roots, + vec![PathBuf::from("/tmp/codewhale")] + ); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn workspace_roots_round_trip_and_listing() { + let store = temp_state_store("workspace-roots-round-trip"); + let mut thread = test_thread("thread-roots"); + thread.workspace_roots = vec![ + PathBuf::from("/tmp/codewhale"), + PathBuf::from("/tmp/shared-lib"), + ]; + store.upsert_thread(&thread).expect("upsert thread"); + + let loaded = store + .get_thread("thread-roots") + .expect("read thread") + .expect("thread must exist"); + assert_eq!(loaded.workspace_roots, thread.workspace_roots); + + let listed = store + .list_threads(ThreadListFilters { + include_archived: false, + limit: Some(10), + }) + .expect("list threads"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].workspace_roots, thread.workspace_roots); + + // Replacing the root set persists through upsert conflict update. + let mut updated = loaded; + updated.workspace_roots = vec![PathBuf::from("/tmp/codewhale")]; + store.upsert_thread(&updated).expect("re-upsert thread"); + let reloaded = store + .get_thread("thread-roots") + .expect("read thread") + .expect("thread must exist"); + assert_eq!( + reloaded.workspace_roots, + vec![PathBuf::from("/tmp/codewhale")] + ); + } + + #[test] + fn workspace_roots_default_for_rows_predating_the_column() { + // A row inserted without the new column (or holding a value written + // by an older writer) must decode to an empty root set, not error. + let store = temp_state_store("workspace-roots-default"); + store + .upsert_thread(&test_thread("thread-legacy")) + .expect("upsert thread"); + let loaded = store + .get_thread("thread-legacy") + .expect("read thread") + .expect("thread must exist"); + assert!(loaded.workspace_roots.is_empty()); + } + + #[test] + fn workspace_roots_from_json_tolerates_missing_and_malformed_values() { + assert!(workspace_roots_from_json(None).is_empty()); + assert!(workspace_roots_from_json(Some(String::new())).is_empty()); + assert!(workspace_roots_from_json(Some("not-json".to_string())).is_empty()); + assert!(workspace_roots_from_json(Some("{}".to_string())).is_empty()); + assert!(workspace_roots_from_json(Some("null".to_string())).is_empty()); + assert_eq!( + workspace_roots_from_json(Some(r#"["/a","/b"]"#.to_string())), + vec![PathBuf::from("/a"), PathBuf::from("/b")] + ); + } + #[test] fn record_thread_goal_usage_accumulates_tokens_and_time() { let store = temp_state_store("thread-goal-usage"); diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index 5be1d89cf7..a24bbb167b 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -16,10 +16,10 @@ fn assert_workflow_trace_schema(conn: &Connection) { let user_version: u32 = conn .query_row("PRAGMA user_version;", [], |row| row.get(0)) .expect("read user_version"); - // v4 (goal-progress migration) adds `thread_goals.continuation_count` on top - // of the v3 workflow-trace + thread_goals tables. The table set asserted - // below is unchanged; only the schema version advanced. - assert_eq!(user_version, 4); + // v5 (workspace roots migration) adds `threads.workspace_roots` on top of + // the v4 goal-progress schema. The table set asserted below is unchanged; + // only the schema version advanced. + assert_eq!(user_version, 5); for table in [ "workflow_runs", @@ -56,6 +56,7 @@ fn upsert_and_resume_thread_metadata() { status: ThreadStatus::Running, path: Some(PathBuf::from("/tmp/project")), cwd: PathBuf::from("/tmp/project"), + workspace_roots: Vec::new(), cli_version: "0.0.0-test".to_string(), source: SessionSource::Interactive, name: Some("Test Thread".to_string()), @@ -370,6 +371,7 @@ fn test_fork() { status: ThreadStatus::Running, path: Some(PathBuf::from("/tmp/project")), cwd: PathBuf::from("/tmp/project"), + workspace_roots: Vec::new(), cli_version: "0.0.0-test".to_string(), source: SessionSource::Interactive, name: Some("Test Thread".to_string()), diff --git a/crates/tui/locales/ca.json b/crates/tui/locales/ca.json index e671748701..3b36db2d5a 100644 --- a/crates/tui/locales/ca.json +++ b/crates/tui/locales/ca.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Mètriques de la sessió: {metrics}", "StatusLabelRoute": "Ruta", "StatusLabelDirectory": "Directori", + "StatusLabelWorkspaceRoots": "Carpetes accessibles", + "WorkspaceSwitchBusy": "L'ús del workspace no ha canviat (l'estat de treball o el runtime està ocupat; espera i torna a provar /workspace)", + "WorkspaceSwitchPersistFailed": "Ha fallat la persistència del canvi del workspace: {error}; l'actor de persistència tampoc no està disponible", + "WorkspaceSwitchSaveFailedActorQueued": "El desat directe del canvi del workspace ha fallat ({error}); l'actor de persistència conserva la instantània posterior al canvi", + "WorkspaceSwitchPersistedActorUnavailable": "El canvi del workspace s'ha desat, però l'actor de persistència no està disponible; el proper desat automàtic el torna a escriure", + "WorkspaceSwitchSnapshotFailed": "Ha fallat la instantània del canvi del workspace: {error}", + "WorkspaceSwitchSessionsDirFailed": "No s'ha pogut obrir el directori de sessions: {error}", + "WorkspaceSwitchSetRefused": "No es pot canviar el workspace a {workspace}: el conjunt d'arrels transportat s'ampliaria més enllà del directori nou ({reason}). El canvi s'ha rebutjat; inicia una sessió nova en aquest directori o elimina les arrels que amplien del conjunt d'arrels desat d'aquesta sessió fora del TUI.", + "SessionRestoreWorkspaceInvalid": "L'espai de treball de la sessió desada ha de ser un camí absolut no buit", + "ForkSourceSetRefused": "No es pot bifurcar: el conjunt d'arrels de l'origen reubicat a {workspace} s'ampliaria més enllà d'ell ({reason}). La bifurcació s'ha rebutjat; comença-la des d'un directori que el conjunt no ampliï, o elimina les arrels que amplien del registre desat fora del TUI.", + "SaveLiveSetRefused": "No es pot desar: el conjunt d'arrels actual del workspace ja no passa l'entrada ({reason}); Torneu a carregar la sessió (les arrels rebutjades es descarten en carregar) o repareu el registre fora del TUI.", + "ForkLiveSetRefused": "No es pot bifurcar: el conjunt d'arrels actual del workspace ja no passa l'entrada ({reason}); Torneu a carregar la sessió (les arrels rebutjades es descarten en carregar) o repareu el registre fora del TUI.", + "SanitizedLoadedRoots": "S'han descartat {count} arrel(s) del workspace de la sessió carregada que l'entrada del workspace rebutja (arrel del sistema de fitxers, ancestre de l'arrel primària, grafia no vàlida o per sobre del límit de 64 arrels). La sessió funciona sense elles i el proper desat persisteix el conjunt reduït; torneu a carregar la sessió per descartar les arrels rebutjades.", + "WorkspaceRootsRemainder": "… (+{count} més)", + "WorkspaceRootsNotice": "Carpetes accessibles a més de {workspace}: {roots}", "StatusLabelProjectDocs": "Docs del projecte", "StatusLabelMode": "Mode", "StatusLabelSafety": "Seguretat", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Dj", "AutomationEditorFriday": "Dv", "AutomationEditorSaturday": "Ds", - "AutomationEditorSunday": "Dg" + "AutomationEditorSunday": "Dg", + "AttachedRootsNotRevertedNote": "Només s'ha restaurat l'workspace primari; les arrels adjuntes del workspace no s'han revertit (els directoris exclosos de la instantània —com vendor/— i els repositoris niats conserven els canvis)." } diff --git a/crates/tui/locales/de.json b/crates/tui/locales/de.json index ea65b6b349..047d80bb55 100644 --- a/crates/tui/locales/de.json +++ b/crates/tui/locales/de.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Sitzungsmetriken: {metrics}", "StatusLabelRoute": "Route", "StatusLabelDirectory": "Verzeichnis", + "StatusLabelWorkspaceRoots": "Zugängliche Ordner", + "WorkspaceSwitchBusy": "Arbeitsbereich unverändert (Work-State oder Laufzeitarbeit beschäftigt; warten, dann /workspace erneut versuchen)", + "WorkspaceSwitchPersistFailed": "Das Persistieren des Arbeitsbereichswechsels ist fehlgeschlagen: {error}; der Persistenz-Aktor ist ebenfalls nicht verfügbar", + "WorkspaceSwitchSaveFailedActorQueued": "Das direkte Speichern des Arbeitsbereichswechsels schlug fehl ({error}); der Persistenz-Aktor hält den Snapshot nach dem Wechsel", + "WorkspaceSwitchPersistedActorUnavailable": "Der Arbeitsbereichswechsel wurde gespeichert, aber der Persistenz-Aktor ist nicht verfügbar; das nächste Autosave schreibt ihn erneut", + "WorkspaceSwitchSnapshotFailed": "Die Momentaufnahme des Arbeitsbereichswechsels ist fehlgeschlagen: {error}", + "WorkspaceSwitchSessionsDirFailed": "Das Sitzungsverzeichnis konnte nicht geöffnet werden: {error}", + "WorkspaceSwitchSetRefused": "Workspace kann nicht zu {workspace} gewechselt werden: der mitgeführte Root-Satz würde über das neue Verzeichnis hinaus erweitert ({reason}). Der Wechsel wurde abgelehnt; starte in dem Verzeichnis eine neue Session oder entferne die erweiternden Roots außerhalb des TUI aus dem gespeicherten Root-Satz dieser Session.", + "SessionRestoreWorkspaceInvalid": "Der Arbeitsbereich der gespeicherten Sitzung muss ein nicht leerer absoluter Pfad sein", + "ForkSourceSetRefused": "Fork nicht möglich: der auf {workspace} neu verankerte Quell-Root-Satz würde darüber hinaus erweitert ({reason}). Der Fork wurde abgelehnt; starte ihn aus einem Verzeichnis, über das der Satz nicht hinaus erweitert, oder entferne die erweiternden Roots außerhalb des TUI aus dem gespeicherten Datensatz.", + "SaveLiveSetRefused": "Speichern nicht möglich: der aktuelle Root-Satz des Workspace besteht die Aufnahme nicht mehr ({reason}); Laden Sie die Session neu (die abgelehnten Roots entfallen beim Laden) oder reparieren Sie den Datensatz außerhalb des TUI.", + "ForkLiveSetRefused": "Fork nicht möglich: der aktuelle Root-Satz des Workspace besteht die Aufnahme nicht mehr ({reason}); Laden Sie die Session neu (die abgelehnten Roots entfallen beim Laden) oder reparieren Sie den Datensatz außerhalb des TUI.", + "SanitizedLoadedRoots": "{count} Workspace-Root(s) aus der geladenen Session entfernt, die die Workspace-Aufnahme verweigert (Dateisystemwurzel, Vorfahr der primären Root, ungültige Schreibweise oder über der 64-Root-Grenze). Die Session läuft ohne sie, und das nächste Speichern persistiert den reduzierten Satz; laden Sie die Session neu, um die abgelehnten Roots zu verwerfen.", + "WorkspaceRootsRemainder": "… (+{count} weitere)", + "WorkspaceRootsNotice": "Zugängliche Ordner neben {workspace}: {roots}", "StatusLabelProjectDocs": "Projektdokumente", "StatusLabelMode": "Modus", "StatusLabelSafety": "Sicherheit", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Do", "AutomationEditorFriday": "Fr", "AutomationEditorSaturday": "Sa", - "AutomationEditorSunday": "So" + "AutomationEditorSunday": "So", + "AttachedRootsNotRevertedNote": "Nur der primäre Workspace wurde wiederhergestellt; angehängte Workspace-Roots wurden nicht zurückgerollt (vom Snapshot ausgeschlossene Verzeichnisse — etwa vendor/ — und verschachtelte Repositories behalten ihre Änderungen)." } diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 68736df4a5..a6a66b3168 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Session metrics: {metrics}", "StatusLabelRoute": "Route", "StatusLabelDirectory": "Directory", + "StatusLabelWorkspaceRoots": "Accessible folders", + "WorkspaceSwitchBusy": "Workspace unchanged (Work state or runtime work busy; wait, then try /workspace again)", + "WorkspaceSwitchPersistFailed": "Failed to persist workspace switch: {error}; the persistence actor is also unavailable", + "WorkspaceSwitchSaveFailedActorQueued": "Direct workspace-switch save failed ({error}); the persistence actor holds the post-switch snapshot", + "WorkspaceSwitchPersistedActorUnavailable": "Workspace switch persisted, but the persistence actor is unavailable; the next autosave re-persists it", + "WorkspaceSwitchSnapshotFailed": "Failed to snapshot the workspace switch: {error}", + "WorkspaceSwitchSessionsDirFailed": "Failed to open the sessions directory: {error}", + "WorkspaceSwitchSetRefused": "Cannot switch workspace to {workspace}: the carried root set would widen past the new directory ({reason}). The switch is refused; start a new session in that directory, or remove the widening roots from this session's saved root set outside the TUI.", + "SessionRestoreWorkspaceInvalid": "The saved session workspace must be a non-empty absolute path", + "ForkSourceSetRefused": "Cannot fork: the source root set re-based onto {workspace} would widen past it ({reason}). The fork is refused; start it from a directory the set does not widen past, or clear the widening roots from the saved record outside the TUI.", + "SaveLiveSetRefused": "Cannot save: the live workspace root set no longer passes intake ({reason}); Reload the session (the refused roots drop on load) or repair the record outside the TUI.", + "ForkLiveSetRefused": "Cannot fork: the live workspace root set no longer passes intake ({reason}); Reload the session (the refused roots drop on load) or repair the record outside the TUI.", + "SanitizedLoadedRoots": "Dropped {count} workspace root(s) from the loaded session that the workspace intake refuses (filesystem root, primary ancestor, invalid spelling, or past the 64-root cap). The session runs without them and the next save persists the reduced set; reload the session to drop the refused roots.", + "WorkspaceRootsRemainder": "… (+{count} more)", + "WorkspaceRootsNotice": "Accessible folders beside {workspace}: {roots}", "StatusLabelProjectDocs": "Project docs", "StatusLabelMode": "Mode", "StatusLabelSafety": "Safety", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Thu", "AutomationEditorFriday": "Fri", "AutomationEditorSaturday": "Sat", - "AutomationEditorSunday": "Sun" + "AutomationEditorSunday": "Sun", + "AttachedRootsNotRevertedNote": "Only the primary workspace was reverted; attached workspace roots were not rolled back (roots excluded from the snapshot — such as vendor/ — and nested repositories keep their changes)." } diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index 42c58299d6..1a5326961b 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Métricas de la sesión: {metrics}", "StatusLabelRoute": "Ruta", "StatusLabelDirectory": "Directorio", + "StatusLabelWorkspaceRoots": "Carpetas accesibles", + "WorkspaceSwitchBusy": "El workspace no cambió (el estado de Trabajo o la ejecución está ocupada; espera y vuelve a intentar /workspace)", + "WorkspaceSwitchPersistFailed": "Error al persistir el cambio de workspace: {error}; el actor de persistencia tampoco está disponible", + "WorkspaceSwitchSaveFailedActorQueued": "El guardado directo del cambio de workspace falló ({error}); el actor de persistencia conserva el snapshot posterior al cambio", + "WorkspaceSwitchPersistedActorUnavailable": "El cambio de workspace se guardó, pero el actor de persistencia no está disponible; el siguiente autoguardado lo vuelve a escribir", + "WorkspaceSwitchSnapshotFailed": "Error al crear la instantánea del cambio de workspace: {error}", + "WorkspaceSwitchSessionsDirFailed": "No se pudo abrir el directorio de sesiones: {error}", + "WorkspaceSwitchSetRefused": "No se puede cambiar el workspace a {workspace}: el conjunto de raíces transportado se ampliaría más allá del nuevo directorio ({reason}). El cambio fue rechazado; inicia una nueva sesión en ese directorio o quita las raíces que amplían del conjunto guardado de esta sesión fuera del TUI.", + "SessionRestoreWorkspaceInvalid": "El espacio de trabajo de la sesión guardada debe ser una ruta absoluta no vacía", + "ForkSourceSetRefused": "No se puede bifurcar: el conjunto de raíces de origen reubicado en {workspace} se ampliaría más allá de él ({reason}). La bifurcación fue rechazada; comiénzala desde un directorio que el conjunto no amplíe, o quita las raíces que amplían del registro guardado fuera del TUI.", + "SaveLiveSetRefused": "No se puede guardar: el conjunto de raíces actual del workspace ya no pasa la entrada ({reason}); Vuelve a cargar la sesión (las raíces rechazadas se descartan al cargar) o repara el registro fuera del TUI.", + "ForkLiveSetRefused": "No se puede bifurcar: el conjunto de raíces actual del workspace ya no pasa la entrada ({reason}); Vuelve a cargar la sesión (las raíces rechazadas se descartan al cargar) o repara el registro fuera del TUI.", + "SanitizedLoadedRoots": "Se descartaron {count} raíz(es) del workspace de la sesión cargada que la entrada del workspace rechaza (raíz del sistema de archivos, ancestro de la raíz primaria, grafía inválida o por encima del límite de 64 raíces). La sesión funciona sin ellas y el próximo guardado persiste el conjunto reducido; vuelve a cargar la sesión para descartar las raíces rechazadas.", + "WorkspaceRootsRemainder": "… (+{count} más)", + "WorkspaceRootsNotice": "Carpetas accesibles además de {workspace}: {roots}", "StatusLabelProjectDocs": "Docs del proyecto", "StatusLabelMode": "Modo", "StatusLabelSafety": "Seguridad", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Jue", "AutomationEditorFriday": "Vie", "AutomationEditorSaturday": "Sáb", - "AutomationEditorSunday": "Dom" + "AutomationEditorSunday": "Dom", + "AttachedRootsNotRevertedNote": "Solo se revirtió el workspace primario; las raíces adjuntas del workspace no se revirtieron (los directorios excluidos de la instantánea —como vendor/— y los repositorios anidados conservan sus cambios)." } diff --git a/crates/tui/locales/fr.json b/crates/tui/locales/fr.json index 362667e2c4..0d6baacfba 100644 --- a/crates/tui/locales/fr.json +++ b/crates/tui/locales/fr.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Métriques de session : {metrics}", "StatusLabelRoute": "Itinéraire", "StatusLabelDirectory": "Répertoire", + "StatusLabelWorkspaceRoots": "Dossiers accessibles", + "WorkspaceSwitchBusy": "L'espace de travail est inchangé (état Work ou exécution occupée ; patientez, puis réessayez /workspace)", + "WorkspaceSwitchPersistFailed": "Échec de la persistance du changement d'espace de travail : {error} ; l'acteur de persistance est également indisponible", + "WorkspaceSwitchSaveFailedActorQueued": "L'enregistrement direct du changement d'espace de travail a échoué ({error}) ; l'acteur de persistance conserve l'instantané après le changement", + "WorkspaceSwitchPersistedActorUnavailable": "Le changement d'espace de travail est enregistré, mais l'acteur de persistance est indisponible ; la prochaine sauvegarde automatique le réécrira", + "WorkspaceSwitchSnapshotFailed": "Échec de l'instantané du changement d'espace de travail : {error}", + "WorkspaceSwitchSessionsDirFailed": "Impossible d'ouvrir le répertoire des sessions : {error}", + "WorkspaceSwitchSetRefused": "Impossible de basculer l'espace de travail vers {workspace} : l'ensemble de racines transporté s'élargirait au-delà du nouveau répertoire ({reason}). Le basculement est refusé ; démarrez une nouvelle session dans ce répertoire, ou retirez hors du TUI les racines élargissantes de l'ensemble enregistré de cette session.", + "SessionRestoreWorkspaceInvalid": "L'espace de travail de la session enregistrée doit être un chemin absolu non vide", + "ForkSourceSetRefused": "Impossible de bifurquer : l'ensemble de racines source réancré sur {workspace} s'élargirait au-delà ({reason}). La bifurcation est refusée ; démarrez-la depuis un répertoire que l'ensemble n'élargit pas, ou nettoyez hors du TUI les racines élargissantes de l'enregistrement.", + "SaveLiveSetRefused": "Impossible d'enregistrer : l'ensemble de racines actuel de l'espace de travail ne passe plus l'admission ({reason}); Rechargez la session (les racines refusées sont écartées au chargement) ou réparez l'enregistrement hors du TUI.", + "ForkLiveSetRefused": "Impossible de bifurquer : l'ensemble de racines actuel de l'espace de travail ne passe plus l'admission ({reason}); Rechargez la session (les racines refusées sont écartées au chargement) ou réparez l'enregistrement hors du TUI.", + "SanitizedLoadedRoots": "Racines d'espace de travail écartées de la session chargée : {count} (racine du système de fichiers, ancêtre de la racine primaire, orthographe invalide ou au-delà de la limite de 64 racines). La session fonctionne sans elles et le prochain enregistrement persiste l'ensemble réduit ; rechargez la session pour écarter les racines refusées.", + "WorkspaceRootsRemainder": "… (+{count} autres)", + "WorkspaceRootsNotice": "Dossiers accessibles en plus de {workspace} : {roots}", "StatusLabelProjectDocs": "Docs du projet", "StatusLabelMode": "Mode", "StatusLabelSafety": "Sécurité", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Jeu", "AutomationEditorFriday": "Ven", "AutomationEditorSaturday": "Sam", - "AutomationEditorSunday": "Dim" + "AutomationEditorSunday": "Dim", + "AttachedRootsNotRevertedNote": "Seul l'espace de travail principal a été restauré ; les racines attachées n'ont pas été annulées (les répertoires exclus de l'instantané — comme vendor/ — et les dépôts imbriqués conservent leurs modifications)." } diff --git a/crates/tui/locales/hi.json b/crates/tui/locales/hi.json index f8580f6c24..2fef6d02c9 100644 --- a/crates/tui/locales/hi.json +++ b/crates/tui/locales/hi.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "सत्र मेट्रिक्स: {metrics}", "StatusLabelRoute": "रूट", "StatusLabelDirectory": "डायरेक्टरी", + "StatusLabelWorkspaceRoots": "सुलभ फ़ोल्डर", + "WorkspaceSwitchBusy": "वर्कस्पेस अपरिवर्तित (कार्य स्थिति या रनटाइम कार्य व्यस्त है; प्रतीक्षा करें, फिर /workspace दोबारा आज़माएँ)", + "WorkspaceSwitchPersistFailed": "वर्कस्पेस बदलना परसिस्ट करने में विफल: {error}; परसिस्टेंस एक्टर भी अनुपलब्ध है", + "WorkspaceSwitchSaveFailedActorQueued": "वर्कस्पेस बदलने की सीधी सहेजगी विफल हुई ({error}); परसिस्टेंस एक्टर बदलाव के बाद का स्नैपशॉट रखता है", + "WorkspaceSwitchPersistedActorUnavailable": "वर्कस्पेस बदलना सहेजा गया, परंतु परसिस्टेंस एक्टर अनुपलब्ध है; अगला ऑटोसेव इसे फिर से लिखेगा", + "WorkspaceSwitchSnapshotFailed": "वर्कस्पेस बदलने का स्नैपशॉट विफल: {error}", + "WorkspaceSwitchSessionsDirFailed": "सत्र निर्देशिका नहीं खोली जा सकी: {error}", + "WorkspaceSwitchSetRefused": "वर्कस्पेस को {workspace} में नहीं बदला जा सकता: साथ लाई गई रूट सेट नई डायरेक्टरी से आगे चौड़ी हो जाएगी ({reason})। बदलाव अस्वीकार कर दिया गया; उस डायरेक्टरी में नया सेशन शुरू करें, या TUI के बाहर इस सेशन के सहेजे गए रूट सेट से चौड़ा करने वाली रूट हटाएँ।", + "SessionRestoreWorkspaceInvalid": "सहेजे गए सेशन का वर्कस्पेस खाली न होने वाला पूर्ण पथ होना चाहिए", + "ForkSourceSetRefused": "फ़ोर्क नहीं किया जा सकता: स्रोत रूट सेट को {workspace} पर पुनः लगाने पर वह उससे आगे चौड़ी हो जाएगी ({reason})। फ़ोर्क अस्वीकार कर दिया गया; ऐसी डायरेक्टरी से शुरू करें जिसे सेट चौड़ा न करे, या TUI के बाहर सहेजे गए रिकॉर्ड से चौड़ा करने वाली रूट हटाएँ।", + "SaveLiveSetRefused": "सहेजा नहीं जा सकता: वर्कस्पेस का वर्तमान रूट सेट अब intake से नहीं गुजरता ({reason}) कृपया सेशन पुनः लोड करें (अस्वीकृत रूट लोड पर हट जाते हैं), या TUI के बाहर रिकॉर्ड की मरम्मत करें।", + "ForkLiveSetRefused": "फ़ोर्क नहीं किया जा सकता: वर्कस्पेस का वर्तमान रूट सेट अब intake से नहीं गुजरता ({reason}) कृपया सेशन पुनः लोड करें (अस्वीकृत रूट लोड पर हट जाते हैं), या TUI के बाहर रिकॉर्ड की मरम्मत करें।", + "SanitizedLoadedRoots": "लोड किए गए सेशन से {count} वर्कस्पेस रूट हटाए गए हैं जिन्हें वर्कस्पेस intake अस्वीकार करता है (फ़ाइलसिस्टम रूट, primary का पूर्वज, अमान्य वर्तनी, या 64-रूट सीमा से अधिक)। सेशन इनके बिना चलता है और अगली सेव क्रिया घटाए हुए सेट को स्थायी करती है; अस्वीकृत रूटों को हटाने के लिए सेशन पुनः लोड करें।", + "WorkspaceRootsRemainder": "… (+{count} और)", + "WorkspaceRootsNotice": "{workspace} के अतिरिक्त सुलभ फ़ोल्डर: {roots}", "StatusLabelProjectDocs": "प्रोजेक्ट दस्तावेज़", "StatusLabelMode": "मोड", "StatusLabelSafety": "सुरक्षा", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "गुरु", "AutomationEditorFriday": "शुक्र", "AutomationEditorSaturday": "शनि", - "AutomationEditorSunday": "रवि" + "AutomationEditorSunday": "रवि", + "AttachedRootsNotRevertedNote": "केवल प्राथमिक वर्कस्पेस पूर्ववत हुआ; संलग्न वर्कस्पेस रूट वापस नहीं लपेटे गए (स्नैपशॉट से बाहर रखे गए डायरेक्टरीज़ — जैसे vendor/ — और नेस्टेड रिपॉज़िटरी अपने परिवर्तन बनाए रखती हैं)।" } diff --git a/crates/tui/locales/id.json b/crates/tui/locales/id.json index 2a42a0389f..24f32c0b5f 100644 --- a/crates/tui/locales/id.json +++ b/crates/tui/locales/id.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Metrik sesi: {metrics}", "StatusLabelRoute": "Rute", "StatusLabelDirectory": "Direktori", + "StatusLabelWorkspaceRoots": "Folder yang dapat diakses", + "WorkspaceSwitchBusy": "Workspace tidak berubah (status Work atau pekerjaan runtime sibuk; tunggu, lalu coba /workspace lagi)", + "WorkspaceSwitchPersistFailed": "Gagal mempersistenkan perubahan workspace: {error}; actor persistensi juga tidak tersedia", + "WorkspaceSwitchSaveFailedActorQueued": "Penyimpanan langsung perubahan workspace gagal ({error}); actor persistensi menyimpan snapshot setelah perubahan", + "WorkspaceSwitchPersistedActorUnavailable": "Perubahan workspace disimpan, tetapi actor persistensi tidak tersedia; penyimpanan otomatis berikutnya akan menulisnya ulang", + "WorkspaceSwitchSnapshotFailed": "Gagal membuat snapshot perubahan workspace: {error}", + "WorkspaceSwitchSessionsDirFailed": "Gagal membuka direktori sesi: {error}", + "WorkspaceSwitchSetRefused": "Tidak dapat mengganti workspace ke {workspace}: himpunan root yang dibawa akan melebar melewati direktori baru ({reason}). Penggantian ditolak; mulai sesi baru di direktori tersebut, atau hapus root yang melebar dari himpunan root tersimpan sesi ini di luar TUI.", + "SessionRestoreWorkspaceInvalid": "Ruang kerja sesi yang tersimpan harus berupa path absolut yang tidak kosong", + "ForkSourceSetRefused": "Tidak dapat fork: himpunan root sumber yang dipasang ulang ke {workspace} akan melebar melewatinya ({reason}). Fork ditolak; mulailah dari direktori yang tidak dilebarkan himpunan tersebut, atau bersihkan root yang melebar dari rekaman tersimpan di luar TUI.", + "SaveLiveSetRefused": "Tidak dapat menyimpan: himpunan root workspace saat ini tidak lagi lolos intake ({reason}); Muat ulang sesi (root yang ditolak dibuang saat dimuat) atau perbaiki rekaman di luar TUI.", + "ForkLiveSetRefused": "Tidak dapat fork: himpunan root workspace saat ini tidak lagi lolos intake ({reason}); Muat ulang sesi (root yang ditolak dibuang saat dimuat) atau perbaiki rekaman di luar TUI.", + "SanitizedLoadedRoots": "{count} root workspace dikeluarkan dari sesi yang dimuat karena ditolak intake workspace (akar sistem berkas, leluhur primary, ejaan tidak valid, atau melebihi batas 64 root). Sesi berjalan tanpanya dan simpanan berikutnya mempertahankan himpunan yang diperkecil; muat ulang sesi untuk membuang root yang ditolak.", + "WorkspaceRootsRemainder": "… (+{count} lainnya)", + "WorkspaceRootsNotice": "Folder yang dapat diakses selain {workspace}: {roots}", "StatusLabelProjectDocs": "Dokumen proyek", "StatusLabelMode": "Mode", "StatusLabelSafety": "Keamanan", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Kam", "AutomationEditorFriday": "Jum", "AutomationEditorSaturday": "Sab", - "AutomationEditorSunday": "Min" + "AutomationEditorSunday": "Min", + "AttachedRootsNotRevertedNote": "Hanya workspace utama yang dikembalikan; root workspace terlampir tidak di-rollbalik (direktori yang dikecualikan dari snapshot — seperti vendor/ — dan repositori bersarang mempertahankan perubahannya)." } diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index 2a1aba5296..3b1065f736 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "セッション指標: {metrics}", "StatusLabelRoute": "経路", "StatusLabelDirectory": "ディレクトリ", + "StatusLabelWorkspaceRoots": "アクセス可能なフォルダ", + "WorkspaceSwitchBusy": "ワークスペースは変更されていません(Work 状態またはランタイム処理が実行中です。待ってから /workspace を再実行してください)", + "WorkspaceSwitchPersistFailed": "ワークスペース切り替えの永続化に失敗しました: {error}。永続化アクターも利用できません", + "WorkspaceSwitchSaveFailedActorQueued": "ワークスペース切り替えの直接保存に失敗しました({error})。永続化アクターが切り替え後のスナップショットを保持しています", + "WorkspaceSwitchPersistedActorUnavailable": "ワークスペース切り替えは保存されましたが、永続化アクターが利用できません。次の自動保存で再保存されます", + "WorkspaceSwitchSnapshotFailed": "ワークスペース切り替えのスナップショットに失敗しました: {error}", + "WorkspaceSwitchSessionsDirFailed": "セッションディレクトリを開けませんでした: {error}", + "WorkspaceSwitchSetRefused": "ワークスペースを {workspace} へ切り替えられません:持ち越しのルート一覧が新しいディレクトリの範囲を超えて拡大します({reason})。切り替えは拒否されました。そのディレクトリで新しいセッションを開始するか、TUI の外でこのセッションの保存ルート一覧から拡大するルートを取り除いてください。", + "SessionRestoreWorkspaceInvalid": "保存されたセッションのワークスペースは、空でない絶対パスである必要があります", + "ForkSourceSetRefused": "フォークできません:ソースのルート一覧を {workspace} に再設定すると範囲を超えて拡大します({reason})。フォークは拒否されました。一覧が拡大しないディレクトリから開始するか、TUI の外で保存レコードから拡大するルートを取り除いてください。", + "SaveLiveSetRefused": "保存できません:現在のルート一覧が intake を通らなくなりました({reason})。セッションを再読み込みしてください(拒否されたルートは読み込み時に除外されます)、または TUI の外で記録を修復してください。", + "ForkLiveSetRefused": "フォークできません:現在のルート一覧が intake を通らなくなりました({reason})。セッションを再読み込みしてください(拒否されたルートは読み込み時に除外されます)、または TUI の外で記録を修復してください。", + "SanitizedLoadedRoots": "読み込んだセッションから、ワークスペース intake が拒否するルート(ファイルシステム根・主根の祖先・無効な綴り・64 根上限超過)を {count} 件除外しました。セッションはそれなしで動作し、次回の保存で縮小後のセットが永続化されます。拒否されたルートを除外するにはセッションを再読み込みしてください。", + "WorkspaceRootsRemainder": "…(ほか {count} 件)", + "WorkspaceRootsNotice": "{workspace} のほかにアクセス可能なフォルダ: {roots}", "StatusLabelProjectDocs": "プロジェクト文書", "StatusLabelMode": "モード", "StatusLabelSafety": "安全性", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "木", "AutomationEditorFriday": "金", "AutomationEditorSaturday": "土", - "AutomationEditorSunday": "日" + "AutomationEditorSunday": "日", + "AttachedRootsNotRevertedNote": "プライマリワークスペースのみ復元されました。アタッチされたワークスペースルートはロールバックされていません(スナップショットから除外されるディレクトリ(vendor/ など)やネストされたリポジトリは変更が残ります)。" } diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index 7582372b55..e7ee397be4 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "세션 지표: {metrics}", "StatusLabelRoute": "경로", "StatusLabelDirectory": "디렉터리", + "StatusLabelWorkspaceRoots": "접근 가능한 폴더", + "WorkspaceSwitchBusy": "워크스페이스가 변경되지 않았습니다 (작업 상태 또는 런타임 작업이 진행 중입니다. 잠시 후 /workspace를 다시 시도하세요)", + "WorkspaceSwitchPersistFailed": "워크스페이스 전환 저장 실패: {error}; 퍼시스턴스 액터도 사용할 수 없습니다", + "WorkspaceSwitchSaveFailedActorQueued": "워크스페이스 전환 직접 저장 실패({error}); 퍼시스턴스 액터가 전환 후 스냅샷을 보관합니다", + "WorkspaceSwitchPersistedActorUnavailable": "워크스페이스 전환이 저장되었지만 퍼시스턴스 액터를 사용할 수 없습니다. 다음 자동 저장이 다시 기록합니다", + "WorkspaceSwitchSnapshotFailed": "워크스페이스 전환 스냅샷 실패: {error}", + "WorkspaceSwitchSessionsDirFailed": "세션 디렉터리를 열 수 없습니다: {error}", + "WorkspaceSwitchSetRefused": "워크스페이스를 {workspace}(으)로 전환할 수 없습니다: 가져온 루트 집합이 새 디렉터리를 벗어나 확장됩니다({reason}). 전환이 거부되었습니다. 해당 디렉터리에서 새 세션을 시작하거나, TUI 외부에서 이 세션의 저장된 루트 집합에서 확장 루트를 제거하세요.", + "SessionRestoreWorkspaceInvalid": "저장된 세션의 작업 공간은 비어 있지 않은 절대 경로여야 합니다", + "ForkSourceSetRefused": "포크할 수 없습니다: 소스 루트 집합을 {workspace}(으)로 재배치하면 벗어나 확장됩니다({reason}). 포크가 거부되었습니다. 집합이 확장되지 않는 디렉터리에서 시작하거나, TUI 외부에서 저장된 기록에서 확장 루트를 제거하세요.", + "SaveLiveSetRefused": "저장할 수 없습니다: 현재 워크스페이스 루트 집합이 더 이상 intake를 통과하지 않습니다({reason}); 세션을 다시 불러오세요(거부된 root는 로드 시 버려짐) 또는 TUI 외부에서 기록을 복구하세요.", + "ForkLiveSetRefused": "포크할 수 없습니다: 현재 워크스페이스 루트 집합이 더 이상 intake를 통과하지 않습니다({reason}); 세션을 다시 불러오세요(거부된 root는 로드 시 버려짐) 또는 TUI 외부에서 기록을 복구하세요.", + "SanitizedLoadedRoots": "불러온 세션에서 워크스페이스 intake가 거부하는 루트(파일 시스템 루트, primary 조상, 잘못된 철자, 64루트 한도 초과)를 {count}개 제외했습니다. 세션은 이 없이 실행되며 다음 저장 시 축소된 집합이 저장됩니다. 거부된 루트를 제외하려면 세션을 다시 불러오세요.", + "WorkspaceRootsRemainder": "… (+{count}개 더)", + "WorkspaceRootsNotice": "{workspace} 옆의 접근 가능한 폴더: {roots}", "StatusLabelProjectDocs": "프로젝트 문서", "StatusLabelMode": "모드", "StatusLabelSafety": "안전", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "목", "AutomationEditorFriday": "금", "AutomationEditorSaturday": "토", - "AutomationEditorSunday": "일" + "AutomationEditorSunday": "일", + "AttachedRootsNotRevertedNote": "기본 워크스페이스만 되돌렸습니다; 첨부된 워크스페이스 루트는 롤백되지 않았습니다(스냅샷에서 제외되는 디렉터리 — 예: vendor/ — 및 중첩 저장소는 변경 사항이 유지됩니다)." } diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index 8e2ff30d0b..79e824ca56 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Métricas da sessão: {metrics}", "StatusLabelRoute": "Rota", "StatusLabelDirectory": "Diretório", + "StatusLabelWorkspaceRoots": "Pastas acessíveis", + "WorkspaceSwitchBusy": "Workspace inalterado (estado de Trabalho ou execução ativa ocupada; aguarde e tente /workspace novamente)", + "WorkspaceSwitchPersistFailed": "Falha ao persistir a mudança de workspace: {error}; o ator de persistência também está indisponível", + "WorkspaceSwitchSaveFailedActorQueued": "O salvamento direto da mudança de workspace falhou ({error}); o ator de persistência mantém o snapshot pós-mudança", + "WorkspaceSwitchPersistedActorUnavailable": "A mudança de workspace foi salva, mas o ator de persistência está indisponível; o próximo salvamento automático a regrava", + "WorkspaceSwitchSnapshotFailed": "Falha no instantâneo da mudança de workspace: {error}", + "WorkspaceSwitchSessionsDirFailed": "Falha ao abrir o diretório de sessões: {error}", + "WorkspaceSwitchSetRefused": "Não é possível trocar o workspace para {workspace}: o conjunto de raízes carregado se expandiria além do novo diretório ({reason}). A troca foi recusada; inicie uma nova sessão nesse diretório ou remova as raízes que ampliam do conjunto de raízes salvo desta sessão fora do TUI.", + "SessionRestoreWorkspaceInvalid": "O espaço de trabalho da sessão salva deve ser um caminho absoluto não vazio", + "ForkSourceSetRefused": "Não é possível bifurcar: o conjunto de raízes da origem rebaseado em {workspace} se expandiria além dele ({reason}). A bifurcação foi recusada; inicie-a em um diretório que o conjunto não amplie, ou limpe as raízes que ampliam do registro salvo fora do TUI.", + "SaveLiveSetRefused": "Não é possível salvar: o conjunto de raízes atual do workspace não passa mais na entrada ({reason}); Recarregue a sessão (as raízes recusadas são descartadas no carregamento) ou repare o registro fora do TUI.", + "ForkLiveSetRefused": "Não é possível bifurcar: o conjunto de raízes atual do workspace não passa mais na entrada ({reason}); Recarregue a sessão (as raízes recusadas são descartadas no carregamento) ou repare o registro fora do TUI.", + "SanitizedLoadedRoots": "Removidas {count} raiz(es) do workspace da sessão carregada que a entrada do workspace recusa (raiz do sistema de arquivos, ancestral da raiz primária, grafia inválida ou acima do limite de 64 raízes). A sessão funciona sem elas e o próximo salvamento persiste o conjunto reduzido; recarregue a sessão para descartar as raízes recusadas.", + "WorkspaceRootsRemainder": "… (+{count} mais)", + "WorkspaceRootsNotice": "Pastas acessíveis além de {workspace}: {roots}", "StatusLabelProjectDocs": "Docs do projeto", "StatusLabelMode": "Modo", "StatusLabelSafety": "Segurança", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Qui", "AutomationEditorFriday": "Sex", "AutomationEditorSaturday": "Sáb", - "AutomationEditorSunday": "Dom" + "AutomationEditorSunday": "Dom", + "AttachedRootsNotRevertedNote": "Apenas o workspace primário foi revertido; as raízes anexadas do workspace não foram revertidas (diretórios excluídos do snapshot — como vendor/ — e repositórios aninhados mantêm suas alterações)." } diff --git a/crates/tui/locales/ru.json b/crates/tui/locales/ru.json index 5ba1336e74..fdc75d602c 100644 --- a/crates/tui/locales/ru.json +++ b/crates/tui/locales/ru.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Метрики сессии: {metrics}", "StatusLabelRoute": "Маршрут", "StatusLabelDirectory": "Каталог", + "StatusLabelWorkspaceRoots": "Доступные папки", + "WorkspaceSwitchBusy": "Рабочее пространство не изменено (состояние Work или фоновая работа заняты; подождите и повторите /workspace)", + "WorkspaceSwitchPersistFailed": "Не удалось сохранить смену рабочего пространства: {error}; субъект персистентности также недоступен", + "WorkspaceSwitchSaveFailedActorQueued": "Прямое сохранение смены рабочего пространства не удалось ({error}); субъект персистентности хранит снимок после смены", + "WorkspaceSwitchPersistedActorUnavailable": "Смена рабочего пространства сохранена, но субъект персистентности недоступен; следующее автосохранение запишет её заново", + "WorkspaceSwitchSnapshotFailed": "Не удалось создать снимок смены рабочего пространства: {error}", + "WorkspaceSwitchSessionsDirFailed": "Не удалось открыть каталог сеансов: {error}", + "WorkspaceSwitchSetRefused": "Нельзя переключить рабочее пространство на {workspace}: переносимый набор корней расширился бы за пределы нового каталога ({reason}). Переключение отклонено; начните новый сеанс в этом каталоге или удалите расширяющие корни из сохранённого набора корней этого сеанса вне TUI.", + "SessionRestoreWorkspaceInvalid": "Рабочая область сохранённого сеанса должна быть непустым абсолютным путём", + "ForkSourceSetRefused": "Нельзя создать вилку: набор корней источника, перепривязанный к {workspace}, расширился бы за его пределы ({reason}). Вилка отклонена; начните её из каталога, за пределы которого набор не расширяется, или очистите расширяющие корни из сохранённой записи вне TUI.", + "SaveLiveSetRefused": "Нельзя сохранить: текущий набор корней рабочего пространства больше не проходит проверку приёма ({reason}). Перезагрузите сеанс (отклонённые корни отбрасываются при загрузке) или почините запись вне TUI.", + "ForkLiveSetRefused": "Нельзя создать вилку: текущий набор корней рабочего пространства больше не проходит проверку приёма ({reason}). Перезагрузите сеанс (отклонённые корни отбрасываются при загрузке) или почините запись вне TUI.", + "SanitizedLoadedRoots": "Из загруженного сеанса исключено корней: {count} — отказано приёмом рабочей области (корень файловой системы, предок primary, недопустимое написание или превышение лимита 64 корней). Сеанс работает без них, а следующее сохранение зафиксирует уменьшенный набор; перезагрузите сеанс, чтобы отбросить отклонённые корни.", + "WorkspaceRootsRemainder": "… (ещё {count})", + "WorkspaceRootsNotice": "Доступные папки кроме {workspace}: {roots}", "StatusLabelProjectDocs": "Документы проекта", "StatusLabelMode": "Режим", "StatusLabelSafety": "Безопасность", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Чт", "AutomationEditorFriday": "Пт", "AutomationEditorSaturday": "Сб", - "AutomationEditorSunday": "Вс" + "AutomationEditorSunday": "Вс", + "AttachedRootsNotRevertedNote": "Откачено только основное рабочее пространство; прикреплённые корни не откатывались (исключённые из снимка каталоги — например vendor/ — и вложенные репозитории сохраняют свои изменения)." } diff --git a/crates/tui/locales/uk.json b/crates/tui/locales/uk.json index 6624dac829..adab71eab9 100644 --- a/crates/tui/locales/uk.json +++ b/crates/tui/locales/uk.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Метрики сесії: {metrics}", "StatusLabelRoute": "Маршрут", "StatusLabelDirectory": "Каталог", + "StatusLabelWorkspaceRoots": "Доступні папки", + "WorkspaceSwitchBusy": "Робочий простір не змінено (стан роботи або виконання зайняті; зачекайте й повторіть /workspace)", + "WorkspaceSwitchPersistFailed": "Не вдалося зберегти зміну робочого простору: {error}; суб'єкт персистентності також недоступний", + "WorkspaceSwitchSaveFailedActorQueued": "Пряме збереження зміни робочого простору не вдалося ({error}); суб'єкт персистентності зберігає знімок після зміни", + "WorkspaceSwitchPersistedActorUnavailable": "Зміну робочого простору збережено, але суб'єкт персистентності недоступний; наступне автозбереження запише її знову", + "WorkspaceSwitchSnapshotFailed": "Не вдалося створити знімок зміни робочого простору: {error}", + "WorkspaceSwitchSessionsDirFailed": "Не вдалося відкрити каталог сеансів: {error}", + "WorkspaceSwitchSetRefused": "Не вдалося перемкнути робочу область на {workspace}: перенесений набір коренів розширився б за межі нового каталогу ({reason}). Перемикання відхилено; почніть нову сесію в цьому каталозі або видаліть корені, що розширюють, із збереженого набору коренів цієї сесії поза TUI.", + "SessionRestoreWorkspaceInvalid": "Робоча область збереженого сеансу має бути непорожнім абсолютним шляхом", + "ForkSourceSetRefused": "Не вдалося створити відгалуження: набір коренів джерела, переприв'язаний до {workspace}, розширився б за його межі ({reason}). Відгалуження відхилено; почніть його з каталогу, за межі якого набір не розширюється, або очистіть корені, що розширюють, із збереженого запису поза TUI.", + "SaveLiveSetRefused": "Не вдалося зберегти: поточний набір коренів робочої області більше не проходить перевірку прийому ({reason}); Перезавантажте сесію (відхилені корені відкидаються при завантаженні) або полагодьте запис поза TUI.", + "ForkLiveSetRefused": "Не вдалося створити відгалуження: поточний набір коренів робочої області більше не проходить перевірку прийому ({reason}); Перезавантажте сесію (відхилені корені відкидаються при завантаженні) або полагодьте запис поза TUI.", + "SanitizedLoadedRoots": "З завантаженої сесії вилучено коренів: {count} — відхилено прийомом робочої області (корінь файлової системи, предок primary, недійсне написання або перевищення ліміту 64 коренів). Сесія працює без них, а наступне збереження зафіксує зменшений набір; перезавантажте сесію, щоб відкинути відхилені корені.", + "WorkspaceRootsRemainder": "… (ще {count})", + "WorkspaceRootsNotice": "Доступні папки окрім {workspace}: {roots}", "StatusLabelProjectDocs": "Документи проєкту", "StatusLabelMode": "Режим", "StatusLabelSafety": "Безпека", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "Чт", "AutomationEditorFriday": "Пт", "AutomationEditorSaturday": "Сб", - "AutomationEditorSunday": "Нд" + "AutomationEditorSunday": "Нд", + "AttachedRootsNotRevertedNote": "Відновлено лише основний робочий простір; долучені корені не відкатувалися (виключені зі знімка каталоги — як-от vendor/ — та вкладені репозиторії зберігають свої зміни)." } diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index fa618e5465..5005d985dc 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "Số liệu phiên: {metrics}", "StatusLabelRoute": "Tuyến", "StatusLabelDirectory": "Thư mục", + "StatusLabelWorkspaceRoots": "Thư mục có thể truy cập", + "WorkspaceSwitchBusy": "Workspace không đổi (trạng thái Công việc hoặc tác vụ thời gian chạy đang bận; hãy chờ rồi thử lại /workspace)", + "WorkspaceSwitchPersistFailed": "Không thể lưu việc chuyển workspace: {error}; actor lưu trữ cũng không khả dụng", + "WorkspaceSwitchSaveFailedActorQueued": "Lưu trực tiếp việc chuyển workspace thất bại ({error}); actor lưu trữ đang giữ snapshot sau chuyển", + "WorkspaceSwitchPersistedActorUnavailable": "Đã lưu việc chuyển workspace, nhưng actor lưu trữ không khả dụng; lần tự lưu kế tiếp sẽ ghi lại", + "WorkspaceSwitchSnapshotFailed": "Không tạo được snapshot khi chuyển workspace: {error}", + "WorkspaceSwitchSessionsDirFailed": "Không mở được thư mục phiên: {error}", + "WorkspaceSwitchSetRefused": "Không thể chuyển workspace sang {workspace}: bộ gốc được mang theo sẽ mở rộng vượt ra thư mục mới ({reason}). Lần chuyển bị từ chối; hãy bắt đầu phiên mới trong thư mục đó, hoặc xóa các gốc mở rộng khỏi bộ gốc đã lưu của phiên này bên ngoài TUI.", + "SessionRestoreWorkspaceInvalid": "Không gian làm việc của phiên đã lưu phải là đường dẫn tuyệt đối không rỗng", + "ForkSourceSetRefused": "Không thể fork: bộ gốc của nguồn đặt lại lên {workspace} sẽ mở rộng vượt ra ngoài nó ({reason}). Lần fork bị từ chối; hãy bắt đầu từ một thư mục mà bộ không mở rộng vượt qua, hoặc xóa các gốc mở rộng khỏi bản ghi đã lưu bên ngoài TUI.", + "SaveLiveSetRefused": "Không thể lưu: bộ gốc workspace hiện tại không còn vượt qua intake ({reason}). Tải lại phiên (các root bị từ chối sẽ bị loại khi tải) hoặc sửa bản ghi bên ngoài TUI.", + "ForkLiveSetRefused": "Không thể fork: bộ gốc workspace hiện tại không còn vượt qua intake ({reason}). Tải lại phiên (các root bị từ chối sẽ bị loại khi tải) hoặc sửa bản ghi bên ngoài TUI.", + "SanitizedLoadedRoots": "Đã loại bỏ {count} root khỏi phiên được tải mà intake của workspace từ chối (gốc hệ thống tệp, tổ tiên của primary, chính tả không hợp lệ hoặc vượt giới hạn 64 root). Phiên chạy không có chúng và lần lưu kế tiếp sẽ ghi bộ đã thu gọn; hãy tải lại phiên để loại bỏ các root bị từ chối.", + "WorkspaceRootsRemainder": "… (+{count} nữa)", + "WorkspaceRootsNotice": "Thư mục có thể truy cập bên cạnh {workspace}: {roots}", "StatusLabelProjectDocs": "Tài liệu dự án", "StatusLabelMode": "Chế độ", "StatusLabelSafety": "An toàn", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "T5", "AutomationEditorFriday": "T6", "AutomationEditorSaturday": "T7", - "AutomationEditorSunday": "CN" + "AutomationEditorSunday": "CN", + "AttachedRootsNotRevertedNote": "Chỉ vùng làm việc chính được khôi phục; các root workspace đính kèm không được hoàn tác (các thư mục bị loại khỏi bản chụp — như vendor/ — và các kho lồng nhau giữ nguyên thay đổi)." } diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index 79e942ba85..92d1f6eb16 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "会话指标:{metrics}", "StatusLabelRoute": "路由", "StatusLabelDirectory": "目录", + "StatusLabelWorkspaceRoots": "可访问的文件夹", + "WorkspaceSwitchBusy": "工作区未更改(工作状态或运行时任务正忙;请等待后重试 /workspace)", + "WorkspaceSwitchPersistFailed": "工作区切换持久化失败:{error};持久化执行器也不可用", + "WorkspaceSwitchSaveFailedActorQueued": "工作区切换的直接保存失败({error});持久化执行器持有切换后的快照", + "WorkspaceSwitchPersistedActorUnavailable": "工作区切换已保存,但持久化执行器不可用;下次自动保存会重新写入", + "WorkspaceSwitchSnapshotFailed": "工作区切换快照失败:{error}", + "WorkspaceSwitchSessionsDirFailed": "无法打开会话目录:{error}", + "WorkspaceSwitchSetRefused": "无法切换工作区到 {workspace}:携带的根集合会越过新目录扩大({reason})。切换已被拒绝;请在该目录启动新会话,或在 TUI 外从此会话的已保存根集合中移除扩大的根。", + "SessionRestoreWorkspaceInvalid": "会话记录的工作区必须是非空的绝对路径", + "ForkSourceSetRefused": "无法分支:源根集合重设到 {workspace} 后会越界扩大({reason})。分支已被拒绝;请从集合不会越界的目录启动,或在 TUI 外从已保存记录中清除扩大的根。", + "SaveLiveSetRefused": "无法保存:当前工作区根集合不再通过 intake({reason})。请重新载入会话(被拒根在载入时丢弃),或在 TUI 外修复记录。", + "ForkLiveSetRefused": "无法分支:当前工作区根集合不再通过 intake({reason})。请重新载入会话(被拒根在载入时丢弃),或在 TUI 外修复记录。", + "SanitizedLoadedRoots": "已从载入的会话中剔除 {count} 个被工作区 intake 拒绝的根(文件系统根、主根祖先、无效拼写或超出 64 根上限)。会话将在不含它们的情况下运行,下次保存时持久化缩减后的集合;重新载入会话即可丢弃被拒的根。", + "WorkspaceRootsRemainder": "…(另有 {count} 个)", + "WorkspaceRootsNotice": "{workspace} 之外可访问的文件夹:{roots}", "StatusLabelProjectDocs": "项目文档", "StatusLabelMode": "模式", "StatusLabelSafety": "安全", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "周四", "AutomationEditorFriday": "周五", "AutomationEditorSaturday": "周六", - "AutomationEditorSunday": "周日" + "AutomationEditorSunday": "周日", + "AttachedRootsNotRevertedNote": "仅回退了主工作区;附加的工作区根未回滚(快照排除的目录(如 vendor/)与嵌套仓库会保留其更改)。" } diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index 67e2921f83..9e6636f5b5 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -1751,6 +1751,21 @@ "SessionMetricsStatusLine": "工作階段指標:{metrics}", "StatusLabelRoute": "路由", "StatusLabelDirectory": "目錄", + "StatusLabelWorkspaceRoots": "可存取的資料夾", + "WorkspaceSwitchBusy": "工作區未更改(工作狀態或執行時任務正忙;請等待後重試 /workspace)", + "WorkspaceSwitchPersistFailed": "工作區切換持久化失敗:{error};持久化執行器也不可用", + "WorkspaceSwitchSaveFailedActorQueued": "工作區切換的直接保存失敗({error});持久化執行器持有切換後的快照", + "WorkspaceSwitchPersistedActorUnavailable": "工作區切換已保存,但持久化執行器不可用;下次自動保存會重新寫入", + "WorkspaceSwitchSnapshotFailed": "工作區切換快照失敗:{error}", + "WorkspaceSwitchSessionsDirFailed": "無法開啟工作階段目錄:{error}", + "WorkspaceSwitchSetRefused": "無法切換工作區到 {workspace}:攜帶的根集合會越過新目錄擴大({reason})。切換已被拒絕;請在該目錄啟動新會話,或在 TUI 外從此會話的已儲存根集合中移除擴大的根。", + "SessionRestoreWorkspaceInvalid": "工作階段記錄的工作區必須是非空的絕對路徑", + "ForkSourceSetRefused": "無法分支:源根集合重設到 {workspace} 後會越界擴大({reason})。分支已被拒絕;請從集合不會越界的目錄啟動,或在 TUI 外從已儲存記錄中清除擴大的根。", + "SaveLiveSetRefused": "無法儲存:目前工作區根集合不再通過 intake({reason})。請重新載入會話(被拒根在載入時丟棄),或在 TUI 外修復記錄。", + "ForkLiveSetRefused": "無法分支:目前工作區根集合不再通過 intake({reason})。請重新載入會話(被拒根在載入時丟棄),或在 TUI 外修復記錄。", + "SanitizedLoadedRoots": "已從載入的會話中剔除 {count} 個被工作區 intake 拒絕的根(檔案系統根、主根祖先、無效拼寫或超出 64 根上限)。會話將在不含它們的情況下執行,下次儲存時持久化縮減後的集合;重新載入會話即可丟棄被拒的根。", + "WorkspaceRootsRemainder": "…(另有 {count} 個)", + "WorkspaceRootsNotice": "{workspace} 之外可存取的資料夾:{roots}", "StatusLabelProjectDocs": "專案文件", "StatusLabelMode": "模式", "StatusLabelSafety": "安全性", @@ -2082,5 +2097,6 @@ "AutomationEditorThursday": "週四", "AutomationEditorFriday": "週五", "AutomationEditorSaturday": "週六", - "AutomationEditorSunday": "週日" + "AutomationEditorSunday": "週日", + "AttachedRootsNotRevertedNote": "僅回退了主工作區;附加的工作區根未回滾(快照排除的目錄(如 vendor/)與嵌套倉庫會保留其更改)。" } diff --git a/crates/tui/src/acp_server.rs b/crates/tui/src/acp_server.rs index 008d9d9b48..1457077416 100644 --- a/crates/tui/src/acp_server.rs +++ b/crates/tui/src/acp_server.rs @@ -700,6 +700,7 @@ fn prepare_acp_tool_admission( &call.name, &prepared.input, workspace, + ®istry.context().workspace_roots, approval_mode, ) .or_else(|| { @@ -708,6 +709,7 @@ fn prepare_acp_tool_admission( &call.name, &prepared.input, workspace, + ®istry.context().workspace_roots, approval_mode, ) }); @@ -732,6 +734,7 @@ fn prepare_acp_tool_admission( approval_mode, crate::config::is_workspace_trusted(workspace), Some(workspace), + ®istry.context().workspace_roots, ); let (auto_review, _audit) = auto_review_plan_decision_for_context(&config.auto_review_policy(), &review_context); @@ -746,9 +749,12 @@ fn prepare_acp_tool_admission( } } - if let Some(repo_law) = - crate::repo_law::repo_law_plan_decision(workspace, &call.name, &prepared.input) - { + if let Some(repo_law) = crate::repo_law::repo_law_plan_decision( + workspace, + ®istry.context().workspace_roots, + &call.name, + &prepared.input, + ) { match repo_law { crate::repo_law::RepoLawPlanDecision::ForcePrompt(reason) => { permission_reason = Some(reason); @@ -1395,6 +1401,11 @@ struct AcpServer { struct AcpSession { cwd: PathBuf, + /// Accessible roots carried beside the session's primary `cwd`. ACP shares + /// the durable session store, so a session created by the TUI or the + /// Runtime API can arrive multi-root; the registry built over it must see + /// the same set or writes the session legitimately held fail here. + workspace_roots: Vec, messages: Vec, config: Config, model: String, @@ -1491,15 +1502,31 @@ impl AcpServer { } fn new_session(&mut self, params: Value) -> std::result::Result { - let cwd = params - .get("cwd") - .and_then(Value::as_str) - .map(PathBuf::from) - .unwrap_or_else(|| self.default_cwd.clone()); + // An explicit empty or RELATIVE cwd would build the tool registry + // over the vacuous containment root (`starts_with("")` accepts + // every path) or over a root no boundary consumer can resolve (a + // relative workspace cannot head a root set — the same intake rule + // as every other lane); reject both rather than falling back to the + // default, so a client typo cannot silently re-anchor or cripple + // the session (round-24 P3: the empty-string guard's own rationale + // always covered the relative spelling). + let cwd = match params.get("cwd").and_then(Value::as_str) { + Some("") => { + return Err(AcpError::invalid_params( + "session/new cwd must not be empty", + )); + } + Some(raw) if !std::path::Path::new(raw).is_absolute() => { + return Err(AcpError::invalid_params("session/new cwd must be absolute")); + } + Some(raw) => PathBuf::from(raw), + None => self.default_cwd.clone(), + }; let session_id = format!("codewhale-{}", uuid::Uuid::new_v4()); let tool_registry = Arc::new(build_acp_tool_registry( &self.config, &cwd, + &[], self.client_supports_terminal, )); @@ -1518,6 +1545,8 @@ impl AcpServer { session_id.clone(), AcpSession { cwd, + // A fresh ACP session has no durable record to inherit from. + workspace_roots: Vec::new(), messages: Vec::new(), config: self.config.clone(), model: self.model.clone(), @@ -1574,9 +1603,40 @@ impl AcpServer { })?; let cwd = saved.metadata.workspace.clone(); + if cwd.as_os_str().is_empty() || !cwd.is_absolute() { + // A legacy or hand-edited record with an empty or RELATIVE + // workspace would arm the vacuous containment root for every + // tool call in the rehydrated session (relative: every file + // tool answers PathEscape forever); refuse to load it, the + // same rule `session/new` applies (round-40 minor 6; the REST + // resume-thread lane 400s the same row since round-41 M41-1). + return Err(AcpError::invalid_params(format!( + "session {session_id} has an empty or relative workspace" + ))); + } + // A28-2: the persisted set is armed VERBATIM into the tool registry + // and the per-turn sandbox — a hand-edited or poisoned record + // carrying `/` (or any primary ancestor) gave the rehydrated + // session a fully-writable posture silently, where the REST resume + // lane answers 400 for the same row and the TUI lane tolerates with + // a disclosure. Validate here: a poisoned row is refused with the + // intake reason (the IDE can repair the record); a clean row passes + // with its stored head stripped — the head is the row's primary + // slot, not a declared addition, so a legal full-load row revalidates + // whole rather than tripping the intake cap (round-37 B37-2). + let workspace_roots = saved.metadata.workspace_roots.clone(); + let workspace_roots = + codewhale_core::validate_stored_workspace_roots(&cwd, &workspace_roots).map_err( + |reason| { + AcpError::invalid_params(format!( + "session {session_id} root set no longer passes intake: {reason}" + )) + }, + )?; let tool_registry = Arc::new(build_acp_tool_registry( &self.config, &cwd, + &workspace_roots, self.client_supports_terminal, )); let resolved_id = saved.metadata.id.clone(); @@ -1590,6 +1650,7 @@ impl AcpServer { resolved_id.clone(), AcpSession { cwd, + workspace_roots, messages: saved.messages, config: self.config.clone(), model: self.model.clone(), @@ -1691,6 +1752,7 @@ impl AcpServer { session.tool_registry = Arc::new(build_acp_tool_registry( &session.config, &session.cwd, + &session.workspace_roots, self.client_supports_terminal, )); } @@ -2069,6 +2131,7 @@ fn acp_mode(config: &Config) -> crate::tui::app::AppMode { fn build_acp_tool_registry( config: &Config, workspace: &std::path::Path, + workspace_roots: &[std::path::PathBuf], client_supports_terminal: bool, ) -> ToolRegistry { let features = config.features(); @@ -2097,14 +2160,19 @@ fn build_acp_tool_registry( } else { ShellPolicy::None }; + // The turn environment is materialized over the session's whole root set, + // mirroring the engine lane: a resumed multi-root session keeps its + // attached roots instead of silently running single-root here. let sandbox_policy = crate::core::authority::sandbox_policy_for_turn( acp_mode(config), crate::tui::approval::ApprovalMode::Suggest, config.sandbox_mode.as_deref(), workspace, + workspace_roots, crate::core::authority::SandboxNetworkAccess::from_config(config.sandbox_network_access), ); let mut context = ToolContext::new(workspace) + .with_workspace_roots(workspace_roots.to_vec()) .with_shell_policy(shell_policy) .with_elevated_sandbox_policy(sandbox_policy); if acp_mode(config) == crate::tui::app::AppMode::Plan { @@ -2594,8 +2662,22 @@ mod tests { /// nothing else, so ACP clients that offer session history could not /// enumerate or resume anything. ACP sessions are in-memory and capped; /// the durable Codewhale sessions are what "resume" means. - #[tokio::test] - async fn session_list_and_load_reach_the_durable_codewhale_sessions() { + // session/load's first tr() initializes the i18n backend; that serde + // load is deeper than the default 2 MiB libtest thread stack. The + // product path runs on the 8 MiB main thread, so run the body on an + // equivalently sized stack (same pattern as + // setup_confirm_toast_names_secret_store_and_global_scope). + #[test] + fn session_list_and_load_reach_the_durable_codewhale_sessions() { + std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(session_list_and_load_reach_the_durable_codewhale_sessions_body) + .expect("spawn test thread") + .join() + .expect("test thread"); + } + + fn session_list_and_load_reach_the_durable_codewhale_sessions_body() { let _guard = crate::test_support::lock_test_env(); let home = tempfile::TempDir::new().expect("isolated codewhale home"); let _home_guard = @@ -2668,6 +2750,313 @@ mod tests { assert_eq!(no_id.expect_err("missing sessionId").code, -32602); } + // Same big-stack wrapper as + // session_list_and_load_reach_the_durable_codewhale_sessions: this load + // also reaches session_configuration's first tr(). + #[test] + fn session_load_carries_the_persisted_workspace_roots() { + std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(session_load_carries_the_persisted_workspace_roots_body) + .expect("spawn test thread") + .join() + .expect("test thread"); + } + + fn session_load_carries_the_persisted_workspace_roots_body() { + let _guard = crate::test_support::lock_test_env(); + let home = tempfile::TempDir::new().expect("isolated codewhale home"); + let _home_guard = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path().as_os_str()); + + let workspace = home.path().join("workspace"); + let attached = home.path().join("shared"); + std::fs::create_dir_all(&workspace).expect("workspace"); + std::fs::create_dir_all(&attached).expect("attached root"); + let mut saved = crate::session_manager::create_saved_session( + &[Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "multi-root session".to_string(), + cache_control: None, + }], + }], + "deepseek-v4-flash", + &workspace, + 0, + None, + ); + saved.metadata.workspace_roots = vec![workspace.clone(), attached.clone()]; + let saved_id = saved.metadata.id.clone(); + let manager = crate::session_manager::SessionManager::new( + crate::session_manager::default_sessions_dir().expect("sessions dir"), + ) + .expect("session manager"); + manager.save_session(&saved).expect("save fixture session"); + + let mut server = AcpServer::new( + Config::default(), + "deepseek-v4-flash".to_string(), + workspace.clone(), + ); + server + .load_session(json!({ "sessionId": saved_id })) + .expect("session/load"); + + let session = server.sessions.get(&saved_id).expect("loaded session"); + assert_eq!( + session.workspace_roots, + vec![workspace.clone(), attached.clone()], + "session/load must keep the persisted set on the ACP session" + ); + // The registry the ask/deny checks, repo law, and the auto-review gate + // all read is built over that same set; an empty context here is the + // silent single-root degradation this lane must not have. + assert_eq!( + session.tool_registry.context().workspace_roots, + vec![workspace.clone(), attached.clone()], + "the ACP tool registry must be built over the session's roots" + ); + // The materialized turn environment spans the attached root. + let policy = session + .tool_registry + .context() + .elevated_sandbox_policy + .as_ref() + .expect("sandbox policy materialized at registry build"); + let writable: Vec = policy + .get_writable_roots(&workspace) + .into_iter() + .map(|root| root.root) + .collect(); + let attached_canonical = attached.canonicalize().expect("canonical attached"); + assert!( + writable.contains(&attached_canonical), + "the attached root must be writable in the ACP turn environment: {writable:?}" + ); + } + + #[test] + fn session_new_rejects_empty_cwd() { + // Regression pin: `session/new` with `cwd: ""` used to build the + // tool registry over the vacuous containment root + // (`starts_with("")` accepts every path). + let workspace = tempfile::tempdir().unwrap(); + let mut server = AcpServer::new( + Config::default(), + "deepseek-v4-flash".into(), + workspace.path().into(), + ); + let err = server + .new_session(json!({"cwd": ""})) + .expect_err("empty cwd must be rejected"); + assert_eq!(err.code, -32602); + } + + fn session_load_rejects_a_poisoned_persisted_root_set_body() { + // A28-2: session/load armed the persisted set verbatim — a record + // carrying `/` gave the rehydrated IDE session a fully-writable + // posture where REST resume answers 400 for the same row. The load + // now runs the validating intake and refuses with the reason. + let _guard = crate::test_support::lock_test_env(); + let home = tempfile::TempDir::new().expect("isolated codewhale home"); + let _home_guard = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path().as_os_str()); + let workspace = home.path().join("ws"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + let mut saved = crate::session_manager::create_saved_session( + &[Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "poisoned".to_string(), + cache_control: None, + }], + }], + "deepseek-v4-flash", + &workspace, + 0, + None, + ); + saved.metadata.workspace_roots = vec![workspace.clone(), std::path::PathBuf::from("/")]; + let session_id = saved.metadata.id.clone(); + let manager = crate::session_manager::SessionManager::new( + crate::session_manager::default_sessions_dir().expect("sessions dir"), + ) + .expect("session manager"); + manager.save_session(&saved).expect("seed poisoned record"); + + let mut server = AcpServer::new( + Config::default(), + "deepseek-v4-flash".to_string(), + workspace.clone(), + ); + let err = server + .load_session(json!({"sessionId": session_id})) + .expect_err("a poisoned root set must be refused at load"); + assert_eq!(err.code, -32602); + assert!( + err.message.contains("no longer passes intake"), + "the refusal carries the intake reason: {}", + err.message + ); + } + + #[test] + fn session_load_rejects_a_poisoned_persisted_root_set() { + std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(session_load_rejects_a_poisoned_persisted_root_set_body) + .expect("spawn test thread") + .join() + .expect("test thread"); + } + + fn session_load_revalidates_a_legal_full_load_root_row_body() { + // Round-37 B37-2 pin: a session saved with a 64-root declaration + // persists the 65-entry cwd-first full form — this build's own save + // shape. Counting the row's head against the intake cap would refuse + // the row its own save path minted; the stored-row strip must load + // it whole. + let _guard = crate::test_support::lock_test_env(); + let home = tempfile::TempDir::new().expect("isolated codewhale home"); + let _home_guard = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path().as_os_str()); + + let workspace = home.path().join("ws"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + let mut saved = crate::session_manager::create_saved_session( + &[Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "full-load session".to_string(), + cache_control: None, + }], + }], + "deepseek-v4-flash", + &workspace, + 0, + None, + ); + let mut stored = vec![workspace.clone()]; + stored.extend((0..codewhale_core::MAX_WORKSPACE_ROOTS).map(|index| { + let root = home.path().join(format!("r{index}")); + std::fs::create_dir_all(&root).expect("full-load root dir"); + root + })); + saved.metadata.workspace_roots = stored; + let saved_id = saved.metadata.id.clone(); + let manager = crate::session_manager::SessionManager::new( + crate::session_manager::default_sessions_dir().expect("sessions dir"), + ) + .expect("session manager"); + manager.save_session(&saved).expect("save fixture session"); + + let mut server = AcpServer::new( + Config::default(), + "deepseek-v4-flash".to_string(), + home.path().to_path_buf(), + ); + server + .load_session(json!({ "sessionId": saved_id })) + .expect("a legal full-load row loads whole"); + let session = server.sessions.get(&saved_id).expect("loaded session"); + assert_eq!( + session.workspace_roots.len(), + codewhale_core::MAX_WORKSPACE_ROOTS + 1, + "session/load arms the full-load set, head included" + ); + } + + #[test] + fn session_load_revalidates_a_legal_full_load_root_row() { + std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(session_load_revalidates_a_legal_full_load_root_row_body) + .expect("spawn test thread") + .join() + .expect("test thread"); + } + + #[test] + fn session_new_rejects_relative_cwd() { + // Round-26 m26-6 pin for the round-25 guard: a relative cwd cannot + // head a root set (the empty-string guard's own rationale always + // covered this spelling — it built a registry no boundary consumer + // could resolve, failing per-call instead of at intake). + let workspace = tempfile::tempdir().unwrap(); + let mut server = AcpServer::new( + Config::default(), + "deepseek-v4-flash".into(), + workspace.path().into(), + ); + let err = server + .new_session(json!({"cwd": "some/relative/dir"})) + .expect_err("a relative cwd must be rejected"); + assert_eq!(err.code, -32602); + assert!( + err.message.contains("absolute"), + "the rejection must say why: {}", + err.message + ); + } + + // Same big-stack wrapper as + // session_list_and_load_reach_the_durable_codewhale_sessions. + #[test] + fn session_load_rejects_empty_workspace() { + std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(session_load_rejects_empty_workspace_body) + .expect("spawn test thread") + .join() + .expect("test thread"); + } + + fn session_load_rejects_empty_workspace_body() { + let _guard = crate::test_support::lock_test_env(); + let home = tempfile::TempDir::new().expect("isolated codewhale home"); + let _home_guard = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path().as_os_str()); + + // A legacy or hand-edited record with an empty workspace must not be + // rehydrated: its tool registry would arm the vacuous containment + // root for every tool call. + let saved = crate::session_manager::create_saved_session( + &[Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "corrupt record".to_string(), + cache_control: None, + }], + }], + "deepseek-v4-flash", + &PathBuf::new(), + 0, + None, + ); + let saved_id = saved.metadata.id.clone(); + let manager = crate::session_manager::SessionManager::new( + crate::session_manager::default_sessions_dir().expect("sessions dir"), + ) + .expect("session manager"); + manager.save_session(&saved).expect("save fixture session"); + + let workspace = home.path().join("workspace"); + let mut server = AcpServer::new( + Config::default(), + "deepseek-v4-flash".to_string(), + workspace.clone(), + ); + let err = server + .load_session(json!({ "sessionId": saved_id })) + .expect_err("empty saved workspace must be rejected"); + assert_eq!(err.code, -32602); + assert!( + !server.sessions.contains_key(&saved_id), + "a rejected load must not register the session" + ); + } + #[tokio::test] async fn standard_session_configuration_is_offered_and_scoped_to_one_session() { let workspace = tempfile::tempdir().unwrap(); @@ -3647,7 +4036,7 @@ mod tests { allow_shell: Some(true), ..Config::default() }; - let registry = build_acp_tool_registry(&config, &workspace, false); + let registry = build_acp_tool_registry(&config, &workspace, &[], false); assert!(!registry.contains("Bash")); assert!(registry.contains("File")); } @@ -3655,7 +4044,7 @@ mod tests { #[test] fn shell_tool_omitted_without_headless_config_opt_in() { let workspace = std::env::temp_dir(); - let registry = build_acp_tool_registry(&Config::default(), &workspace, true); + let registry = build_acp_tool_registry(&Config::default(), &workspace, &[], true); assert!(!registry.contains("Bash")); assert_eq!(registry.context().shell_policy, ShellPolicy::None); assert!(!registry.context().auto_approve); @@ -3670,7 +4059,7 @@ mod tests { sandbox_url: Some("http://127.0.0.1:8080".to_string()), ..Config::default() }; - let registry = build_acp_tool_registry(&configured, &workspace, true); + let registry = build_acp_tool_registry(&configured, &workspace, &[], true); assert!(registry.contains("bash")); assert!(registry.context().sandbox_backend.is_some()); @@ -3679,7 +4068,7 @@ mod tests { sandbox_backend: Some("unsupported-backend".to_string()), ..Config::default() }; - let registry = build_acp_tool_registry(&unsupported, &workspace, true); + let registry = build_acp_tool_registry(&unsupported, &workspace, &[], true); assert!(!registry.contains("bash")); assert!(!registry.contains("Bash")); assert!(registry.context().sandbox_backend.is_none()); @@ -3697,7 +4086,7 @@ mod tests { }), ..Config::default() }; - let registry = build_acp_tool_registry(&config, &std::env::temp_dir(), true); + let registry = build_acp_tool_registry(&config, &std::env::temp_dir(), &[], true); assert!(!registry.contains("bash")); assert!(!registry.contains("Bash")); assert!( @@ -3796,7 +4185,7 @@ mod tests { allow_shell: Some(true), ..Config::default() }; - let registry = build_acp_tool_registry(&config, dir.path(), true); + let registry = build_acp_tool_registry(&config, dir.path(), &[], true); (dir, registry) } @@ -3849,7 +4238,7 @@ mod tests { json!({"decision": "deny", "reason": "release gate"}), true, ); - let registry = build_acp_tool_registry(&config, dir.path(), false); + let registry = build_acp_tool_registry(&config, dir.path(), &[], false); let error = prepare_acp_tool_with_hooks( &config, "test-model", @@ -3880,7 +4269,7 @@ mod tests { }), true, ); - let registry = build_acp_tool_registry(&config, dir.path(), false); + let registry = build_acp_tool_registry(&config, dir.path(), &[], false); let raw = pending_call("File", json!({"action": "read", "path": "safe.txt"})); let (_, raw_admission) = prepare_acp_tool_admission(&config, ®istry, &raw).unwrap(); assert_eq!(raw_admission, AcpToolAdmission::Auto); @@ -4053,6 +4442,45 @@ mod tests { } } + #[test] + fn acp_admission_repo_law_judges_attached_roots() { + // Round-15 pin at the ACP admission layer: + // `prepare_acp_tool_admission` must forward the registry's root set + // to `repo_law_plan_decision`. A `&[]` mutation at that call site + // unloads the attached root's constitution, so the hold below + // vanishes with every repo-law unit test (one layer down) still + // green. + let dir = tempfile::tempdir().expect("tempdir"); + let attached = tempfile::tempdir().expect("attached root"); + let law_dir = attached.path().join(".codewhale"); + std::fs::create_dir_all(&law_dir).unwrap(); + std::fs::write( + law_dir.join("constitution.json"), + r#"{ + "protected_invariants": [ + { "text": "Never rewrite the shared wire", "paths": ["wire.rs"], "action": "block" } + ] + }"#, + ) + .unwrap(); + let config = Config { + allow_shell: Some(true), + ..Config::default() + }; + let registry = + build_acp_tool_registry(&config, dir.path(), &[attached.path().to_path_buf()], true); + let target = attached.path().join("wire.rs"); + let call = pending_call( + "File", + json!({"action": "write", "path": target.to_string_lossy(), "content": "new"}), + ); + let (_, admission) = prepare_acp_tool_admission(&config, ®istry, &call).unwrap(); + assert!(matches!( + admission, + AcpToolAdmission::Block(reason) if reason.contains("Never rewrite the shared wire") + )); + } + #[tokio::test] async fn acp_read_runs_without_permission_but_reports_pending_before_in_progress() { let (dir, registry) = workspace_registry(); diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index eca6f693f8..daea90304b 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -1608,6 +1608,19 @@ impl CommandSkillGroupContext for SkillGroupAdapter<'_> { .map_err(|err| format!("Restore failed: {err}")) } + fn restore_covers_primary_only(&self) -> bool { + let app = self.host.app.borrow(); + crate::snapshot::restore_covers_primary_only(&app.workspace, &app.workspace_roots) + } + + fn attached_roots_not_reverted_note(&self) -> String { + // Round-45 R45-3: `/restore` is a human-facing TUI surface, so the + // boundary disclosure rides the localized form (the model/API lanes + // keep the stable English constant). + let app = self.host.app.borrow(); + crate::snapshot::attached_roots_not_reverted_note(app.ui_locale) + } + fn approval_state(&self) -> CommandApprovalState { let app = self.host.app.borrow(); CommandApprovalState { diff --git a/crates/tui/src/commands/groups/config/status.rs b/crates/tui/src/commands/groups/config/status.rs index 7f1fa0e48a..5b120ffcdd 100644 --- a/crates/tui/src/commands/groups/config/status.rs +++ b/crates/tui/src/commands/groups/config/status.rs @@ -45,6 +45,20 @@ fn format_status(app: &App) -> String { MessageId::StatusLabelDirectory, &display_path(&app.workspace), ); + // Human-facing disclosure of the session's accessible root set: the + // `Accessible folders:` turn-meta line is model-facing only, and a + // session whose roots were attached once (or inherited through resume or + // a bare fork) re-entered the TUI with zero indication that writes under + // any attached root are governed by this session's policy. Absent for a + // single-root session, matching the model-facing line's convention. + if let Some(roots) = workspace_roots_summary(app) { + push_row( + &mut out, + locale, + MessageId::StatusLabelWorkspaceRoots, + &roots, + ); + } push_row( &mut out, locale, @@ -273,12 +287,44 @@ fn push_row(out: &mut String, locale: Locale, label: MessageId, value: &str) { let _ = writeln!(out, " {label: Option { + const MAX_LISTED_ROOTS: usize = 5; + // Round-39 minor 7: the shared lexical strip — a `..`-spelled copy of + // the primary is the head, not an attached root, on the display too. + let additional: Vec = + codewhale_core::strip_workspace_roots_head(&app.workspace, &app.workspace_roots) + .iter() + .map(|root| display_path(root)) + .collect(); + if additional.is_empty() { + return None; + } + let remainder = additional.len().saturating_sub(MAX_LISTED_ROOTS); + let mut listed = additional + .into_iter() + .take(MAX_LISTED_ROOTS) + .collect::>() + .join(", "); + if remainder > 0 { + listed.push_str( + &tr(app.ui_locale, MessageId::WorkspaceRootsRemainder) + .replace("{count}", &remainder.to_string()), + ); + } + Some(listed) +} + fn safety_summary(app: &App) -> Cow<'static, str> { let policy = crate::core::authority::sandbox_policy_for_turn( app.mode, app.approval_mode, app.configured_sandbox_mode.as_deref(), &app.workspace, + &app.workspace_roots, crate::core::authority::SandboxNetworkAccess::from_config(app.configured_sandbox_network), ); // The policy is the intent; `sandbox_backend` is what this platform can @@ -920,4 +966,91 @@ mod tests { let tmpdir = TempDir::new().expect("temp dir"); assert_eq!(project_docs(tmpdir.path(), Locale::En), "no project docs"); } + + #[test] + fn status_report_names_the_accessible_root_set() { + let tmpdir = TempDir::new().expect("temp dir"); + let mut app = create_test_app(tmpdir.path().to_path_buf()); + + // A single-root session keeps the report unchanged: the Directory row + // already names the only accessible root. + let single = format_status(&app); + assert!(!single.contains("Accessible folders:")); + + let attached = TempDir::new().expect("attached dir"); + app.workspace_roots = vec![ + app.workspace.clone(), + attached.path().to_path_buf(), + PathBuf::from("/shared/telemetry"), + ]; + let multi = format_status(&app); + assert!( + multi.contains("Accessible folders:"), + "the live root set must be visible to the human: {multi}" + ); + assert!( + multi.contains(&display_path(attached.path())), + "attached roots are enumerated: {multi}" + ); + assert!(multi.contains("/shared/telemetry"), "{multi}"); + let roots_line = multi + .lines() + .find(|line| line.contains("Accessible folders:")) + .expect("roots row"); + assert!( + !roots_line.contains(&display_path(&app.workspace)), + "the primary is the Directory row's fact, not the enumeration's: {roots_line}" + ); + } + + #[test] + fn status_report_caps_the_root_enumeration() { + let tmpdir = TempDir::new().expect("temp dir"); + let mut app = create_test_app(tmpdir.path().to_path_buf()); + app.workspace_roots = (0..7) + .map(|index| PathBuf::from(format!("/shared/root-{index}"))) + .collect(); + let multi = format_status(&app); + assert!(multi.contains("/shared/root-4"), "{multi}"); + assert!(!multi.contains("/shared/root-5"), "{multi}"); + assert!(multi.contains("(+2 more)"), "{multi}"); + } + + #[test] + fn workspace_roots_notice_only_fires_beside_the_primary() { + use crate::tui::ui::workspace_roots_notice; + + assert_eq!( + workspace_roots_notice(Locale::En, Path::new("/w"), &[]), + None + ); + assert_eq!( + workspace_roots_notice(Locale::En, Path::new("/w"), &[PathBuf::from("/w")]), + None, + "the primary alone is not a disclosure" + ); + let notice = workspace_roots_notice( + Locale::En, + Path::new("/w"), + &[PathBuf::from("/w"), PathBuf::from("/r2")], + ) + .expect("notice"); + assert!(notice.contains("Accessible folders beside /w"), "{notice}"); + assert!(notice.contains("/r2"), "{notice}"); + + // The disclosure is user-visible prose: a non-English locale must + // render its own pack, not the English template. + let japanese = workspace_roots_notice( + Locale::Ja, + Path::new("/w"), + &[PathBuf::from("/w"), PathBuf::from("/r2")], + ) + .expect("notice"); + assert!(japanese.contains("アクセス可能なフォルダ"), "{japanese}"); + assert!( + !japanese.contains("Accessible folders beside"), + "{japanese}" + ); + assert!(japanese.contains("/r2"), "{japanese}"); + } } diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index cb77e7cb93..ba7976e8ce 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -180,6 +180,7 @@ pub fn clear(app: &mut App) -> CommandResult { system_prompt: None, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: Vec::new(), mode: app.mode, }, ) diff --git a/crates/tui/src/commands/groups/debug/tests.rs b/crates/tui/src/commands/groups/debug/tests.rs index 0023ea9fdb..d880c60589 100644 --- a/crates/tui/src/commands/groups/debug/tests.rs +++ b/crates/tui/src/commands/groups/debug/tests.rs @@ -1307,6 +1307,123 @@ fn test_patch_undo_requests_session_resync_after_restore() { )); } +#[test] +fn test_patch_undo_with_attached_roots_names_rollback_boundary() { + // Round-45 R45-1/P2-8: the snapshot/restore machinery in this pin is + // deep enough to overflow the default 2 MiB test-thread stack (CI sets + // RUST_MIN_STACK=8 MiB, which had silently become load-bearing). Run the + // body on an explicitly sized stack so the pin holds at any default. + std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(test_patch_undo_with_attached_roots_names_rollback_boundary_body) + .expect("spawn rollback-boundary test thread") + .join() + .expect("rollback-boundary test thread panicked"); +} + +fn test_patch_undo_with_attached_roots_names_rollback_boundary_body() { + // Snapshots are primary-bound (M15-3): with attached roots the /undo + // report and transcript cell must name the boundary instead of implying + // every accessible root was reverted. Single-root wording is untouched. + use crate::snapshot::SnapshotRepo; + use crate::test_support::lock_test_env; + use tempfile::tempdir; + + struct HomeGuard { + prev: Option, + _lock: crate::test_support::TestEnvLock, + } + + impl Drop for HomeGuard { + fn drop(&mut self) { + // SAFETY: process-wide lock still held. + unsafe { + match self.prev.take() { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } + } + + fn scoped_home(home: &std::path::Path) -> HomeGuard { + let lock = lock_test_env(); + let prev = std::env::var_os("HOME"); + // SAFETY: serialized by the global env lock. + unsafe { + std::env::set_var("HOME", home); + } + HomeGuard { prev, _lock: lock } + } + + let tmp = tempdir().unwrap(); + let workspace = tmp.path().join("ws"); + let attached = tmp.path().join("attached"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&attached).unwrap(); + let _guard = scoped_home(tmp.path()); + + let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); + std::fs::write(workspace.join("a.txt"), b"original").unwrap(); + repo.snapshot_with_session("pre-turn:1", Some("test-session")) + .unwrap(); + std::fs::write(workspace.join("a.txt"), b"modified").unwrap(); + + let mut app = create_test_app(); + app.workspace = workspace.clone(); + app.workspace_roots = vec![attached]; + app.yolo = true; + app.current_session_id = Some("test-session".to_string()); + + let result = patch_undo(&mut app); + + assert!(!result.is_error); + let message = result.message.expect("undo summary"); + // Round-45 R45-1: /undo renders the LOCALIZED boundary note, so the pin + // asserts that form (locale-following, not the English const the way the + // model/API lanes spell it). + let boundary_note = crate::snapshot::attached_roots_not_reverted_note(app.ui_locale); + assert!(message.contains(&boundary_note), "{message}"); + let cell = app + .history + .iter() + .find_map(|cell| match cell { + HistoryCell::System { content } => Some(content.as_str()), + _ => None, + }) + .expect("transcript cell"); + assert!(cell.contains(&boundary_note), "{cell}"); + assert!(!cell.contains("/undo reverted workspace to"), "{cell}"); + + // Single-root report stays byte-identical: no boundary clause. + std::fs::write(workspace.join("a.txt"), b"again").unwrap(); + let mut single = create_test_app(); + single.workspace = workspace.clone(); + single.yolo = true; + single.current_session_id = Some("test-session".to_string()); + let single_result = patch_undo(&mut single); + assert!(!single_result.is_error); + let single_message = single_result.message.expect("undo summary"); + assert!( + !single_message.contains(&crate::snapshot::attached_roots_not_reverted_note( + single.ui_locale + )), + "{single_message}" + ); + let single_cell = single + .history + .iter() + .find_map(|cell| match cell { + HistoryCell::System { content } => Some(content.as_str()), + _ => None, + }) + .expect("transcript cell"); + assert!( + single_cell.contains("/undo reverted workspace to snapshot"), + "{single_cell}" + ); +} + #[test] fn test_undo_legacy_chain_falls_back_to_conversation_only() { use crate::snapshot::SnapshotRepo; diff --git a/crates/tui/src/commands/groups/debug/undo.rs b/crates/tui/src/commands/groups/debug/undo.rs index d77c957654..bd9cfb505b 100644 --- a/crates/tui/src/commands/groups/debug/undo.rs +++ b/crates/tui/src/commands/groups/debug/undo.rs @@ -234,16 +234,29 @@ pub fn patch_undo(app: &mut App) -> CommandResult { .unwrap_or(None); let short = &target.id.as_str()[..target.id.as_str().len().min(8)]; + // The snapshot side-repo is rooted at the primary workspace: with + // attached roots in the session set, name the rollback boundary instead + // of implying every accessible root was reverted. + let primary_only = + crate::snapshot::restore_covers_primary_only(&app.workspace, &app.workspace_roots); + let boundary_note = if primary_only { + format!( + "\n{}", + crate::snapshot::attached_roots_not_reverted_note(app.ui_locale) + ) + } else { + String::new() + }; let summary = match diff_stat { Some(ref stat) => { format!( - "Restored snapshot '{}' ({}). Files affected:\n{stat}", + "Restored snapshot '{}' ({}). Files affected:\n{stat}{boundary_note}", target.label, short ) } None => { format!( - "Restored snapshot '{}' ({}). No diff changes detected.", + "Restored snapshot '{}' ({}). No diff changes detected.{boundary_note}", target.label, short ) } @@ -251,10 +264,19 @@ pub fn patch_undo(app: &mut App) -> CommandResult { // Post a system cell so the reverted state is visible in the transcript. app.push_history_cell(HistoryCell::System { - content: format!( - "/undo reverted workspace to snapshot '{}' ({})", - target.label, short - ), + content: if primary_only { + format!( + "/undo reverted the primary workspace to snapshot '{}' ({}). {}", + target.label, + short, + crate::snapshot::attached_roots_not_reverted_note(app.ui_locale) + ) + } else { + format!( + "/undo reverted workspace to snapshot '{}' ({})", + target.label, short + ) + }, }); CommandResult::with_message_and_action( @@ -265,6 +287,7 @@ pub fn patch_undo(app: &mut App) -> CommandResult { system_prompt: app.system_prompt.clone(), model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }, ) diff --git a/crates/tui/src/commands/groups/session/branch.rs b/crates/tui/src/commands/groups/session/branch.rs index d791b87f0b..be20819dd5 100644 --- a/crates/tui/src/commands/groups/session/branch.rs +++ b/crates/tui/src/commands/groups/session/branch.rs @@ -65,6 +65,11 @@ fn branch(app: &mut App, arg: Option<&str>) -> CommandResult { .unwrap_or(0); match session.journal_branch_to(entry_id) { Ok(()) => { + // Deliberately NOT stamping the live workspace/root set here + // (round-24 P3, enumerated against the "interactive lanes stamp" + // claim): this is a pure disk round-trip — load, move the + // journal leaf, save — and the leaf move cannot change the + // sandbox. The next autosave stamps the live set if it diverged. if let Err(e) = manager.save_session(&session) { return CommandResult::error(format!("branch saved but persist failed: {e}")); } diff --git a/crates/tui/src/commands/groups/session/rename.rs b/crates/tui/src/commands/groups/session/rename.rs index c013110c58..18446960a4 100644 --- a/crates/tui/src/commands/groups/session/rename.rs +++ b/crates/tui/src/commands/groups/session/rename.rs @@ -115,6 +115,14 @@ pub(crate) fn rename_with_manager( .metadata .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence()); session.metadata.workspace.clone_from(&app.workspace); + // The paired set travels with the workspace it belongs to. Syncing only + // the primary would let a `/rename` after a `/cd` whose direct save + // failed rewrite `workspace: new` next to the stale pre-switch set — + // resurrecting the abandoned directory on the next resume. + session + .metadata + .workspace_roots + .clone_from(&app.workspace_roots); session.metadata.mode = Some(app.mode.as_setting().to_string()); app.sync_cost_to_metadata(&mut session.metadata); session.metadata.title = new_title.to_string(); @@ -154,17 +162,23 @@ pub(crate) fn live_session_before_first_snapshot( if let Ok(Some(checkpoint)) = manager.load_session_checkpoint(session_id) { return Some(checkpoint); } - Some( - crate::session_manager::create_saved_session_with_id_and_mode( - session_id.to_string(), - &app.api_messages, - &app.model_selection_for_persistence(), - &app.workspace, - u64::from(app.session.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.as_setting()), - ), - ) + let mut rebuilt = crate::session_manager::create_saved_session_with_id_and_mode( + session_id.to_string(), + &app.api_messages, + &app.model_selection_for_persistence(), + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.as_setting()), + ); + // The rebuilt document pairs `workspace` with the live root set, exactly + // like `build_session_snapshot`; a roots-blind rebuild would persist a + // workspace whose primary root is still the previous directory. + rebuilt + .metadata + .workspace_roots + .clone_from(&app.workspace_roots); + Some(rebuilt) } #[cfg(test)] @@ -382,6 +396,69 @@ mod tests { assert_eq!(persisted.messages.len(), 1); } + #[test] + fn rename_stamps_the_live_workspace_roots() { + let tmp = TempDir::new().unwrap(); + let manager = make_session_manager(&tmp); + let mut app = make_app(&tmp); + let new_workspace = tmp.path().join("after-cd"); + let shared = tmp.path().join("shared"); + let mut session = create_saved_session_with_mode( + &[], + "deepseek-v4-pro", + &tmp.path().join("before-cd"), + 0, + None, + None, + ); + session.metadata.id = "rename-roots".to_string(); + // The disk record still carries the pre-`/cd` pair, the shape a + // failed direct save leaves behind. + session.metadata.workspace_roots = vec![tmp.path().join("before-cd"), shared.clone()]; + manager.save_session(&session).unwrap(); + + app.current_session_id = Some("rename-roots".to_string()); + app.workspace = new_workspace.clone(); + app.workspace_roots = vec![new_workspace.clone(), shared.clone()]; + + let result = rename_with_manager("Rooted Rename", "rename-roots", &manager, &mut app); + assert!(!result.is_error, "{result:?}"); + + let reloaded = manager.load_session("rename-roots").unwrap(); + assert_eq!(reloaded.metadata.workspace, new_workspace); + assert_eq!( + reloaded.metadata.workspace_roots, + vec![new_workspace, shared], + "a /rename save must pair the new workspace with the live root set, \ + or the abandoned directory re-enters on the next resume" + ); + } + + // Same stamp on the rebuild path: nothing persisted yet, so the document + // is constructed from in-memory App state and must carry the live set. + #[test] + fn rename_mid_first_turn_rebuild_stamps_the_live_workspace_roots() { + let tmp = TempDir::new().unwrap(); + let manager = make_session_manager(&tmp); + let mut app = make_app(&tmp); + + let session_id = "live-rebuild-roots"; + let shared = tmp.path().join("shared"); + app.current_session_id = Some(session_id.to_string()); + app.api_messages = vec![user_message("turn one, nothing persisted yet")]; + app.workspace_roots = vec![app.workspace.clone(), shared.clone()]; + + let result = rename_with_manager("Rebuilt Roots", session_id, &manager, &mut app); + assert!(!result.is_error, "{result:?}"); + + let persisted = manager.load_session(session_id).unwrap(); + assert_eq!( + persisted.metadata.workspace_roots, + vec![app.workspace.clone(), shared], + "the rebuilt document must pair the workspace with the live root set" + ); + } + fn user_message(text: &str) -> crate::models::Message { crate::models::Message { role: Role::User, diff --git a/crates/tui/src/commands/groups/session/resume.rs b/crates/tui/src/commands/groups/session/resume.rs index cb0846b3c7..7c4c2bf09b 100644 --- a/crates/tui/src/commands/groups/session/resume.rs +++ b/crates/tui/src/commands/groups/session/resume.rs @@ -113,6 +113,18 @@ fn import_container( app.current_session_id = Some(new_id.clone()); app.current_session_metadata = Some(imported.metadata.clone()); app.api_messages = imported.messages.clone(); + // The import is a fresh session in the current workspace, so the live + // root set is re-pointed with it. Leaving the previous session's set in + // place would let the next autosave stamp it onto the imported record — + // durable roots bleed across sessions that never shared a directory. + // B27-1/B27-2: seeded under the record's own primary — `import_foreign` + // mints the record under the CURRENT workspace with an EMPTY root set, + // so this is the single-root pure-load shape by construction. + crate::tui::ui::seed_loaded_workspace_roots_into( + app, + &imported.metadata.workspace, + &imported.metadata.workspace_roots, + ); app.view_stack.push(SessionPickerView::new_selecting( &app.workspace, app.ui_locale, diff --git a/crates/tui/src/commands/groups/session/session.rs b/crates/tui/src/commands/groups/session/session.rs index 362212a2e6..32ff5d2653 100644 --- a/crates/tui/src/commands/groups/session/session.rs +++ b/crates/tui/src/commands/groups/session/session.rs @@ -40,6 +40,27 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { Err(err) => return CommandResult::error(format!("Failed to snapshot Work state: {err}")), }; session.last_auto_route = app.auto_route_for_persistence(); + // Stamp the live root set before the write, exactly like the fork paths + // and `build_session_snapshot`: the freshly built metadata carries an + // empty set, and an explicit-path `/save` has no later autosave to heal + // it — `/save` followed by quit durably degrades a multi-root session. + // Round-37 M37-4: the live set is validated before it is stamped — the + // tolerant-load lane can seed App with a hand-edited row's set, and + // persisting it verbatim would launder the poison past the intake every + // resume lane enforces. A clean live set passes unchanged. + session.metadata.workspace_roots = + match codewhale_core::validate_stored_workspace_roots(&app.workspace, &app.workspace_roots) + { + Ok(roots) => roots, + Err(err) => { + // Round-38 minor: localized like the sibling /cd and /fork + // refusals (M37-8's doctrine applies to this wave's own copy). + return CommandResult::error( + app.tr(crate::localization::MessageId::SaveLiveSetRefused) + .replace("{reason}", &format!("{err:#}")), + ); + } + }; let save_path = explicit_save_path.unwrap_or_else(|| { let dir = crate::session_manager::default_sessions_dir() .unwrap_or_else(|_| app.workspace.clone()); @@ -147,6 +168,35 @@ pub fn fork_from_session(app: &mut App, session_id_or_prefix: &str) -> CommandRe forked.artifacts = source_session.artifacts.clone(); forked.work_state = source_session.work_state.clone(); forked.last_auto_route = source_session.last_auto_route.clone(); + // The fork is anchored to the current workspace, so it takes the same + // primary-swap semantics `/cd` and the runtime PATCH workspace-only + // branch apply: the source's primary directory leaves the set — it is + // not this fork's directory — and the source's additional roots survive, + // re-normalized against the fork's own workspace. Stamping the source + // set verbatim would persist `workspace: ` next to a set led by + // the abandoned directory, which engine-side normalization then re-admits + // as a writable root on the next resume. + // Round-38 M38-3: the lexical head strip — a `..`-spelled copy of the + // abandoned primary must not ride the fork into the re-based set. + let additional_roots: Vec = codewhale_core::strip_workspace_roots_head( + &source_session.metadata.workspace, + &source_session.metadata.workspace_roots, + ); + // Round-20 should-fix 1: same intake rule as /cd and the runtime lanes — + // a re-based set that widens past the fork's primary must not persist. + forked.metadata.workspace_roots = + match codewhale_core::validate_workspace_roots(&app.workspace, &additional_roots) { + Ok(roots) => roots, + Err(err) => { + // Round-37 M37-8: the refusal is localized like its sibling + // messages (the /cd widening refusal ships in all 15 packs). + return CommandResult::error( + app.tr(crate::localization::MessageId::ForkSourceSetRefused) + .replace("{workspace}", &app.workspace.display().to_string()) + .replace("{reason}", &format!("{err:#}")), + ); + } + }; if let Err(err) = manager.save_session(&forked) { return CommandResult::error(format!("Failed to save forked session: {err}")); } @@ -171,6 +221,7 @@ pub fn fork_from_session(app: &mut App, session_id_or_prefix: &str) -> CommandRe .map(|s| crate::models::SystemPrompt::Text(s.clone())), model: forked.metadata.model.clone(), workspace: app.workspace.clone(), + workspace_roots: forked.metadata.workspace_roots.clone(), mode: app.mode, }, ) @@ -223,6 +274,25 @@ pub fn fork(app: &mut App) -> CommandResult { .clone_from(&cached.parent_session_id); parent.metadata.forked_from_message_count = cached.forked_from_message_count; } + // The freshly constructed metadata has empty roots; the live App state + // is the current set for this session (it supersedes the cached copy, + // which the App itself wrote at the last snapshot). Stamp it before the + // save for the same durable-erasure reason as above. Round-37 M37-4: + // like every other fork face, the mint runs the validating intake — a + // tolerant-load seed's forbidden entries must not ride the plain fork + // into durable propagation across BOTH rows. A clean live set passes + // unchanged. + parent.metadata.workspace_roots = + match codewhale_core::validate_stored_workspace_roots(&app.workspace, &app.workspace_roots) + { + Ok(roots) => roots, + Err(err) => { + return CommandResult::error( + app.tr(crate::localization::MessageId::ForkLiveSetRefused) + .replace("{reason}", &format!("{err:#}")), + ); + } + }; app.sync_cost_to_metadata(&mut parent.metadata); parent.context_references = app.session_context_references.clone(); parent.artifacts = app.session_artifacts.clone(); @@ -258,6 +328,7 @@ pub fn fork(app: &mut App) -> CommandResult { j.spawn_depth = parent.metadata.spawn_depth; } forked.metadata.mark_forked_from(&parent.metadata); + forked.metadata.workspace_roots = parent.metadata.workspace_roots.clone(); forked.context_references = app.session_context_references.clone(); forked.artifacts = app.session_artifacts.clone(); forked.work_state = work_state; @@ -289,6 +360,7 @@ pub fn fork(app: &mut App) -> CommandResult { system_prompt: app.system_prompt.clone(), model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: parent.metadata.workspace_roots.clone(), mode: app.mode, }, ) @@ -351,6 +423,7 @@ pub fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult { system_prompt: None, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: Vec::new(), mode: app.mode, }, ) @@ -664,6 +737,73 @@ mod tests { ); } + #[test] + fn save_stamps_the_live_workspace_roots() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("roots_session.json"); + app.workspace_roots = vec![app.workspace.clone(), tmpdir.path().join("shared")]; + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error, "{:?}", result.message); + let saved: crate::session_manager::SavedSession = + serde_json::from_str(&std::fs::read_to_string(&save_path).unwrap()).unwrap(); + assert_eq!( + saved.metadata.workspace_roots, + vec![app.workspace.clone(), tmpdir.path().join("shared")], + "an explicit-path /save must persist the live set, or /save-then-quit degrades it" + ); + assert_eq!(saved.metadata.workspace, app.workspace); + } + + #[test] + fn fork_from_session_swaps_the_primary_and_keeps_additional_roots() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let home_guard = EnvVarGuard::set("HOME", &home); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.workspace = tmpdir.path().join("current"); + app.workspace_roots = vec![app.workspace.clone()]; + + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + let abandoned = tmpdir.path().join("abandoned"); + let shared = tmpdir.path().join("shared"); + let mut source = create_saved_session_with_id_and_mode( + "source-session".to_string(), + &[crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "fork me elsewhere".to_string(), + cache_control: None, + }], + }], + &app.model, + &abandoned, + 0, + None, + Some(app.mode.label()), + ); + source.metadata.workspace_roots = vec![abandoned.clone(), shared.clone()]; + manager.save_session(&source).unwrap(); + + let result = fork_from_session(&mut app, "source-session"); + + assert!(!result.is_error, "{:?}", result.message); + let fork = manager + .load_session(app.current_session_id.as_deref().expect("fork id")) + .expect("fork persisted"); + assert_eq!(fork.metadata.workspace, app.workspace); + assert_eq!( + fork.metadata.workspace_roots, + vec![app.workspace.clone(), shared], + "the fork's primary is its own workspace; the abandoned primary must not re-enter" + ); + drop(home_guard); + } + #[test] fn fork_saves_parent_and_switches_to_child_session() { let tmpdir = TempDir::new().unwrap(); @@ -774,6 +914,56 @@ mod tests { assert_eq!(std::env::var_os("HOME"), previous_home); } + #[test] + fn fork_stamps_the_live_workspace_roots() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let home_guard = EnvVarGuard::set("HOME", &home); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("parent-roots".to_string()); + let shared = tmpdir.path().join("shared"); + app.workspace_roots = vec![app.workspace.clone(), shared.clone()]; + app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "fork with roots".to_string(), + cache_control: None, + }], + }); + + let result = fork(&mut app); + + assert!(!result.is_error, "{:?}", result.message); + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + let expected = vec![app.workspace.clone(), shared]; + let parent = manager.load_session("parent-roots").expect("parent saved"); + assert_eq!( + parent.metadata.workspace_roots, expected, + "the /fork parent save must persist the live set, or the fork's own \ + save degrades it on the next snapshot" + ); + let fork_id = app.current_session_id.clone().expect("fork session id"); + let child = manager.load_session(&fork_id).expect("child saved"); + assert_eq!( + child.metadata.workspace_roots, expected, + "the forked session inherits the parent's live set" + ); + match result.action { + Some(AppAction::SyncSession { + workspace_roots, .. + }) => { + assert_eq!( + workspace_roots, expected, + "the SyncSession handoff carries the same set the fork persisted" + ); + } + other => panic!("fork must hand off a SyncSession, got {other:?}"), + } + drop(home_guard); + } + #[test] fn fork_rejects_active_runtime_without_switching_sessions() { let tmpdir = TempDir::new().unwrap(); @@ -849,6 +1039,127 @@ mod tests { } } + #[test] + fn fork_from_session_refuses_a_poisoned_source_row() { + // Round-38 minor 9: the ForkSourceSetRefused arm stays validating + // and persists nothing — the base behavior persisted the raw + // source set, so the pin must hold both directions. + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let home_guard = EnvVarGuard::set("HOME", &home); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.workspace = tmpdir.path().join("current"); + app.workspace_roots = vec![app.workspace.clone()]; + + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + let abandoned = tmpdir.path().join("abandoned"); + let mut source = create_saved_session_with_id_and_mode( + "poisoned-source".to_string(), + &[crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "poisoned source".to_string(), + cache_control: None, + }], + }], + &app.model, + &abandoned, + 0, + None, + Some(app.mode.label()), + ); + source.metadata.workspace_roots = vec![abandoned.clone(), std::path::PathBuf::from("/")]; + manager.save_session(&source).unwrap(); + + let result = fork_from_session(&mut app, "poisoned-source"); + drop(home_guard); + + assert!( + result.is_error, + "a poisoned source row must not ride the /fork re-base" + ); + let msg = result.message.expect("refusal message"); + assert!( + msg.contains("The fork is refused") && msg.contains("filesystem root"), + "the refusal names the widening and the remedy: {msg}" + ); + assert_eq!( + app.current_session_id.as_deref(), + None, + "the refused fork must not switch the active session" + ); + // Nothing was minted: the store still holds exactly the source row + // (a persisted fork would add a second session file). + let rows = manager.list_sessions().unwrap(); + assert_eq!( + rows.len(), + 1, + "the refusal must not persist a fork row: {rows:?}" + ); + assert_eq!(rows[0].id, "poisoned-source"); + } + + #[test] + fn save_and_plain_fork_refuse_a_poisoned_live_root_set() { + // Round-37 M37-4: /save and the plain /fork stamped the live set + // with no intake, while every other fork face validates its mint — + // a fault-tolerant seed's forbidden entries (here the filesystem + // root) would durably propagate across both rows. + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("poisoned_session.json"); + app.workspace_roots = vec![app.workspace.clone(), std::path::PathBuf::from("/")]; + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + assert!( + result.is_error, + "a poisoned live set must not be persisted by /save" + ); + let msg = result.message.expect("refusal message"); + assert!( + msg.contains("no longer passes intake"), + "the refusal names the intake reason: {msg}" + ); + assert!( + !save_path.exists(), + "the poisoned row must not reach the disk" + ); + + let _lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let home_guard = EnvVarGuard::set("HOME", &home); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("parent-poisoned".to_string()); + app.workspace_roots = vec![app.workspace.clone(), std::path::PathBuf::from("/")]; + app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "fork with poison".to_string(), + cache_control: None, + }], + }); + + let result = fork(&mut app); + drop(home_guard); + assert!( + result.is_error, + "a poisoned live set must not ride the plain /fork into two rows" + ); + let msg = result.message.expect("refusal message"); + assert!( + msg.contains("no longer passes intake"), + "the refusal names the intake reason: {msg}" + ); + assert_eq!( + app.current_session_id.as_deref(), + Some("parent-poisoned"), + "the refused fork must not switch the active session" + ); + } + #[test] fn new_session_blocks_unsent_input_without_force() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/commands/groups/session/title.rs b/crates/tui/src/commands/groups/session/title.rs index fffa928214..7dfb02f88d 100644 --- a/crates/tui/src/commands/groups/session/title.rs +++ b/crates/tui/src/commands/groups/session/title.rs @@ -160,6 +160,15 @@ pub(crate) fn set_window_title_with_manager( .metadata .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence()); session.metadata.workspace.clone_from(&app.workspace); + // The paired set travels with the workspace it belongs to, exactly as in + // `/rename`: syncing only the primary would let a `/title` after a `/cd` + // whose direct save failed rewrite `workspace: new` next to the stale + // pre-switch set — and `/title` then quit is durable, since no turn + // checkpoint follows to heal it. + session + .metadata + .workspace_roots + .clone_from(&app.workspace_roots); session.metadata.mode = Some(app.mode.as_setting().to_string()); app.sync_cost_to_metadata(&mut session.metadata); session.window_title = title.clone(); @@ -248,6 +257,45 @@ mod tests { assert_eq!(reloaded.metadata.title, "Original Name"); } + #[test] + fn set_title_stamps_the_live_workspace_roots() { + let tmp = TempDir::new().unwrap(); + let manager = make_session_manager(&tmp); + let mut app = make_app(&tmp); + let new_workspace = tmp.path().join("after-cd"); + let shared = tmp.path().join("shared"); + let mut session = create_saved_session_with_mode( + &[], + "deepseek-v4-pro", + &tmp.path().join("before-cd"), + 0, + None, + None, + ); + session.metadata.id = "title-roots".to_string(); + // The disk record still carries the pre-`/cd` pair, the shape a + // failed direct save leaves behind. + session.metadata.workspace_roots = vec![tmp.path().join("before-cd"), shared.clone()]; + manager.save_session(&session).unwrap(); + + app.current_session_id = Some("title-roots".to_string()); + app.workspace = new_workspace.clone(); + app.workspace_roots = vec![new_workspace.clone(), shared.clone()]; + + let result = + set_window_title_with_manager(&mut app, Some("after-cd-task".to_string()), &manager); + assert!(!result.is_error, "unexpected error: {:?}", result.message); + + let reloaded = manager.load_session("title-roots").unwrap(); + assert_eq!(reloaded.metadata.workspace, new_workspace); + assert_eq!( + reloaded.metadata.workspace_roots, + vec![new_workspace, shared], + "a /title save must pair the new workspace with the live root set, \ + or the abandoned directory re-enters on the next resume" + ); + } + #[test] fn clear_title_removes_the_session_level_title() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/commands/groups/skills/restore.rs b/crates/tui/src/commands/groups/skills/restore.rs index e77223987d..b282c362a4 100644 --- a/crates/tui/src/commands/groups/skills/restore.rs +++ b/crates/tui/src/commands/groups/skills/restore.rs @@ -132,11 +132,23 @@ fn restore(group: &mut dyn CommandSkillGroupContext, arg: Option<&str>) -> Comma return CommandResult::error(err); } - CommandResult::message(format!( - "Restored snapshot #{n} ('{}', {}). Workspace files have been reverted; conversation history is unchanged.", - target.label, - short_sha(target.id.as_str()), - )) + CommandResult::message(if group.restore_covers_primary_only() { + // Round-45 R45-3: the disclosure rides the host's localized form + // (the TUI overrides it with the translated sentence); the stable + // English constant remains the model/API lane spelling. + format!( + "Restored snapshot #{n} ('{}', {}). {} Conversation history is unchanged.", + target.label, + short_sha(target.id.as_str()), + group.attached_roots_not_reverted_note(), + ) + } else { + format!( + "Restored snapshot #{n} ('{}', {}). Workspace files have been reverted; conversation history is unchanged.", + target.label, + short_sha(target.id.as_str()), + ) + }) } fn parse_list_arg(arg: &str) -> Result, String> { @@ -212,6 +224,10 @@ mod tests { snapshots: Result, String>, restore: Result<(), String>, approval: CommandApprovalState, + primary_only: bool, + /// Host-provided localized boundary note (round-45 R45-3); `None` + /// exercises the contract default (the stable English sentence). + note: Option, } impl FakeSkillGroup { fn new(snapshots: Vec) -> Self { @@ -222,6 +238,8 @@ mod tests { yolo: true, trust_mode: false, }, + primary_only: false, + note: None, } } } @@ -287,6 +305,14 @@ mod tests { fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> { self.restore.clone() } + fn restore_covers_primary_only(&self) -> bool { + self.primary_only + } + fn attached_roots_not_reverted_note(&self) -> String { + self.note + .clone() + .unwrap_or_else(|| crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE.to_string()) + } fn approval_state(&self) -> CommandApprovalState { self.approval } @@ -381,6 +407,71 @@ mod tests { assert!(result.message.unwrap().contains("Restored snapshot #2")); } + #[test] + fn restore_with_attached_roots_names_rollback_boundary() { + // Snapshots are primary-bound: with attached roots the report must + // say only the primary workspace was reverted instead of claiming a + // full rollback (M15-3). Single-root wording stays byte-identical. + let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]); + group.primary_only = true; + let result = restore(&mut group, Some("1")); + assert!(!result.is_error); + let msg = result.message.unwrap(); + assert!( + msg.contains(crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE), + "{msg}" + ); + assert!(!msg.contains("Workspace files have been reverted"), "{msg}"); + + let mut single = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]); + let single_msg = restore(&mut single, Some("1")).message.unwrap(); + assert!( + single_msg.contains("Workspace files have been reverted"), + "{single_msg}" + ); + assert!( + !single_msg.contains(crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE), + "{single_msg}" + ); + } + + #[test] + fn restore_boundary_note_rides_the_host_localized_form() { + // The localization store's generated lookup has a large debug-build + // stack frame; the default 2 MiB test thread overflows (round-45 + // R45-1/P2-8 — CI's 8 MiB RUST_MIN_STACK is load-bearing), so the + // body runs on an explicitly sized stack. + std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(restore_boundary_note_rides_the_host_localized_form_body) + .expect("spawn localized-note test thread") + .join() + .expect("localized-note test thread panicked"); + } + + fn restore_boundary_note_rides_the_host_localized_form_body() { + // Round-45 R45-3: the disclosure must come from the host facet, not + // be hardcoded English — a localized host surface (the TUI adapter + // passes `snapshot::attached_roots_not_reverted_note(ui_locale)`) + // must reach the report verbatim. + let zh_note = + crate::snapshot::attached_roots_not_reverted_note(crate::localization::Locale::ZhHans); + assert_ne!( + zh_note, + crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE, + "fixture locale must actually translate the note" + ); + let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]); + group.primary_only = true; + group.note = Some(zh_note.clone()); + let msg = restore(&mut group, Some("1")).message.unwrap(); + assert!(msg.contains(&zh_note), "{msg}"); + assert!( + !msg.contains(crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE), + "{msg}" + ); + } + #[test] fn restore_outside_trust_mode_refuses() { let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]); diff --git a/crates/tui/src/commands/groups/skills/review.rs b/crates/tui/src/commands/groups/skills/review.rs index 62543d3f74..f8eee92ab3 100644 --- a/crates/tui/src/commands/groups/skills/review.rs +++ b/crates/tui/src/commands/groups/skills/review.rs @@ -166,6 +166,9 @@ mod tests { fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> { unimplemented!("not used by review tests") } + fn restore_covers_primary_only(&self) -> bool { + unimplemented!("not used by review tests") + } fn approval_state(&self) -> CommandApprovalState { self.approval } diff --git a/crates/tui/src/commands/groups/skills/skills.rs b/crates/tui/src/commands/groups/skills/skills.rs index 533c060d3a..6820a814a4 100644 --- a/crates/tui/src/commands/groups/skills/skills.rs +++ b/crates/tui/src/commands/groups/skills/skills.rs @@ -1039,6 +1039,9 @@ mod tests { fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> { self.restore.clone() } + fn restore_covers_primary_only(&self) -> bool { + unimplemented!("not used by skills tests") + } fn approval_state(&self) -> CommandApprovalState { self.approval } diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index fa927de908..e0a50bc6b4 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -636,6 +636,7 @@ reason = "read_file is allowed" crate::tui::approval::ApprovalMode::Auto, true, None, + &[], ); let shell_decision = policy.evaluate(&shell_context); assert_eq!( @@ -651,6 +652,7 @@ reason = "read_file is allowed" crate::tui::approval::ApprovalMode::Auto, true, None, + &[], ); let read_decision = policy.evaluate(&read_context); assert_eq!( @@ -772,6 +774,7 @@ command = "cargo test" path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::OnFailure, sandbox_mode: None, + workspace_roots: Vec::new(), }) .expect("check permission"); @@ -807,6 +810,7 @@ command = "npm test" path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::OnFailure, sandbox_mode: None, + workspace_roots: Vec::new(), }) .expect("check permission"); diff --git a/crates/tui/src/core/authority.rs b/crates/tui/src/core/authority.rs index e03f055f7f..6ae987fba7 100644 --- a/crates/tui/src/core/authority.rs +++ b/crates/tui/src/core/authority.rs @@ -4,7 +4,6 @@ //! in one place so prompt metadata, tool catalogs, and runtime gates cannot //! drift independently. -use std::ffi::OsStr; use std::path::{Component, Path, PathBuf}; use crate::sandbox::SandboxPolicy; @@ -210,6 +209,7 @@ impl TurnAuthority { pub(crate) fn sandbox_policy( &self, workspace: &Path, + workspace_roots: &[PathBuf], configured_mode: Option<&str>, network_access: SandboxNetworkAccess, ) -> SandboxPolicy { @@ -218,6 +218,7 @@ impl TurnAuthority { self.approval_mode_for_session(), configured_mode, workspace, + workspace_roots, network_access, ) } @@ -327,6 +328,7 @@ pub(crate) fn sandbox_policy_for_turn( approval_mode: ApprovalMode, configured_mode: Option<&str>, workspace: &Path, + workspace_roots: &[PathBuf], network_access: SandboxNetworkAccess, ) -> SandboxPolicy { let default = if mode == AppMode::Plan { @@ -334,7 +336,7 @@ pub(crate) fn sandbox_policy_for_turn( } else if approval_mode == ApprovalMode::Bypass { SandboxPolicy::DangerFullAccess } else { - workspace_write_policy(workspace, network_access) + workspace_write_policy(workspace, workspace_roots, network_access) }; // The effective Config has already applied managed/project precedence. @@ -343,7 +345,7 @@ pub(crate) fn sandbox_policy_for_turn( match (default, configured_mode) { (SandboxPolicy::ReadOnly, _) | (_, Some("read-only")) => SandboxPolicy::ReadOnly, (SandboxPolicy::DangerFullAccess, Some("workspace-write")) => { - workspace_write_policy(workspace, network_access) + workspace_write_policy(workspace, workspace_roots, network_access) } (SandboxPolicy::DangerFullAccess, Some("external-sandbox")) => { SandboxPolicy::ExternalSandbox { @@ -387,9 +389,21 @@ impl SandboxNetworkAccess { } } -fn workspace_write_policy(workspace: &Path, network_access: SandboxNetworkAccess) -> SandboxPolicy { +/// The per-turn materialization of the session's full workspace root set: +/// `workspace` stays the primary writable root and the normalized additional +/// roots follow it. An empty `workspace_roots` degenerates to `[workspace]`, +/// exactly the historical single-root policy. Every materialization point +/// (turn sandbox policy, tool context, exec-policy checks) normalizes +/// through `normalize_workspace_roots`, so a root-set change made while a +/// session is running takes effect on the next turn without rewriting +/// persisted rules or configuration. +fn workspace_write_policy( + workspace: &Path, + workspace_roots: &[PathBuf], + network_access: SandboxNetworkAccess, +) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { - writable_roots: vec![workspace.to_path_buf()], + writable_roots: codewhale_core::normalize_workspace_roots(workspace, workspace_roots), network_access: network_access.is_allowed(), exclude_tmpdir: false, exclude_slash_tmp: false, @@ -490,35 +504,81 @@ pub(crate) fn write_carve_out_posture( } /// Whether every target path of a file-write call qualifies for the -/// in-workspace write carve-out (#5185): the workspace is a git work tree, -/// each path resolves inside it, and none touches `.git` internals, runtime -/// state, or a sensitive file. +/// in-workspace write carve-out (#5185): an absolute path resolves inside at +/// least one workspace root (primary or additional); a relative path is +/// judged against the primary root alone, because execution resolves it +/// there (ToolContext::resolve_path). The qualifying root must be a git work +/// tree, and the path must touch no `.git` internals, runtime state, or +/// sensitive file. Every root is judged independently. +/// +/// The absolute/relative branch runs on the *untrimmed* spelling, exactly as +/// execution does (`ToolContext::resolve_path`): a leading-whitespace +/// absolute-looking target is joined onto the primary by the write tools, so +/// judging it as an absolute attached-root path would auto-approve a write +/// that lands somewhere else. /// /// The git work-tree marker is deliberate (the same shape as kimi-code's /// `git-cwd-write-approve` policy): the carve-out exists because /// version-controlled edits stay reviewable and recoverable, so a workspace /// without git keeps the modal. #[must_use] -pub(crate) fn paths_within_workspace_write_carve_out(workspace: &Path, paths: &[String]) -> bool { +pub(crate) fn paths_within_workspace_write_carve_out( + workspace: &Path, + workspace_roots: &[PathBuf], + paths: &[String], +) -> bool { if paths.is_empty() { return false; } - // `.git` may be a directory (normal checkout) or a file (worktree or - // submodule); either marks a git work tree. - if workspace.join(".git").symlink_metadata().is_err() { - return false; - } - let Ok(workspace_canonical) = workspace.canonicalize() else { - return false; - }; - paths + let roots = codewhale_core::normalize_workspace_roots(workspace, workspace_roots); + // Round-24 P3 perf: the per-root `.git` stat and canonicalize are + // hoisted out of the per-target loop — the old shape re-canonicalized + // every root for every absolute target (roots × targets realpath + // chains on one judgment). Eligibility (a git work tree that + // canonicalizes) is per call, not per target; the allowed-check per + // target is untouched. + let eligible_git_roots: Vec<(&Path, PathBuf)> = roots .iter() - .all(|raw| carve_out_target_allowed(workspace, &workspace_canonical, raw)) + // `.git` may be a directory (normal checkout) or a file (worktree + // or submodule); either marks a git work tree. + .filter(|root| root.join(".git").symlink_metadata().is_ok()) + .filter_map(|root| { + root.canonicalize() + .ok() + .map(|canonical| (root.as_path(), canonical)) + }) + .collect(); + let primary_git_canonical = if workspace.join(".git").symlink_metadata().is_ok() { + workspace.canonicalize().ok() + } else { + None + }; + paths.iter().all(|raw| { + if Path::new(raw).is_absolute() { + eligible_git_roots + .iter() + .any(|(root, canonical)| carve_out_target_allowed(root, canonical, raw)) + } else { + // A relative target is joined onto the primary workspace at + // execution time, so approval must judge it there: qualifying it + // through an attached git root would let the write land in the + // non-git primary tree modal-free and defeat the carve-out's own + // reviewability rationale. + match &primary_git_canonical { + Some(canonical) => carve_out_target_allowed(workspace, canonical, raw), + None => false, + } + } + }) } fn carve_out_target_allowed(workspace: &Path, workspace_canonical: &Path, raw: &str) -> bool { - let raw = raw.trim(); - if raw.is_empty() { + // The branch runs on the untrimmed spelling, matching both the caller's + // branch and execution's (`ToolContext::resolve_path`): trimming here + // would judge ` /abs/path` as an absolute out-of-tree path while the + // write lands at `/ /abs/path`, or the reverse. Trim decides only + // whether the target is empty. + if raw.trim().is_empty() { return false; } let raw_path = Path::new(raw); @@ -555,21 +615,33 @@ fn carve_out_target_allowed(workspace: &Path, workspace_canonical: &Path, raw: & /// Canonicalize the deepest existing ancestor of `candidate` and re-append /// the not-yet-existing tail, so write targets that do not exist yet still -/// get a real-path check. -fn resolve_deepest_existing(candidate: &Path) -> Option { - let mut ancestor = candidate; - let mut suffix: Vec<&OsStr> = Vec::new(); - loop { - if let Ok(canonical) = ancestor.canonicalize() { - let mut resolved = canonical; - for part in suffix.iter().rev() { - resolved.push(part); - } - return Some(resolved); - } - suffix.push(ancestor.file_name()?); - ancestor = ancestor.parent()?; - } +/// get a real-path check. Round-45 R45-4: the walk itself is delegated to +/// the single core implementation (`codewhale_core::walk_deepest_existing`, +/// DeepestAncestor fallback) so this lane can never drift from the intake +/// gate's copy again. +/// +/// Round-46 P2-1 (behavior flip, disclosed and pinned; doc corrected in +/// round-47 item 3): the delegation composes a path for EVERY spelling +/// where the old loop returned `None` — notably a ghost component + `..` +/// (`/ghost_dir/../target`), which the old `file_name()? == None` +/// corner walked past and DENIED modally. For that shape the walk composes +/// `/ghost_dir/target` (the trailing `..` pops without +/// canceling the ghost component, which re-appends into the tail); the +/// LEXICAL landing is `/target`, and the composed spelling is the +/// execution-materialized path — judging it is exactly why the carve-out +/// admits (both spellings sit inside the workspace). A false-denial fix, +/// visible even single-root (pinned by +/// `carve_out_admits_a_ghost_dotdot_shape_composed_onto_the_parent`). The +/// `Option` return is retained for signature stability; the walked-past-root +/// corner now yields a root-anchored spelling that containment judges. +pub(crate) fn resolve_deepest_existing(candidate: &Path) -> Option { + Some( + codewhale_core::walk_deepest_existing( + candidate, + codewhale_core::DeepestExistingFallback::DeepestAncestor, + ) + .compose(), + ) } fn carve_out_relative_path_allowed(relative: &Path) -> bool { @@ -749,7 +821,7 @@ mod tests { vec!["src/main.rs".to_string(), "src/other.rs".to_string()], ] { assert!( - paths_within_workspace_write_carve_out(workspace, &paths), + paths_within_workspace_write_carve_out(workspace, &[], &paths), "{paths:?} should qualify" ); } @@ -776,17 +848,39 @@ mod tests { vec!["src/main.rs".to_string(), ".env".to_string()], ] { assert!( - !paths_within_workspace_write_carve_out(workspace, &paths), + !paths_within_workspace_write_carve_out(workspace, &[], &paths), "{paths:?} must keep the modal" ); } } + #[test] + fn carve_out_admits_a_ghost_dotdot_shape_composed_onto_the_parent() { + // Round-46 P2-1 (doc corrected in round-47 item 3): a target spelled + // through a ghost component + `..` (`ghost_dir/../src/main.rs`, + // ghost_dir nonexistent) used to hit the walk's `file_name()? == + // None` corner and DENY modally; the shared core walk composes + // `/ghost_dir/src/main.rs` (the ghost component + // re-appends into the tail) while the lexical landing is + // `/src/main.rs` — both inside the workspace, so the carve-out + // admits. Single-root-visible behavior (pinned for the body's list). + let tmp = carve_out_workspace(); + assert!( + paths_within_workspace_write_carve_out( + tmp.path(), + &[], + &["ghost_dir/../src/main.rs".to_string()] + ), + "the ghost+dotdot spelling lands in the workspace and qualifies" + ); + } + #[test] fn carve_out_requires_a_git_work_tree() { let tmp = tempfile::tempdir().expect("tempdir"); assert!(!paths_within_workspace_write_carve_out( tmp.path(), + &[], &["src/main.rs".to_string()] )); } @@ -794,7 +888,227 @@ mod tests { #[test] fn carve_out_rejects_empty_target_list() { let tmp = carve_out_workspace(); - assert!(!paths_within_workspace_write_carve_out(tmp.path(), &[])); + assert!(!paths_within_workspace_write_carve_out( + tmp.path(), + &[], + &[] + )); + } + + #[test] + fn workspace_write_policy_materializes_additional_roots() { + let workspace = Path::new("/work"); + let agent = authority(AppMode::Agent, false, ApprovalMode::Suggest); + let roots = vec![PathBuf::from("/shared"), PathBuf::from("/work")]; + + let policy = + agent.sandbox_policy(workspace, &roots, None, SandboxNetworkAccess::Restricted); + let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy else { + panic!("agent posture must stay workspace-write"); + }; + assert_eq!( + writable_roots, + vec![PathBuf::from("/work"), PathBuf::from("/shared")], + "the primary root stays first and duplicates are removed" + ); + + // An empty root set is byte-identical to the historical single-root + // policy. + let single = agent.sandbox_policy(workspace, &[], None, SandboxNetworkAccess::Restricted); + let SandboxPolicy::WorkspaceWrite { + writable_roots: single_roots, + .. + } = single + else { + panic!("agent posture must stay workspace-write"); + }; + assert_eq!(single_roots, vec![workspace.to_path_buf()]); + } + + #[test] + fn carve_out_spans_additional_roots_independently() { + let primary = carve_out_workspace(); + let shared = carve_out_workspace(); + let no_git = tempfile::tempdir().expect("no-git tempdir"); + let roots = vec![shared.path().to_path_buf(), no_git.path().to_path_buf()]; + let workspace = primary.path(); + + // A target inside an additional git work tree qualifies, named + // relative to that root. + assert!(paths_within_workspace_write_carve_out( + workspace, + &roots, + &[shared + .path() + .join("src/main.rs") + .to_string_lossy() + .into_owned()], + )); + + // A root without a git work tree never qualifies, even for paths + // inside it. + assert!(!paths_within_workspace_write_carve_out( + workspace, + &roots, + &[no_git + .path() + .join("src/main.rs") + .to_string_lossy() + .into_owned()], + )); + + // Excluded names stay excluded under every root. + assert!(!paths_within_workspace_write_carve_out( + workspace, + &roots, + &[shared.path().join(".env").to_string_lossy().into_owned()], + )); + assert!(!paths_within_workspace_write_carve_out( + workspace, + &roots, + &[shared + .path() + .join(".git/config") + .to_string_lossy() + .into_owned()], + )); + } + + #[test] + fn carve_out_relative_target_keeps_modal_when_only_attached_root_has_git() { + // Execution joins a relative target onto the primary workspace, so a + // git work tree among the additional roots must not qualify a + // relative write into the non-git primary tree: approval judges the + // relative target against the primary root alone and keeps the + // modal. + let primary = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(primary.path().join("src")).expect("primary src dir"); + let attached = carve_out_workspace(); + + assert!(!paths_within_workspace_write_carve_out( + primary.path(), + &[attached.path().to_path_buf()], + &["src/main.rs".to_string()], + )); + + // The same target spelled absolutely inside the attached git root + // still qualifies: absolute targets are judged against every root. + assert!(paths_within_workspace_write_carve_out( + primary.path(), + &[attached.path().to_path_buf()], + &[attached + .path() + .join("src/main.rs") + .to_string_lossy() + .into_owned()], + )); + } + + #[test] + fn forkguard_workspace_roots_carve_out_spans_attached_roots() { + let primary = carve_out_workspace(); + let attached = carve_out_workspace(); + let roots = vec![attached.path().to_path_buf()]; + + // A write target inside the attached git work tree takes the + // carve-out exactly as a primary-root target would. + assert!(paths_within_workspace_write_carve_out( + primary.path(), + &roots, + &[attached + .path() + .join("src/main.rs") + .to_string_lossy() + .into_owned()], + )); + + // The excluded names keep the modal inside attached roots too. + for target in [".env", ".git/config", ".codewhale/mcp.json"] { + assert!( + !paths_within_workspace_write_carve_out( + primary.path(), + &roots, + &[attached.path().join(target).to_string_lossy().into_owned()], + ), + "{target} must stay excluded under an attached root" + ); + } + } + + #[test] + fn forkguard_workspace_roots_carve_out_branches_on_the_untrimmed_spelling() { + // Judgment branches exactly as execution does (`resolve_path`): a + // leading-whitespace absolute-looking target is a *relative* path to + // the write tools, joined onto the primary. Judging it on its trimmed + // spelling would let it qualify through an attached git root + // modal-free while the write lands under the primary — the exact + // reviewability gap the carve-out's primary-only relative rule + // exists to prevent. + let primary = tempfile::tempdir().expect("non-git primary"); + std::fs::create_dir_all(primary.path().join("src")).expect("primary src dir"); + let attached = carve_out_workspace(); + let roots = vec![attached.path().to_path_buf()]; + + let whitespace_prefixed = format!(" {}", attached.path().join("src/main.rs").display()); + assert!( + !paths_within_workspace_write_carve_out(primary.path(), &roots, &[whitespace_prefixed],), + "a whitespace-prefixed absolute-looking target must keep the modal" + ); + + // The trimmed spelling stays qualified: trimming decides nothing but + // emptiness at the inner guard. + assert!(paths_within_workspace_write_carve_out( + primary.path(), + &roots, + &[attached + .path() + .join("src/main.rs") + .to_string_lossy() + .into_owned()], + )); + } + + #[test] + fn forkguard_workspace_roots_sandbox_materializes_every_root() { + let primary = tempfile::tempdir().expect("primary"); + let attached = tempfile::tempdir().expect("attached"); + let roots = vec![attached.path().to_path_buf()]; + let policy = authority(AppMode::Agent, false, ApprovalMode::Suggest).sandbox_policy( + primary.path(), + &roots, + None, + SandboxNetworkAccess::Restricted, + ); + + let writable: Vec = policy + .get_writable_roots(primary.path()) + .into_iter() + .map(|root| root.root) + .collect(); + let primary_canonical = primary.path().canonicalize().expect("canonical primary"); + let attached_canonical = attached.path().canonicalize().expect("canonical attached"); + // `get_writable_roots` canonicalizes every root it enumerates (on + // macOS the raw tempdir spelling and its `/private/var` reality + // differ), so the canonical form is the guarantee under test. + for expected in [primary_canonical, attached_canonical] { + assert!( + writable.contains(&expected), + "writable roots {writable:?} must contain {expected:?}" + ); + } + + // Empty root set materializes exactly the historical single-root + // policy value. + let single = authority(AppMode::Agent, false, ApprovalMode::Suggest).sandbox_policy( + Path::new("/work"), + &[], + None, + SandboxNetworkAccess::Restricted, + ); + let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = single else { + panic!("agent posture must stay workspace-write"); + }; + assert_eq!(writable_roots, vec![PathBuf::from("/work")]); } #[cfg(unix)] @@ -805,6 +1119,7 @@ mod tests { std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).expect("symlink"); assert!(!paths_within_workspace_write_carve_out( tmp.path(), + &[], &["link/evil.rs".to_string()] )); // A symlink that stays inside the workspace is fine. @@ -812,6 +1127,7 @@ mod tests { .expect("inner symlink"); assert!(paths_within_workspace_write_carve_out( tmp.path(), + &[], &["src-link/main.rs".to_string()] )); } @@ -822,23 +1138,25 @@ mod tests { let full_access = authority(AppMode::Agent, true, ApprovalMode::Bypass); assert_eq!( - full_access.sandbox_policy(workspace, None, SandboxNetworkAccess::Restricted), + full_access.sandbox_policy(workspace, &[], None, SandboxNetworkAccess::Restricted), SandboxPolicy::DangerFullAccess ); // Clamping full-access down to workspace-write must land on the same // restricted posture an ordinary Agent turn gets, not on a wider one. assert!(matches!( - full_access.sandbox_policy( - workspace, - Some("workspace-write"), - SandboxNetworkAccess::Restricted - ), - SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. } - if writable_roots == vec![workspace.to_path_buf()] && !network_access - )); + full_access.sandbox_policy( + workspace, + &[], + Some("workspace-write"), + SandboxNetworkAccess::Restricted + ), + SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. } + if writable_roots == vec![workspace.to_path_buf()] && !network_access + )); assert_eq!( full_access.sandbox_policy( workspace, + &[], Some("read-only"), SandboxNetworkAccess::Restricted ), @@ -849,6 +1167,7 @@ mod tests { assert!(matches!( full_access.sandbox_policy( workspace, + &[], Some("external-sandbox"), SandboxNetworkAccess::Restricted ), @@ -859,6 +1178,7 @@ mod tests { assert!(matches!( full_access.sandbox_policy( workspace, + &[], Some("external-sandbox"), SandboxNetworkAccess::Allowed ), @@ -879,8 +1199,12 @@ mod tests { ] { for configured in [None, Some("workspace-write"), Some("danger-full-access")] { let auth = authority(AppMode::Agent, false, approval_mode); - let policy = - auth.sandbox_policy(workspace, configured, SandboxNetworkAccess::Restricted); + let policy = auth.sandbox_policy( + workspace, + &[], + configured, + SandboxNetworkAccess::Restricted, + ); assert!( !policy.has_network_access(), "{approval_mode:?}/{configured:?} leaked network: {policy:?}" @@ -892,7 +1216,7 @@ mod tests { // semantics: DangerFullAccess reports network regardless of this key, // because it applies no sandbox at all. let bypass = authority(AppMode::Agent, true, ApprovalMode::Bypass); - let policy = bypass.sandbox_policy(workspace, None, SandboxNetworkAccess::Restricted); + let policy = bypass.sandbox_policy(workspace, &[], None, SandboxNetworkAccess::Restricted); assert_eq!(policy, SandboxPolicy::DangerFullAccess); assert!(policy.has_network_access()); @@ -904,7 +1228,7 @@ mod tests { ] { assert!( !plan - .sandbox_policy(workspace, None, access) + .sandbox_policy(workspace, &[], None, access) .has_network_access() ); } @@ -938,6 +1262,7 @@ mod tests { assert!(matches!( authority.sandbox_policy( workspace, + &[], Some("danger-full-access"), SandboxNetworkAccess::Restricted ), @@ -949,6 +1274,7 @@ mod tests { assert_eq!( plan.sandbox_policy( workspace, + &[], Some("danger-full-access"), SandboxNetworkAccess::Restricted ), diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index ef2be48775..e0f03cc009 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -294,6 +294,12 @@ pub struct EngineConfig { pub active_route_limits: Option, /// Workspace root for tool execution and file operations. pub workspace: PathBuf, + /// Additional workspace roots for this engine session; `workspace` stays + /// the primary root. Materialized into the per-turn sandbox policy, tool + /// context boundary, and exec-policy checks on every turn, so a root-set + /// change takes effect on the next turn. Empty preserves single-root + /// behavior exactly. + pub workspace_roots: Vec, /// Host-owned conversation id the engine adopts at construction. /// /// Interactive hosts claim a session id before the engine exists: the @@ -562,6 +568,7 @@ impl Default for EngineConfig { model: DEFAULT_TEXT_MODEL.to_string(), active_route_limits: None, workspace: PathBuf::from("."), + workspace_roots: Vec::new(), session_id: None, subagent_state_root: None, allow_shell: true, @@ -1837,6 +1844,9 @@ impl Engine { { session.id = session_id.to_string(); } + config.workspace_roots = + codewhale_core::normalize_workspace_roots(&config.workspace, &config.workspace_roots); + session.workspace_roots = config.workspace_roots.clone(); // Set up stable system prompt with project context (default to agent mode). // Per-turn working-set metadata is injected into the latest user // message at request time so file churn does not rewrite this prefix. @@ -2202,6 +2212,7 @@ impl Engine { &tool_name, &tool_input, &self.session.workspace, + &self.session.workspace_roots, self.session.approval_mode, ); if let Some(ToolAskRuleDecision::Block(reason)) = ask_rule_decision { @@ -3401,6 +3412,7 @@ impl Engine { system_prompt_override, model, workspace, + workspace_roots, mode, } => { self.drop_all_steers().await; @@ -3495,9 +3507,12 @@ impl Engine { self.session.auto_model = model.trim().eq_ignore_ascii_case("auto"); self.session.model = model; self.session.workspace = workspace.clone(); + self.session.workspace_roots = + codewhale_core::normalize_workspace_roots(&workspace, &workspace_roots); self.current_mode = mode; self.config.model.clone_from(&self.session.model); self.config.workspace = workspace.clone(); + self.config.workspace_roots = self.session.workspace_roots.clone(); if plugin_workspace_changed { self.plugin_registry = self.plugin_registry.rediscover_for_workspace(&workspace); @@ -3514,6 +3529,10 @@ impl Engine { // conversation (see the method). self.invalidate_mcp_boot_for_workspace_change(); } + // Base reloads the project context on every session + // sync; keep that unconditional so mid-session + // instruction edits are picked up on same-workspace + // re-syncs. The loader reads the primary root only. let ctx = crate::project_context::load_project_context_with_parents(&workspace); self.session.project_context = if ctx.has_instructions() { @@ -3558,6 +3577,7 @@ impl Engine { model_provider: self.api_provider.as_str().to_string(), model_provider_id: self.api_provider_id.clone(), workspace: self.session.workspace.clone(), + workspace_roots: self.session.workspace_roots.clone(), system_prompt: self.session.system_prompt.clone(), mode: self.current_mode.as_setting().to_string(), }; @@ -4142,6 +4162,7 @@ impl Engine { approval_mode, self.api_config.sandbox_mode.as_deref(), &self.config.workspace, + &self.session.workspace_roots, crate::core::authority::SandboxNetworkAccess::from_config( self.api_config.sandbox_network_access, ), @@ -4152,6 +4173,44 @@ impl Engine { // the static system prefix stays byte-stable across sessions (see // `render_environment_block` for the prefix-cache rationale). format!("Current workspace: {}", self.config.workspace.display()), + ]; + // The channel by which a multi-root session's model learns it may touch + // the attached roots: restricted postures need it for the permission + // boundary, full-access postures for plain visibility. The primary root + // is already named by the workspace line above. Order is the normalized + // storage order, never re-sorted per turn, so a stable root set keeps + // the line byte-identical; long sets are truncated to a bound. + // Round-41 minor 14 + round-42 minor 1: the shared head strip — + // the model sees the same attached set the human surfaces list (a + // `..`-spelled or otherwise head-equal copy is the head, not an + // attachment). NOTE: this runs PER TURN (turn_meta construction + // and preview requests), so the strip's canonicalize legs run + // here too — 1 realpath for the head plus one per existing + // additional root, bounded by the 64-root cap (ledgered). + let attached_roots = codewhale_core::strip_workspace_roots_head( + &self.session.workspace, + &self.session.workspace_roots, + ); + if !attached_roots.is_empty() { + const MAX_LISTED_ROOTS: usize = 5; + // Round-40 minor 8: escape the separator so a root spelling + // cannot forge the list boundary (a root ending in ", " would + // otherwise splice the model's view of the set). + let listed = attached_roots + .iter() + .take(MAX_LISTED_ROOTS) + .map(|root| root.display().to_string().replace(',', "\\,")) + .collect::>() + .join(", "); + let remainder = attached_roots.len().saturating_sub(MAX_LISTED_ROOTS); + let suffix = if remainder > 0 { + format!(" … (+{remainder} more)") + } else { + String::new() + }; + lines.push(format!("Accessible folders: {listed}{suffix}")); + } + lines.extend([ format!( "Current permission posture: {}", approval_mode.permission_chip_label() @@ -4165,7 +4224,7 @@ impl Engine { crate::sandbox::process_hardening::no_new_privs_active(), ) ), - ]; + ]); if approval_mode == crate::tui::approval::ApprovalMode::Never { lines.push( "Approval prompts are disabled; do not request escalation for this turn." @@ -6696,11 +6755,20 @@ impl Engine { self.session.auto_approve, self.session.approval_mode, ); + // Round-26 B26-1 (latent site made safe): the per-turn live context + // re-assigns the pair on a clone that may share a memo Arc with an + // earlier turn's context — even a same-set assignment goes through + // `rebase_roots` so the memo can never outlive its pair. + context.rebase_roots( + self.session.workspace.clone(), + self.session.workspace_roots.clone(), + ); context.trust_mode = authority.trust_mode; context.auto_approve = authority.auto_approve; context.set_shell_policy(self.effective_turn_shell_policy(authority.shell_policy())); context.elevated_sandbox_policy = Some(authority.sandbox_policy( &self.session.workspace, + &self.session.workspace_roots, self.api_config.sandbox_mode.as_deref(), crate::core::authority::SandboxNetworkAccess::from_config( self.api_config.sandbox_network_access, @@ -6748,6 +6816,7 @@ impl Engine { self.session.mcp_config_path.clone(), authority.auto_approve, ) + .with_workspace_roots(self.session.workspace_roots.clone()) .with_state_namespace(self.session.id.clone()) .with_route_context_window(crate::route_budget::route_context_window_tokens( route.provider, @@ -6819,6 +6888,7 @@ impl Engine { let policy = authority.sandbox_policy( &self.session.workspace, + &self.session.workspace_roots, self.api_config.sandbox_mode.as_deref(), crate::core::authority::SandboxNetworkAccess::from_config( self.api_config.sandbox_network_access, @@ -8122,6 +8192,7 @@ pub(super) fn exec_shell_ask_rule_decision( tool_name: &str, tool_input: &Value, workspace: &Path, + workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, ) -> Option { exec_shell_ask_rule_decision_for_policy( @@ -8129,6 +8200,7 @@ pub(super) fn exec_shell_ask_rule_decision( tool_name, tool_input, workspace, + workspace_roots, approval_mode, ) } @@ -8141,6 +8213,7 @@ pub(crate) fn exec_shell_ask_rule_decision_for_policy( tool_name: &str, tool_input: &Value, workspace: &Path, + workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, ) -> Option { let policy_tool_name = @@ -8149,12 +8222,33 @@ pub(crate) fn exec_shell_ask_rule_decision_for_policy( return None; } let command = tool_input.get("command").and_then(Value::as_str)?; + // The exec lane resolves a `cwd:`/`working_dir:` operand through the + // roots-aware `ToolContext::resolve_path` and executes there, so the + // approval context must judge the same effective cwd: an allow rule + // scoped to the primary repo must not auto-approve the same command + // redirected into an attached root. Execution canonicalizes existing + // components (symlinks included) before its boundary check, so the + // judgment canonicalizes the same way: the lexical join alone let + // `link/..` normalize back into the primary while execution landed in + // the symlink target, firing a primary-scoped allow across the boundary + // (review #484/CodeWhale round-20 B20-1). A nonexistent operand + // resolves through the deepest existing ancestor (symlinks included), + // matching resolve_nonexistent_path's behavior (round-21 B21-3). The + // walk lives in core (`resolve_operand_cwd`) so the headless + // `Runtime::invoke_tool` lane judges the identical effective cwd with + // one shared implementation (round-22 B22-5). + let effective_cwd = ["cwd", "working_dir"] + .iter() + .find_map(|name| tool_input.get(name).and_then(Value::as_str)) + .map(|dir| codewhale_core::resolve_operand_cwd(workspace, dir)); tool_ask_rule_decision_for_context( exec_policy_engine, policy_tool_name, command, None, + effective_cwd.as_deref().unwrap_or(workspace), workspace, + workspace_roots, approval_mode, ) } @@ -8164,6 +8258,7 @@ pub(super) fn file_tool_ask_rule_decision( tool_name: &str, tool_input: &Value, workspace: &Path, + workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, ) -> Option { file_tool_ask_rule_decision_for_policy( @@ -8171,6 +8266,7 @@ pub(super) fn file_tool_ask_rule_decision( tool_name, tool_input, workspace, + workspace_roots, approval_mode, ) } @@ -8178,11 +8274,22 @@ pub(super) fn file_tool_ask_rule_decision( /// Evaluate the persisted file ask/allow/deny rules without requiring a full /// [`EngineConfig`]. This keeps protocol adapters on the canonical path and /// preserves the all-targets-must-match rule for multi-file patches. +/// +/// Disclosed residual (pre-existing at base, round-24 P3): the file lane +/// judges the operand's RAW spelling against the declared workspace — it +/// canonicalizes nothing — while execution canonicalizes. A `..`-spelled +/// operand that execution lands outside the declared roots therefore keeps +/// an ABSOLUTE-path deny rule inert here (the per-root relative +/// normalization cannot spell it, and the absolute fallback compares the +/// raw spelling). Fail-closed in practice through the other gate layers +/// (sandbox boundary + repo law judge the landing path); pinned as known +/// behavior, not silently shipped. pub(crate) fn file_tool_ask_rule_decision_for_policy( exec_policy_engine: &codewhale_execpolicy::ExecPolicyEngine, tool_name: &str, tool_input: &Value, workspace: &Path, + workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, ) -> Option { let policy_tool_name = @@ -8195,6 +8302,8 @@ pub(crate) fn file_tool_ask_rule_decision_for_policy( "", None, workspace, + workspace, + workspace_roots, approval_mode, ); } @@ -8208,6 +8317,8 @@ pub(crate) fn file_tool_ask_rule_decision_for_policy( "", Some(&path), workspace, + workspace, + workspace_roots, approval_mode, ) { Some(ToolAskRuleDecision::Block(reason)) => { @@ -8235,10 +8346,12 @@ fn tool_ask_rule_decision_for_context( tool_name: &str, command: &str, path: Option<&str>, + cwd: &Path, workspace: &Path, + workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, ) -> Option { - let cwd = workspace.to_string_lossy(); + let judged_cwd = cwd.to_string_lossy(); let ask_for_approval = match approval_mode { crate::tui::approval::ApprovalMode::Never => AskForApproval::Never, crate::tui::approval::ApprovalMode::Auto @@ -8248,11 +8361,17 @@ fn tool_ask_rule_decision_for_context( let decision = exec_policy_engine .check(ExecPolicyContext { command, - cwd: cwd.as_ref(), + cwd: judged_cwd.as_ref(), tool: Some(tool_name), path, ask_for_approval, sandbox_mode: None, + // `check` prepends the judged cwd itself and dedupes, so pass the + // full normalized session set (primary included): ask/deny scope + // matching keeps spanning every declared root even when the + // judged cwd differs from the session primary (an exec `cwd:` + // operand), while allow rules stay narrowed to the judged cwd. + workspace_roots: codewhale_core::normalize_workspace_roots(workspace, workspace_roots), }) .ok()?; if !decision.allow { @@ -8272,12 +8391,18 @@ fn tool_ask_rule_decision_for_context( } fn file_tool_permission_paths(tool_name: &str, input: &Value) -> Option> { + // Alias-aware (`file_path`/`filePath`): the ask-rule and carve-out + // judgments run before execution folds `PATH_ALIASES` onto `path`, so + // an alias-spelled call was invisible to every persisted rule and to + // the write carve-out (round-22 B22-2). match tool_name { - "read_file" | "write_file" | "edit_file" | "file_search" | "grep_files" => { - Some(string_field(input, "path").into_iter().collect()) - } + "read_file" | "write_file" | "edit_file" | "file_search" | "grep_files" => Some( + crate::tools::file::path_param_value(input) + .into_iter() + .collect(), + ), "list_dir" => Some(vec![ - string_field(input, "path").unwrap_or_else(|| ".".to_string()), + crate::tools::file::path_param_value(input).unwrap_or_else(|| ".".to_string()), ]), "apply_patch" => Some(apply_patch_permission_paths(input)), _ => None, @@ -8295,15 +8420,6 @@ fn file_write_tool_target_paths(tool_name: &str, input: &Value) -> Option Option { - input - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - fn apply_patch_permission_paths(input: &Value) -> Vec { crate::tools::apply_patch::preflight_apply_patch(input) .map(|preflight| preflight.touched_files) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index e2cb830a1f..4087fc0b1a 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -8897,6 +8897,7 @@ fn auto_review_plan_decision( approval_mode, workspace_trusted, workspace, + &[], ); auto_review_plan_decision_for_context(policy, &context) } @@ -9351,6 +9352,7 @@ fn workspace_write_carve_out_covers_the_default_ask_posture_only() { ask.1, ask.2, workspace, + &[], tool, input, ApprovalRequirement::Suggest, @@ -9411,6 +9413,7 @@ fn workspace_write_carve_out_covers_the_default_ask_posture_only() { approval_mode, auto_approve, workspace, + &[], "write_file", &json!({"path": "src/main.rs"}), ApprovalRequirement::Suggest, @@ -9425,12 +9428,57 @@ fn workspace_write_carve_out_covers_the_default_ask_posture_only() { ask.1, ask.2, workspace, + &[], "write_file", &json!({"path": "src/main.rs"}), ApprovalRequirement::Required, )); } +#[test] +fn workspace_write_carve_out_judges_the_raw_spelling_on_the_real_pipeline() { + // The gate must judge exactly what execution resolves: the engine's path + // collector passes the tool input through untrimmed, and + // `ToolContext::resolve_path` branches on the raw spelling. A + // leading-whitespace absolute-looking target is a *relative* path to the + // write tools (joined onto the primary), so a trimmed judgment would let + // it qualify through an attached git root modal-free while the write + // lands inside the non-git primary tree — fail-open where base was + // fail-closed. + let primary = tempdir().expect("non-git primary"); + let attached = tempdir().expect("attached dir"); + std::fs::create_dir(attached.path().join(".git")).expect("git marker"); + let ask = ( + crate::tui::app::AppMode::Agent, + crate::tui::approval::ApprovalMode::Suggest, + false, + ); + let carve_out = |input: &serde_json::Value| { + workspace_write_carve_out_applies( + ask.0, + ask.1, + ask.2, + primary.path(), + &[attached.path().to_path_buf()], + "write_file", + input, + ApprovalRequirement::Suggest, + ) + }; + + let whitespace_prefixed = format!(" {}", attached.path().join("src/main.rs").display()); + assert!( + !carve_out(&json!({"path": whitespace_prefixed})), + "the raw pipeline must keep the modal for a whitespace-prefixed target" + ); + + // The trimmed spelling stays qualified on the same pipeline: trimming + // decides nothing but emptiness anywhere on this path. + assert!(carve_out( + &json!({"path": attached.path().join("src/main.rs")}) + )); +} + #[test] fn sandbox_escalation_requires_a_pair_and_a_strictly_wider_mode() { use crate::sandbox::SandboxPolicy; @@ -9755,6 +9803,7 @@ fn exec_shell_scenario() { "exec_shell", &json!({"command": "cargo test --workspace"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Auto, ); @@ -9778,6 +9827,7 @@ fn exec_shell_scenario() { "exec_shell", &json!({"command": "cargo test --workspace"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Never, ); @@ -9800,6 +9850,7 @@ fn exec_shell_scenario() { "exec_shell", &json!({"command": "git status"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Auto, ); @@ -9819,6 +9870,7 @@ fn canonical_bash_run_honors_legacy_typed_ask_rules() { "Bash", &json!({"action": "run", "command": "cargo test --workspace"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Auto, ); @@ -9847,6 +9899,7 @@ fn exec_shell_allow_rule_decision_allows_only_exact_command_in_scoped_repo() { "exec_shell", &json!({"command": "cargo test"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Suggest, ), Some(ToolAskRuleDecision::Allow) @@ -9857,6 +9910,7 @@ fn exec_shell_allow_rule_decision_allows_only_exact_command_in_scoped_repo() { "exec_shell", &json!({"command": "cargo test --workspace"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Suggest, ), None @@ -9867,12 +9921,152 @@ fn exec_shell_allow_rule_decision_allows_only_exact_command_in_scoped_repo() { "exec_shell", &json!({"command": "cargo test"}), Path::new("/other"), + &[], + crate::tui::approval::ApprovalMode::Suggest, + ), + None + ); +} + +#[test] +fn exec_shell_scoped_allow_rule_does_not_follow_cwd_into_attached_root() { + // B15-3 regression pin: the normal exec lane resolves a `cwd:` operand + // into any declared root and executes there, so the approval context + // must judge the resolved effective cwd. A grant scoped to the primary + // repo must not auto-approve the same command redirected into an + // attached root — a different repository the grant never named. + let rule = codewhale_execpolicy::ToolAskRule::exec_shell("git push") + .into_exact_workspace_allow("/repo"); + let config = EngineConfig { + exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]), + ]), + ..EngineConfig::default() + }; + let roots = [PathBuf::from("/shared")]; + + // Control: inside the scoped repo the grant still auto-approves, with + // the attached root declared. + assert_eq!( + exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push"}), + Path::new("/repo"), + &roots, + crate::tui::approval::ApprovalMode::Suggest, + ), + Some(ToolAskRuleDecision::Allow) + ); + // A relative `cwd:` resolves against the primary root (execution + // semantics), so it keeps the grant. + assert_eq!( + exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push", "cwd": "."}), + Path::new("/repo"), + &roots, + crate::tui::approval::ApprovalMode::Suggest, + ), + Some(ToolAskRuleDecision::Allow) + ); + // Redirected into the attached root, the /repo-scoped grant must not + // auto-approve. + assert_eq!( + exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push", "cwd": "/shared"}), + Path::new("/repo"), + &roots, + crate::tui::approval::ApprovalMode::Suggest, + ), + None, + "a /repo-scoped allow must not auto-approve execution under an attached root" + ); + + // The scope match is honest in both directions: a grant scoped to the + // attached root fires exactly when execution lands there. + let attached_rule = codewhale_execpolicy::ToolAskRule::exec_shell("git push") + .into_exact_workspace_allow("/shared"); + let attached_config = EngineConfig { + exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![attached_rule]), + ]), + ..EngineConfig::default() + }; + assert_eq!( + exec_shell_ask_rule_decision( + &attached_config, + "exec_shell", + &json!({"command": "git push", "cwd": "/shared"}), + Path::new("/repo"), + &roots, + crate::tui::approval::ApprovalMode::Suggest, + ), + Some(ToolAskRuleDecision::Allow) + ); + assert_eq!( + exec_shell_ask_rule_decision( + &attached_config, + "exec_shell", + &json!({"command": "git push"}), + Path::new("/repo"), + &roots, crate::tui::approval::ApprovalMode::Suggest, ), None ); } +#[test] +fn exec_shell_attached_root_scoped_deny_reaches_rule_decision() { + // Pin for the engine-level exec-policy glue: the declared root set must + // reach the typed-rule decision. Every other engine-level caller passes + // `&[]`, so a glue mutation dropping the set only shows up as an + // attached-root-dependent outcome flipping. + let rule = codewhale_execpolicy::ToolAskRule { + tool: "exec_shell".into(), + command: Some("git push".into()), + command_exact: false, + path: None, + workspace: Some("/shared".into()), + action: codewhale_execpolicy::PermissionAction::Deny, + }; + let config = EngineConfig { + exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]), + ]), + ..EngineConfig::default() + }; + + let decision = exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push origin main"}), + Path::new("/repo"), + &[PathBuf::from("/shared")], + crate::tui::approval::ApprovalMode::Suggest, + ); + assert!( + matches!(decision, Some(ToolAskRuleDecision::Block(_))), + "a deny rule scoped to an attached root must reach the decision: {decision:?}" + ); + + // Control: with the historical empty root set the /shared-scoped deny + // does not reach the call. + let decision = exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push origin main"}), + Path::new("/repo"), + &[], + crate::tui::approval::ApprovalMode::Suggest, + ); + assert_eq!(decision, None); +} + #[test] fn file_ask_scenario() { // Scenario consolidation of: file_ask_rule_decision_prompts_for_matching_read_path, file_ask_rule_decision_prompts_for_absolute_workspace_path, file_ask_rule_decision_blocks_matching_read_path_when_approval_is_never, file_ask_rule_decision_ignores_unmatched_path @@ -9888,6 +10082,7 @@ fn file_ask_scenario() { "read_file", &json!({"path": "secrets/api_key.txt"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Auto, ); @@ -9911,6 +10106,7 @@ fn file_ask_scenario() { "read_file", &json!({"path": "/repo/secrets/api_key.txt"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Auto, ); @@ -9934,6 +10130,7 @@ fn file_ask_scenario() { "read_file", &json!({"path": "secrets/api_key.txt"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Never, ); @@ -9956,6 +10153,7 @@ fn file_ask_scenario() { "read_file", &json!({"path": "docs/readme.md"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Auto, ); @@ -9975,6 +10173,7 @@ fn canonical_file_action_honors_legacy_path_ask_rules() { "File", &json!({"action": "write", "path": "src/lib.rs", "content": "new\n"}), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Auto, ); @@ -9986,6 +10185,62 @@ fn canonical_file_action_honors_legacy_path_ask_rules() { ); } +#[test] +#[allow(non_snake_case)] +fn camelCase_file_path_alias_reaches_the_ask_rule_and_carve_out_judgments() { + // Round-22 B22-2: execution folds `filePath` (and `file_path`) onto + // `path` via `PATH_ALIASES` only at execute time, and the default + // `ToolSpec::prepare` passes input through unchanged — so an + // alias-spelled write was invisible to the persisted ask-rule judgment + // and to the in-workspace write carve-out at once. + let config = EngineConfig { + exec_policy_engine: file_ask_rule_engine("write_file", "src/lib.rs"), + ..EngineConfig::default() + }; + + let camel = file_tool_ask_rule_decision( + &config, + "File", + &json!({"action": "write", "filePath": "src/lib.rs", "content": "new\n"}), + Path::new("/repo"), + &[], + crate::tui::approval::ApprovalMode::Auto, + ); + assert_eq!( + camel, + Some(ToolAskRuleDecision::Prompt( + "Typed ask rule 'tool=write_file path=src/lib.rs' requires approval.".to_string() + )), + "a filePath-spelled write must reach the ask-rule judgment" + ); + + let snake = file_tool_ask_rule_decision( + &config, + "File", + &json!({"action": "write", "file_path": "src/lib.rs", "content": "new\n"}), + Path::new("/repo"), + &[], + crate::tui::approval::ApprovalMode::Auto, + ); + assert_eq!( + snake, camel, + "the file_path alias must behave identically to filePath" + ); + + // The canonical key still wins when present, matching the execute-time + // fold (both present and disagreeing fails the call at execution, so the + // judgment is free to prefer the canonical spelling). + let canonical_wins = file_tool_ask_rule_decision( + &config, + "File", + &json!({"action": "write", "path": "src/other.rs", "filePath": "src/lib.rs"}), + Path::new("/repo"), + &[], + crate::tui::approval::ApprovalMode::Auto, + ); + assert_eq!(canonical_wins, None, "path wins; other.rs is not ruled"); +} + #[test] fn apply_patch_allow_requires_every_touched_path_to_match() { let rules = ["src/a.rs", "src/b.rs"] @@ -10012,6 +10267,7 @@ fn apply_patch_allow_requires_every_touched_path_to_match() { ] }), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Suggest, ); assert_eq!(fully_allowed, Some(ToolAskRuleDecision::Allow)); @@ -10026,6 +10282,7 @@ fn apply_patch_allow_requires_every_touched_path_to_match() { ] }), Path::new("/repo"), + &[], crate::tui::approval::ApprovalMode::Suggest, ); assert_eq!(partially_allowed, None); @@ -13638,6 +13895,7 @@ async fn full_access_permission_allow_cannot_bypass_repo_law() { "write_file", &tool_input, workspace.path(), + &[], crate::tui::approval::ApprovalMode::Bypass, ), Some(ToolAskRuleDecision::Allow), @@ -13658,6 +13916,62 @@ async fn full_access_permission_allow_cannot_bypass_repo_law() { assert!(!target.exists(), "repo-law block must prevent the write"); } +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn full_access_repo_law_holds_writes_under_attached_roots() { + // Round-15 pin at the turn-loop layer: `run_turn` must forward the + // session root set to `repo_law_plan_decision`. A `&[]` mutation at the + // turn-loop call site leaves the attached root's constitution unloaded, + // so the write below would execute with every repo-law unit test (one + // layer down) still green. + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let attached = tempdir().expect("attached root"); + let law_dir = attached.path().join(".codewhale"); + fs::create_dir_all(&law_dir).expect("create law directory"); + fs::write( + law_dir.join("constitution.json"), + r#"{ + "protected_invariants": [{ + "text": "Shared root notes need human review", + "paths": ["SHARED.md"] + }] + }"#, + ) + .expect("write repo law fixture"); + let target = attached.path().join("SHARED.md"); + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + workspace_roots: vec![attached.path().to_path_buf()], + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + ..EngineConfig::default() + }; + let tool_input = json!({ + "action": "write", + "path": target.to_string_lossy(), + "content": "must not be written\n" + }); + + assert_full_access_model_tool_batch_is_blocked( + engine_config, + vec![("File", tool_input)], + &[( + "File", + "Repository law blocked tool 'File' in Full Access: Repo law holds this write: \"Shared root notes need human review\"", + )], + "Repository law blocked tool 'File' in Full Access: Repo law holds this write:", + ) + .await; + + assert!( + !target.exists(), + "repo-law block must prevent the write under the attached root" + ); +} + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn auto_review_auto_resolves_hallucinated_question_without_prompting() { @@ -13931,6 +14245,7 @@ async fn full_access_permission_allow_cannot_bypass_background_catastrophic_floo "exec_shell", &tool_input, workspace.path(), + &[], crate::tui::approval::ApprovalMode::Bypass, ), Some(ToolAskRuleDecision::Allow), @@ -15303,6 +15618,7 @@ fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() { ApprovalMode::Suggest, None, &workspace, + &[], SandboxNetworkAccess::Restricted, ), SandboxPolicy::ReadOnly @@ -15314,6 +15630,7 @@ fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() { ApprovalMode::Suggest, None, &workspace, + &[], SandboxNetworkAccess::Restricted, ) { SandboxPolicy::WorkspaceWrite { @@ -15336,6 +15653,7 @@ fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() { ApprovalMode::Suggest, None, &workspace, + &[], SandboxNetworkAccess::Allowed, ) { SandboxPolicy::WorkspaceWrite { network_access, .. } => { @@ -15354,6 +15672,7 @@ fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() { ApprovalMode::Bypass, None, &workspace, + &[], SandboxNetworkAccess::Restricted, ), SandboxPolicy::DangerFullAccess @@ -16612,6 +16931,7 @@ async fn sync_session_restores_current_mode() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Plan, }) .await @@ -16690,6 +17010,7 @@ async fn sync_session_without_prompt_repins_full_system_prompt_on_next_turn() { system_prompt_override: false, model: crate::config::DEFAULT_TEXT_MODEL.to_string(), workspace: workspace.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -16771,6 +17092,7 @@ async fn sync_session_same_id_does_not_finalize_live_worker() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: workspace.clone(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -16791,6 +17113,7 @@ async fn sync_session_same_id_does_not_finalize_live_worker() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: workspace.clone(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -16832,6 +17155,7 @@ async fn sync_session_different_id_finalizes_live_worker() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: workspace.clone(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -16855,6 +17179,7 @@ async fn sync_session_different_id_finalizes_live_worker() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: workspace.clone(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -16921,6 +17246,7 @@ async fn sync_session_migrates_one_checkpoint_and_strips_its_system_carrier() { system_prompt_override: true, model: "deepseek-v4-pro".to_string(), workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -17004,6 +17330,7 @@ async fn sync_session_projects_persisted_subagent_handoff_for_headless_restore() system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -17070,6 +17397,107 @@ async fn session_snapshot_omits_id_for_legacy_root_custom_route() { run.abort(); } +#[test] +fn tool_context_for_turn_materializes_session_workspace_roots() { + let tmp = tempdir().expect("tempdir"); + let shared = tempdir().expect("shared root"); + let expected = vec![tmp.path().to_path_buf(), shared.path().to_path_buf()]; + + let mut config = deterministic_engine_config(tmp.path()); + config.workspace_roots = vec![shared.path().to_path_buf()]; + let (engine, _handle) = Engine::new(config, &Config::default()); + let ctx = engine.build_tool_context(AppMode::Agent, false); + assert_eq!(ctx.workspace_roots, expected); + match &ctx.elevated_sandbox_policy { + Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { writable_roots, .. }) => { + assert_eq!(writable_roots, &expected); + } + other => panic!("agent turn must carry a workspace-write policy: {other:?}"), + } + + // No configured roots: the session degenerates to the primary root and + // the materialized policy is byte-identical to the historical shape. + let (engine, _handle) = + Engine::new(deterministic_engine_config(tmp.path()), &Config::default()); + let ctx = engine.build_tool_context(AppMode::Agent, false); + assert_eq!(ctx.workspace_roots, vec![tmp.path().to_path_buf()]); + match &ctx.elevated_sandbox_policy { + Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { writable_roots, .. }) => { + assert_eq!(writable_roots, &vec![tmp.path().to_path_buf()]); + } + other => panic!("agent turn must carry a workspace-write policy: {other:?}"), + } +} + +#[tokio::test] +async fn sync_session_replaces_workspace_roots_for_the_next_turn() { + let tmp = tempdir().expect("tempdir"); + let shared = tempdir().expect("shared root"); + let (engine, handle) = Engine::new(deterministic_engine_config(tmp.path()), &Config::default()); + let run = tokio::spawn(engine.run()); + + // A roots-only sync (same primary workspace) swaps the materialized set. + handle + .send(Op::SyncSession { + session_id: Some("roots-session".to_string()), + messages: Vec::new(), + system_prompt: None, + system_prompt_override: false, + model: "deepseek-v4-pro".to_string(), + workspace: tmp.path().to_path_buf(), + workspace_roots: vec![shared.path().to_path_buf()], + mode: AppMode::Agent, + }) + .await + .expect("sync session"); + + let (tx, rx) = tokio::sync::oneshot::channel(); + handle + .send(Op::GetSessionSnapshot { + tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))), + }) + .await + .expect("request snapshot"); + let snapshot = tokio::time::timeout(Duration::from_secs(2), rx) + .await + .expect("snapshot response") + .expect("snapshot"); + assert_eq!( + snapshot.workspace_roots, + vec![tmp.path().to_path_buf(), shared.path().to_path_buf()], + "next turn must materialize the replaced root set" + ); + + // A later sync with no roots configured falls back to the primary root. + handle + .send(Op::SyncSession { + session_id: Some("roots-session".to_string()), + messages: Vec::new(), + system_prompt: None, + system_prompt_override: false, + model: "deepseek-v4-pro".to_string(), + workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), + mode: AppMode::Agent, + }) + .await + .expect("sync session without roots"); + let (tx, rx) = tokio::sync::oneshot::channel(); + handle + .send(Op::GetSessionSnapshot { + tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))), + }) + .await + .expect("request snapshot"); + let snapshot = tokio::time::timeout(Duration::from_secs(2), rx) + .await + .expect("snapshot response") + .expect("snapshot"); + assert_eq!(snapshot.workspace_roots, vec![tmp.path().to_path_buf()]); + + run.abort(); +} + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn edit_last_turn_preserves_current_mode() { @@ -17139,6 +17567,7 @@ async fn edit_last_turn_preserves_current_mode() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -17296,6 +17725,7 @@ async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -17409,6 +17839,7 @@ async fn edit_last_turn_without_user_prompt_errors_and_sends_nothing() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -17523,6 +17954,7 @@ async fn edit_last_turn_without_user_prompt_errors_and_sends_nothing() { system_prompt_override: false, model: "deepseek-v4-pro".to_string(), workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -19080,6 +19512,100 @@ fn working_set_reaches_model_as_turn_metadata() { assert!(text.contains("src/lib.rs")); } +fn turn_meta_text(engine: &mut Engine, input: &str) -> String { + let user_msg = engine.user_text_message_with_turn_metadata(input.to_string()); + user_msg + .content + .iter() + .find_map(|block| match block { + ContentBlock::Text { text, .. } if text.starts_with("") => { + Some(text.clone()) + } + _ => None, + }) + .expect("turn metadata block") +} + +#[test] +fn forkguard_workspace_roots_turn_meta_lists_attached_roots() { + let tmp = tempdir().expect("tempdir"); + let shared_a = tempdir().expect("shared a"); + let shared_b = tempdir().expect("shared b"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + workspace_roots: vec![shared_a.path().to_path_buf(), shared_b.path().to_path_buf()], + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + + let first = turn_meta_text(&mut engine, "one"); + let expected_line = format!( + "Accessible folders: {}, {}", + shared_a.path().display(), + shared_b.path().display() + ); + assert!(first.contains(&expected_line), "{first}"); + // The line sits immediately after the workspace line (the model reads the + // primary root there and the attached roots here). + let workspace_pos = first.find("Current workspace:").expect("workspace line"); + let folders_pos = first.find("Accessible folders:").expect("folders line"); + assert!(folders_pos > workspace_pos); + assert!( + !first[workspace_pos..folders_pos].contains("Current permission posture:"), + "the folders line must precede the posture lines: {first}" + ); + // A stable root set renders the line byte-identically every turn. + let second = turn_meta_text(&mut engine, "two"); + assert_eq!( + first + .lines() + .find(|line| line.starts_with("Accessible folders:")), + second + .lines() + .find(|line| line.starts_with("Accessible folders:")), + ); +} + +#[test] +fn turn_meta_omits_accessible_folders_for_single_root() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + + let text = turn_meta_text(&mut engine, "hello"); + assert!( + !text.contains("Accessible folders:"), + "single-root sessions keep the historical block byte shape: {text}" + ); +} + +#[test] +fn turn_meta_accessible_folders_truncates_long_root_sets() { + let tmp = tempdir().expect("tempdir"); + let roots: Vec = (0..7) + .map(|index| tmp.path().join(format!("root-{index}"))) + .collect(); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + workspace_roots: roots.clone(), + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + + let text = turn_meta_text(&mut engine, "hello"); + let line = text + .lines() + .find(|line| line.starts_with("Accessible folders:")) + .expect("folders line"); + // At most five attached roots listed, the rest folded into a count. + assert!(line.contains("root-0") && line.contains("root-4"), "{line}"); + assert!(!line.contains("root-5"), "{line}"); + assert!(line.ends_with("… (+2 more)"), "{line}"); +} + #[test] fn turn_metadata_includes_git_workspace_snapshot_in_repo() { use crate::dependencies::ExternalTool; @@ -25898,6 +26424,7 @@ async fn forkguard_workspace_sync_invalidates_in_flight_boot() { system_prompt_override: false, model: crate::config::DEFAULT_TEXT_MODEL.to_string(), workspace: workspace_b.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -27661,6 +28188,7 @@ async fn session_sync_drops_a_parked_toolbox_eviction_note() { system_prompt_override: false, model: crate::config::DEFAULT_TEXT_MODEL.to_string(), workspace: workspace.path().to_path_buf(), + workspace_roots: Vec::new(), mode: AppMode::Agent, }) .await @@ -27972,3 +28500,154 @@ async fn toolbox_note_survives_a_batch_without_results() { "the re-parked note must keep its content: {notes:?}" ); } + +// Round-20 B20-1: the judged cwd must resolve symlinks the way execution +// does. `link/..` normalizes lexically back into the primary but lands +// in the attached repo on disk; a primary-scoped allow must not fire for +// it, and neither for the link itself. Unix-gated: symlink creation is +// the fixture (architecture-guard allow-target-cfg). +#[test] +#[cfg(unix)] +fn exec_judged_cwd_resolves_symlinks_like_execution() { + use std::path::Path; + + let temp = tempfile::tempdir().expect("tempdir"); + let primary_raw = temp.path().join("ws"); + let attached_raw = temp.path().join("attached").join("repo"); + std::fs::create_dir_all(&primary_raw).unwrap(); + std::fs::create_dir_all(&attached_raw).unwrap(); + // Judge and scope against canonical spellings so the control legs are + // platform-stable (macOS /var → /private/var would otherwise flip the + // control leg to a modal). + let primary = std::fs::canonicalize(&primary_raw).unwrap(); + let attached = std::fs::canonicalize(&attached_raw).unwrap(); + std::os::unix::fs::symlink(&attached, primary_raw.join("link")).unwrap(); + + let rule = codewhale_execpolicy::ToolAskRule::exec_shell("git push") + .into_exact_workspace_allow(primary.to_string_lossy().as_ref()); + let config = EngineConfig { + exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]), + ]), + ..EngineConfig::default() + }; + let roots = [attached.clone()]; + + // Control: in the primary itself the grant fires. + assert_eq!( + exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push", "cwd": "."}), + Path::new(&primary), + &roots, + crate::tui::approval::ApprovalMode::Suggest, + ), + Some(ToolAskRuleDecision::Allow) + ); + + // symlink+`..`: lexically back in the primary, canonically the + // attached repo — the /primary-scoped grant must not fire. + assert_ne!( + exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push", "cwd": "link/.."}), + Path::new(&primary), + &roots, + crate::tui::approval::ApprovalMode::Suggest, + ), + Some(ToolAskRuleDecision::Allow) + ); + + // symlink-interior: the link itself resolves into the attached repo. + let through_link = primary.join("link"); + assert_ne!( + exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push", "cwd": through_link}), + Path::new(&primary), + &roots, + crate::tui::approval::ApprovalMode::Suggest, + ), + Some(ToolAskRuleDecision::Allow) + ); +} + +// Round-21 B21-3 absent-interior leg: a nonexistent component behind the +// link used to keep the lexical join (normalize pops `absent/..` and +// `link/..` back onto the primary) while execution's +// resolve_nonexistent_path canonicalizes the deepest existing ancestor — +// the link — landing in the attached repo. +#[test] +#[cfg(unix)] +fn exec_judged_cwd_resolves_absent_interior_behind_symlink() { + use std::path::Path; + + let temp = tempfile::tempdir().expect("tempdir"); + let primary_raw = temp.path().join("ws"); + let attached_raw = temp.path().join("attached").join("repo"); + std::fs::create_dir_all(&primary_raw).unwrap(); + std::fs::create_dir_all(&attached_raw).unwrap(); + let primary = std::fs::canonicalize(&primary_raw).unwrap(); + let attached = std::fs::canonicalize(&attached_raw).unwrap(); + std::os::unix::fs::symlink(&attached, primary_raw.join("link")).unwrap(); + + let rule = codewhale_execpolicy::ToolAskRule::exec_shell("git push") + .into_exact_workspace_allow(primary.to_string_lossy().as_ref()); + let config = EngineConfig { + exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]), + ]), + ..EngineConfig::default() + }; + let roots = [attached]; + + // The `link//../..` spelling: the lexical join pops back onto + // the primary; the deepest-existing resolution (what execution's + // resolve_nonexistent_path does) lands in the attached repo through + // the link. The primary-scoped allow must not fire. + assert_ne!( + exec_shell_ask_rule_decision( + &config, + "exec_shell", + &json!({"command": "git push", "cwd": "link/absent/../.."}), + Path::new(&primary), + &roots, + crate::tui::approval::ApprovalMode::Suggest, + ), + Some(ToolAskRuleDecision::Allow) + ); +} +/// Round-32 (M31-2 residual): the composer-bang ask-rule call site in +/// engine.rs (distinct from the turn_loop pair) also forwards the +/// session's workspace_roots — `&[]` there shipped green under every +/// existing pin. +#[test] +fn composer_bang_ask_rule_site_forwards_session_workspace_roots() { + let src = include_str!("../engine.rs"); + let cut = &src[..src.find("mod tests").unwrap_or(src.len())]; + let mut found_any = false; + let mut forwarded_all = true; + let mut cursor = 0; + while let Some(rel) = cut[cursor..].find("exec_shell_ask_rule_decision(") { + let at = cursor + rel; + let window = &cut[at..(at + 300).min(cut.len())]; + if window.contains("&self.config") { + found_any = true; + if !window.contains("&self.session.workspace_roots") { + forwarded_all = false; + } + } + cursor = at + 1; + } + assert!( + found_any, + "the engine.rs composer-bang call site must exist" + ); + assert!( + forwarded_all, + "the engine.rs composer-bang call site must forward the session's workspace_roots" + ); +} diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index b18513dfce..3330fcf7e9 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -836,6 +836,7 @@ pub(super) fn workspace_write_carve_out_applies( approval_mode: crate::tui::approval::ApprovalMode, auto_approve: bool, workspace: &std::path::Path, + workspace_roots: &[std::path::PathBuf], tool_name: &str, input: &serde_json::Value, approval: ApprovalRequirement, @@ -848,7 +849,11 @@ pub(super) fn workspace_write_carve_out_applies( let Some(paths) = file_write_tool_target_paths(tool_name, input) else { return false; }; - crate::core::authority::paths_within_workspace_write_carve_out(workspace, &paths) + crate::core::authority::paths_within_workspace_write_carve_out( + workspace, + workspace_roots, + &paths, + ) } pub(super) fn registered_tool_forces_prompt( @@ -3631,6 +3636,7 @@ impl Engine { batch_approval_mode, self.api_config.sandbox_mode.as_deref(), &self.session.workspace, + &self.session.workspace_roots, crate::core::authority::SandboxNetworkAccess::from_config( self.api_config.sandbox_network_access, ), @@ -3880,6 +3886,7 @@ impl Engine { self.session.approval_mode, self.session.auto_approve, &self.session.workspace, + &self.session.workspace_roots, &tool_name, &tool_input, prepared.call.approval, @@ -3939,6 +3946,7 @@ impl Engine { &tool_name, &tool_input, &self.session.workspace, + &self.session.workspace_roots, self.session.approval_mode, ) .or_else(|| { @@ -3947,6 +3955,7 @@ impl Engine { &tool_name, &tool_input, &self.session.workspace, + &self.session.workspace_roots, self.session.approval_mode, ) }); @@ -3990,6 +3999,7 @@ impl Engine { self.session.approval_mode, crate::config::is_workspace_trusted(&self.session.workspace), Some(&self.session.workspace), + &self.session.workspace_roots, ); let (decision, audit_event) = auto_review_plan_decision_for_context( &self.config.auto_review_policy, @@ -4060,10 +4070,13 @@ impl Engine { // Repo law: protected invariants with path globs compile into // mechanical write holds. Like the safety floor, law is not // bypassable by mode — it can only add holds, never remove - // one, so this cannot weaken any gate above. + // one, so this cannot weaken any gate above. Each accessible root + // carries its own constitution: a write under an attached root is + // judged against that root's law, not only the primary's. if blocked_error.is_none() && let Some(decision) = crate::repo_law::repo_law_plan_decision( &self.session.workspace, + &self.session.workspace_roots, &tool_name, &tool_input, ) @@ -4859,10 +4872,16 @@ impl Engine { &tool_input, ) .0; + // Round-37 M37-6: the grouping key resolves the cwd + // operand against the session's CURRENT primary (the + // same anchor the TUI approval card uses), so a + // grant approved in one root cannot cover the same + // raw operand after a workspace move. let approval_grouping_key = crate::tools::approval_cache::build_approval_grouping_key( &tool_name, &tool_input, + &self.session.workspace, ) .0; let approval_event = Event::ApprovalRequired { @@ -9032,4 +9051,25 @@ mod tests { ); } } + /// Round-31 M31-2: the PRODUCTION ask-rule call sites forward the + /// session's workspace_roots — every existing pin calls the functions + /// directly, so `&self.session.workspace_roots` -> `&[]` at either call + /// site shipped green. Source-order pin on the turn-loop text (the + /// sibling repo-law e2e pin lives in engine/tests.rs). + #[test] + fn ask_rule_call_sites_forward_session_workspace_roots() { + let src = include_str!("turn_loop.rs"); + let cut = &src[..src.find("mod tests {").unwrap_or(src.len())]; + for site in [ + "exec_shell_ask_rule_decision(", + "file_tool_ask_rule_decision(", + ] { + let at = cut.rfind(site).expect("production call site must exist"); + let window = &cut[at..(at + 400).min(cut.len())]; + assert!( + window.contains("&self.session.workspace_roots"), + "the {site} call site must forward the session's workspace_roots" + ); + } + } } diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 3fbd102d0b..4c9795c36c 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -181,6 +181,9 @@ pub struct SessionSnapshot { /// Exact non-secret configured provider key. pub model_provider_id: Option, pub workspace: PathBuf, + /// Workspace roots currently materialized for the session; `workspace` + /// is the primary root at position 0. + pub workspace_roots: Vec, pub system_prompt: Option, pub mode: String, } @@ -471,6 +474,7 @@ pub enum Op { system_prompt_override: bool, model: String, workspace: PathBuf, + workspace_roots: Vec, mode: AppMode, }, diff --git a/crates/tui/src/core/protocol_parity.rs b/crates/tui/src/core/protocol_parity.rs index 0a0a5b578c..d0bbcebbee 100644 --- a/crates/tui/src/core/protocol_parity.rs +++ b/crates/tui/src/core/protocol_parity.rs @@ -1105,6 +1105,7 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op { system_prompt_override, model, workspace, + workspace_roots, mode, } => wire_op::Op::SyncSession { engine_session_id: session_id.clone(), @@ -1113,6 +1114,7 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op { system_prompt_override: *system_prompt_override, model: model.clone(), workspace: workspace.clone(), + workspace_roots: workspace_roots.clone(), mode: app_mode_str(*mode).to_string(), }, Op::CompactContext { @@ -1452,6 +1454,36 @@ mod tests { ); } + /// The tui→wire SyncSession projection must carry the root set: the + /// engine's whole multi-root posture rides on this one mapping, and a + /// regression to an empty set compiles and stays green without this + /// round-trip (round-13 note). + #[test] + fn sync_session_projection_round_trips_a_non_empty_root_set() { + let op = Op::SyncSession { + session_id: Some("sess-1".to_string()), + messages: Vec::new(), + system_prompt: None, + system_prompt_override: false, + model: "m".to_string(), + workspace: std::path::PathBuf::from("/work"), + workspace_roots: vec![ + std::path::PathBuf::from("/work"), + std::path::PathBuf::from("/shared"), + ], + mode: AppMode::Agent, + }; + let msg = op_to_protocol(&op); + let value = serde_json::to_value(&msg).unwrap(); + let back: wire_op::Op = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(back, msg); + assert_eq!( + value["workspace_roots"], + json!(["/work", "/shared"]), + "the projection must ship the live set, not an empty one" + ); + } + #[test] fn mode_labels_round_trip_through_app_mode_parse() { for mode in [AppMode::Agent, AppMode::Plan, AppMode::Operate] { diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index c78cc91f9a..8ef5a86b4b 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -352,6 +352,12 @@ pub struct Session { /// Workspace directory pub workspace: PathBuf, + /// Additional workspace roots for this session; `workspace` is always the + /// primary root. Read on every turn when the sandbox policy, tool + /// boundary, and exec-policy context are materialized, so a root-set + /// change takes effect on the next turn. Empty means single-root. + pub workspace_roots: Vec, + /// System prompt (optional) pub system_prompt: Option, /// True when `system_prompt` is a persisted/runtime-supplied prefix that @@ -501,6 +507,7 @@ impl Session { reasoning_effort_auto: false, auto_model: false, workspace, + workspace_roots: Vec::new(), system_prompt: None, system_prompt_override: false, compaction_summary_prompt: None, diff --git a/crates/tui/src/exec_agent.rs b/crates/tui/src/exec_agent.rs index ef82bb388a..127f60a02e 100644 --- a/crates/tui/src/exec_agent.rs +++ b/crates/tui/src/exec_agent.rs @@ -202,10 +202,41 @@ pub(crate) async fn run_exec_agent( ..crate::tools::spec::RuntimeToolServices::default() }; + // Roots enter exec only through the persisted session metadata (Runtime + // API / headless), so capture them once at resume: the engine starts with + // this set and the save path must write the same set back, or a follow-up + // `exec --resume` silently degrades the thread to single-root. + // Round-35 M2: a PERSISTED set is validated before arming — every sibling + // lane consuming one (ACP session/load, REST resume-thread, fork, + // /cd, fork_from_session) hard-refuses a hand-edited/poisoned row, and + // normalize-only here silently granted a filesystem-writable sandbox in + // headless runs (the review's sibling-parity finding). + // Round-37 B37-1: the row validates against its OWN saved primary — the + // CLI `--workspace`/cwd is informational here and never re-anchors a + // resumed row. The r35 shape misjudged cross-directory resumes against + // the launch dir (killing legitimate rows whose launch cwd sat inside a + // declared root, passing poisoned rows from an unrelated cwd) and let + // the launch dir leak into the persisted row as a moved primary. The + // stored-row strip keeps a legal full-load row's head from counting + // against the intake cap (B37-2), so the SAME validated set arms the + // engine, rides `Op::SyncSession`, and reaches the persistence stamp. + let resume_workspace_roots: Vec = match resume_session.as_ref() { + None => Vec::new(), + Some(saved) => { + let stored = saved.metadata.workspace_roots.clone(); + if stored.is_empty() { + Vec::new() + } else { + validate_resume_workspace_roots(&saved.metadata.workspace, &stored)? + } + } + }; + let engine_config = EngineConfig { model: effective_model.clone(), active_route_limits, workspace: workspace.clone(), + workspace_roots: resume_workspace_roots.clone(), session_id: None, subagent_state_root: None, plugin_registry: Some(std::sync::Arc::clone(&engine_plugin_registry)), @@ -356,15 +387,36 @@ pub(crate) async fn run_exec_agent( let mode = AppMode::Agent; let resuming_session = resume_session.is_some(); + let latest_workspace_roots = resume_workspace_roots; let mut loaded_session_id = None; if let Some(saved) = resume_session { let saved_id = saved.metadata.id.clone(); - if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text { - eprintln!( - "Warning: session {} was created in a different workspace ({}). Resuming anyway.", - truncate_id(&saved_id), - saved.metadata.workspace.display(), - ); + if saved.metadata.workspace != workspace { + // The engine runs the SAVED workspace/root pair, so the CLI + // `--workspace` does not re-anchor a resumed session: the + // validated set above is anchored at the saved primary, flows to + // `Op::SyncSession` unchanged, and the persistence stamp writes + // the same pair back — the lane never mints a moved row + // (round-37 B37-1 keeps this comment's claim true). The warning + // only tells the user which directory the session actually runs + // in (round-22 SF22-3 retires the round-21 hard block, which + // refused legitimate resumes against a workspace the lane never + // adopts and named a `--workspace-roots` flag that does not + // exist). + // Round-24 P3 + round-41 minor 9: the gate stays — the + // warning goes to stderr and never touches the stdout JSON + // stream, but JSON consumers parse that stream precisely + // because they want machine-clean output, so an unasked-for + // stderr warning is noise there (recorded in the round-41 + // disposition's single-root-visible list). + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!( + "Warning: session {} was created in a different workspace ({}). \ + Resuming in the session's own workspace.", + truncate_id(&saved_id), + saved.metadata.workspace.display(), + ); + } } engine_handle @@ -375,6 +427,11 @@ pub(crate) async fn run_exec_agent( system_prompt_override: false, model: saved.metadata.model, workspace: saved.metadata.workspace, + // The SAME validated, saved-primary-anchored set that armed + // the engine and will reach the persistence stamp — sending + // the raw row here would let the handler's normalize-only + // re-arm overwrite the validated set (round-37 B37-1). + workspace_roots: latest_workspace_roots.clone(), mode, }) .await?; @@ -929,6 +986,7 @@ pub(crate) async fn run_exec_agent( id: effective_provider_id.as_deref(), }, &latest_workspace, + &latest_workspace_roots, &latest_system_prompt, latest_session_id.as_deref(), u64::from(usage.input_tokens) + u64::from(usage.output_tokens), @@ -1134,3 +1192,78 @@ pub(crate) async fn run_exec_agent( Ok(()) } + +/// Validate a persisted exec session's root row for resume (round-37 +/// B37-1/B37-2): the row validates against its OWN saved primary — the CLI +/// `--workspace`/cwd is informational on this lane and never re-anchors a +/// resumed row — and the stored cwd-first head does not count against the +/// intake cap, so a legal full-load row resumes whole. The returned set is +/// what arms the engine, rides `Op::SyncSession`, and reaches the +/// persistence stamp: one validated set, no moved-row mint. +fn validate_resume_workspace_roots( + saved_primary: &std::path::Path, + stored: &[std::path::PathBuf], +) -> anyhow::Result> { + codewhale_core::validate_stored_workspace_roots(saved_primary, stored).map_err(|error| { + anyhow::anyhow!( + "persisted workspace_roots failed validation on resume \ + (session row is corrupt or hand-edited): {error}" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::validate_resume_workspace_roots; + use std::path::{Path, PathBuf}; + + #[test] + fn exec_resume_validates_against_the_saved_primary_not_the_cli_cwd() { + // Round-37 B37-1: a cross-directory resume (session saved in /repo, + // `codewhale exec --resume` launched from an unrelated cwd) must + // judge the row against the SAVED primary. The r35 shape validated + // against the CLI cwd, which minted moved rows — roots[0] became + // the launch dir while the row's workspace stayed the saved + // primary, accumulating a permanent writable root per resume. + let saved_primary = Path::new("/repo"); + let stored = vec![PathBuf::from("/repo"), PathBuf::from("/shared")]; + let validated = validate_resume_workspace_roots(saved_primary, &stored) + .expect("a clean row anchored at its own primary validates"); + assert_eq!( + validated, + vec![PathBuf::from("/repo"), PathBuf::from("/shared")], + "the validated set keeps the saved primary as its head — the CLI \ + cwd is not part of this signature and cannot re-anchor it" + ); + } + + #[test] + fn exec_resume_arms_a_legal_full_load_row_whole() { + // Round-37 B37-2: the save path mints the 65-entry cwd-first full + // form for a 64-root declaration; resume must not miscount the + // row's own head against the intake cap (that false "corrupt" + // verdict would brick every later `exec --resume` of the row). + let saved_primary = Path::new("/repo"); + let mut stored = vec![PathBuf::from("/repo")]; + stored.extend( + (0..codewhale_core::MAX_WORKSPACE_ROOTS) + .map(|index| PathBuf::from(format!("/repo/r{index}"))), + ); + let validated = validate_resume_workspace_roots(saved_primary, &stored) + .expect("a legal full-load row resumes whole"); + assert_eq!(validated.len(), codewhale_core::MAX_WORKSPACE_ROOTS + 1); + assert_eq!(validated[0], PathBuf::from("/repo")); + } + + #[test] + fn exec_resume_refuses_a_poisoned_row_with_the_intake_reason() { + let saved_primary = Path::new("/repo"); + let stored = vec![PathBuf::from("/repo"), PathBuf::from("/")]; + let err = validate_resume_workspace_roots(saved_primary, &stored) + .expect_err("the strip must not launder a poisoned entry"); + assert!( + err.to_string().contains("corrupt or hand-edited"), + "the refusal names the row state: {err}" + ); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index cc352bda11..2ade7cb005 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1831,7 +1831,7 @@ fn run_with_args(args: Vec) -> Result<()> { // project-level config — `--no-project-config` opts the layer out for // every roster read in this process. crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config); - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; let mut plugin_discovery = None; let mut plugin_registry = None; let (cli, command) = prepare_cli_startup( @@ -2225,7 +2225,7 @@ async fn run_async_main_dispatch( ) } }; - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; if args.context_json { run_doctor_context_json(&config, &workspace) } else if args.json { @@ -2269,7 +2269,7 @@ async fn run_async_main_dispatch( Commands::SessionDiagnostics(args) => run_session_diagnostics(args), Commands::Setup(args) => { let config = load_config_from_cli(&cli)?; - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; run_setup(&config, &workspace, args, plugin_registry.as_ref()) } Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args), @@ -2483,7 +2483,7 @@ async fn run_async_main_dispatch( } Commands::Fleet(args) => { let config = load_config_from_cli(&cli)?; - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; run_fleet_command(&workspace, &config, args).await } Commands::WorkflowTool(args) => { @@ -2515,7 +2515,7 @@ async fn run_async_main_dispatch( Commands::Scorecard(args) => run_scorecard(args), Commands::Mcp { command } => { let config = load_config_from_cli(&cli)?; - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await } Commands::Features(command) => { @@ -2526,7 +2526,7 @@ async fn run_async_main_dispatch( // Identity derivation is structural: credential-bearing // environment values never enter this path. let config = load_structural_config_from_cli(&cli)?; - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; integrations::cli::run(&config, &workspace, command) } Commands::Sandbox(args) => run_sandbox_command(args), @@ -2589,7 +2589,7 @@ async fn run_async_main_dispatch( } Commands::Resume { session_id, last } => { let config = load_config_from_cli(&cli)?; - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; let resume_id = resolve_session_id(session_id, last, &workspace)?; run_interactive( &cli, @@ -2603,7 +2603,7 @@ async fn run_async_main_dispatch( } Commands::Fork { session_id, last } => { let config = load_config_from_cli(&cli)?; - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; let new_session_id = fork_session(&config, session_id, last, &workspace)?; run_interactive( &cli, @@ -2638,7 +2638,7 @@ async fn run_async_main_dispatch( // snapshots are preserved for explicit resume, but never auto-attached. let mut startup_notice = None; let resume_session_id = if cli.continue_session { - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; resolve_continue_session_id( &workspace, io::stdin().is_terminal() && io::stdout().is_terminal(), @@ -2646,7 +2646,7 @@ async fn run_async_main_dispatch( } else if let Some(id) = cli.resume.clone() { Some(id) } else if !cli.fresh { - let workspace = resolve_workspace(&cli); + let workspace = resolve_workspace(&cli)?; preserve_interrupted_checkpoint_for_explicit_resume(&workspace); // Opt-in auto-resume (#2934). Off by default, so the historical // "plain `codewhale` starts fresh" behaviour is unchanged unless the @@ -8089,10 +8089,29 @@ fn init_project() -> Result<()> { Ok(()) } -fn resolve_workspace(cli: &Cli) -> PathBuf { - cli.workspace +fn resolve_workspace(cli: &Cli) -> Result { + let workspace = cli + .workspace .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + // An empty `--workspace ""` would persist a vacuous primary root + // (`starts_with("")` contains every path); reject it like the runtime + // thread guard does. + if workspace.as_os_str().is_empty() { + bail!("workspace must not be empty"); + } + if workspace.is_absolute() { + return Ok(workspace); + } + // A relative workspace reaches boundary_roots() as a root whose + // normalized form is empty ("." carries no components), which contains + // every path; resolve it against the process cwd at intake, the same + // rule checked_workspace_path applies to MCP config paths. + Ok(crate::mcp::normalize_path_components( + &std::env::current_dir() + .context("failed to resolve current directory for workspace")? + .join(workspace), + )) } fn load_config_from_cli(cli: &Cli) -> Result { @@ -8397,6 +8416,24 @@ fn fork_session( ); forked.metadata.copy_cost_from(&saved.metadata); forked.metadata.mark_forked_from(&saved.metadata); + // The fork continues the same conversation over the same accessible + // set: stamp the source's roots before the save. Without it the freshly + // constructed (empty) metadata persists, and the disk-authority + // lifecycle merge keeps re-erasing any later correction - the same + // sticky erasure the in-app `/fork` stamp prevents. + // M27-3: the CLI fork mints a session-row copy like every other fork + // face — the same validating intake applies, so an out-of-band poisoned + // source row cannot ride the fork to durable propagation past the + // source's own runtime resume rejection. The stored-row strip keeps the + // source's own head from counting against the intake cap, so forking a + // legal full-load row succeeds whole (round-37 B37-2). + forked.metadata.workspace_roots = match codewhale_core::validate_stored_workspace_roots( + &saved.metadata.workspace, + &saved.metadata.workspace_roots, + ) { + Ok(roots) => roots, + Err(reason) => bail!("source session root set no longer passes intake: {reason}"), + }; manager.save_session(&forked)?; let source_title = saved.metadata.title.trim(); @@ -8775,7 +8812,7 @@ async fn run_pr( let prompt = format_pr_prompt(number, &view, &diff); let resume_session_id = if cli.continue_session { - let workspace = resolve_workspace(cli); + let workspace = resolve_workspace(cli)?; latest_session_id_for_workspace(&workspace).ok().flatten() } else { cli.resume.clone() @@ -11811,7 +11848,7 @@ async fn run_workflow_tool_command_inner( bail!("workflow-tool accepts only action=run"); } - let workspace = resolve_workspace(cli); + let workspace = resolve_workspace(cli)?; let mut config = load_config_from_cli(cli)?; merge_user_workspace_config(&mut config, cli.config.clone(), &workspace); if let Ok(env_url) = @@ -12058,6 +12095,12 @@ async fn build_direct_workflow_tool( let allow_shell = yolo || config.allow_shell(); let shell_policy = shell_policy_for_mode(mode, allow_shell); let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace); + // Headless workflow contexts are single-root: the caller reaches this + // builder with a workspace path and no root set, so an attached-root write + // of a multi-root session is denied here rather than held. Enforcement + // only (no display surface), byte-identical to base, and the same + // disclosed gap `Runtime::invoke_tool` carries; wiring a session's roots + // through is a scheduled follow-up. let mut context = crate::tools::ToolContext::with_auto_approve( workspace.to_path_buf(), yolo, @@ -12082,6 +12125,7 @@ async fn build_direct_workflow_tool( }, config.sandbox_mode.as_deref(), workspace, + &[], crate::core::authority::SandboxNetworkAccess::from_config(config.sandbox_network_access), )); let network_policy = config.network.clone().map(|network| { @@ -12381,10 +12425,28 @@ fn persist_exec_session( model: &str, provider_route: PersistedProviderRoute<'_>, workspace: &Path, + workspace_roots: &[PathBuf], system_prompt: &Option, session_id: Option<&str>, total_tokens: u64, ) -> Result { + // Round-24 P3 (behavior residual closed): a legacy relative-workspace + // row resumed through this lane used to be re-stamped verbatim, so the + // exec persist durably MINTED relative spellings a fresh `--workspace` + // could never declare (intake absolutizes). Absolutize against the + // process cwd here — the row converts to an absolute spelling on its + // next persist instead of propagating. + let workspace_abs; + let workspace: &Path = if workspace.is_absolute() { + workspace + } else { + workspace_abs = crate::mcp::normalize_path_components( + &std::env::current_dir() + .context("failed to resolve current directory for exec persist")? + .join(workspace), + ); + workspace_abs.as_path() + }; let manager = SessionManager::default_location().context("could not open session manager for save")?; let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) { @@ -12424,6 +12486,7 @@ fn persist_exec_session( provider_route.kind, provider_route.id, workspace, + workspace_roots, ); let id = saved.metadata.id.clone(); manager @@ -12438,12 +12501,14 @@ fn stamp_exec_session_metadata( model_provider_kind: &str, model_provider_id: Option<&str>, workspace: &Path, + workspace_roots: &[PathBuf], ) { saved.metadata.model = model.to_string(); saved .metadata .set_model_provider_route(model_provider_kind, model_provider_id); saved.metadata.workspace = workspace.to_path_buf(); + saved.metadata.workspace_roots = workspace_roots.to_vec(); saved.metadata.mode = Some("exec".to_string()); } @@ -16260,6 +16325,7 @@ api_key = "test-only-key" crate::config::ApiProvider::Custom.as_str(), Some("custom-b"), Path::new("/tmp/exec-resume"), + &[PathBuf::from("/tmp/exec-resume-shared")], ); let mut next_config = custom_exec_config("custom-a"); @@ -16272,6 +16338,18 @@ api_key = "test-only-key" Some("custom-b") ); assert_eq!(persisted.metadata.model, "model-b"); + // The stamp must carry the root set, not only the primary: a resumed + // multi-root exec that dropped it would persist single-root and the + // next `exec --resume` would run degraded. + assert_eq!( + persisted.metadata.workspace_roots, + vec![PathBuf::from("/tmp/exec-resume-shared")], + "the exec stamp must write the root set it was given" + ); + assert_eq!( + persisted.metadata.workspace, + PathBuf::from("/tmp/exec-resume") + ); assert_eq!(next_config.provider.as_deref(), Some("custom-b")); assert_eq!(resumed_model, "model-b"); } @@ -16292,6 +16370,7 @@ api_key = "test-only-key" crate::config::ApiProvider::Custom.as_str(), None, Path::new("/tmp/exec-root"), + &[], ); assert_eq!(saved.metadata.model_provider, "custom"); @@ -16689,6 +16768,30 @@ api_key = "test-only-key" assert!(args.continue_session); } + #[test] + fn workspace_flag_rejects_empty_path() { + // clap's PathBuf parser rejects an empty `--workspace ""` before it + // reaches resolve_workspace; pin that contract so a parser change + // cannot re-open the vacuous-containment lane (`starts_with("")` is + // true for every path). resolve_workspace additionally hard-rejects + // an empty value itself, so the slot stays fail-closed however the + // Cli is built. + let err = Cli::try_parse_from(["codewhale", "--workspace", "", "exec", "probe"]) + .expect_err("empty workspace must be rejected"); + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue); + } + + #[test] + fn workspace_flag_resolves_relative_against_process_cwd() { + // A relative spelling resolves to absolute at intake: its normalized + // form would otherwise be the vacuous containment root ("." carries + // no components), so the CLI must not hand it downstream as-is. + let cli = parse_cli(&["codewhale", "--workspace", ".", "exec", "probe"]); + let resolved = resolve_workspace(&cli).expect("relative workspace resolves"); + assert!(resolved.is_absolute()); + assert_eq!(resolved, std::env::current_dir().expect("process cwd")); + } + #[test] fn sessions_footer_points_to_resume_subcommand() { let cli = parse_cli(&["codewhale", "resume", "abc123"]); @@ -19637,3 +19740,102 @@ mod telemetry_surface_tests; #[cfg(test)] #[path = "tests/telemetry_counters.rs"] mod telemetry_counter_tests; + +#[cfg(test)] +mod fork_session_tests { + use super::*; + use crate::session_manager::SessionManager; + use crate::test_support::{EnvVarGuard, lock_test_env}; + + /// The CLI fork continues the same conversation over the same accessible + /// set: the source's roots must reach the persisted fork, or the + /// disk-authority lifecycle merge keeps re-erasing any later correction. + #[test] + fn fork_session_stamps_the_source_workspace_roots() { + let _lock = lock_test_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let home = tmp.path().join("home"); + std::fs::create_dir_all(&home).expect("home"); + let _home = EnvVarGuard::set("HOME", &home); + + let workspace = tmp.path().join("workspace"); + let shared = tmp.path().join("shared"); + let manager = SessionManager::default_location().expect("default manager"); + let mut source = create_saved_session( + &[crate::models::Message { + role: crate::models::Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "fork me with my roots".to_string(), + cache_control: None, + }], + }], + "deepseek-chat", + &workspace, + 0, + None, + ); + source.metadata.workspace_roots = vec![workspace.clone(), shared.clone()]; + manager.save_session(&source).expect("save source"); + + let config = Config::default(); + let forked_id = fork_session(&config, Some(source.metadata.id.clone()), false, &workspace) + .expect("fork session"); + + let forked = manager.load_session(&forked_id).expect("fork persisted"); + assert_eq!(forked.metadata.workspace, workspace); + assert_eq!( + forked.metadata.workspace_roots, + vec![workspace, shared], + "the fork must persist the source's root set, not the freshly constructed empty one" + ); + } + + #[test] + fn fork_session_forks_a_legal_full_load_row_whole() { + // Round-38 minor 10: the CLI fork face's at-cap round trip — a + // 64-root declaration persists as the 65-entry cwd-first full form, + // and the fork's stored-row strip must revalidate it whole instead + // of miscounting the row's own head against the intake cap. + let _lock = lock_test_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let home = tmp.path().join("home"); + std::fs::create_dir_all(&home).expect("home"); + let _home = EnvVarGuard::set("HOME", &home); + + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let mut stored = vec![workspace.clone()]; + stored.extend((0..codewhale_core::MAX_WORKSPACE_ROOTS).map(|index| { + let root = tmp.path().join(format!("r{index}")); + std::fs::create_dir_all(&root).expect("full-load root"); + root + })); + let manager = SessionManager::default_location().expect("default manager"); + let mut source = create_saved_session( + &[crate::models::Message { + role: crate::models::Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "fork my full load".to_string(), + cache_control: None, + }], + }], + "deepseek-chat", + &workspace, + 0, + None, + ); + source.metadata.workspace_roots = stored; + manager.save_session(&source).expect("save source"); + + let config = Config::default(); + let forked_id = fork_session(&config, Some(source.metadata.id.clone()), false, &workspace) + .expect("a legal full-load row forks whole"); + + let forked = manager.load_session(&forked_id).expect("fork persisted"); + assert_eq!( + forked.metadata.workspace_roots.len(), + codewhale_core::MAX_WORKSPACE_ROOTS + 1, + "the full-load form round-trips through the CLI fork" + ); + } +} diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 74ffed4b8e..edcdd21ba7 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1524,6 +1524,7 @@ pub enum MessageId { // `/status` report labels and runtime summaries. StatusLabelRoute, StatusLabelDirectory, + StatusLabelWorkspaceRoots, StatusLabelProjectDocs, StatusLabelMode, StatusLabelSafety, @@ -1576,6 +1577,22 @@ pub enum MessageId { StatusSafetyDisabledSetuidAllowed, StatusSafetyExternal, StatusPointers, + // Multi-root workspace disclosure and /cd persistence receipts. + WorkspaceRootsNotice, + WorkspaceRootsRemainder, + WorkspaceSwitchBusy, + WorkspaceSwitchPersistedActorUnavailable, + WorkspaceSwitchSaveFailedActorQueued, + WorkspaceSwitchPersistFailed, + WorkspaceSwitchSnapshotFailed, + WorkspaceSwitchSessionsDirFailed, + WorkspaceSwitchSetRefused, + SessionRestoreWorkspaceInvalid, + ForkSourceSetRefused, + SaveLiveSetRefused, + ForkLiveSetRefused, + SanitizedLoadedRoots, + AttachedRootsNotRevertedNote, // Underwater post-launch empty state. EmptyStateNoGit, EmptyStateMcpLabel, @@ -3646,6 +3663,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::SessionMetricsStatusLine, MessageId::StatusLabelRoute, MessageId::StatusLabelDirectory, + MessageId::StatusLabelWorkspaceRoots, MessageId::StatusLabelProjectDocs, MessageId::StatusLabelMode, MessageId::StatusLabelSafety, @@ -3698,6 +3716,21 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::StatusSafetyDisabledSetuidAllowed, MessageId::StatusSafetyExternal, MessageId::StatusPointers, + MessageId::WorkspaceRootsNotice, + MessageId::WorkspaceRootsRemainder, + MessageId::WorkspaceSwitchBusy, + MessageId::WorkspaceSwitchPersistedActorUnavailable, + MessageId::WorkspaceSwitchSaveFailedActorQueued, + MessageId::WorkspaceSwitchPersistFailed, + MessageId::WorkspaceSwitchSnapshotFailed, + MessageId::WorkspaceSwitchSessionsDirFailed, + MessageId::WorkspaceSwitchSetRefused, + MessageId::SessionRestoreWorkspaceInvalid, + MessageId::ForkSourceSetRefused, + MessageId::SaveLiveSetRefused, + MessageId::ForkLiveSetRefused, + MessageId::SanitizedLoadedRoots, + MessageId::AttachedRootsNotRevertedNote, MessageId::EmptyStateNoGit, MessageId::EmptyStateMcpLabel, MessageId::EmptyStatePrompt, @@ -4856,6 +4889,51 @@ mod tests { } } + /// Round-45 R45-2: a REAL segment-level no-duplication gate over every + /// complete pack and every message (the round-44 claim of one covered + /// only a prefix family that did not include SaveLiveSetRefused). The + /// round-44 hi.json defect was a remedy clause pasted twice with + /// drifting punctuation ("…करें।सेशन…"), which an exact-repeat check + /// misses — so this gate compares word skeletons: every value is split + /// on whitespace and sentence/punctuation delimiters, and any + /// 6-word window that occurs twice inside the SAME value is a + /// containment-shape duplicate. Developed against the broken hi.json + /// (it went red there before the dedup landed). + #[test] + fn complete_packs_have_no_duplicated_segments() { + const MIN_WINDOW_WORDS: usize = 6; + let delimiters: &[char] = &[ + '.', ',', ';', ':', '!', '?', '(', ')', '{', '}', '"', '\'', '-', '–', '—', '।', '॥', + '。', '、', ',', ';', ':', '!', '?', '(', ')', + ]; + for locale in Locale::shipped_complete() { + let pack = raw_locale_messages(*locale); + for (key, value) in &pack { + let Some(text) = value.as_str() else { + continue; + }; + let words: Vec<&str> = text + .split(|c: char| c.is_whitespace() || delimiters.contains(&c)) + .filter(|word| !word.is_empty()) + .collect(); + if words.len() < MIN_WINDOW_WORDS * 2 { + continue; + } + let skeleton = words.join(" "); + for window in words.windows(MIN_WINDOW_WORDS) { + let segment = window.join(" "); + if skeleton.matches(&segment).count() > 1 { + panic!( + "{} key {key} repeats the segment \"{segment}\" inside one message \ + — a pasted-twice remedy clause shape (R45-2)", + locale.tag() + ); + } + } + } + } + } + #[test] fn automation_complete_packs_have_raw_key_and_placeholder_parity() { let english = raw_locale_messages(Locale::En); @@ -5197,35 +5275,96 @@ mod tests { ); } + /// `Some` when `pack` spells a different placeholder set for `key` than + /// the English pack — the signal every placeholder-parity gate fails on. + /// Missing keys are gate-precondition violations and panic, matching the + /// gates' previous inline behavior. + fn placeholder_parity_mismatch( + english: &serde_json::Map, + pack: &serde_json::Map, + key: &str, + tag: &str, + ) -> Option { + let english_value = english + .get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("English {key} must be a string")); + let translated = pack + .get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("{tag} is missing raw key {key}")); + let expected = message_placeholders(english_value); + let actual = message_placeholders(translated); + (actual != expected) + .then(|| format!("{key}: {tag} placeholders {actual:?} != English {expected:?}")) + } + #[test] - fn status_report_copy_has_placeholder_parity_across_complete_packs() { + fn status_and_workspace_copy_has_placeholder_parity_across_complete_packs() { let english = raw_locale_messages(Locale::En); - let status_ids = ALL_MESSAGE_IDS + let gated_ids = ALL_MESSAGE_IDS .iter() - .filter(|id| format!("{id:?}").starts_with("Status")); + .filter(|id| { + let key = format!("{id:?}"); + key.starts_with("Status") + || key.starts_with("Workspace") + || key.starts_with("Fork") + || key.starts_with("Save") + || key.starts_with("Sanitized") + }) + .collect::>(); + assert!( + gated_ids + .iter() + .any(|id| format!("{id:?}").starts_with("Workspace")), + "the gate must cover the Workspace* MessageIds" + ); - for id in status_ids { + for id in gated_ids { let key = format!("{id:?}"); - let english_value = english - .get(&key) - .and_then(serde_json::Value::as_str) - .unwrap_or_else(|| panic!("English {key} must be a string")); for locale in Locale::shipped_complete() { let pack = raw_locale_messages(*locale); - let translated = pack - .get(&key) - .and_then(serde_json::Value::as_str) - .unwrap_or_else(|| panic!("{} is missing raw key {key}", locale.tag())); - assert_eq!( - message_placeholders(translated), - message_placeholders(english_value), - "{} changed placeholders for {key}", - locale.tag() - ); + if let Some(mismatch) = + placeholder_parity_mismatch(&english, &pack, &key, locale.tag()) + { + panic!("{} changed placeholders: {mismatch}", locale.tag()); + } } } } + /// A pack that drops a `{error}` placeholder from a `Workspace*` key must + /// fail the gate — the exact slip that used to ship silently while only + /// `Status*` keys were gated (round-15). + #[test] + fn workspace_placeholder_parity_gate_detects_a_dropped_placeholder() { + let key = "WorkspaceSwitchSnapshotFailed"; + let english = serde_json::json!({ + key: "Failed to snapshot the session for the workspace switch: {error}", + }) + .as_object() + .expect("object") + .clone(); + let mut pack = english.clone(); + pack.insert( + key.to_string(), + serde_json::Value::String("snapshot failed".to_string()), + ); + assert!( + placeholder_parity_mismatch(&english, &pack, key, "test").is_some(), + "a dropped {{error}} placeholder must fail the gate" + ); + + pack.insert( + key.to_string(), + serde_json::Value::String("échec de l'instantané : {error}".to_string()), + ); + assert!( + placeholder_parity_mismatch(&english, &pack, key, "test").is_none(), + "a translated string keeping the placeholder set passes" + ); + } + #[test] fn status_report_copy_preserves_technical_identities_across_complete_packs() { let required: &[(MessageId, &[&str])] = &[ diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index ec2323864b..fe158d2038 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -5040,7 +5040,7 @@ fn resolve_project_mcp_cwd(workspace: &Path, cwd: Option<&Path>) -> Result PathBuf { +pub(crate) fn normalize_path_components(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { match component { diff --git a/crates/tui/src/mcp_server.rs b/crates/tui/src/mcp_server.rs index e49781c9df..960eab20f6 100644 --- a/crates/tui/src/mcp_server.rs +++ b/crates/tui/src/mcp_server.rs @@ -82,6 +82,12 @@ fn mcp_request_model(arguments: &Value, default_model: &str) -> String { } pub fn run_mcp_server(workspace: PathBuf) -> Result<()> { + // The tool registry treats an empty workspace as the vacuous containment + // root (`starts_with("")` accepts every path); fail fast instead of + // serving an uncontained session. + if workspace.as_os_str().is_empty() { + anyhow::bail!("workspace must not be empty"); + } let settings = McpServerSettings::load()?; let mut server = McpServer::new(workspace, settings)?; server.run() diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 1040fc004c..51a625d872 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -22,7 +22,7 @@ use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; -pub(crate) use self::constitution::{RepoLawAction, RepoLawRule, load_repo_law_rules}; +pub(crate) use self::constitution::{RepoLawAction, load_repo_law_rules}; use self::constitution::{load_repo_constitution_block, repo_constitution_candidate_paths}; use self::pack::generate_bounded_project_overview; pub use self::pack::generate_project_context_pack; @@ -481,6 +481,12 @@ pub(crate) fn enforce_project_instruction_budget(ctx: &mut ProjectContext) { /// Production goes through [`load_project_context_with_imports`] so the import /// set is explicit at the call site; this exists for tests that only care about /// default behaviour. +/// +/// Multi-root sessions (`workspace_roots`) deliberately discover instructions +/// from the primary root only: additional roots grant filesystem access, not +/// prompt authority. Widening this scan would inflate the prompt and shift the +/// KV prefix-cache stable region with the root set, so any change must weigh +/// that first (see `forkguard_workspace_roots_instruction_discovery_takes_only_the_primary_root`). #[cfg(test)] pub fn load_project_context(workspace: &Path) -> ProjectContext { load_project_context_with_imports(workspace, &foreign_instruction_imports()) @@ -596,6 +602,10 @@ fn load_dir_instructions( /// or paths mentioned in conversation — and the chain never crosses the /// repository boundary. Outside any repository only the workspace itself is /// searched. +/// +/// The walk starts at the primary root only — the same multi-root policy as +/// [`load_project_context`]: additional workspace roots are never scanned for +/// instructions. pub fn load_project_context_with_parents(workspace: &Path) -> ProjectContext { load_project_context_for_host( workspace, @@ -1330,6 +1340,47 @@ mod tests { assert_eq!(load_repo_law_rules(tmp.path()).len(), 1); } + #[test] + fn forkguard_workspace_roots_instruction_discovery_takes_only_the_primary_root() { + // The multi-root discipline for instructions is structural: the + // discovery loader's signature admits exactly one root (`&Path`), so + // an attached root has no input channel into the injected block — the + // lock is the signature, not a runtime branch this test could flip. + // What this test does exercise is the consequence: the block comes + // from the primary root and stays byte-stable whatever else exists. + let primary = tempdir().expect("primary root"); + let attached = tempdir().expect("attached root"); + fs::write( + primary.path().join("AGENTS.md"), + "primary-root instructions", + ) + .expect("write primary AGENTS.md"); + fs::write( + attached.path().join("AGENTS.md"), + "attached-root instructions that must never load", + ) + .expect("write attached AGENTS.md"); + + let with_attached = load_project_context_with_parents_cached_and_home(primary.path(), None); + // Byte-for-byte the same context as loading the primary root alone. + fs::remove_file(attached.path().join("AGENTS.md")).expect("remove attached AGENTS.md"); + let without_attached = + load_project_context_with_parents_cached_and_home(primary.path(), None); + + assert_eq!( + with_attached.instructions, without_attached.instructions, + "the injected instructions are a function of the primary root alone" + ); + let instructions = with_attached + .instructions + .expect("primary instructions load"); + assert!(instructions.contains("primary-root instructions")); + assert!( + !instructions.contains("attached-root"), + "attached root instructions must never be injected: {instructions}" + ); + } + #[test] fn test_load_project_context_empty() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/project_context/constitution.rs b/crates/tui/src/project_context/constitution.rs index 3649ad786d..5c9fccb8c4 100644 --- a/crates/tui/src/project_context/constitution.rs +++ b/crates/tui/src/project_context/constitution.rs @@ -97,10 +97,92 @@ pub(crate) struct RepoLawRule { /// degrades to fewer (or zero) rules: enforcement can silently do less, /// never more, and never poisons the tool gate. Parse warnings still reach /// the user through the prompt-side load path, which reads the same file. -pub(crate) fn load_repo_law_rules(workspace: &Path) -> Vec { - let Some((_, constitution)) = discover_repo_constitution(workspace) else { - return Vec::new(); +/// +/// The read+parse+globset compile is cached per constitution file and +/// revalidated by (mtime, size) on every call (review #484/CodeWhale +/// round-24 B24-5): the repo-law gate runs on every write-tool call, and a +/// 64-root session re-walking git roots and recompiling the same +/// constitution once per root per call converted the gate into a hot loop. +/// The upward discovery walk itself stays uncached so a constitution +/// created at a nearer directory is found on the next call; only the +/// compile of an already-resolved file is memoized. +pub(crate) fn load_repo_law_rules(workspace: &Path) -> std::sync::Arc> { + static CACHE: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + let Some(path) = discover_repo_constitution_path(workspace) else { + return std::sync::Arc::new(Vec::new()); }; + let Ok(metadata) = std::fs::metadata(&path) else { + return std::sync::Arc::new(Vec::new()); + }; + let modified = metadata + .modified() + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let len = metadata.len(); + let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())); + let mut guard = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(hit) = guard.get(&path) + && hit.modified == modified + && hit.len == len + { + return std::sync::Arc::clone(&hit.rules); + } + // Round-31 M31-1: a READ FAILURE is never cached — `chmod 000 -> + // enforcement trigger -> chmod 644` leaves mtime/len unchanged (chmod + // only touches ctime), so a cached empty entry would hit forever and + // repo law stays silent for the process's lifetime (fail-open; the + // base re-reads every call and self-heals). read_repo_constitution + // cannot distinguish not-found/parse-failure from a legitimately empty + // file at its Option boundary, so the discrimination happens here: + // only a file that READS successfully (load_context_file Ok) enters + // the cache — including an unparseable-but-readable one, whose + // empty-rule compile is then honestly keyed to an mtime the repair + // will change. + if !path.is_file() { + return std::sync::Arc::new(Vec::new()); + } + // Round-42 minor 2: ONE read, and only its success enters the cache. + // The probe read + read_repo_constitution's second read raced (the + // file could become unreadable in between), and the Ok probe with a + // failed second read cached EMPTY rules under the pre-probe key — + // repo law silently inert for the process lifetime against this + // function's own contract. + let raw = match load_context_file(&path) { + Ok(raw) => raw, + Err(_) => { + eprintln!("[repo-law] constitution read failed (not cached; next call re-reads)"); + return std::sync::Arc::new(Vec::new()); + } + }; + let rules = std::sync::Arc::new( + serde_json::from_str::(&raw) + .map(compile_repo_law_rules) + .unwrap_or_default(), + ); + guard.insert( + path, + CachedRepoLawRules { + modified, + len, + rules: std::sync::Arc::clone(&rules), + }, + ); + rules +} + +struct CachedRepoLawRules { + modified: std::time::SystemTime, + len: u64, + rules: std::sync::Arc>, +} + +/// Compile the enforceable rules from an already-parsed constitution: text +/// and path globs are trimmed, empty or uncompilable entries degrade to +/// fewer rules, and only entries carrying usable globs become holds. +fn compile_repo_law_rules(constitution: RepoConstitution) -> Vec { let mut rules = Vec::new(); for invariant in constitution.protected_invariants.into_iter().flatten() { let ProtectedInvariant::Enforced(enforced) = invariant else { @@ -116,7 +198,19 @@ pub(crate) fn load_repo_law_rules(workspace: &Path) -> Vec { if trimmed.is_empty() { continue; } - if let Ok(glob) = globset::Glob::new(trimmed) { + // Round-40 M1: the law's contract is that it may only ADD + // holds, so the globs match case-insensitively on every host — + // a wrong-cased create-shaped tail (`VENDOR/new/x.rs` against a + // `vendor/**` block) is resolved by the OS onto the protected + // directory on case-insensitive filesystems, and the model's + // verbatim casing must not bypass the hold. Over-holding two + // case-distinct directories on a case-sensitive host is the + // contract-safe direction. (Unicode NFC/NFD divergence stays a + // disclosed residual — folding case does not normalize.) + if let Ok(glob) = globset::GlobBuilder::new(trimmed) + .case_insensitive(true) + .build() + { builder.add(glob); patterns.push(trimmed.to_string()); } @@ -138,9 +232,9 @@ pub(crate) fn load_repo_law_rules(workspace: &Path) -> Vec { } /// Walk from `workspace` toward the git root looking for the repo -/// constitution; parse best-effort. Shared by the enforcement loader; the -/// prompt-side loader keeps its richer warning handling. -fn discover_repo_constitution(workspace: &Path) -> Option<(PathBuf, RepoConstitution)> { +/// constitution; existence-only, no read. The enforcement loader caches the +/// compile behind the returned path. +fn discover_repo_constitution_path(workspace: &Path) -> Option { let git_root = find_git_root(workspace); let mut current = workspace.to_path_buf(); loop { @@ -149,10 +243,7 @@ fn discover_repo_constitution(workspace: &Path) -> Option<(PathBuf, RepoConstitu path.push(component); } if context_candidate_exists(&path) { - let constitution = load_context_file(&path) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok())?; - return Some((path, constitution)); + return Some(path); } if let Some(ref root) = git_root && current == *root @@ -459,4 +550,50 @@ mod tests { warnings ); } + /// Round-32 (M31-1 pin): a READ failure is never cached — the chmod-000 + /// window self-heals on the next read once permissions recover (chmod + /// only touches ctime, so mtime/len stay identical), and the empty + /// answer never becomes sticky. Drives the public cache entry with the + /// discoverable `.codewhale/constitution.json` placement. + #[test] + fn constitution_read_failure_is_not_cached_across_permission_recovery() { + let dir = tempfile::tempdir().expect("tempdir"); + let nested = dir.path().join("work"); + std::fs::create_dir_all(nested.join(".codewhale")).unwrap(); + let path = nested.join(".codewhale").join("constitution.json"); + std::fs::write( + &path, + r#"{"protected_invariants": [{"text": "stay kind", "paths": ["src/**"]}]}"#, + ) + .expect("seed"); + + // The failure window comes FIRST (nothing cached yet) — the cache + // is a process-global keyed on the resolved path, so a prior good + // call for the same path would legitimately serve its cached rules + // and mask the pin. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + let degraded = load_repo_law_rules(&nested); + let read_blocked = std::fs::read(&path).is_err(); + if read_blocked { + assert!( + degraded.is_empty(), + "the read failure answers empty for this call" + ); + } + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let recovered = load_repo_law_rules(&nested); + assert!( + !recovered.is_empty(), + "recovery is not blocked by a cached empty entry (mtime/len unchanged by chmod)" + ); + } + let readable = load_repo_law_rules(&nested); + assert!( + !readable.is_empty(), + "the readable constitution compiles to at least one rule" + ); + } } diff --git a/crates/tui/src/repo_law.rs b/crates/tui/src/repo_law.rs index c0cf6f647f..f82ce67f95 100644 --- a/crates/tui/src/repo_law.rs +++ b/crates/tui/src/repo_law.rs @@ -18,11 +18,11 @@ //! - Only the repo-local constitution participates. The user-global //! constitution stays advisory prose and never reaches this module. -use std::path::Path; +use std::path::{Path, PathBuf}; use serde_json::Value; -use crate::project_context::{RepoLawAction, RepoLawRule, load_repo_law_rules}; +use crate::project_context::{RepoLawAction, load_repo_law_rules}; use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input}; /// Semantic write actions whose inputs name filesystem targets we can hold. @@ -39,11 +39,25 @@ pub(crate) enum RepoLawPlanDecision { Block(String), } -/// Evaluate the workspace's repo law against a proposed tool call. Returns -/// `None` for tools without write targets, workspaces without enforceable -/// law, and writes outside every protected glob. +/// Evaluate the accessible roots' repo law against a proposed tool call. +/// +/// `workspace_roots` carries the roots attached beside the primary; an empty +/// set (a host that never materialized one) keeps exactly the single-root +/// behavior. Each root is judged in its own namespace because the globs are +/// workspace-relative and two roots can carry different laws, so a root's +/// constitution holds the writes that land under it. +/// +/// A *relative* target is judged against every root, not only the one +/// execution will resolve it under: `push_normalized` keeps the relative tail +/// per root, so an attached root's law can hold a write that execution would +/// place under the primary. That direction is deliberate and fail-closed — +/// the worst case is an extra prompt or block, never a missed hold — and +/// `strongest_hold_wins_across_roots` pins it. Returns `None` for tools +/// without write targets, roots without enforceable law, and writes outside +/// every protected glob. pub(crate) fn repo_law_plan_decision( workspace: &Path, + workspace_roots: &[PathBuf], tool_name: &str, tool_input: &Value, ) -> Option { @@ -57,62 +71,135 @@ pub(crate) fn repo_law_plan_decision( if !WRITE_POLICY_ACTIONS.contains(&policy_action) { return None; } - let targets = write_target_paths(workspace, tool_input); - if targets.is_empty() { - return None; - } - let rules = load_repo_law_rules(workspace); - if rules.is_empty() { + + // Strongest action wins across all (root, rule, target) matches. The + // reason is built where the match is found so the borrow does not have to + // outlive the per-root rule set. + // Root-independent input work happens ONCE (review #484/CodeWhale + // round-24 B24-5): the landing candidates and their deepest-existing + // resolutions are byte-identical for every root, and the patch parse + // does not depend on the root set at all — re-deriving them inside the + // loop made a 64-root session re-walk symlink chains and re-resolve + // constitutions on every write-tool call. + let candidates = collect_write_candidates(workspace, tool_input); + if candidates.is_empty() { return None; } - - // Strongest action wins across all (rule, target) matches. - let mut hold: Option<(&RepoLawRule, &str)> = None; - for rule in &rules { - for target in &targets { - if rule.globs.is_match(target) { - let stronger = matches!(rule.action, RepoLawAction::Block) || hold.is_none(); - let already_blocking = hold - .as_ref() - .is_some_and(|(held, _)| matches!(held.action, RepoLawAction::Block)); - if stronger && !already_blocking { - hold = Some((rule, target.as_str())); + let mut hold: Option<(bool, String)> = None; + for root in codewhale_core::normalize_workspace_roots(workspace, workspace_roots) { + // Round-44 R44-3: key discovery AND the strips on the LEXICALLY + // RESOLVED spelling — `normalize_path_lexically` folds `..` and `.` + // the way execution's containment identity does for NOT-FULLY- + // EXISTING declarations (round-45 P2-14 correction: for a root + // declared through an EXISTING symlink + `..`, execution's + // boundary_roots canonicalize the raw spelling kernel-true while + // this fold keys the symlink-adjacent path — the mirror residual + // is pinned by the symlink_dotdot mirror test below and scheduled + // for the deepest-existing-canonical re-keying). The old raw-canonicalize fallback let a `ghost/../att` + // declaration keep its unresolved spelling: discovery stats + // `/ghost/../att/.codewhale/constitution.json` (ENOENT — + // the mid-component does not exist), every strip compares the + // unresolved prefix, and the root's law silently never loads while + // execution admits writes under the RESOLVED root. The lexical fold + // resolves the spelling without touching the filesystem, so a + // genuinely nonexistent root still judges raw spellings (its law + // stays fail-closed) while folded spellings land on the resolved + // root's identity. + let root_resolved = codewhale_core::normalize_path_lexically(&root); + let root_canonical = root_resolved + .canonicalize() + .unwrap_or_else(|_| root_resolved.clone()); + let targets = write_target_paths(&candidates, &root_resolved, &root_canonical); + if targets.is_empty() { + continue; + } + let rules = load_repo_law_rules(&root_resolved); + for rule in rules.iter() { + for target in &targets { + if !rule.globs.is_match(target) { + continue; + } + let blocking = matches!(rule.action, RepoLawAction::Block); + let already_blocking = hold.as_ref().is_some_and(|(blocking, _)| *blocking); + if (blocking || hold.is_none()) && !already_blocking { + hold = Some(( + blocking, + format!( + "Repo law holds this write: \"{}\" protects {} (matched {target}, .codewhale/constitution.json)", + rule.text, + rule.patterns.join(", ") + ), + )); } } } } - let (rule, target) = hold?; - let protects = rule.patterns.join(", "); - let reason = format!( - "Repo law holds this write: \"{}\" protects {protects} (matched {target}, .codewhale/constitution.json)", - rule.text - ); - Some(match rule.action { - RepoLawAction::Ask => RepoLawPlanDecision::ForcePrompt(reason), - RepoLawAction::Block => RepoLawPlanDecision::Block(reason), + let (blocking, reason) = hold?; + Some(if blocking { + RepoLawPlanDecision::Block(reason) + } else { + RepoLawPlanDecision::ForcePrompt(reason) }) } -/// Extract workspace-relative write targets from a tool input. Covers the -/// `path`/`target`/`destination`/`file_path` params, canonical +/// One collected write target from the tool input, prepared once per call. +/// Every field is root-independent, so the per-root loop only does +/// containment strips (review #484/CodeWhale round-24 B24-5 — the old shape +/// re-derived the landing candidate and re-resolved it through +/// `resolve_deepest_existing` once per root, over byte-identical inputs). +struct WriteCandidate { + /// The trimmed, backslash-normalized spelling the tool supplied. + trimmed: String, + /// `raw_joined` lexically collapsed exactly like the landing normalizer: + /// the raw spelling joined onto the primary (relative targets) or + /// as-given (absolute), before any collapse. + candidate: PathBuf, + /// `candidate` resolved through its deepest existing ancestor, once. + candidate_resolved: Option, + /// The PRE-normalization raw candidate resolved through its deepest + /// existing ancestor, once — the symlink-expanded reality execution + /// walks (round-22 B22-1 leg). + raw_resolved: Option, +} + +/// Collect every write spelling a tool input carries, with the +/// root-independent resolution work done once. Covers the +/// `path`/`filePath`/`target`/`destination`/`file_path` params, canonical /// `replace[].path`, legacy `changes[].path`, and /// every unified-diff / codex-envelope header shape the patch tools accept — /// old (`--- `) and new (`+++ `) paths, with or without an `a/`/`b/` prefix, /// tab-timestamp suffixes stripped, and `/dev/null` (deletion) falling back /// to the counterpart path. Missing any shape the tool honors is a hold /// bypass, so this deliberately over-collects candidate paths. -fn write_target_paths(workspace: &Path, input: &Value) -> Vec { - let mut targets = Vec::new(); - for key in ["path", "target", "destination", "file_path"] { +fn collect_write_candidates(workspace: &Path, input: &Value) -> Vec { + let mut spellings: Vec = Vec::new(); + // `filePath`/`file_path` are the spellings `PATH_ALIASES` folds onto + // `path` at execute time (`tools/file.rs`); the default `ToolSpec:: + // prepare` passes input through unchanged, so plan-time judgment must + // scan them too — a `filePath`-spelled write otherwise gets zero + // repo-law targets and no constitution ever fires (round-22 B22-2). + // Round-23 nit: the alias spellings derive from the shared + // `PATH_ALIASES` constant (no second source of truth). + let alias_keys: Vec = crate::tools::file::PATH_ALIASES + .iter() + .map(|alias| alias.alias.to_string()) + .collect(); + let mut keys: Vec = vec![ + "path".to_string(), + "target".to_string(), + "destination".to_string(), + ]; + keys.extend(alias_keys); + for key in &keys { if let Some(path) = input.get(key).and_then(Value::as_str) { - push_normalized(&mut targets, workspace, path); + spellings.push(path.to_string()); } } match normalize_apply_patch_input(input) { Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => { for change in entries { if let Some(path) = change.get("path").and_then(Value::as_str) { - push_normalized(&mut targets, workspace, path); + spellings.push(path.to_string()); } } } @@ -120,25 +207,25 @@ fn write_target_paths(workspace: &Path, input: &Value) -> Vec { let mut pending_old: Option = None; for line in patch.lines() { if let Some(rest) = line.strip_prefix("*** Update File: ") { - push_normalized(&mut targets, workspace, rest.trim()); + spellings.push(rest.trim().to_string()); } else if let Some(rest) = line.strip_prefix("*** Add File: ") { - push_normalized(&mut targets, workspace, rest.trim()); + spellings.push(rest.trim().to_string()); } else if let Some(rest) = line.strip_prefix("*** Delete File: ") { - push_normalized(&mut targets, workspace, rest.trim()); + spellings.push(rest.trim().to_string()); } else if let Some(rest) = line.strip_prefix("--- ") { // Old path: remember it so a `+++ /dev/null` deletion still // holds the file being removed. pending_old = diff_header_path(rest); if let Some(ref p) = pending_old { - push_normalized(&mut targets, workspace, p); + spellings.push(p.clone()); } } else if let Some(rest) = line.strip_prefix("+++ ") { match diff_header_path(rest) { - Some(new_path) => push_normalized(&mut targets, workspace, &new_path), + Some(new_path) => spellings.push(new_path), // `+++ /dev/null` → deletion; the target is the old path. None => { if let Some(old) = pending_old.take() { - push_normalized(&mut targets, workspace, &old); + spellings.push(old); } } } @@ -147,6 +234,46 @@ fn write_target_paths(workspace: &Path, input: &Value) -> Vec { } Err(_) => {} } + spellings.sort(); + spellings.dedup(); + spellings + .into_iter() + .filter_map(|raw| { + let trimmed = raw.trim().replace('\\', "/"); + if trimmed.is_empty() { + return None; + } + let raw_path = Path::new(&raw); + let raw_joined = if raw_path.is_absolute() { + raw_path.to_path_buf() + } else { + workspace.join(raw_path) + }; + let candidate = normalize_lexical_components(&raw_joined); + let candidate_resolved = crate::core::authority::resolve_deepest_existing(&candidate); + let raw_resolved = crate::core::authority::resolve_deepest_existing(&raw_joined); + Some(WriteCandidate { + trimmed, + candidate, + candidate_resolved, + raw_resolved, + }) + }) + .collect() +} + +/// Root-relative glob targets for one root, from the pre-resolved +/// candidates. Sorted and deduped: the rule loop below matches a set, and +/// the hold reason cites a match, not an order. +fn write_target_paths( + candidates: &[WriteCandidate], + root: &Path, + root_canonical: &Path, +) -> Vec { + let mut targets = Vec::new(); + for candidate in candidates { + push_normalized(&mut targets, candidate, root, root_canonical); + } targets.sort(); targets.dedup(); targets @@ -167,20 +294,48 @@ fn diff_header_path(rest: &str) -> Option { Some(stripped.to_string()) } -/// Normalize to a forward-slash, workspace-relative string so globs written +/// Normalize to a forward-slash, root-relative string so globs written /// as `crates/x/**` match regardless of how the tool spelled the path. Crucially /// this collapses `.`/`..` path components the same way the write tools' /// `resolve_path` does, so an interior `crates/./protocol/x` or /// `x/../crates/protocol/x` cannot spell its way past a glob (a confirmed /// bypass before this). -fn push_normalized(targets: &mut Vec, workspace: &Path, raw: &str) { - let trimmed = raw.trim().replace('\\', "/"); - if trimmed.is_empty() { - return; - } - // Make workspace-relative when the tool gave an absolute path inside it. - let path = Path::new(&trimmed); - let relative = path.strip_prefix(workspace).unwrap_or(path); +/// +/// `root` is the root whose law is being judged, with `root_canonical` its +/// resolved spelling; the candidate's root-independent resolution work was +/// already done once in [`collect_write_candidates`] +/// (review #484/CodeWhale round-24 B24-5). +/// +/// A relative spelling is judged against every root — keep the raw collapsed +/// tail per root, so an attached root's law can hold a write that execution +/// would place under the primary (fail-closed: an extra prompt or block at +/// worst). +/// +/// Spelling alone is not enough, because execution resolves writes +/// canonically: `ToolContext::resolve_path` joins a relative spelling onto +/// the primary (an absolute one stands), normalizes lexically, and then +/// admits a candidate that canonicalizes into any boundary root with no +/// containment re-check. So each root also judges where the write actually +/// lands — the execution candidate collapsed lexically, then resolved through +/// symlinks — stripped against both the raw and the canonical root spelling, +/// the same dual check `carve_out_target_allowed` performs. Without that, a +/// canonical spelling of a symlinked root, an interior symlink, or an +/// absolute `..` collapse carries the write into a root whose anchored globs +/// never fired — a block-class law bypass. +fn push_normalized( + targets: &mut Vec, + candidate: &WriteCandidate, + root: &Path, + root_canonical: &Path, +) { + // Make root-relative when the tool gave an absolute path inside the root, + // under either its raw or its canonical spelling (a root reached through + // a symlink still carries its law). + let path = Path::new(&candidate.trimmed); + let relative = path + .strip_prefix(root) + .or_else(|_| path.strip_prefix(root_canonical)) + .unwrap_or(path); // Lexically collapse CurDir (`.`) and ParentDir (`..`) components, and // drop any leading root/empty component. An absolute path outside the @@ -192,8 +347,13 @@ fn push_normalized(targets: &mut Vec, workspace: &Path, raw: &str) { "" | "." => {} ".." => { // A `..` that pops above the root escapes the workspace; keep - // an explicit marker so it can never match a workspace-relative - // glob, and the ordinary approval/sandbox gates still govern it. + // an explicit marker so this spelling tail can never match a + // workspace-ANCHORED glob (one whose first segment is + // literal). A `**`-leading glob can still match the marker + // tail — recorded for accuracy (round-24 P3); those globs + // stay fail-closed through the landing-path leg below. + // Where the write actually lands is judged separately below + // from the clamped execution candidate. if parts.pop().is_none() { parts.push("..".to_string()); } @@ -201,12 +361,69 @@ fn push_normalized(targets: &mut Vec, workspace: &Path, raw: &str) { other => parts.push(other.to_string()), } } + // Judge the execution-landing path against this root, under both its raw + // and its canonical spelling. Execution joins the *raw* spelling onto the + // primary (`ToolContext::resolve_path`), so the candidate was derived + // from the raw string with component operations — splitting display + // strings is not a path operation and silently misparses Windows + // separators. + if let Ok(tail) = candidate + .candidate + .strip_prefix(root) + .or_else(|_| candidate.candidate.strip_prefix(root_canonical)) + { + let tail = tail.to_string_lossy().replace('\\', "/"); + if !tail.is_empty() { + targets.push(tail); + } + } + // Then symlink reality: resolve the deepest existing ancestor and + // judge the resolved path against the canonical root, so an interior + // symlink hop into this root (or a root reached through one) cannot + // spell its way past the law. + if let Some(resolved) = &candidate.candidate_resolved + && let Ok(tail) = resolved.strip_prefix(root_canonical) + { + let tail = tail.to_string_lossy().replace('\\', "/"); + if !tail.is_empty() { + targets.push(tail); + } + } + // Then symlink reality on the PRE-normalization candidate: the lexical + // normalize above collapses `l/..` BEFORE the resolved leg runs, so a + // symlink+`..` hop (`/p/l/../secret` with `/p/l → /shared/x`) judged the + // collapsed `/p/secret` while execution's canonicalize expands the link + // FIRST and only then applies `..` — landing in `/shared/secret`. The + // attached root's constitution never saw the target (round-22 B22-1). + // Resolving the raw joined candidate walks the same symlink-expanded + // reality execution walks; the already-normalized leg above stays so + // the overshoot-clamp behavior is judged both ways. + if let Some(resolved) = &candidate.raw_resolved + && let Ok(tail) = resolved.strip_prefix(root_canonical) + { + let tail = tail.to_string_lossy().replace('\\', "/"); + if !tail.is_empty() { + targets.push(tail); + } + } let normalized = parts.join("/"); if !normalized.is_empty() { targets.push(normalized); } } +/// Lexically collapse CurDir and ParentDir components of `path` into the +/// candidate the write actually lands on. This IS the normalizer execution +/// applies to a joined candidate (`tools::spec::normalize_path`, called by +/// `ToolContext::resolve_path`), reused so the law can never drift from the +/// gate: a `..` at the filesystem root (or a Windows drive root) CLAMPS, it +/// does not fail, so `/w/x/../../../att/vendor/lib.rs` judges as +/// `/att/vendor/lib.rs` — the landing path execution admits. A `..` a +/// relative spelling cannot pop is kept, again matching execution. +fn normalize_lexical_components(path: &Path) -> PathBuf { + crate::tools::spec::normalize_path(path) +} + #[cfg(test)] mod tests { use super::*; @@ -219,6 +436,16 @@ mod tests { std::fs::write(dir.join("constitution.json"), body).unwrap(); } + /// Single-root call, the shape every host without a materialized root set + /// uses. + fn decide( + workspace: &Path, + tool_name: &str, + tool_input: &Value, + ) -> Option { + repo_law_plan_decision(workspace, &[], tool_name, tool_input) + } + const LAW: &str = r#"{ "authority": ["AGENTS.md"], "protected_invariants": [ @@ -236,7 +463,7 @@ mod tests { r#"{"protected_invariants": ["Prose only, no paths."]}"#, ); assert_eq!( - repo_law_plan_decision( + decide( tmp.path(), "write_file", &json!({"path": "src/main.rs", "content": "x"}), @@ -249,7 +476,7 @@ mod tests { fn block_action_denies_protected_write() { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), LAW); - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "write_file", &json!({"path": "crates/protocol/wire.rs", "content": "x"}), @@ -266,7 +493,7 @@ mod tests { fn ask_action_force_prompts_and_names_the_law() { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), LAW); - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "edit_file", &json!({"path": "CHANGELOG.md", "old": "a", "new": "b"}), @@ -285,7 +512,7 @@ mod tests { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), LAW); - let blocked = repo_law_plan_decision( + let blocked = decide( tmp.path(), "File", &json!({ @@ -296,7 +523,7 @@ mod tests { ); assert!(matches!(blocked, Some(RepoLawPlanDecision::Block(_)))); - let held = repo_law_plan_decision( + let held = decide( tmp.path(), "File", &json!({ @@ -309,12 +536,151 @@ mod tests { assert!(matches!(held, Some(RepoLawPlanDecision::ForcePrompt(_)))); } + #[test] + #[cfg(unix)] + fn dot_dot_folded_root_declaration_still_loads_its_constitution() { + // Round-44 R44-3: a root declared as /ghost/../att (ghost + // nonexistent) used to keep its UNRESOLVED spelling — discovery + // stat'd the folded path (ENOENT), every strip compared the folded + // prefix, and the root's law silently never loaded while execution + // admitted writes under the RESOLVED root. The lexical fold keys + // discovery and the strips on the resolved identity. + let tmp = tempfile::TempDir::new().unwrap(); + let parent = tmp.path().canonicalize().unwrap(); + let att = parent.join("att"); + std::fs::create_dir_all(att.join("vendor")).unwrap(); + write_law( + &att, + r#"{ "protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ] }"#, + ); + // The folded spelling: ghost does not exist. + let folded = parent.join("ghost").join("..").join("att"); + + let decision = crate::repo_law::repo_law_plan_decision( + &parent, + &[folded], + "write_file", + &serde_json::json!({"path": "att/vendor/lib.rs", "content": "x"}), + ); + assert!( + matches!( + decision, + Some(crate::repo_law::RepoLawPlanDecision::Block(_)) + ), + "the folded spelling must load the resolved root's constitution: {decision:?}" + ); + } + + #[test] + #[cfg(unix)] + fn symlink_dotdot_root_declaration_keys_the_fold_not_the_kernel_truth() { + // Round-45 P2-14: a root declared through an EXISTING symlink + + // `..` (`/link/../x`, link -> elsewhere) is keyed by the + // LEXICAL fold (`/x`), while execution's boundary_roots + // canonicalize the raw declaration kernel-true (`elsewhere/x`). + // This pin documents the CURRENT residual honestly: the law + // discovered under the fold does not key a write spelled kernel- + // true — discovery and the strips miss, the hold never fires. The + // proper fix (key discovery+strips on the deepest-existing- + // canonical identity) is scheduled; until then this is the + // disclosed divergence between the two identities. + let tmp = tempfile::TempDir::new().unwrap(); + let parent = tmp.path().canonicalize().unwrap(); + let elsewhere = parent.join("elsewhere"); + let kernel_true = elsewhere.join("x"); + std::fs::create_dir_all(kernel_true.join("vendor")).unwrap(); + std::os::unix::fs::symlink(&elsewhere, parent.join("link")).unwrap(); + // The fold identity gets the constitution (this is where discovery + // stats it), the kernel-true tree is where execution writes land. + let folded_identity = parent.join("x"); + std::fs::create_dir_all(folded_identity.join("vendor")).unwrap(); + write_law( + &folded_identity, + r#"{ "protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ] }"#, + ); + let declared = parent.join("link").join("..").join("x"); + + let decision = crate::repo_law::repo_law_plan_decision( + &parent, + &[declared], + "write_file", + &serde_json::json!({ + "path": kernel_true.join("vendor").join("lib.rs"), + "content": "x" + }), + ); + assert!( + decision.is_none(), + "the kernel-true write must NOT match the fold-keyed law (documented \ + residual): {decision:?}" + ); + } + + #[test] + fn a_genuinely_nonexistent_root_stays_lawless_for_raw_spellings() { + // Round-45 P2-15: the R44-3 comment's fail-closed direction — a + // root whose declaration cannot resolve on disk never loads a law + // and never strips any write, including writes spelled under the + // raw declaration itself. No law from ANY source may appear for a + // root that does not exist. + let tmp = tempfile::TempDir::new().unwrap(); + let parent = tmp.path().canonicalize().unwrap(); + let ghost_root = parent.join("no-such-dir").join("att"); + // A same-named directory under the FOLD of a ghost/../att spelling + // must not be reached either: the ghost component does not exist, + // so even the folded identity stays on disk absent here. + let decision = crate::repo_law::repo_law_plan_decision( + &parent, + &[ghost_root.clone()], + "write_file", + &serde_json::json!({ + "path": ghost_root.join("vendor").join("lib.rs"), + "content": "x" + }), + ); + assert!( + decision.is_none(), + "a genuinely nonexistent root must judge raw spellings with no law: {decision:?}" + ); + } + + #[test] + fn wrong_cased_create_shaped_tail_hits_a_block_glob() { + // Round-40 M1: the law may only ADD holds, so the globs match + // case-insensitively — on a case-insensitive host the OS resolves + // `VENDOR/new/x.rs` onto the protected `vendor/` at create time, + // and the model's verbatim casing must not bypass the hold (the + // canonicalization legs only catch targets that already exist). + let tmp = TempDir::new().unwrap(); + write_law( + tmp.path(), + r#"{ "protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ] }"#, + ); + std::fs::create_dir_all(tmp.path().join("vendor")).unwrap(); + + let decision = decide( + tmp.path(), + "write_file", + &json!({"path": "VENDOR/new/x.rs", "content": "x"}), + ); + assert!( + matches!(decision, Some(RepoLawPlanDecision::Block(_))), + "a wrong-cased create-shaped tail must still hit the block: {decision:?}" + ); + } + #[test] fn unprotected_writes_and_non_write_tools_pass() { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), LAW); assert_eq!( - repo_law_plan_decision( + decide( tmp.path(), "write_file", &json!({"path": "src/main.rs", "content": "x"}), @@ -322,7 +688,7 @@ mod tests { None ); assert_eq!( - repo_law_plan_decision( + decide( tmp.path(), "read_file", &json!({"path": "crates/protocol/wire.rs"}), @@ -336,28 +702,28 @@ mod tests { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), LAW); // Canonical replace[].path shape. - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "apply_patch", &json!({"replace": [{"path": "crates/protocol/msg.rs"}]}), ); assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); // Legacy changes[].path shape must receive the same hold. - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "apply_patch", &json!({"changes": [{"path": "crates/protocol/msg.rs"}]}), ); assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); // unified diff shape - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "apply_patch", &json!({"patch": "--- a/crates/protocol/msg.rs\n+++ b/crates/protocol/msg.rs\n@@\n"}), ); assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); // codex envelope shape - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "apply_patch", &json!({"patch": "*** Begin Patch\n*** Update File: crates/protocol/msg.rs\n*** End Patch\n"}), @@ -375,7 +741,7 @@ mod tests { { "text": "never", "paths": ["docs/frozen/**"], "action": "block" } ]}"#, ); - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "write_file", &json!({"path": "docs/frozen/spec.md", "content": "x"}), @@ -388,13 +754,13 @@ mod tests { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), LAW); let absolute = tmp.path().join("crates/protocol/wire.rs"); - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "write_file", &json!({"path": absolute.to_string_lossy(), "content": "x"}), ); assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "write_file", &json!({"path": "./CHANGELOG.md", "content": "x"}), @@ -410,7 +776,7 @@ mod tests { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), "{ not json"); assert_eq!( - repo_law_plan_decision( + decide( tmp.path(), "write_file", &json!({"path": "crates/protocol/wire.rs", "content": "x"}), @@ -424,7 +790,7 @@ mod tests { ]}"#, ); assert_eq!( - repo_law_plan_decision( + decide( tmp.path(), "write_file", &json!({"path": "crates/protocol/wire.rs", "content": "x"}), @@ -443,7 +809,7 @@ mod tests { "x/../crates/protocol/wire.rs", "./crates/protocol/wire.rs", ] { - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "write_file", &json!({ "path": path, "content": "x" }), @@ -459,7 +825,7 @@ mod tests { fn fim_edit_is_gated_like_other_write_tools() { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), LAW); - let decision = repo_law_plan_decision( + let decision = decide( tmp.path(), "fim_edit", &json!({ "path": "crates/protocol/wire.rs", "prefix": "a", "suffix": "b" }), @@ -475,7 +841,7 @@ mod tests { let tmp = TempDir::new().unwrap(); write_law(tmp.path(), LAW); // no a/ or b/ prefix - let d = repo_law_plan_decision( + let d = decide( tmp.path(), "apply_patch", &json!({ "patch": "--- crates/protocol/wire.rs\n+++ crates/protocol/wire.rs\n@@\n" }), @@ -485,7 +851,7 @@ mod tests { "no-prefix: {d:?}" ); // deletion: +++ /dev/null, target is the old path - let d = repo_law_plan_decision( + let d = decide( tmp.path(), "apply_patch", &json!({ "patch": "--- a/crates/protocol/wire.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n" }), @@ -495,7 +861,7 @@ mod tests { "deletion: {d:?}" ); // tab-timestamp suffix on the header - let d = repo_law_plan_decision( + let d = decide( tmp.path(), "apply_patch", &json!({ "patch": "--- a/x\t2026-01-01\n+++ b/crates/protocol/wire.rs\t2026-01-01 10:00:00\n@@\n" }), @@ -510,7 +876,7 @@ mod tests { fn no_law_file_means_no_holds() { let tmp = TempDir::new().unwrap(); assert_eq!( - repo_law_plan_decision( + decide( tmp.path(), "write_file", &json!({"path": "anything.rs", "content": "x"}), @@ -518,4 +884,377 @@ mod tests { None ); } + + #[test] + fn attached_root_law_holds_writes_under_that_root() { + let primary = TempDir::new().unwrap(); + let attached = TempDir::new().unwrap(); + write_law(primary.path(), LAW); + write_law( + attached.path(), + r#"{"protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ]}"#, + ); + let roots = vec![attached.path().to_path_buf()]; + + // Root-relative globs stay root-relative: the primary's + // `crates/protocol/**` must not fire on a same-shaped path under an + // attached root just because the tail happens to look alike. + assert_eq!( + repo_law_plan_decision( + primary.path(), + &roots, + "write_file", + &json!({ + "path": attached.path().join("crates/protocol/wire.rs"), + "content": "x" + }), + ), + None + ); + + let decision = repo_law_plan_decision( + primary.path(), + &roots, + "write_file", + &json!({"path": attached.path().join("vendor/lib.rs"), "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!("expected the attached root's law to block, got {decision:?}"); + }; + assert!(reason.contains("Vendored tree is read-only"), "{reason}"); + } + + #[test] + fn strongest_hold_wins_across_roots() { + let primary = TempDir::new().unwrap(); + let attached = TempDir::new().unwrap(); + write_law( + primary.path(), + r#"{"protected_invariants": [ + { "text": "ask in primary", "paths": ["shared/**"] } + ]}"#, + ); + write_law( + attached.path(), + r#"{"protected_invariants": [ + { "text": "block in attached", "paths": ["shared/**"], "action": "block" } + ]}"#, + ); + + // The same relative target matches an Ask in the primary and a Block + // in the attached root; law can only add holds, so the Block wins. + let decision = repo_law_plan_decision( + primary.path(), + &[attached.path().to_path_buf()], + "write_file", + &json!({"path": "shared/lib.rs", "content": "x"}), + ); + assert!( + matches!(decision, Some(RepoLawPlanDecision::Block(_))), + "{decision:?}" + ); + } + + #[test] + fn dotdot_spelled_relative_target_still_holds_in_the_attached_root() { + // A `..`-spelled relative target execution resolves into an attached + // root must not escape that root's anchored globs by spelling: the + // judged tail is the execution-resolved path under the containing + // root, not the raw `..` spelling (which never matched anything). + let primary = TempDir::new().unwrap(); + let attached = TempDir::new().unwrap(); + write_law( + attached.path(), + r#"{"protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ]}"#, + ); + let spelling = format!( + "../{}/vendor/lib.rs", + attached.path().file_name().unwrap().to_string_lossy() + ); + + let decision = repo_law_plan_decision( + primary.path(), + &[attached.path().to_path_buf()], + "write_file", + &json!({"path": spelling, "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!( + "expected the attached root's law to hold a ..-spelled target, got {decision:?}" + ); + }; + assert!(reason.contains("Vendored tree is read-only"), "{reason}"); + + // A `..`-spelled target that resolves outside every root stays + // unheld by anchored globs (the ordinary gates govern it). + let decision = repo_law_plan_decision( + primary.path(), + &[attached.path().to_path_buf()], + "write_file", + &json!({"path": "../../elsewhere/lib.rs", "content": "x"}), + ); + assert_eq!(decision, None, "an out-of-tree escape has no anchored hold"); + } + + #[cfg(unix)] + #[test] + fn symlinked_attached_root_law_holds_a_canonical_spelled_target() { + // The attached root is a symlink to the real repo. A target spelled + // with the real (canonical) path never strip-prefixes the raw root + // spelling, yet execution canonicalizes the write straight into the + // repo — the root's law must still hold it. + let primary = TempDir::new().unwrap(); + let real = TempDir::new().unwrap(); + write_law( + real.path(), + r#"{"protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ]}"#, + ); + std::fs::create_dir(real.path().join("vendor")).unwrap(); + let link_parent = TempDir::new().unwrap(); + let linked = link_parent.path().join("linked"); + std::os::unix::fs::symlink(real.path(), &linked).expect("symlink"); + + let decision = repo_law_plan_decision( + primary.path(), + &[linked], + "write_file", + &json!({"path": real.path().join("vendor/lib.rs"), "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!( + "expected the symlinked root's law to hold a canonical-spelled target, got {decision:?}" + ); + }; + assert!(reason.contains("Vendored tree is read-only"), "{reason}"); + } + + #[cfg(unix)] + #[test] + fn interior_symlink_target_lands_under_the_attached_roots_law() { + // A relative spelling through an interior symlink of the primary + // canonicalizes into the attached repo at execution; the judged tail + // must be the resolved path under that root, not the raw spelling + // (which matches none of its anchored globs). + let primary = TempDir::new().unwrap(); + let attached = TempDir::new().unwrap(); + write_law( + attached.path(), + r#"{"protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ]}"#, + ); + std::fs::create_dir(attached.path().join("vendor")).unwrap(); + std::os::unix::fs::symlink(attached.path(), primary.path().join("linked2")) + .expect("symlink"); + + let decision = repo_law_plan_decision( + primary.path(), + &[attached.path().to_path_buf()], + "write_file", + &json!({"path": "linked2/vendor/lib.rs", "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!( + "expected the attached root's law to hold an interior-symlink target, got {decision:?}" + ); + }; + assert!(reason.contains("Vendored tree is read-only"), "{reason}"); + } + + #[test] + fn absolute_dotdot_spelling_into_an_attached_root_is_held() { + // An absolute spelling whose `..` collapse lands inside an attached + // root: execution's lexical normalize pops the `..` and admits the + // write, so the attached root's anchored globs must judge the + // collapsed landing path, not the raw spelling. + let primary = TempDir::new().unwrap(); + let attached = TempDir::new().unwrap(); + write_law( + attached.path(), + r#"{"protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ]}"#, + ); + let spelling = format!( + "{}/x/../../{}/vendor/lib.rs", + primary.path().display(), + attached.path().file_name().unwrap().to_string_lossy() + ); + + let decision = repo_law_plan_decision( + primary.path(), + &[attached.path().to_path_buf()], + "write_file", + &json!({"path": spelling, "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!( + "expected the attached root's law to hold an absolute `..`-spelled target, got {decision:?}" + ); + }; + assert!(reason.contains("Vendored tree is read-only"), "{reason}"); + } + + #[test] + fn absolute_dotdot_overshoot_into_an_attached_root_is_held() { + // Sibling of the two-`..` pin above, with one `..` MORE than the + // spelling is deep: the collapse overshoots the filesystem root. + // Execution's `normalize_path` CLAMPS the extra `..` at the root, so + // `/w/x/../../../att/vendor/lib.rs` lands on `/att/vendor/lib.rs` and + // the attached repo's law must hold it; a strict collapse that bails + // on the overshoot judges no landing path and silently admits the + // write in every posture (a confirmed block-class bypass). + let primary = TempDir::new().unwrap(); + let attached = TempDir::new().unwrap(); + write_law( + attached.path(), + r#"{"protected_invariants": [ + { "text": "Vendored tree is read-only", "paths": ["vendor/**"], "action": "block" } + ]}"#, + ); + // Pop `x` plus every primary component, then overshoot once more + // (RootDir/Prefix components are not popped, so the count is exact on + // Unix and one over on Windows — extra `..`s clamp harmlessly). Then + // re-descend the attached root's own components from the root. + let dots = vec![".."; primary.path().components().count() + 1].join("/"); + let attached_tail = attached + .path() + .components() + .filter_map(|c| match c { + std::path::Component::Normal(part) => Some(part.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/"); + let spelling = format!( + "{}/x/{dots}/{attached_tail}/vendor/lib.rs", + primary.path().display() + ); + + let decision = repo_law_plan_decision( + primary.path(), + &[attached.path().to_path_buf()], + "write_file", + &json!({"path": spelling, "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!( + "expected the attached root's law to hold a `..`-overshoot target (clamps to {attached_tail}/vendor/lib.rs), got {decision:?}" + ); + }; + assert!(reason.contains("Vendored tree is read-only"), "{reason}"); + } + + #[test] + fn lexical_normalize_clamps_parent_dir_at_the_filesystem_root() { + // The landing normalizer is execution's own `normalize_path`: a `..` + // at the filesystem root clamps instead of failing, so the overshoot + // spelling judges as the path execution would admit. + assert_eq!( + normalize_lexical_components(Path::new("/w/x/../../../att/vendor/lib.rs")), + PathBuf::from("/att/vendor/lib.rs") + ); + assert_eq!( + normalize_lexical_components(Path::new("/a/..")), + PathBuf::from("/") + ); + // A `..` a relative spelling cannot pop is kept, matching execution. + assert_eq!( + normalize_lexical_components(Path::new("a/../../b")), + PathBuf::from("../b") + ); + } + + #[test] + fn relative_target_landing_under_an_ancestor_root_is_held() { + // The session cwd is a subdirectory of an attached root: execution + // joins a relative target onto the primary, landing INSIDE the + // attached root, whose anchored globs must judge the landing path — + // the raw tail alone (`x/y` against `sub/**`) never matches. + let attached = TempDir::new().unwrap(); + let primary = attached.path().join("sub"); + std::fs::create_dir_all(&primary).unwrap(); + write_law( + attached.path(), + r#"{"protected_invariants": [ + { "text": "Sub tree is read-only", "paths": ["sub/**"], "action": "block" } + ]}"#, + ); + + let decision = repo_law_plan_decision( + &primary, + &[attached.path().to_path_buf()], + "write_file", + &json!({"path": "x/y", "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!( + "expected the ancestor root's law to hold a relative target landing inside it, got {decision:?}" + ); + }; + assert!(reason.contains("Sub tree is read-only"), "{reason}"); + } + + #[cfg(unix)] + #[test] + fn symlink_dotdot_hop_into_an_attached_root_is_held() { + // Round-22 B22-1: the kernel applies `..` AFTER symlink expansion, so + // `/p/l/../secret` with `/p/l → /shared/x` executes in + // `/shared/secret` — but the lexical normalize collapsed `l/..` FIRST + // and the resolved leg ran on that collapsed spelling, so the + // attached root's constitution judged `/p/secret` and never fired. + // Under Full Access the write landed in a constitution-protected path + // with no hold at all (at base it was PathEscape-blocked). + let primary = TempDir::new().unwrap(); + let attached = TempDir::new().unwrap(); + write_law( + attached.path(), + r#"{"protected_invariants": [ + { "text": "secret is read-only", "paths": ["secret"], "action": "block" } + ]}"#, + ); + std::fs::create_dir(attached.path().join("x")).unwrap(); + std::fs::create_dir(attached.path().join("secret")).unwrap(); + std::os::unix::fs::symlink(attached.path().join("x"), primary.path().join("l")) + .expect("symlink"); + + let spelling = format!("{}/l/../secret", primary.path().display()); + let decision = repo_law_plan_decision( + primary.path(), + &[attached.path().to_path_buf()], + "write_file", + &json!({"path": spelling, "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!("expected the attached root's law to hold a symlink+`..` hop, got {decision:?}"); + }; + assert!(reason.contains("secret is read-only"), "{reason}"); + } + + #[test] + #[allow(non_snake_case)] + fn filePath_spelled_write_receives_the_same_hold() { + // Round-22 B22-2: execution folds the camelCase `filePath` alias onto + // `path` (`PATH_ALIASES`) only at execute time, and the default + // `ToolSpec::prepare` passes input through unchanged — so a + // `filePath`-spelled write produced zero repo-law targets and no + // constitution ever fired. + let tmp = TempDir::new().unwrap(); + write_law(tmp.path(), LAW); + let decision = decide( + tmp.path(), + "write_file", + &json!({"filePath": "crates/protocol/wire.rs", "content": "x"}), + ); + let Some(RepoLawPlanDecision::Block(reason)) = decision else { + panic!("expected the filePath-spelled write to be held, got {decision:?}"); + }; + assert!(reason.contains("The wire format is frozen"), "{reason}"); + } } diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 7a38b32ab7..b04f2501dd 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -4473,7 +4473,11 @@ async fn patch_undo_thread_turn( .get_thread(&id) .await .map_err(map_thread_err)?; - let patch_result = patch_undo_workspace_files(&thread.workspace, thread.session_id.as_deref()); + let patch_result = patch_undo_workspace_files( + &thread.workspace, + &thread.workspace_roots, + thread.session_id.as_deref(), + ); // Step 2: Remove the last conversation turn (undo_conversation). let (forked_thread, original_user_text) = state @@ -4496,6 +4500,7 @@ async fn patch_undo_thread_turn( /// current workspace — same target selection as the TUI's `patch_undo`. fn patch_undo_workspace_files( workspace: &FsPath, + workspace_roots: &[std::path::PathBuf], current_session_id: Option<&str>, ) -> PatchUndoResult { let repo = match crate::snapshot::SnapshotRepo::open_or_init(workspace) { @@ -4565,13 +4570,21 @@ fn patch_undo_workspace_files( }); let short = &target.id.as_str()[..target.id.as_str().len().min(8)]; + // Snapshots are primary-bound: with attached roots in the thread set, + // name the rollback boundary instead of implying a full revert. + let boundary_note = if crate::snapshot::restore_covers_primary_only(workspace, workspace_roots) + { + format!("\n{}", crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE) + } else { + String::new() + }; let summary = match diff_stat { Some(ref stat) => format!( - "Restored snapshot '{}' ({}). Files affected:\n{stat}", + "Restored snapshot '{}' ({}). Files affected:\n{stat}{boundary_note}", target.label, short ), None => format!( - "Restored snapshot '{}' ({}). No diff changes detected.", + "Restored snapshot '{}' ({}). No diff changes detected.{boundary_note}", target.label, short ), }; @@ -5904,34 +5917,104 @@ async fn restore_snapshot( State(state): State, Path(id): Path, ) -> Result, ApiError> { - restore_snapshot_for_workspace(&state.workspace, &id)?; - Ok(Json(json!({ - "restored": id, - }))) + // Round-23 SF23-1 + B23-2: every read happens BEFORE the mutating + // restore (a thread-store failure must not turn a completed rollback + // into a 500), and the boundary decision no longer depends on the + // snapshot's `[sid=...]` tag matching a live thread record — `PUT + // /v1/sessions` re-keys the thread to a new session handle, so the + // exact join silently dropped the clause through an ordinary, + // disclosed-API sequence. + let snapshot = snapshot_for_workspace(&state.workspace, &id)?; + let mut payload = json!({ "restored": id }); + let mut boundary = false; + // Snapshots are primary-bound: with attached roots in the owning + // thread's set, name the rollback boundary instead of implying a full + // revert (review #484/CodeWhale round-22 B22-4). An untagged (legacy) + // snapshot has no owner to consult and stays unchanged; a TAGGED + // snapshot always names the boundary — fail-safe by construction: + // a matching multi-root thread proves it, and a tagged snapshot whose + // owner no longer matches (the PUT re-key sequence) proves an + // interactive owner existed while proving nothing about the roots + // (round-23 B23-2). + if snapshot.session_id.is_some() { + match state + .runtime_threads + .list_threads(ThreadListFilter::IncludeArchived, None) + .await + { + Ok(threads) => { + let matched = threads + .iter() + .find(|thread| thread.session_id.as_deref() == snapshot.session_id.as_deref()); + boundary = match matched { + Some(thread) => crate::snapshot::restore_covers_primary_only( + &thread.workspace, + &thread.workspace_roots, + ), + None => true, + }; + } + // A thread-store read failure degrades to the conservative + // clause (naming the boundary) rather than a 500 after the + // rollback, and without leaking the store error text. + Err(err) => { + eprintln!( + "[runtime_api] restore boundary lookup failed; naming the boundary conservatively: {err}" + ); + boundary = true; + } + } + } + if boundary { + payload["boundary"] = json!({ + "attached_roots_not_reverted": true, + "note": crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE, + }); + } + restore_snapshot_for_workspace(&state.workspace, &snapshot)?; + Ok(Json(payload)) } -fn restore_snapshot_for_workspace(workspace: &FsPath, id: &str) -> Result<(), ApiError> { +/// Round-23 B23-2/SF23-1: the read half of the restore — find and return +/// the snapshot WITHOUT mutating, so callers can compute the response +/// before the rollback runs. +fn snapshot_for_workspace( + workspace: &FsPath, + id: &str, +) -> Result { let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace) .map_err(|e| ApiError::internal(format!("Snapshot repo init failed: {e}")))?; - // The id arrives from the request path and is handed to git as a - // treeish, so it is accepted only if the side repo actually knows it — - // anything else is a 404 rather than an arbitrary string on a git - // command line. The membership check also marks the id as validated - // for command-line-injection scanners (an allowlist `contains` is - // their modeled trust boundary), so the pre-existing, accepted flow - // stops re-flagging when call sites are refactored. - let known_ids: Vec = repo + let known = repo .list(usize::MAX) - .map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))? - .into_iter() - .map(|snapshot| snapshot.id.as_str().to_string()) - .collect(); - if !known_ids.contains(&id.to_string()) { - return Err(ApiError::not_found(format!("no such snapshot: {id}"))); - } - let snapshot_id = crate::snapshot::SnapshotId(id.to_string()); - repo.restore(&snapshot_id) - .map_err(|e| ApiError::internal(format!("Snapshot restore failed: {e}"))) + .map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))?; + known + .iter() + .find(|snapshot| snapshot.id.as_str() == id) + .cloned() + .ok_or_else(|| ApiError::not_found(format!("no such snapshot: {id}"))) +} + +fn restore_snapshot_for_workspace( + workspace: &FsPath, + snapshot: &crate::snapshot::Snapshot, +) -> Result<(), ApiError> { + // Round-24 P3 perf: the already-fetched membership-checked snapshot is + // threaded through instead of re-listing the store a second time per + // restore request (the read half above already proved the id belongs to + // this repo). + let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace) + .map_err(|e| ApiError::internal(format!("Snapshot repo init failed: {e}")))?; + // The id arrived from the request path and is handed to git as a + // treeish; it reached this fn only through the side repo's own + // membership list, so anything else was already answered 404 rather + // than an arbitrary string on a git command line. The membership check + // also marks the id as validated for command-line-injection scanners + // (an allowlist `contains` is their modeled trust boundary), so the + // pre-existing, accepted flow stops re-flagging when call sites are + // refactored. + repo.restore(&snapshot.id) + .map_err(|e| ApiError::internal(format!("Snapshot restore failed: {e}")))?; + Ok(()) } fn snapshot_entries_for_workspace( diff --git a/crates/tui/src/runtime_api/sessions.rs b/crates/tui/src/runtime_api/sessions.rs index 617f2e8687..c6b58ab93b 100644 --- a/crates/tui/src/runtime_api/sessions.rs +++ b/crates/tui/src/runtime_api/sessions.rs @@ -22,6 +22,22 @@ use crate::session_projection::{SessionQuery, SessionSortMode, SessionSummary, p use super::{ApiError, RuntimeApiState, map_thread_err, truncate_text}; use crate::models::Role; +/// Round-45 R45-5: the sanitize belt's drop receipt must be surfaced +/// EXPLICITLY on every API face that runs it — a root-set shrink (cap +/// truncation, refused/poison entries) silently re-persisted is permanent, +/// undisclosed loss of declared roots, exactly what the sanitize contract's +/// "strip-and-warn" half exists to prevent. The TUI load face already notes +/// it through `SanitizedLoadedRoots`; these faces warn through the log so +/// the laundering boundary stays observable. +fn warn_sanitized_dropped(surface: &str, dropped: usize) { + if dropped > 0 { + tracing::warn!( + "{surface}: {dropped} stored workspace root(s) dropped by the sanitize belt \ + (cap truncation or refused entries) and NOT re-persisted as declared roots" + ); + } +} + #[derive(Debug, Serialize)] pub(super) struct SessionsResponse { sessions: Vec, @@ -198,6 +214,16 @@ pub(super) async fn patch_session( Path(id): Path, Json(req): Json, ) -> Result, ApiError> { + // Normalize the id once at the boundary: the store judges the trimmed + // value, so a padded id (`"%20sess…"` in the path) is one session to the + // store, not two rows distinguished by whitespace. An empty id has no + // target. + let id = id.trim().to_string(); + if id.is_empty() { + return Err(ApiError::bad_request( + "PATCH /v1/sessions/{id} requires a non-empty session id", + )); + } if req.title.is_none() && req.archived.is_none() { return Err(ApiError::bad_request( "PATCH /v1/sessions/{id} requires at least one of `title` or `archived`", @@ -305,6 +331,33 @@ pub(super) async fn resume_session_thread( .unwrap_or_else(|| "agent".to_string()) }); + // Round-41 M41-1: a RELATIVE workspace row refuses here like every + // sibling intake (ACP session/load, session/new, the core resume/fork + // lanes) — forwarding it verbatim lets create_thread's degenerate + // collapse silently destroy the declared root set and persist a + // relative-primary thread no other intake will load (A28-1's class). + if session.metadata.workspace.as_os_str().is_empty() + || !session.metadata.workspace.is_absolute() + { + return Err(ApiError::bad_request( + "Failed to create thread: session workspace must be a non-empty absolute path", + )); + } + + // The session row persists the cwd-first full form (primary included), + // but `create_thread` validates its `workspace_roots` as DECLARED + // additional entries under the intake cap — re-declaring the row's own + // head would miscount a legal full-load row (64 additions + head) as + // over-cap and strand the resume at a 400 with no repair path. Strip the + // head before declaring; create_thread re-anchors it as the primary + // (round-37 B37-2, the fifth face; round-38 M38-3: the strip is the + // shared LEXICAL primitive, so a `..`-spelled copy of the head drops + // with it). + let declared_roots: Vec = codewhale_core::strip_workspace_roots_head( + &session.metadata.workspace, + &session.metadata.workspace_roots, + ); + let thread = state .runtime_threads .create_thread(CreateThreadRequest { @@ -312,6 +365,7 @@ pub(super) async fn resume_session_thread( model_provider: Some(session.metadata.model_provider.clone()), model_provider_id: session.metadata.model_provider_id.clone(), workspace: Some(session.metadata.workspace.clone()), + workspace_roots: declared_roots, mode: Some(mode), allow_shell: None, trust_mode: None, @@ -418,6 +472,27 @@ pub(super) async fn create_session_from_thread( )?; } session.system_prompt = detail.thread.system_prompt.clone(); + // Round-40 minor 1 + round-41 minor 1: the one row-minting lane that + // skipped intake — downstream is fail-closed (resume re-validates), but + // a tolerant thread row would propagate its poison one row deeper. A + // refused set now degrades to the SANITIZED remainder (matching the + // tolerant-load faces: cap-truncate, drop the refused entries) instead + // of a bare empty set that silently discards legal additions too. + session.metadata.workspace_roots = match codewhale_core::validate_stored_workspace_roots( + &session.metadata.workspace, + &detail.thread.workspace_roots, + ) { + Ok(roots) => roots, + Err(reason) => { + tracing::warn!("session export degraded the source thread's root set: {reason}"); + let (sanitized, dropped) = codewhale_core::sanitize_workspace_roots( + &session.metadata.workspace, + &detail.thread.workspace_roots, + ); + warn_sanitized_dropped("session export", dropped); + sanitized + } + }; if let Some(title) = session_title_override(req.title.as_deref(), detail.thread.title.as_deref()) @@ -719,8 +794,22 @@ async fn persist_thread_cost( /// token counts and message ordering are authoritative. pub(super) async fn save_current_session( State(state): State, - Json(req): Json, + Json(mut req): Json, ) -> Result, ApiError> { + // Normalize the id once at the boundary: the store judges the trimmed + // value, so a padded id (`" sess… "`) is one session to the store, not + // two rows distinguished by whitespace. An explicit-but-empty id has no + // target at all and is rejected, not silently turned into "create new". + if let Some(raw) = req.session_id.as_deref() { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ApiError::bad_request( + "`session_id` must not be empty when provided", + )); + } + req.session_id = Some(trimmed.to_string()); + } + // Find the thread to save. let thread_id = match req.thread_id { Some(id) => id, @@ -739,6 +828,14 @@ pub(super) async fn save_current_session( } }; + // Round-20 B20-3: the live-session guard was removed. The static + // registry is process-local and the runtime HTTP server never coexists + // with an interactive TUI surface in any shipped topology, so the guard + // could never engage — it headlined a breaking change that is + // unobservable (the registry itself stays: same-process retention + // pruning consults it). A concurrent writer in the same process is + // still arbitrated by last-write-wins at the store layer. + // Get the engine handle (loads the thread into an engine if needed), // then request a session snapshot. This reuses the same code path as // TUI's `build_session_snapshot`: the engine holds the authoritative @@ -747,7 +844,17 @@ pub(super) async fn save_current_session( .runtime_threads .get_engine(&thread_id) .await - .map_err(|e| ApiError::internal(format!("Failed to get engine for thread: {e}")))?; + .map_err(|e| { + // Round-39 minor 4: the arming gate's poison refusal is a + // client-side record defect — the identical row answers 400 on + // `/v1/threads/{id}/resume`; 500 here was a within-PR + // inconsistency. + if e.to_string().contains("corrupt or hand-edited") { + ApiError::bad_request(format!("Failed to get engine for thread: {e}")) + } else { + ApiError::internal(format!("Failed to get engine for thread: {e}")) + } + })?; let snapshot = engine .get_session_snapshot() @@ -776,6 +883,23 @@ pub(super) async fn save_current_session( snapshot.model_provider_id.as_deref(), ); updated.metadata.mode = Some(snapshot.mode.clone()); + // The paired set travels with the workspace it belongs to, + // exactly as in `/rename` and `/fork`: a PATCH may have moved + // the thread since this session was last saved, and stamping + // only the roots would persist `workspace: ` next + // to a set led by the new directory — a later resume-thread + // then re-admits the abandoned directory as a writable + // primary root. Both fields come from the same snapshot, + // re-normalized so the pair is consistent by construction. + updated.metadata.workspace = snapshot.workspace.clone(); + // Round-41 minor 2: the sanitize belt the TUI autosave runs + // (launder prevention) instead of the bare normalizer. + let (sanitized_put_roots, dropped) = codewhale_core::sanitize_workspace_roots( + &snapshot.workspace, + &snapshot.workspace_roots, + ); + warn_sanitized_dropped("session update from a live snapshot", dropped); + updated.metadata.workspace_roots = sanitized_put_roots; updated } Err(e) => { @@ -793,6 +917,14 @@ pub(super) async fn save_current_session( &snapshot.model_provider, snapshot.model_provider_id.as_deref(), ); + // Round-42 minor 8: the sanitize belt on every mint arm (the update arm + // already runs it — the create/recover arms cloned verbatim). + let (sanitized_roots, dropped) = codewhale_core::sanitize_workspace_roots( + &snapshot.workspace, + &snapshot.workspace_roots, + ); + warn_sanitized_dropped("session recovery mint", dropped); + session.metadata.workspace_roots = sanitized_roots; session } else { return Err(ApiError::internal(format!( @@ -814,6 +946,14 @@ pub(super) async fn save_current_session( &snapshot.model_provider, snapshot.model_provider_id.as_deref(), ); + // Round-42 minor 8: the sanitize belt on every mint arm (the update arm + // already runs it — the create/recover arms cloned verbatim). + let (sanitized_roots, dropped) = codewhale_core::sanitize_workspace_roots( + &snapshot.workspace, + &snapshot.workspace_roots, + ); + warn_sanitized_dropped("session mint", dropped); + session.metadata.workspace_roots = sanitized_roots; session }; @@ -972,9 +1112,9 @@ fn map_session_err(id: &str, err: std::io::Error, action: &str) -> ApiError { std::io::ErrorKind::InvalidInput => { ApiError::bad_request(format!("Invalid session id '{id}'")) } - // The session is open in an interactive Codewhale session, which holds - // the authoritative copy in memory. Fail closed with a typed conflict - // rather than write something its next autosave would revert. + // Round-20 B20-3 retired the producing live-session guard, so this + // kind has no producer left on Unix; the mapping is retained + // defensively (a future io producer must not surface as a 500). std::io::ErrorKind::ResourceBusy => ApiError { status: StatusCode::CONFLICT, message: err.to_string(), @@ -984,12 +1124,29 @@ fn map_session_err(id: &str, err: std::io::Error, action: &str) -> ApiError { } fn map_resume_thread_create_err(err: anyhow::Error) -> ApiError { + // Intake validation failures are client errors (round-24 P3): the + // resume lane forwards the SAVED session's workspace/roots into + // `create_thread`, whose validating intake can reject them — a + // hand-edited or migrated session row with a `/`-rooted or over-cap + // root set must answer 400 with the reason, exactly like the `/thread` + // and `/tool` lanes, not a server-fault 500. + if err + .downcast_ref::() + .is_some() + { + return ApiError::bad_request(format!("Failed to create thread: {err}")); + } let reason = err.to_string(); let message = format!("Failed to create thread: {reason}"); if reason.starts_with("saved session has an empty provider identity") || reason.starts_with("saved session requires custom provider") || reason.starts_with("legacy session records only the generic `custom` provider kind") || reason.starts_with("legacy `provider = \"custom\"`") + // Round-38 minor: a legacy/hand-edited row with an EMPTY workspace + // is a client-side record defect (create_thread's own guard + // refuses it); the face's doctrine is 400 with the reason, not a + // 500 — the ACP lane refuses the same row with invalid_params. + || reason.starts_with("workspace must not be empty") { ApiError::bad_request(message) } else { @@ -1100,4 +1257,25 @@ mod resume_thread_error_tests { )); assert_eq!(storage.status, StatusCode::INTERNAL_SERVER_ERROR); } + + #[test] + fn intake_validation_errors_answer_400_like_the_thread_lane() { + // Round-24 P3: the resume lane forwards the saved session's root + // set into create_thread's validating intake; a rejected row (here + // a `/`-rooted set read back from a hand-edited session file) is a + // client-fixable record, not a server fault — the mapping must + // match the /thread and /tool lanes instead of the old 500. + let err = codewhale_core::validate_workspace_roots( + std::path::Path::new("/ws"), + &[std::path::PathBuf::from("/")], + ) + .expect_err("a filesystem-root declaration is rejected at intake"); + let mapped = map_resume_thread_create_err(err); + assert_eq!(mapped.status, StatusCode::BAD_REQUEST); + assert!( + mapped.message.contains("filesystem root"), + "the 400 body carries the intake reason: {}", + mapped.message + ); + } } diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index ac220076f3..2f60d620dd 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -296,6 +296,7 @@ fn saved_session_with_blocks(blocks: Vec) -> SavedS model_provider: "deepseek".to_string(), model_provider_id: None, workspace: PathBuf::from("."), + workspace_roots: Vec::new(), mode: None, cost: Default::default(), parent_session_id: None, @@ -433,6 +434,7 @@ fn messages_from_thread_detail_batches_tool_results() { reasoning_effort: None, allowed_tools: None, workspace: PathBuf::from("."), + workspace_roots: Vec::new(), mode: "agent".to_string(), permission_posture: Some("ask".to_string()), allow_shell: false, @@ -623,6 +625,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() { reasoning_effort: None, allowed_tools: None, workspace: PathBuf::from("."), + workspace_roots: Vec::new(), mode: "agent".to_string(), permission_posture: None, allow_shell: false, @@ -4375,6 +4378,778 @@ async fn session_resume_thread_creates_thread_from_saved_session() -> Result<()> Ok(()) } +#[tokio::test] +async fn session_resume_thread_carries_persisted_workspace_roots() -> Result<()> { + let root = + std::env::temp_dir().join(format!("deepseek-session-resume-roots-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + fs::create_dir_all(&sessions_dir)?; + let session = json!({ + "schema_version": 1, + "metadata": { + "id": "sess_roots_resume", + "title": "Roots resume session", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:10:00Z", + "message_count": 1, + "total_tokens": 10, + "model": "deepseek-v4-pro", + "workspace": "/tmp/test", + "workspace_roots": ["/tmp/shared"], + "mode": "agent" + }, + "messages": [ + { + "role": "user", + "content": [{ "type": "text", "text": "Hello, roots!" }] + } + ], + "system_prompt": null + }); + fs::write( + sessions_dir.join("sess_roots_resume.json"), + serde_json::to_string_pretty(&session)?, + )?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let resp = client + .post(format!( + "http://{addr}/v1/sessions/sess_roots_resume/resume-thread" + )) + .json(&json!({ "model": "deepseek-v4-pro" })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + let resumed: serde_json::Value = resp.json().await?; + let thread_id = resumed["thread_id"] + .as_str() + .context("missing resumed thread id")?; + + // The HTTP resume must carry the session's persisted roots into the + // created thread: resuming a multi-root session and saving once must + // not launder it back to single-root. + let detail: serde_json::Value = client + .get(format!("http://{addr}/v1/threads/{thread_id}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(detail["thread"]["workspace"], "/tmp/test"); + assert_eq!( + detail["thread"]["workspace_roots"], + json!(["/tmp/test", "/tmp/shared"]), + "resume must normalize the persisted set with the workspace leading" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn session_export_degrades_a_refused_thread_root_set_to_the_sanitized_remainder() -> Result<()> +{ + // Round-42 minor 7: the export mint's degrade leg — a thread row whose + // set fails the validating intake keeps its LEGAL additions (the + // sanitized remainder) instead of collapsing to the bare primary. + let root = std::env::temp_dir().join(format!("deepseek-export-degrade-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + fs::create_dir_all(&sessions_dir)?; + + let Some((addr, runtime_threads, handle)) = + spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // Seed a thread whose stored set carries poison (the fs root) beside a + // legal additional root — poisoned directly in the store, the shape + // every validating face refuses but a tolerant row can carry. + let workspace = root.join("ws"); + fs::create_dir_all(&workspace)?; + let shared = root.join("shared"); + fs::create_dir_all(&shared)?; + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ "workspace": workspace, "workspace_roots": [shared] })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = created["id"].as_str().context("thread id")?.to_string(); + { + let mut row = runtime_threads.get_thread(&thread_id).await?; + row.workspace_roots = vec![workspace.clone(), shared.clone(), "/".into()]; + runtime_threads.test_store().save_thread(&row)?; + } + + // Seed one completed user/assistant turn directly in the store — the + // export needs message content, not a live engine. + { + use crate::runtime_threads::{ + RuntimeTurnStatus, TurnItemKind, TurnItemLifecycleStatus, TurnItemRecord, TurnRecord, + }; + let now = chrono::Utc::now(); + let turn_id = "turn_seed_0"; + runtime_threads.test_store().save_item(&TurnItemRecord { + schema_version: crate::runtime_threads::CURRENT_RUNTIME_SCHEMA_VERSION, + id: "item_user_0".to_string(), + turn_id: turn_id.to_string(), + kind: TurnItemKind::UserMessage, + status: TurnItemLifecycleStatus::Completed, + summary: "seed".to_string(), + detail: Some("seed".to_string()), + metadata: None, + artifact_refs: Vec::new(), + started_at: Some(now), + ended_at: Some(now), + })?; + let record = TurnRecord { + schema_version: crate::runtime_threads::CURRENT_RUNTIME_SCHEMA_VERSION, + id: turn_id.to_string(), + thread_id: thread_id.clone(), + status: RuntimeTurnStatus::Completed, + created_at: now, + started_at: Some(now), + ended_at: Some(now), + duration_ms: Some(0), + usage: None, + permission_posture: None, + effective_provider: None, + effective_provider_id: None, + effective_billing_surface: None, + effective_endpoint_fingerprint: None, + effective_billing_mode: None, + effective_dispatched_at: None, + effective_model: None, + routed_usage: Vec::new(), + routed_usage_source_ids: Vec::new(), + routed_usage_dropped_records: 0, + error: None, + input_summary: "seed".to_string(), + item_ids: vec!["item_user_0".to_string()], + steer_count: 0, + agent_mail_message_id: None, + }; + runtime_threads.test_store().save_turn(&record)?; + } + + let resp: serde_json::Value = client + .post(format!("http://{addr}/v1/sessions")) + .json(&json!({ "thread_id": thread_id })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let session_id = resp["session_id"] + .as_str() + .context("session id")? + .to_string(); + + let detail: serde_json::Value = client + .get(format!("http://{addr}/v1/sessions/{session_id}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + let roots = detail["metadata"]["workspace_roots"] + .as_array() + .context("exported roots")?; + assert!( + !roots.iter().any(|r| r == "/"), + "the poison never reaches the minted row" + ); + assert!( + roots + .iter() + .any(|r| r.as_str() == Some(shared.to_str().unwrap())), + "the legal additional root survives the degrade: {roots:?}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn session_resume_thread_answers_400_for_an_empty_workspace_row() -> Result<()> { + // Round-39 minor 11: a legacy/hand-edited row with an EMPTY workspace + // is a client-side record defect — the face's own doctrine is 400 with + // the reason (round-38 fixed the mapping; this pins it). + let root = std::env::temp_dir().join(format!("deepseek-session-empty-ws-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + fs::create_dir_all(&sessions_dir)?; + let session = json!({ + "schema_version": 1, + "metadata": { + "id": "sess_empty_ws", + "title": "Empty workspace", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:10:00Z", + "message_count": 1, + "total_tokens": 10, + "model": "deepseek-v4-pro", + "workspace": "", + "mode": "agent" + }, + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "hi" }] } + ], + "system_prompt": null + }); + fs::write( + sessions_dir.join("sess_empty_ws.json"), + serde_json::to_string_pretty(&session)?, + )?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let resp = client + .post(format!( + "http://{addr}/v1/sessions/sess_empty_ws/resume-thread" + )) + .json(&json!({ "model": "deepseek-v4-pro" })) + .send() + .await?; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "an empty-workspace row is a client defect, not a server fault" + ); + let body: serde_json::Value = resp.json().await?; + assert!( + body.to_string().contains("non-empty absolute path"), + "the 400 carries the face's unified refusal: {body}" + ); + + // Round-41 M41-1 endpoint-level regression: the RELATIVE spelling + // refuses on the same face — forwarding it used to collapse the + // declared set to empty and mint a relative-primary thread (201). + let relative = json!({ + "schema_version": 1, + "metadata": { + "id": "sess_relative_ws", + "title": "Relative workspace", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:10:00Z", + "message_count": 1, + "total_tokens": 10, + "model": "deepseek-v4-pro", + "workspace": "relative/dir", + "workspace_roots": ["/srv/data"], + "mode": "agent" + }, + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "hi" }] } + ], + "system_prompt": null + }); + fs::write( + sessions_dir.join("sess_relative_ws.json"), + serde_json::to_string_pretty(&relative)?, + )?; + let resp = client + .post(format!( + "http://{addr}/v1/sessions/sess_relative_ws/resume-thread" + )) + .json(&json!({ "model": "deepseek-v4-pro" })) + .send() + .await?; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "a relative-workspace row must not mint a collapsed thread" + ); + let body: serde_json::Value = resp.json().await?; + assert!( + body.to_string().contains("non-empty absolute path"), + "the 400 names the relative-workspace refusal: {body}" + ); + + // Round-42 minor 6: the refused row mints nothing — no session file + // exists for either rejected spelling (the store is the durable + // evidence; the guard fires before any create). + let mut entries = fs::read_dir(&sessions_dir)?; + let mut session_files = 0usize; + while let Some(Ok(entry)) = entries.next() { + if entry.file_name().to_string_lossy().ends_with(".json") { + session_files += 1; + } + } + assert_eq!( + session_files, 2, + "only the two fixture rows exist — no minted session: {sessions_dir:?}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn session_resume_thread_survives_a_legal_full_load_row() -> Result<()> { + // Round-37 B37-2, the resume-thread face: a session saved with a 64-root + // declaration persists the 65-entry cwd-first full form. Re-declaring + // that row verbatim into create_thread would miscount its own head + // against the intake cap and strand the resume at a 400 with no repair + // path; the face strips the head before declaring. + let root = std::env::temp_dir().join(format!( + "deepseek-session-resume-full-load-{}", + Uuid::new_v4() + )); + let sessions_dir = root.join("sessions"); + fs::create_dir_all(&sessions_dir)?; + let full_load_roots: Vec = (0..codewhale_core::MAX_WORKSPACE_ROOTS) + .map(|index| format!("/tmp/test/r{index}")) + .collect(); + let stored_full_form: Vec = std::iter::once("/tmp/test".to_string()) + .chain(full_load_roots.iter().cloned()) + .collect(); + let session = json!({ + "schema_version": 1, + "metadata": { + "id": "sess_full_load_resume", + "title": "Full-load resume session", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:10:00Z", + "message_count": 1, + "total_tokens": 10, + "model": "deepseek-v4-pro", + "workspace": "/tmp/test", + // The stored full form: the head plus 64 additions. + "workspace_roots": stored_full_form, + "mode": "agent" + }, + "messages": [ + { + "role": "user", + "content": [{ "type": "text", "text": "Hello, full load!" }] + } + ], + "system_prompt": null + }); + fs::write( + sessions_dir.join("sess_full_load_resume.json"), + serde_json::to_string_pretty(&session)?, + )?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let resp = client + .post(format!( + "http://{addr}/v1/sessions/sess_full_load_resume/resume-thread" + )) + .json(&json!({ "model": "deepseek-v4-pro" })) + .send() + .await?; + assert_eq!( + resp.status(), + StatusCode::CREATED, + "a legal full-load row must resume, not trip the intake cap" + ); + let resumed: serde_json::Value = resp.json().await?; + let thread_id = resumed["thread_id"] + .as_str() + .context("missing resumed thread id")?; + + let detail: serde_json::Value = client + .get(format!("http://{addr}/v1/threads/{thread_id}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + let armed = detail["thread"]["workspace_roots"] + .as_array() + .context("thread workspace_roots")?; + assert_eq!( + armed.len(), + codewhale_core::MAX_WORKSPACE_ROOTS + 1, + "the resumed thread arms the full-load set whole, head re-anchored" + ); + assert_eq!(detail["thread"]["workspace_roots"][0], "/tmp/test"); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn saved_sessions_carry_thread_workspace_roots_through_save_and_resave() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-session-roots-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, runtime_threads, handle)) = + spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ + "model": "deepseek-v4-pro", + "workspace": root.join("workspace"), + "workspace_roots": ["/shared"] + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = created["id"] + .as_str() + .context("missing thread id")? + .to_string(); + + runtime_threads + .seed_thread_from_messages( + &thread_id, + &[ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "Persist these roots".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: "Roots should survive the save.".to_string(), + cache_control: None, + }], + }, + ], + ) + .await?; + + // Save the thread as a session: the session metadata must carry the + // thread's root set, or a later `exec --resume` silently degrades the + // thread to single-root. + let resp = client + .post(format!("http://{addr}/v1/sessions")) + .json(&json!({ "thread_id": thread_id })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + let saved: serde_json::Value = resp.json().await?; + let session_handle = saved["session_id"] + .as_str() + .context("missing session id")? + .to_string(); + + let session_manager = crate::session_manager::SessionManager::new(sessions_dir.clone())?; + let stored = session_manager.load_session_by_prefix(&session_handle)?; + let expected_roots = json!([root.join("workspace"), "/shared"]); + assert_eq!( + serde_json::to_value(&stored.metadata.workspace_roots)?, + expected_roots, + "save-thread-as-session must stamp the thread's workspace_roots" + ); + + // Change the thread's set before the re-save. The POST above already + // wrote `expected_roots` to the file, so asserting the same value after + // the PUT would pass with the PUT stamp deleted; the newer set is what + // makes the snapshot save path load-bearing. + let patched: serde_json::Value = client + .patch(format!("http://{addr}/v1/threads/{thread_id}")) + .json(&json!({ "workspace_roots": ["/later"] })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!( + patched["workspace_roots"], + json!([root.join("workspace"), "/later"]) + ); + + // Re-saving through the snapshot path must write the thread's *current* + // roots: the engine builds from the thread and the save copies the + // snapshot roots over the metadata. + client + .put(format!("http://{addr}/v1/sessions")) + .json(&json!({ + "thread_id": thread_id, + "session_id": session_handle + })) + .send() + .await? + .error_for_status()?; + let resaved = session_manager.load_session_by_prefix(&session_handle)?; + assert_eq!( + serde_json::to_value(&resaved.metadata.workspace_roots)?, + json!([root.join("workspace"), "/later"]), + "the snapshot save path must write the thread's current roots" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn put_empty_session_id_and_patch_invalid_path_id_answer_400() -> Result<()> { + // Round-24 P3 pin: the two boundary-validation 400 arms whose pins were + // lost with the 409-guard removal — an explicit-but-empty `session_id` + // on PUT /v1/sessions is a client error (not "create new"), and a + // session PATCH addressed at an invalid id shape answers 400, not 500. + let root = std::env::temp_dir().join(format!("deepseek-put-400-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // PUT with an explicit empty session_id: rejected at the boundary. + let rejected = client + .put(format!("http://{addr}/v1/sessions")) + .json(&json!({ "session_id": "" })) + .send() + .await?; + assert_eq!( + rejected.status(), + StatusCode::BAD_REQUEST, + "an explicit empty session_id must be a 400, not a silent create" + ); + + // Session PATCH addressed at an invalid id shape: 400 with the reason. + let invalid = client + .patch(format!("http://{addr}/v1/sessions/not a valid id!")) + .json(&json!({ "title": "x" })) + .send() + .await?; + assert_eq!( + invalid.status(), + StatusCode::BAD_REQUEST, + "an invalid session id in the path must answer 400, not 500" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn session_resave_after_workspace_move_keeps_workspace_and_roots_paired() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-session-move-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, runtime_threads, handle)) = + spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let w1 = root.join("w1"); + let w2 = root.join("w2"); + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ + "model": "deepseek-v4-pro", + "workspace": w1, + "workspace_roots": ["/shared"] + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = created["id"] + .as_str() + .context("missing thread id")? + .to_string(); + + runtime_threads + .seed_thread_from_messages( + &thread_id, + &[Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "Save me at w1, then move me.".to_string(), + cache_control: None, + }], + }], + ) + .await?; + + // Save at w1, then move the thread to w2. The PATCH evicts the engine, + // so the re-save snapshots a fresh engine built from the moved thread. + let resp = client + .post(format!("http://{addr}/v1/sessions")) + .json(&json!({ "thread_id": thread_id })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + let saved: serde_json::Value = resp.json().await?; + let session_handle = saved["session_id"] + .as_str() + .context("missing session id")? + .to_string(); + + let patched: serde_json::Value = client + .patch(format!("http://{addr}/v1/threads/{thread_id}")) + .json(&json!({ "workspace": w2 })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(patched["workspace"], json!(w2)); + assert_eq!(patched["workspace_roots"], json!([w2, "/shared"])); + + client + .put(format!("http://{addr}/v1/sessions")) + .json(&json!({ + "thread_id": thread_id, + "session_id": session_handle + })) + .send() + .await? + .error_for_status()?; + + // The persisted pair must be internally consistent: `workspace` is w2 + // and the abandoned w1 is nowhere in the root set, or a later + // resume-thread would re-admit w1 as a writable primary root. + let session_manager = crate::session_manager::SessionManager::new(sessions_dir.clone())?; + let resaved = session_manager.load_session_by_prefix(&session_handle)?; + assert_eq!( + resaved.metadata.workspace, w2, + "re-save must stamp the moved workspace beside the roots" + ); + assert_eq!( + serde_json::to_value(&resaved.metadata.workspace_roots)?, + json!([w2, "/shared"]), + "re-save must not leave the abandoned w1 in the persisted root set" + ); + + handle.abort(); + Ok(()) +} + +/// Declared root sets are validated at intake, not silently reshaped. The +/// per-turn sandbox copies the set into `WorkspaceWrite.writable_roots` +/// verbatim, so attaching `/` (or `/..`, or the workspace's parent — the +/// same reach one spelling at a time) would make the sandboxed exec lane +/// filesystem-writable, and a non-absolute entry like `~/shared` used to be +/// silently dropped, shrinking the declared set without a word. All four are +/// rejected now; a normal sibling root still attaches. +#[tokio::test] +async fn thread_create_rejects_super_root_ancestor_and_non_absolute_roots() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-thread-root-intake-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let workspace = root.join("workspace"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root_token_mobile_workspace( + root.clone(), + sessions_dir, + None, + false, + workspace.clone(), + ) + .await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // The filesystem root, in the spelling a client would type and in the + // `..` spelling that normalizes to it. + for declared in ["/", "/.."] { + let rejected = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ "workspace_roots": [declared] })) + .send() + .await?; + assert_eq!( + rejected.status(), + StatusCode::BAD_REQUEST, + "attaching {declared:?} must be rejected at intake" + ); + } + + // The workspace's parent contains the primary: the same widening, one + // spelling at a time. + let parent = workspace + .parent() + .and_then(|dir| dir.to_str()) + .context("test workspace must have a parent")? + .to_string(); + let rejected = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ "workspace_roots": [parent] })) + .send() + .await?; + assert_eq!( + rejected.status(), + StatusCode::BAD_REQUEST, + "attaching the primary's parent must be rejected at intake" + ); + + // A `~` spelling is non-absolute: rejected instead of silently dropped + // from the declared set. + let rejected = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ "workspace_roots": ["~/shared"] })) + .send() + .await?; + assert_eq!( + rejected.status(), + StatusCode::BAD_REQUEST, + "a non-absolute root must be rejected, not silently dropped" + ); + + // A normal sibling root still attaches, and the PATCH lane reuses the + // same validation for an explicit replacement set. + let sibling = root.join("sibling-lib"); + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ "workspace_roots": [sibling.to_string_lossy()] })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = created["id"].as_str().context("missing thread id")?; + assert_eq!( + created["workspace_roots"], + json!([workspace.to_string_lossy(), sibling.to_string_lossy()]), + "a sibling root attaches beside the absolute primary" + ); + + let rejected = client + .patch(format!("http://{addr}/v1/threads/{thread_id}")) + .json(&json!({ "workspace_roots": ["/deep/../../.."] })) + .send() + .await?; + assert_eq!( + rejected.status(), + StatusCode::BAD_REQUEST, + "PATCH re-declares the set and must reject a super-root spelling too" + ); + + handle.abort(); + Ok(()) +} + #[tokio::test] async fn session_create_from_completed_thread_saves_messages() -> Result<()> { let root = std::env::temp_dir().join(format!("deepseek-thread-session-{}", Uuid::new_v4())); @@ -4685,12 +5460,12 @@ fn patch_undo_helper_restores_only_the_bound_session() -> Result<()> { repo.snapshot_with_session("pre-turn:foreign", Some("session-foreign"))?; fs::write(&file, "current-after")?; - let restored = patch_undo_workspace_files(&workspace, Some("session-current")); + let restored = patch_undo_workspace_files(&workspace, &[], Some("session-current")); assert!(restored.files_restored, "{:?}", restored.summary); assert_eq!(fs::read_to_string(&file)?, "current-before"); fs::write(&file, "must-stay")?; - let unbound = patch_undo_workspace_files(&workspace, None); + let unbound = patch_undo_workspace_files(&workspace, &[], None); assert!(!unbound.files_restored); assert_eq!(fs::read_to_string(&file)?, "must-stay"); Ok(()) @@ -4741,8 +5516,9 @@ fn restore_snapshot_endpoint_helper_restores_workspace_files() -> Result<()> { let snapshot_id = repo.snapshot("pre-turn:1")?; fs::write(workspace.join("a.txt"), "v2")?; - restore_snapshot_for_workspace(&workspace, snapshot_id.as_str()) - .expect("snapshot restore should succeed"); + let snapshot = + snapshot_for_workspace(&workspace, snapshot_id.as_str()).expect("membership lookup"); + restore_snapshot_for_workspace(&workspace, &snapshot).expect("snapshot restore should succeed"); assert_eq!(fs::read_to_string(workspace.join("a.txt"))?, "v1"); Ok(()) } @@ -4762,10 +5538,11 @@ fn restore_snapshot_endpoint_helper_rejects_unknown_snapshot_id() -> Result<()> repo.snapshot("pre-turn:1")?; // An id the side repo does not know must 404 without reaching git, - // instead of being handed over as an arbitrary treeish. - let err = - restore_snapshot_for_workspace(&workspace, "0123456789abcdef0123456789abcdef01234567") - .expect_err("an unknown snapshot id must be rejected"); + // instead of being handed over as an arbitrary treeish. (Round-24 P3: + // the membership gate lives in the read half — the restore half now + // takes the fetched snapshot — so this pins the read half's 404.) + let err = snapshot_for_workspace(&workspace, "0123456789abcdef0123456789abcdef01234567") + .expect_err("an unknown snapshot id must be rejected"); assert_eq!(err.status, StatusCode::NOT_FOUND); assert!( err.message.contains("no such snapshot"), @@ -4776,6 +5553,203 @@ fn restore_snapshot_endpoint_helper_rejects_unknown_snapshot_id() -> Result<()> Ok(()) } +#[tokio::test] +async fn restore_route_names_the_attached_roots_boundary_for_multi_root_threads() -> Result<()> { + // Round-22 B22-4: the restore face reported a bare `{"restored": id}` + // while every sibling rollback face carried the attached-roots clause. + // The snapshot's `[sid=...]` tag joins it to the owning thread record, + // whose declared root set decides whether the clause is added. + let _lock = lock_test_env(); + let root = tempfile::tempdir()?; + let home = root.path().join("home"); + fs::create_dir_all(&home)?; + let _home = EnvVarGuard::set("HOME", &home); + + let sessions_dir = root.path().join("sessions"); + let workspace = root.path().join("workspace"); + let Some((addr, runtime_threads, handle)) = spawn_test_server_with_root_token_mobile_workspace( + root.path().to_path_buf(), + sessions_dir, + None, + false, + workspace.clone(), + ) + .await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let multi = runtime_threads + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + workspace_roots: vec![root.path().join("attached")], + ..CreateThreadRequest::default() + }) + .await?; + runtime_threads + .set_thread_session_id(&multi.id, "sess-multi") + .await?; + let single = runtime_threads + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + ..CreateThreadRequest::default() + }) + .await?; + runtime_threads + .set_thread_session_id(&single.id, "sess-single") + .await?; + + let repo = crate::snapshot::SnapshotRepo::open_or_init(&workspace)?; + fs::write(workspace.join("a.txt"), "v1")?; + let multi_snap = repo.snapshot_with_session("pre-turn:1", Some("sess-multi"))?; + fs::write(workspace.join("a.txt"), "v2")?; + let single_snap = repo.snapshot_with_session("pre-turn:2", Some("sess-single"))?; + fs::write(workspace.join("a.txt"), "v3")?; + + // Multi-root: the clause names what the restore does NOT revert. + let response = client + .post(format!( + "http://{addr}/v1/snapshots/{}/restore", + multi_snap.0 + )) + .send() + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await?; + assert_eq!(body["restored"], multi_snap.0.as_str()); + assert_eq!( + body["boundary"]["attached_roots_not_reverted"], + json!(true), + "a disjoint attached root persists past the restore: {body}" + ); + assert!( + body["boundary"]["note"] + .as_str() + .is_some_and(|note| note.contains("attached workspace roots")), + "{body}" + ); + + // Single-root: the legacy response shape stays byte-identical. + let response = client + .post(format!( + "http://{addr}/v1/snapshots/{}/restore", + single_snap.0 + )) + .send() + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await?; + assert!( + body.get("boundary").is_none(), + "a single-root restore must not grow the clause: {body}" + ); + + // Round-23 B23-2: the ordinary save/re-key sequence — a `PUT /v1/sessions` + // moves the multi-root thread to a NEW session handle after the snapshot + // was tagged with the old one. The clause must survive the re-key: the + // tagged snapshot proves an interactive owner existed and proves nothing + // about the roots, so the boundary is named even though no live thread + // matches the old sid any more. + let rekey: serde_json::Value = client + .put(format!("http://{addr}/v1/sessions")) + .json(&json!({ + "thread_id": multi.id, + "session_id": "sess-multi-rekeyed", + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(rekey["session_id"], "sess-multi-rekeyed", "{rekey}"); + let response = client + .post(format!( + "http://{addr}/v1/snapshots/{}/restore", + multi_snap.0 + )) + .send() + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await?; + assert_eq!( + body["boundary"]["attached_roots_not_reverted"], + json!(true), + "the re-keyed owner must not drop the boundary clause: {body}" + ); + + // Round-23 B23-1 legs: a `..`-spelled root and an inward-symlink root + // component-wise "nest" under the primary over raw spellings while + // consumers canonicalize them outside — the clause must fire for both + // (the old predicate withheld it, fail-unsafe). + let dotdot_thread = runtime_threads + .create_thread(CreateThreadRequest { + workspace: Some(workspace.join("ws")), + workspace_roots: vec![workspace.join("ws").join("..").join("shared2")], + ..CreateThreadRequest::default() + }) + .await?; + runtime_threads + .set_thread_session_id(&dotdot_thread.id, "sess-dotdot") + .await?; + let dotdot_snap = repo.snapshot_with_session("pre-turn:3", Some("sess-dotdot"))?; + let response = client + .post(format!( + "http://{addr}/v1/snapshots/{}/restore", + dotdot_snap.0 + )) + .send() + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await?; + assert_eq!( + body["boundary"]["attached_roots_not_reverted"], + json!(true), + "a ..-spelled attached root must still fire the boundary: {body}" + ); + + // Round-24 B24-1: the symlink leg the round-23 commit message claimed + // but never built — an inward-symlink root (`ws3/link → elsewhere3`) + // lexically nests under `ws3` while the predicate's consumers resolve + // it outside, so the clause must fire through the canonical spelling. + #[cfg(unix)] + { + let symlink_workspace = workspace.join("ws3"); + fs::create_dir_all(&symlink_workspace)?; + let outside = workspace.join("elsewhere3"); + fs::create_dir_all(&outside)?; + std::os::unix::fs::symlink(&outside, symlink_workspace.join("link"))?; + let symlink_thread = runtime_threads + .create_thread(CreateThreadRequest { + workspace: Some(symlink_workspace.clone()), + workspace_roots: vec![symlink_workspace.join("link")], + ..CreateThreadRequest::default() + }) + .await?; + runtime_threads + .set_thread_session_id(&symlink_thread.id, "sess-symlink") + .await?; + let symlink_snap = repo.snapshot_with_session("pre-turn:4", Some("sess-symlink"))?; + let response = client + .post(format!( + "http://{addr}/v1/snapshots/{}/restore", + symlink_snap.0 + )) + .send() + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await?; + assert_eq!( + body["boundary"]["attached_roots_not_reverted"], + json!(true), + "an inward-symlink attached root must fire the boundary: {body}" + ); + } + + handle.abort(); + Ok(()) +} + #[tokio::test] async fn session_create_from_thread_rejects_active_turn() -> Result<()> { let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { @@ -5099,41 +6073,6 @@ async fn session_patch_route_renames_archives_and_reports_real_changes() -> Resu Ok(()) } -/// A session the TUI holds open is refused with a typed 409 rather than -/// written behind its back. -#[tokio::test] -async fn session_patch_route_refuses_a_live_session_with_a_conflict() -> Result<()> { - // The live-session claim is process-global by construction (the embedded - // API runs inside the TUI process), so this test must not run alongside - // anything else that claims or clears it. - let _lock = lock_test_env(); - let Some((addr, _dir, handle)) = - spawn_server_with_saved_sessions(&[("sess-live", "Held open", false)]).await? - else { - return Ok(()); - }; - let client = crate::tls::reqwest_client(); - - crate::session_manager::set_live_session(Some("sess-live")); - let conflict = client - .patch(format!("http://{addr}/v1/sessions/sess-live")) - .json(&json!({ "title": "Renamed from the dashboard" })) - .send() - .await?; - assert_eq!(conflict.status(), StatusCode::CONFLICT); - - crate::session_manager::set_live_session(None); - let allowed = client - .patch(format!("http://{addr}/v1/sessions/sess-live")) - .json(&json!({ "title": "Renamed from the dashboard" })) - .send() - .await?; - assert_eq!(allowed.status(), StatusCode::OK); - - handle.abort(); - Ok(()) -} - /// `?peek=true` returns the bounded redacted projection, and the plain route /// still returns the full detail shape. #[tokio::test] @@ -11472,3 +12411,27 @@ async fn marketplace_add_rejects_symlink_documents_over_http() -> Result<()> { handle.abort(); Ok(()) } + +#[test] +fn sanitize_belt_drop_receipts_are_surfaced_on_every_api_face() { + // Round-45 R45-5: every production `sanitize_workspace_roots` call in + // the sessions API must consume its drop receipt (`warn_sanitized_dropped`) + // — a site that rebinds `_dropped` discards the shrink silently and the + // shrunk set is then re-persisted (permanent, undisclosed loss). This + // source pin goes red the moment a call site regresses to `_dropped` or + // drops the warn (same shape as the composer-bang call-site pin). + let src = include_str!("sessions.rs"); + assert!( + !src.contains(", _dropped)"), + "a sanitize_workspace_roots call site discards its drop receipt" + ); + let call_sites = src.matches("sanitize_workspace_roots(").count(); + let surfaced = src.matches("warn_sanitized_dropped(").count(); + // One warn per call site plus the helper's own definition doc-free name + // (the helper body is the definition; call sites = surfaced - 1). + assert_eq!( + call_sites, + surfaced - 1, + "every sanitize_workspace_roots call site must surface its receipt" + ); +} diff --git a/crates/tui/src/runtime_chat_relay.rs b/crates/tui/src/runtime_chat_relay.rs index 1d30c1f1fd..2d159d43c8 100644 --- a/crates/tui/src/runtime_chat_relay.rs +++ b/crates/tui/src/runtime_chat_relay.rs @@ -543,6 +543,7 @@ impl RuntimeChatRelayHost { task_id: None, dynamic_tools: Vec::new(), environments: Vec::new(), + workspace_roots: Vec::new(), }) .await .map_err(|_| "Runtime Chat could not create its replay fixture.".to_string())? @@ -669,6 +670,7 @@ impl RuntimeChatRelayHost { task_id: None, dynamic_tools: Vec::new(), environments: Vec::new(), + workspace_roots: Vec::new(), }) .await .map_err(|_| { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 61ece593cb..ce41b58e01 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -488,7 +488,7 @@ fn sort_turn_items_by_start(items: &mut [TurnItemRecord]) { /// Bumped to 2 for v0.6.6 after live engine semantics changed. The persisted /// thread/turn/item records did not change shape, but a v1 reader on a v2 /// session should still fail closed rather than silently mis-replay. -const CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 2; +pub(crate) const CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 2; fn is_zero_u64(value: &u64) -> bool { *value == 0 @@ -639,6 +639,12 @@ pub struct ThreadRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub allowed_tools: Option>, pub workspace: PathBuf, + /// Additional workspace roots attached to this thread; `workspace` is + /// always the primary root. Additive like `title`: records written + /// before multi-root support have no key and behave as a single-root + /// workspace, so the schema version is not bumped. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, pub mode: String, /// Named default permission posture for new turns. Absent on legacy /// records, whose effective posture is derived from the old fields. @@ -680,6 +686,7 @@ fn thread_execution_state_matches(left: &ThreadRecord, right: &ThreadRecord) -> && left.reasoning_effort == right.reasoning_effort && left.allowed_tools == right.allowed_tools && left.workspace == right.workspace + && left.workspace_roots == right.workspace_roots && left.mode == right.mode && left.permission_posture == right.permission_posture && left.allow_shell == right.allow_shell @@ -1410,6 +1417,25 @@ impl RuntimeThreadStore { } pub fn save_thread(&self, thread: &ThreadRecord) -> Result<()> { + // Workspace/roots pairing guard (review #54 round-9 should-fix): the + // storage convention makes `workspace` a member of the declared set. + // A writer that moves the workspace without the set — or the set + // without the workspace — used to persist silently; this is the one + // choke point all persistence callers share, so a warn here turns + // that failure mode into a searchable signal. + if !thread.workspace_roots.is_empty() + && !thread + .workspace_roots + .iter() + .any(|root| root == &thread.workspace) + { + tracing::warn!( + "thread {} persists workspace {} outside its declared root set {:?}", + thread.id, + thread.workspace.display(), + thread.workspace_roots + ); + } write_json_atomic(&self.thread_path(&thread.id)?, thread) } @@ -2128,6 +2154,11 @@ pub struct CreateThreadRequest { #[serde(default)] pub allowed_tools: Option>, pub workspace: Option, + /// Additional accessible roots for the thread; `workspace` stays the + /// primary root and the set is normalized on create (workspace first, + /// deduped). Empty or omitted preserves the single-root default. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, pub mode: Option, #[serde(default)] pub permission_posture: Option, @@ -2163,6 +2194,7 @@ pub struct UpdateThreadRequest { pub title: Option, pub system_prompt: Option, pub workspace: Option, + pub workspace_roots: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -5454,6 +5486,12 @@ impl RuntimeThreadManager { .filter(|m| !m.trim().is_empty()) .unwrap_or(default_model); let workspace = req.workspace.unwrap_or_else(|| self.workspace.clone()); + // Same guard as update_thread: an empty workspace persists a vacuous + // primary root (`starts_with("")` contains every path), so reject it + // at intake instead of defaulting it silently. + if workspace.as_os_str().is_empty() { + bail!("workspace must not be empty"); + } let requested_mode = req .mode .filter(|m| !m.trim().is_empty()) @@ -5471,6 +5509,18 @@ impl RuntimeThreadManager { let trust_mode = req.trust_mode.unwrap_or(false); let auto_approve = policy.auto_approve(); + // The declared set is validated at intake, not silently reshaped: a + // super-root (`/`, `/..`), an ancestor of the primary, or a + // non-absolute entry (`~/shared`) each widens — or silently shrinks — + // the sandbox the caller thinks it declared. The primary slot was + // already guarded non-empty above; a RELATIVE primary is not errored + // here — the validator fail-closed collapses its set to empty (the + // round-17 decision: every boundary consumer then treats the row as + // single-root on the degenerate primary and admits no writes through + // it), recorded for accuracy because "requires it to be absolute" + // overclaimed (round-24 P3). + let workspace_roots = + codewhale_core::validate_workspace_roots(&workspace, &req.workspace_roots)?; let thread = ThreadRecord { schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, id: format!("thr_{}", &Uuid::new_v4().to_string()[..8]), @@ -5494,6 +5544,7 @@ impl RuntimeThreadManager { task_id: req.task_id, title: None, session_id: None, + workspace_roots, }; self.store.save_thread(&thread)?; if let Err(error) = self @@ -5752,6 +5803,7 @@ impl RuntimeThreadManager { && req.title.is_none() && req.system_prompt.is_none() && req.workspace.is_none() + && req.workspace_roots.is_none() { bail!("At least one thread field is required"); } @@ -5771,10 +5823,17 @@ impl RuntimeThreadManager { { bail!("permission_posture must not be empty"); } + // Round-29/30/31 B3: a RELATIVE workspace is rejected exactly like + // the empty spelling — the start/resume/fork lanes' A28-1 doctrine + // ("cwd must be a non-empty absolute path"). The PATCH intake used + // to accept it, and the workspace-only leg then silently dropped + // the declared root set (validate_workspace_roots' degenerate + // collapse) while persisting the relative primary — one + // token-holding PATCH wiped the declared authorization state. if let Some(workspace) = req.workspace.as_ref() - && workspace.as_os_str().is_empty() + && (workspace.as_os_str().is_empty() || !workspace.is_absolute()) { - bail!("workspace must not be empty"); + bail!("workspace must be a non-empty absolute path"); } let configured_sandbox_mode = self.read_config().sandbox_mode.clone(); @@ -5867,22 +5926,76 @@ impl RuntimeThreadManager { changes.insert("system_prompt".to_string(), json!(new_sys)); } } + if let Some(roots) = req.workspace_roots { + // Explicit roots replace the whole set, normalized with the + // (possibly also-updated) workspace as the primary root. + // Caller-declared: validate instead of silently reshaping — + // the same super-root/ancestor/non-absolute rejections the + // create lane applies. + let primary = req + .workspace + .clone() + .unwrap_or_else(|| thread.workspace.clone()); + let normalized = codewhale_core::validate_workspace_roots(&primary, &roots)?; + if thread.workspace_roots != normalized { + changes.insert("workspace_roots".to_string(), json!(normalized)); + thread.workspace_roots = normalized; + } + } if let Some(workspace) = req.workspace && thread.workspace != workspace { changes.insert("workspace".to_string(), json!(workspace)); + if !changes.contains_key("workspace_roots") { + // A workspace-only change keeps the additional roots and + // hands the primary slot to the new workspace — except a + // degenerate relative OLD primary, whose stored set the + // intake collapsed to empty: there are no additional + // roots to keep, and the row stays single-root on the new + // primary (recorded for accuracy, round-24 P3). The + // moved primary re-declares the set: an additional root + // that would end up an ancestor of the new primary (or a + // super-root inherited from a legacy row) is a widening + // decision and is rejected, not admitted silently. + // Round-38 M38-3: the lexical head strip — a + // `..`-spelled copy of the OLD primary leaves the set + // with the primary itself. + let additional: Vec = codewhale_core::strip_workspace_roots_head( + &thread.workspace, + &thread.workspace_roots, + ); + let normalized = + codewhale_core::validate_workspace_roots(&workspace, &additional)?; + if thread.workspace_roots != normalized { + changes.insert("workspace_roots".to_string(), json!(normalized)); + thread.workspace_roots = normalized; + } + } thread.workspace = workspace; } let workspace_changed = changes.contains_key("workspace"); - if workspace_changed + let roots_changed = changes.contains_key("workspace_roots"); + if (workspace_changed || roots_changed) && active .engines .get(id) .and_then(|state| state.active_turn.as_ref()) .is_some() { - bail!("workspace cannot be changed while the thread has an active turn"); + // Name the leg that actually moved: a roots-only PATCH must + // not report a workspace change that was never requested. + let what = match (workspace_changed, roots_changed) { + (true, true) | (false, false) => "workspace/roots", + (true, false) => "workspace", + (false, true) => "workspace_roots", + }; + // Round-47 item 1 (P2-5): the wording must carry the conflict substring + // `map_thread_err` keys 409 on ("already has an active turn"), + // matching the four sibling turn-start guards — an IDE client + // treating 400 as "fix your body" would retry a mid-turn PATCH + // forever. + bail!("{what} cannot be changed while the thread already has an active turn"); } // A posture/mode edit must reach the live engine even while a @@ -5900,14 +6013,14 @@ impl RuntimeThreadManager { } else { thread.updated_at = Utc::now(); self.store.save_thread(&thread)?; - if workspace_changed { + if workspace_changed || roots_changed { active.lru.retain(|thread_id| thread_id != id); active.engines.remove(id).map(|state| state.engine) } else { None } }; - let posture_engine = if posture_changed && !workspace_changed { + let posture_engine = if posture_changed && !workspace_changed && !roots_changed { active.engines.get(id).map(|state| state.engine.clone()) } else { None @@ -6061,6 +6174,30 @@ impl RuntimeThreadManager { forked.updated_at = now; forked.latest_turn_id = None; forked.archived = false; + // M27-2: the fork must NOT inherit the source's session handle — + // ensure_engine_loaded prefers it and would replay the FULL source + // history into the model context (for fork_at_user_message that + // includes exactly the turns the backtrack dropped). + forked.session_id = None; + forked.task_id = None; + // A fork MINTS a new row, so the cloned set passes the same + // validating intake the create lane applies (round-24 P3, closing + // the "a bare fork fails loud" over-generalization): a poisoned + // source row (a super-root, a primary-ancestor, or an over-cap set + // hand-edited into the store) is rejected instead of duplicated + // into a fresh id. A degenerate relative primary collapses to the + // empty set fail-closed, exactly like create. The stored-row strip + // is lexical, so a `..`-spelled copy of the primary drops with the + // head instead of riding into the fork as a phantom additional + // root, and a legal full-load source row forks whole (round-37 + // B37-2). + forked.workspace_roots = codewhale_core::validate_stored_workspace_roots( + &source.workspace, + &source.workspace_roots, + ) + .map_err(|reason| { + anyhow::anyhow!("source thread root set no longer passes intake: {reason}") + })?; let source_turns = self.store.list_turns_for_thread(&source.id)?; let mut cloned_records = Vec::with_capacity(source_turns.len()); @@ -6179,6 +6316,25 @@ impl RuntimeThreadManager { forked.updated_at = now; forked.latest_turn_id = None; forked.archived = false; + // M27-2: same session-handle clearing as fork_thread — the undo + // contract must hold at the model-context layer too, or + // ensure_engine_loaded replays the full source history (dropped + // tail included) through the inherited handle. + forked.session_id = None; + forked.task_id = None; + // Round-26 M26-1: this fork face (the live /undo, /patch-undo, and + // /retry routes) MINTS a new row exactly like `fork_thread` and now + // runs the same validating intake on the cloned set — a poisoned + // source row (super-root, primary-ancestor, over-cap) is rejected + // instead of duplicated into a fresh id. Same stored-row strip as + // `fork_thread` (round-37 B37-2). + forked.workspace_roots = codewhale_core::validate_stored_workspace_roots( + &source.workspace, + &source.workspace_roots, + ) + .map_err(|reason| { + anyhow::anyhow!("source thread root set no longer passes intake: {reason}") + })?; let mut cloned_records = Vec::with_capacity(target_turn_idx); for source_turn in source_turns.iter().take(target_turn_idx) { @@ -8118,10 +8274,32 @@ impl RuntimeThreadManager { crate::tools::goal::new_shared_goal_state(), ), }; + // Round-35 M2: a PERSISTED root set is validated before arming + // (sibling parity — the REST resume lane 400s the same shape; + // normalize-only here silently granted a hand-edited row a + // writable sandbox). Round-37 B37-2: the stored-row strip keeps + // the row's own head from counting against the intake cap, so a + // legal full-load row revalidates whole instead of bricking + // every later resume of the thread. + let armed_workspace_roots = if thread.workspace_roots.is_empty() { + Vec::new() + } else { + codewhale_core::validate_stored_workspace_roots( + &thread.workspace, + &thread.workspace_roots, + ) + .map_err(|error| { + anyhow::anyhow!( + "persisted workspace_roots failed validation on resume \ + (thread row is corrupt or hand-edited): {error}" + ) + })? + }; let engine_cfg = EngineConfig { model: route_model.clone(), active_route_limits: route_limits, workspace: thread.workspace.clone(), + workspace_roots: armed_workspace_roots.clone(), session_id: None, subagent_state_root: None, plugin_registry: thread_plugin_registry.clone(), @@ -8324,6 +8502,11 @@ impl RuntimeThreadManager { system_prompt_override: thread.system_prompt.is_some(), model: route_model.clone(), workspace: thread.workspace.clone(), + // Round-40 minor 2: the SAME validated, head-stripped + // set the engine armed with — the raw stored set can + // carry an intake-admitted `..`-spelled head copy + // that every current consumer collapses. + workspace_roots: armed_workspace_roots.clone(), mode: RuntimePolicyProjection::from_persisted( &thread.mode, thread.permission_posture.as_deref(), diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 627ec21196..6044ea717b 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -382,6 +382,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord { reasoning_effort: None, allowed_tools: None, workspace: PathBuf::from("."), + workspace_roots: Vec::new(), mode: AppMode::Agent.as_setting().to_string(), permission_posture: Some("ask".to_string()), allow_shell: false, @@ -5825,6 +5826,322 @@ async fn update_thread_workspace_persists_event_and_evicts_idle_engine() -> Resu Ok(()) } +#[tokio::test] +async fn update_thread_combined_patch_validates_roots_against_the_new_primary() -> Result<()> { + // Round-24 P3 pin (manager-level combined PATCH): when workspace and + // roots arrive in ONE PATCH, the declared set is validated against the + // NEW primary — an ancestor of the incoming workspace is rejected and + // the whole PATCH fails without moving the thread; a valid combined + // PATCH persists the re-normalized set under the new primary. + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-combined-patch"); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + ..Default::default() + }) + .await?; + + let new_workspace = std::env::temp_dir().join("codewhale-runtime-combined-patch-next"); + let err = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(new_workspace.clone()), + workspace_roots: Some(vec![std::env::temp_dir()]), + ..UpdateThreadRequest::default() + }, + ) + .await + .expect_err("an ancestor of the new primary must be rejected in the combined PATCH"); + assert!( + err.to_string().contains("ancestor of the primary"), + "unexpected error: {err:#}" + ); + let unchanged = manager.get_thread(&thread.id).await?; + assert!( + unchanged.workspace == workspace, + "a rejected combined PATCH must not move the workspace either" + ); + assert_ne!( + unchanged.workspace_roots, + vec![workspace.clone(), std::env::temp_dir()], + "a rejected combined PATCH must not persist the wide set" + ); + assert_eq!( + unchanged.workspace_roots, + vec![workspace], + "the set stays the create-time single-root form" + ); + + let shared = std::env::temp_dir().join("codewhale-runtime-combined-patch-shared"); + manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(new_workspace.clone()), + workspace_roots: Some(vec![shared.clone()]), + ..UpdateThreadRequest::default() + }, + ) + .await?; + let updated = manager.get_thread(&thread.id).await?; + assert_eq!(updated.workspace, new_workspace); + assert_eq!( + updated.workspace_roots, + vec![new_workspace.clone(), shared], + "the valid combined PATCH persists the set normalized under the new primary" + ); + Ok(()) +} + +#[tokio::test] +async fn update_thread_roots_evicts_idle_engine() -> Result<()> { + // Eviction fires under `workspace_changed || roots_changed`; the workspace + // leg is pinned above, this pins the roots leg — regressing the + // disjunction would leave the stale single-root engine cached with every + // other test green. + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-roots-evict"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(workspace.clone()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + ..Default::default() + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + + manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace_roots: Some(vec![std::path::PathBuf::from("/shared")]), + ..UpdateThreadRequest::default() + }, + ) + .await?; + + { + let active = manager.active.lock().await; + assert!( + !active.engines.contains_key(&thread.id), + "a roots change must evict the stale cached engine just like a workspace change" + ); + assert!(!active.lru.iter().any(|id| id == &thread.id)); + } + + match tokio::time::timeout(Duration::from_secs(1), rx_op.recv()).await { + Ok(Some(Op::Shutdown)) => {} + other => panic!("expected cached engine shutdown, got {other:?}"), + } + Ok(()) +} + +#[tokio::test] +async fn workspace_move_strips_a_dot_dot_spelled_old_primary_copy() -> Result<()> { + // Round-38 M38-3: intake admits a root canonically equal to the primary + // (a `..`-spelled copy — the ancestor check skips equality and the + // normalizer dedups raw). A primary move must drop that copy with the + // OLD primary itself (the shared lexical strip), or the abandoned + // directory stays writable through the surviving spelling. + let manager = test_manager(test_runtime_dir())?; + let old_ws = std::env::temp_dir().join("codewhale-dotdot-move/old"); + let new_ws = std::env::temp_dir().join("codewhale-dotdot-move/new"); + let keep = std::env::temp_dir().join("codewhale-dotdot-move/shared"); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(old_ws.clone()), + workspace_roots: vec![keep.clone()], + ..Default::default() + }) + .await?; + // Seed the stored row with the dot-dot-spelled copy of the primary + // beside the head (the shape intake admits). + let mut row = manager.get_thread(&thread.id).await?; + row.workspace_roots = vec![old_ws.clone(), old_ws.join("sub").join(".."), keep.clone()]; + manager.store.save_thread(&row)?; + + let updated = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(new_ws.clone()), + ..UpdateThreadRequest::default() + }, + ) + .await?; + assert_eq!( + updated.workspace_roots, + vec![new_ws.clone(), keep.clone()], + "the dot-dot-spelled old primary drops with the head on the move" + ); + Ok(()) +} + +#[tokio::test] +async fn update_thread_explicit_empty_roots_clears_to_primary_and_evicts_engine() -> Result<()> { + // Round-15 pin for the explicit-clear leg: PATCH `workspace_roots: []` + // is not "no change" — it clears the set back to the bare primary root, + // evicts the cached engine, and a later parameterless resume must not + // resurrect the cleared roots. + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-roots-clear"); + let shared = std::env::temp_dir().join("codewhale-runtime-roots-clear-shared"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(workspace.clone()), + workspace_roots: vec![shared.clone()], + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + ..Default::default() + }) + .await?; + assert_eq!(thread.workspace_roots, vec![workspace.clone(), shared]); + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + + let updated = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace_roots: Some(Vec::new()), + ..UpdateThreadRequest::default() + }, + ) + .await?; + + // The explicit empty set clears back to the bare primary root and the + // cleared set is the persisted set. + assert_eq!(updated.workspace_roots, vec![workspace.clone()]); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace_roots, + vec![workspace.clone()], + ); + + // Clearing roots is a roots change: the stale multi-root engine is + // evicted from the cache and the LRU, and told to shut down. + { + let active = manager.active.lock().await; + assert!( + !active.engines.contains_key(&thread.id), + "an explicit-empty roots clear must evict the stale cached engine" + ); + assert!(!active.lru.iter().any(|id| id == &thread.id)); + } + match tokio::time::timeout(Duration::from_secs(1), rx_op.recv()).await { + Ok(Some(Op::Shutdown)) => {} + other => panic!("expected cached engine shutdown, got {other:?}"), + } + + // A parameterless resume reloads the persisted (cleared) set — the old + // multi-root set must not resurrect. + let resumed = manager.resume_thread(&thread.id).await?; + assert_eq!(resumed.workspace_roots, vec![workspace.clone()]); + Ok(()) +} + +#[tokio::test] +async fn update_thread_roots_preserves_session_and_turn_context() -> Result<()> { + // Review #484 round-9 must-fix 2: a roots-bearing resume must re-shape + // the SAME runtime thread (PATCH primitive), preserving session_id and + // the accumulated turns — re-creating the thread would run the next + // turn with correct roots but a blank memory. + let manager = test_manager(test_runtime_dir())?; + let primary = std::env::temp_dir().join("codewhale-ctx-primary"); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(primary.clone()), + ..Default::default() + }) + .await?; + + // Simulate a session that already ran: a session binding and a + // recorded turn. + { + let mut record = manager.store.load_thread(&thread.id)?; + record.session_id = Some("ses_context".to_string()); + record.latest_turn_id = Some("turn_context".to_string()); + manager.store.save_thread(&record)?; + } + + let updated = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace_roots: Some(vec![primary.clone(), primary.join("extra")]), + ..UpdateThreadRequest::default() + }, + ) + .await?; + + assert_eq!( + updated.workspace_roots, + vec![primary.clone(), primary.join("extra")], + "the PATCH carries the new root set" + ); + assert_eq!( + updated.session_id.as_deref(), + Some("ses_context"), + "context continuity: session binding survives the roots change" + ); + assert_eq!( + updated.latest_turn_id.as_deref(), + Some("turn_context"), + "context continuity: the recorded turn survives the roots change" + ); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace_roots.len(), + 2, + "the persisted record carries the new set" + ); + Ok(()) +} + +#[tokio::test] +async fn create_thread_workspace_rejects_empty_path() -> Result<()> { + // Regression pin: POST /v1/threads with `"workspace": ""` used to persist + // the empty string as the primary root, where boundary_roots() turns it + // into the vacuous containment root (`starts_with("")` is true for every + // path) — the same poison update_thread already rejects. + let manager = test_manager(test_runtime_dir())?; + let err = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(PathBuf::new()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + ..Default::default() + }) + .await + .expect_err("empty workspace must be rejected"); + // create_thread's own guard still says "must not be empty" (only the + // PATCH lane moved to the A28-1 text). + assert!(format!("{err:#}").contains("workspace must not be empty")); + Ok(()) +} + #[tokio::test] async fn update_thread_workspace_rejects_empty_path() -> Result<()> { let manager = test_manager(test_runtime_dir())?; @@ -5853,7 +6170,78 @@ async fn update_thread_workspace_rejects_empty_path() -> Result<()> { ) .await .expect_err("empty workspace must be rejected"); - assert!(format!("{err:#}").contains("workspace must not be empty")); + // Round-32 B32-1: production moved to "a non-empty absolute path"; + // the stable fragment "must not be empty" no longer matches — assert + // the current text family instead. + assert!(format!("{err:#}").contains("non-empty absolute path")); + Ok(()) +} + +#[tokio::test] +async fn update_thread_roots_rejects_active_turn() -> Result<()> { + // The fence covers `workspace_changed || roots_changed`; this pins the + // roots leg on its own: a mid-turn root-set replacement is deferred + // exactly like a mid-turn workspace swap. + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-roots-active"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(workspace.clone()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + ..Default::default() + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + { + let mut active = manager.active.lock().await; + let state = active.engines.get_mut(&thread.id).expect("mock engine"); + state.active_turn = Some(ActiveTurnState { + turn_id: "turn_live_roots".to_string(), + interrupt_requested: false, + compaction_id: None, + }); + } + + let roots_before = thread.workspace_roots.clone(); + let err = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace_roots: Some(vec![std::path::PathBuf::from("/shared")]), + ..UpdateThreadRequest::default() + }, + ) + .await + .expect_err("roots update during active turn must fail"); + + assert!(format!("{err:#}").contains("active turn")); + let persisted = manager.store.load_thread(&thread.id)?; + assert_eq!( + persisted.workspace_roots, roots_before, + "rejected update must not touch the persisted root set" + ); + { + let active = manager.active.lock().await; + assert!( + active.engines.contains_key(&thread.id), + "active engine should stay cached after rejected update" + ); + } + assert!( + tokio::time::timeout(Duration::from_millis(100), rx_op.recv()) + .await + .is_err(), + "a rejected roots update must not reach the engine" + ); Ok(()) } @@ -5921,6 +6309,213 @@ async fn update_thread_workspace_rejects_active_turn() -> Result<()> { Ok(()) } +#[test] +fn thread_record_workspace_roots_default_for_legacy_json() { + let thread = sample_thread("thr_roots"); + let mut value = serde_json::to_value(&thread).expect("serialize thread"); + assert!( + value.get("workspace_roots").is_none(), + "an empty root set must stay off the wire" + ); + let decoded: ThreadRecord = + serde_json::from_value(value.clone()).expect("legacy thread decodes"); + assert!(decoded.workspace_roots.is_empty()); + + value["workspace_roots"] = serde_json::json!(["/repo", "/repo/lib"]); + let decoded: ThreadRecord = serde_json::from_value(value).expect("thread with roots decodes"); + assert_eq!( + decoded.workspace_roots, + vec![PathBuf::from("/repo"), PathBuf::from("/repo/lib")] + ); +} + +#[tokio::test] +async fn create_thread_normalizes_workspace_roots() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-create-roots-ws"); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + workspace_roots: vec![ + PathBuf::from("/shared"), + workspace.clone(), + PathBuf::from("/dup"), + PathBuf::from("/dup"), + ], + ..Default::default() + }) + .await?; + + // The workspace is the primary root and leads; additional roots dedupe + // in request order. + assert_eq!( + thread.workspace_roots, + vec![ + workspace.clone(), + PathBuf::from("/shared"), + PathBuf::from("/dup") + ] + ); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace_roots, + thread.workspace_roots, + "the normalized set must be the persisted set" + ); + Ok(()) +} + +#[test] +fn forkguard_workspace_roots_thread_record_persists_and_legacy_defaults_empty() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let mut thread = sample_thread("thr_roots_forkguard"); + let roots = vec![PathBuf::from("/repo"), PathBuf::from("/shared")]; + thread.workspace_roots = roots.clone(); + manager.store.save_thread(&thread)?; + + // The root set survives a durable save→load round trip unchanged. + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace_roots, + roots + ); + + // A record serialized without the key (pre-multi-root shape) loads as an + // empty, single-root set. + let mut legacy = serde_json::to_value(&thread)?; + legacy + .as_object_mut() + .expect("thread serializes as object") + .remove("workspace_roots"); + let legacy: ThreadRecord = serde_json::from_value(legacy)?; + assert!(legacy.workspace_roots.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn update_thread_workspace_roots_replace_set_and_are_idempotent() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-roots-primary"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(workspace.clone()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + ..Default::default() + }) + .await?; + + let roots = vec![ + std::env::temp_dir().join("codewhale-runtime-roots-shared-a"), + std::env::temp_dir().join("codewhale-runtime-roots-shared-b"), + ]; + let updated = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace_roots: Some(roots.clone()), + ..UpdateThreadRequest::default() + }, + ) + .await?; + + // Explicit roots replace the whole set; the workspace stays primary. + let mut expected = vec![workspace.clone()]; + expected.extend(roots.iter().cloned()); + assert_eq!(updated.workspace_roots, expected); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace_roots, + expected + ); + + let events = manager.events_since(&thread.id, None)?; + let event = events + .iter() + .rev() + .find(|event| event.event == "thread.updated") + .expect("thread.updated event"); + assert_eq!( + event + .payload + .get("changes") + .and_then(|changes| changes.get("workspace_roots")), + Some(&serde_json::to_value(&expected)?) + ); + + // Re-applying the same roots is a no-op: no change receipt, no timestamp + // bump. + let reapplied = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace_roots: Some(roots), + ..UpdateThreadRequest::default() + }, + ) + .await?; + assert_eq!(reapplied.workspace_roots, expected); + assert_eq!(reapplied.updated_at, updated.updated_at); + Ok(()) +} + +#[tokio::test] +async fn update_thread_workspace_only_keeps_additional_roots() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let old_workspace = std::env::temp_dir().join("codewhale-runtime-roots-old"); + let new_workspace = std::env::temp_dir().join("codewhale-runtime-roots-new"); + let shared = std::env::temp_dir().join("codewhale-runtime-roots-shared"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(old_workspace.clone()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + ..Default::default() + }) + .await?; + manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace_roots: Some(vec![shared.clone()]), + ..UpdateThreadRequest::default() + }, + ) + .await?; + + let updated = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(new_workspace.clone()), + ..UpdateThreadRequest::default() + }, + ) + .await?; + + // The new workspace takes over the primary slot; the old workspace leaves + // the set while additional roots survive. + assert_eq!(updated.workspace, new_workspace); + assert_eq!( + updated.workspace_roots, + vec![new_workspace.clone(), shared.clone()] + ); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace_roots, + vec![new_workspace, shared] + ); + Ok(()) +} + #[tokio::test] async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> { let manager = test_manager(test_runtime_dir())?; @@ -11385,6 +11980,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> { reasoning_effort: None, allowed_tools: None, workspace: PathBuf::from("."), + workspace_roots: Vec::new(), mode: "agent".to_string(), permission_posture: None, allow_shell: false, @@ -11777,6 +12373,172 @@ fn seed_turns_with_user_messages( Ok(turn_ids) } +#[tokio::test] +async fn both_fork_faces_reject_a_poisoned_source_root_set() -> Result<()> { + // Round-26 M26-1 + m26-6: every fork face MINTS a new row, so every one + // validates the cloned set — `fork_thread` (the round-25 fix, unpinned + // until now) and `fork_at_user_message` (the live /undo, /patch-undo, + // /retry routes, added this round). A hand-edited row carrying the + // filesystem root is rejected with the intake reason instead of being + // duplicated into a fresh id. + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-fork-poison"); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + ..Default::default() + }) + .await?; + seed_turns_with_user_messages(&manager, &thread.id, &["first", "second"])?; + + // Poison the stored row directly (the load faces stay tolerant by + // design — this is exactly the input they exist to survive). + let mut poisoned = manager.get_thread(&thread.id).await?; + poisoned.workspace_roots = vec![workspace.clone(), std::path::PathBuf::from("/")]; + manager.store.save_thread(&poisoned)?; + + let bare = manager + .fork_thread(&thread.id) + .await + .expect_err("fork_thread must reject the poisoned source row"); + assert!( + bare.to_string().contains("no longer passes intake"), + "fork_thread: {bare:#}" + ); + let backtracked = manager + .fork_at_user_message(&thread.id, 0) + .await + .expect_err("fork_at_user_message must reject the poisoned source row too"); + assert!( + backtracked.to_string().contains("no longer passes intake"), + "fork_at_user_message: {backtracked:#}" + ); + Ok(()) +} + +#[tokio::test] +async fn both_fork_faces_and_engine_arming_survive_a_legal_full_load_row() -> Result<()> { + // Round-37 B37-2 at-cap round trip: a 64-root declaration stores as the + // 65-entry cwd-first full form (this build's own save shape). Every + // re-declaring face — the fork mint and the resume arming under + // `get_engine`/`ensure_engine_loaded` — must consume that row whole; + // counting the row's own head against the intake cap would brick the + // row one resume after its own creation. + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-full-load"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + let roots: Vec = (0..codewhale_core::MAX_WORKSPACE_ROOTS) + .map(|index| { + let root = workspace.join(format!("r{index}")); + std::fs::create_dir_all(&root).expect("full-load root dir"); + root + }) + .collect(); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + workspace_roots: roots.clone(), + ..Default::default() + }) + .await?; + // The stored row is the cwd-first full form: 65 entries. + let stored = manager.get_thread(&thread.id).await?; + assert_eq!( + stored.workspace_roots.len(), + codewhale_core::MAX_WORKSPACE_ROOTS + 1, + "the save face mints the full-load form" + ); + assert_eq!(stored.workspace_roots[0], workspace); + + seed_turns_with_user_messages(&manager, &thread.id, &["first", "second"])?; + let bare = manager.fork_thread(&thread.id).await?; + assert_eq!( + bare.workspace_roots, stored.workspace_roots, + "fork_thread revalidates the full-load row whole" + ); + let (backtracked, _) = manager.fork_at_user_message(&thread.id, 0).await?; + assert_eq!( + backtracked.workspace_roots, stored.workspace_roots, + "fork_at_user_message revalidates the full-load row whole" + ); + + // The resume arming lane (round-35 M2's validating gate) takes the same + // row through the stored-row strip: engine acquisition must succeed, + // not refuse the 65-entry row as over-cap. + let _engine = manager + .get_engine(&thread.id) + .await + .expect("a legal full-load row must arm an engine"); + Ok(()) +} + +#[tokio::test] +async fn engine_arming_refuses_a_poisoned_thread_row() -> Result<()> { + // Round-38 minor 10: the arming gate's poison-refusal half — a stored + // row carrying the filesystem root must fail engine acquisition with + // the intake reason, not arm it (the legal-row half lives in the + // full-load test above). + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-arming-poison"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let mut poisoned = manager.get_thread(&thread.id).await?; + poisoned.workspace_roots = vec![workspace.clone(), std::path::PathBuf::from("/")]; + manager.store.save_thread(&poisoned)?; + + let err = match manager.get_engine(&thread.id).await { + Ok(_) => panic!("a poisoned row must not arm an engine"), + Err(err) => err, + }; + assert!( + err.to_string().contains("corrupt or hand-edited"), + "the refusal names the row state: {err:#}" + ); + Ok(()) +} + +#[tokio::test] +async fn both_fork_faces_clear_the_source_session_handle() -> Result<()> { + // M27-2: ensure_engine_loaded prefers thread.session_id and would + // replay the FULL source session file into the fork's model context — + // for the backtrack faces that includes exactly the turns the fork + // dropped. Both runtime fork faces clear the handle (and task_id, the + // sibling owner identity). + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-runtime-fork-handle"); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + ..Default::default() + }) + .await?; + manager + .set_thread_session_id(&thread.id, "source-session-handle") + .await + .expect("seed source session handle"); + seed_turns_with_user_messages(&manager, &thread.id, &["first", "second"])?; + + let bare = manager.fork_thread(&thread.id).await?; + assert_eq!( + bare.session_id, None, + "fork_thread must not inherit the source session handle" + ); + assert_eq!(bare.task_id, None); + + let (backtracked, _) = manager.fork_at_user_message(&thread.id, 0).await?; + assert_eq!( + backtracked.session_id, None, + "the backtrack fork must not replay the dropped tail through the inherited handle" + ); + assert_eq!(backtracked.task_id, None); + Ok(()) +} + #[tokio::test] async fn fork_at_user_message_drops_tail_and_returns_user_text() -> Result<()> { // Seed three completed user/assistant turns. Backtracking with @@ -12679,3 +13441,37 @@ async fn the_emit_failure_rollback_never_evicts_a_foreign_same_id_entry() -> Res assert_eq!(manager.pending_approvals_count(), 0); Ok(()) } + +#[tokio::test] +async fn update_thread_rejects_a_relative_workspace() -> Result<()> { + // Round-29/30/31 B3 pin: a PATCH carrying a relative workspace must be + // rejected with the same typed 400 as the start/resume/fork lanes + // (A28-1 doctrine) — the workspace-only leg used to silently drop the + // declared root set and persist the relative primary. + let manager = test_manager(test_runtime_dir())?; + let workspace = std::env::temp_dir().join("codewhale-update-relative-ws"); + let thread = manager + .create_thread(CreateThreadRequest { + workspace: Some(workspace.clone()), + ..CreateThreadRequest::default() + }) + .await?; + let err = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(std::path::PathBuf::from("relative/ws")), + ..UpdateThreadRequest::default() + }, + ) + .await + .expect_err("a relative workspace must be rejected"); + assert!( + err.to_string().contains("non-empty absolute path"), + "typed rejection: {err}" + ); + // The stored thread is untouched by the refused patch. + let reloaded = manager.get_thread(&thread.id).await?; + assert_eq!(reloaded.workspace, workspace); + Ok(()) +} diff --git a/crates/tui/src/sandbox/policy.rs b/crates/tui/src/sandbox/policy.rs index a40bf15b0b..fd7f827894 100644 --- a/crates/tui/src/sandbox/policy.rs +++ b/crates/tui/src/sandbox/policy.rs @@ -313,7 +313,14 @@ impl SandboxPolicy { exclude_slash_tmp, .. } => { - let mut roots: Vec = writable_roots.clone(); + // Canonicalize the configured roots the same way as the cwd, + // /tmp, and TMPDIR entries below: on macOS a `/var` spelling + // and its `/private/var` reality are the same directory, and + // the sandbox consumers compare canonical forms. + let mut roots: Vec = writable_roots + .iter() + .map(|root| root.canonicalize().unwrap_or_else(|_| root.clone())) + .collect(); // Add the current working directory if let Ok(canonical_cwd) = cwd.canonicalize() { @@ -325,10 +332,15 @@ impl SandboxPolicy { // Git worktrees keep mutable metadata outside the worktree // directory. Allow only the gitdir and commondir derived from // a workspace `.git` pointer, preserving the workspace boundary - // for all other external paths. - for root in roots.clone() { - roots.extend(resolve_git_worktree_writable_roots(&root)); + // for all other external paths. (Round-24 P3 perf: the walk + // results buffer into a side vec — the old `roots.clone()` + // copied the whole root vec per exec command only to iterate + // it while extending.) + let mut git_pointer_roots = Vec::new(); + for root in &roots { + git_pointer_roots.extend(resolve_git_worktree_writable_roots(root)); } + roots.extend(git_pointer_roots); // Add /tmp unless excluded if !exclude_slash_tmp && let Ok(tmp) = Path::new("/tmp").canonicalize() { @@ -343,6 +355,16 @@ impl SandboxPolicy { roots.push(canonical); } + // Round-37 M37-7: a root that canonicalizes to the + // filesystem root is dropped here, at the one enumeration + // every boundary consumer reads — seatbelt used to emit it + // as a plain `(subpath /)` allow, granting a + // whole-filesystem-write sandbox to any spelling (symlink, + // case-fold) that slipped past the lexical intake, while + // bwrap silently dropped the same root. Both platforms now + // fail closed on it. + roots.retain(|root| root != Path::new("/")); + // Convert to WritableRoot with read-only subpaths roots .into_iter() @@ -768,6 +790,38 @@ mod tests { assert!(root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git"))); } + #[test] + #[cfg(unix)] + fn workspace_write_drops_a_root_that_canonicalizes_to_the_filesystem_root() { + // Round-37 M37-7: a symlink spelling of `/` past the lexical intake + // used to reach seatbelt's enumeration as a plain writable root — + // `(subpath /)` is a whole-filesystem-write allow — while bwrap + // dropped the same root. The shared enumeration now drops it on + // every platform: the boundary fails closed instead of widening. + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("ws"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + let slash_link = tmp.path().join("slash-link"); + std::os::unix::fs::symlink("/", &slash_link).expect("symlink to /"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![slash_link], + network_access: true, + exclude_tmpdir: true, + exclude_slash_tmp: true, + }; + + let root_paths: Vec = policy + .get_writable_roots(&workspace) + .into_iter() + .map(|root| root.root) + .collect(); + assert!( + !root_paths.contains(&PathBuf::from("/")), + "a root canonicalizing to / must not survive enumeration: {root_paths:?}" + ); + } + #[test] fn workspace_write_resolves_git_worktree_metadata_from_subdirectory() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/tui/src/session_control_acceptance.rs b/crates/tui/src/session_control_acceptance.rs index 64713442c7..9bdbe06319 100644 --- a/crates/tui/src/session_control_acceptance.rs +++ b/crates/tui/src/session_control_acceptance.rs @@ -121,8 +121,8 @@ pub const ACCEPTANCE_MATRIX: &[AcceptanceCase] = &[ }, AcceptanceCase { contract: Contract::ControlPlane, - behavior: "An external writer is refused with a typed conflict while a session is live", - test: "an_external_writer_is_refused_while_a_session_is_live", + behavior: "External writers converge by last-write-wins (the live-session refusal is retired; the registry serves retention pruning)", + test: "external_writers_converge_by_last_write_wins_guard_retired", }, AcceptanceCase { contract: Contract::PersistentSessions, @@ -722,48 +722,37 @@ mod tests { } #[test] - fn an_external_writer_is_refused_while_a_session_is_live() { - // The archive-race gate. The TUI owns the in-memory copy, so an - // out-of-band write must fail closed rather than be reverted later. + fn external_writers_converge_by_last_write_wins_guard_retired() { + // Round-20 B20-3: the External live-session refusal is retired. The + // process-local registry cannot coexist with the runtime HTTP lane in + // any shipped topology, so the refusal could never engage; an + // external writer now converges by last-write-wins at the store + // layer. The registry itself remains — same-process retention pruning + // consults it — which is why set_live_session stays. let _lock = crate::test_support::lock_test_env(); let fx = Fixture::new(); fx.save("owned", "Open in the TUI", &fx.workspace); crate::session_manager::set_live_session(Some("owned")); - let refused = fx - .manager + fx.manager .set_session_archived("owned", true, SessionMutator::External) - .expect_err("external write must be refused while the session is live"); - assert_eq!(refused.kind(), std::io::ErrorKind::ResourceBusy); + .expect("the retired guard no longer refuses external writes"); assert!( - !fx.manager + fx.manager .load_session("owned") .expect("reload") .metadata .archived, - "a refused write must not have partially applied" - ); - - let refused_rename = fx - .manager - .rename_session("owned", "Nope", SessionMutator::External) - .expect_err("external rename must be refused too"); - assert_eq!(refused_rename.kind(), std::io::ErrorKind::ResourceBusy); - - // The owner is still allowed. - assert!( - fx.manager - .set_session_archived("owned", true, SessionMutator::Owner) - .is_ok() + "the external write landed" ); + fx.manager + .rename_session("owned", "Renamed out-of-band", SessionMutator::External) + .expect("external rename converges too"); - // Releasing the claim re-opens external writes. + // The registry still tracks the interactive claim for retention. + assert!(crate::session_manager::is_live_session("owned")); crate::session_manager::set_live_session(None); - assert!( - fx.manager - .rename_session("owned", "Now allowed", SessionMutator::External) - .is_ok() - ); + assert!(!crate::session_manager::is_live_session("owned")); } #[test] diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 40317e066c..f70b7c88bf 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -159,6 +159,11 @@ pub struct SessionMetadata { pub model_provider_id: Option, /// Workspace directory pub workspace: PathBuf, + /// Additional workspace roots attached to this session; `workspace` is + /// always the primary root. Sessions written before multi-root support + /// have no key and load as an empty set (single-root behavior). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_roots: Vec, /// Optional mode label (agent/plan/etc.) #[serde(default)] pub mode: Option, @@ -199,16 +204,20 @@ fn is_not_archived(archived: &bool) -> bool { /// Sessions currently owned by an in-process interactive surface (the TUI). /// -/// A saved session is a file, and a running TUI holds the authoritative copy -/// in memory: it autosaves the whole document from `App` state. That makes an -/// out-of-band write to the *same* session unsafe — the next autosave would -/// silently revert it. Rather than let that happen quietly, the owner claims -/// the id here and any external writer is refused. +/// The registry's remaining consumer is the orphan-reclamation keep-chain: +/// a running TUI holds the authoritative session copy in memory and re-saves +/// the whole document, so the directory `reclaim_orphaned_session_dirs` +/// sweeps must never treat a live owner's session as an orphan. (The +/// write-conflict guard this registry used to feed was retired — round-20 +/// B20-3: the registry and the runtime HTTP server never share a process +/// in a shipped topology, so the conflict path could never engage; +/// same-process writers converge by last-write-wins at the store layer. +/// Stale coexistence wording corrected round-24 P3.) /// /// A static registry rather than a field on `RuntimeApiState` because the /// embedded Runtime API runs inside the TUI process; a standalone -/// `codewhale web` has an empty registry and is therefore never blocked, which -/// is exactly right — there is no TUI holding anything. +/// `codewhale web` has an empty registry, and the reclaim sweep needs no +/// entries there — a headless process holds no interactive autosave. static LIVE_SESSIONS: std::sync::OnceLock>> = std::sync::OnceLock::new(); @@ -287,9 +296,11 @@ pub fn is_claimed_session_dir(session_id: &str) -> bool { /// Who is asking to mutate a saved session. /// -/// This is an authority distinction, not a convenience one: the owner may -/// write because it will update its in-memory copy in the same step; anyone -/// else may not, because it cannot. +/// This was an authority distinction when a live owner's out-of-band write +/// could revert the next autosave; since the live guard's retirement +/// (round-20 B20-3) both spellings write, so the parameter is retained only +/// in the API shape. The owner distinction still documents intent: the owner +/// updates its cached copy atomically with the write, anyone else cannot. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionMutator { /// The in-process surface that currently owns the session (the TUI). It @@ -297,7 +308,7 @@ pub enum SessionMutator { /// write — see `App::apply_session_mutation`. Owner, /// Any other writer: the Runtime API, the web dashboard, a second - /// process. Refused while the session is claimed. + /// process. Writes converge by last-write-wins at the store layer. External, } @@ -305,13 +316,19 @@ pub enum SessionMutator { /// /// The TUI owns at most one session at a time, so switching sessions must /// release the previous claim in the same step — otherwise a `/new` would -/// leave the old id permanently locked against the dashboard. +/// leave the old id in the registry and the orphan-reclamation sweep would +/// keep a directory no interactive surface is using. pub fn set_live_session(session_id: Option<&str>) { - if let Ok(mut live) = live_sessions().write() { - live.clear(); - if let Some(id) = session_id.map(str::trim).filter(|id| !id.is_empty()) { - live.insert(id.to_string()); - } + // Recover a poisoned write lock rather than dropping the claim: a lost + // claim unblocks external writers against a session that may still + // autosave, and the wholesale clear below makes any stale content moot. + let mut live = match live_sessions().write() { + Ok(live) => live, + Err(poisoned) => poisoned.into_inner(), + }; + live.clear(); + if let Some(id) = session_id.map(str::trim).filter(|id| !id.is_empty()) { + live.insert(id.to_string()); } } @@ -335,27 +352,21 @@ fn is_session_uuid(name: &str) -> bool { /// /// The registry is process-local. Reclamation must not treat a missing entry /// here as proof that no other Codewhale process still owns the directory. +/// +/// The query is judged against the trimmed id, the same normalized value +/// `set_live_session` stores: every store path trims (`validated_session_id`), +/// so the keep-chain compares one canonical value. A poisoned lock means +/// ownership cannot be determined, so the answer fails closed: treat the +/// session as live and keep its directory out of the orphan sweep, rather +/// than reclaim a directory an autosave nobody can see is still writing. #[must_use] pub fn is_live_session(session_id: &str) -> bool { + let trimmed = session_id.trim(); live_sessions() .read() - .is_ok_and(|live| live.contains(session_id)) -} - -/// The error an external writer gets when the session is live. -/// -/// `ResourceBusy` so callers can map it to a typed conflict rather than -/// pattern-matching on a message. -fn live_session_conflict(session_id: &str) -> std::io::Error { - std::io::Error::new( - std::io::ErrorKind::ResourceBusy, - format!( - "session '{session_id}' is open in an interactive Codewhale session; \ - change it there instead — an external write would be reverted by its next autosave" - ), - ) + .map(|live| live.contains(trimmed)) + .unwrap_or(true) } - /// File-name stem of the sidecar mapping session ids to the session /// instance (process boot) that created their persisted record. Lives in /// the sessions directory next to the `.json` records it describes. @@ -894,6 +905,7 @@ impl SavedSession { model_provider: default_model_provider(), model_provider_id: None, workspace, + workspace_roots: Vec::new(), mode: None, cost: SessionCostSnapshot::default(), parent_session_id: None, @@ -1624,9 +1636,11 @@ impl SessionManager { archived: bool, mutator: SessionMutator, ) -> std::io::Result { - if mutator == SessionMutator::External && is_live_session(id) { - return Err(live_session_conflict(id)); - } + let _ = mutator; // retained in the API shape; the live guard is retired (round-20 B20-3) + // Round-20 B20-3: no live-session guard here — the registry is + // process-local and the External mutator's only lane (the runtime + // HTTP server) never coexists with an interactive surface in a + // shipped topology, so the check could never engage. let mut session = self.load_session(id)?; if session.metadata.archived == archived { return Ok(session.metadata); @@ -1666,6 +1680,16 @@ impl SessionManager { metadata.created_at = persisted.created_at; metadata.parent_session_id = persisted.parent_session_id; metadata.forked_from_message_count = persisted.forked_from_message_count; + // The persisted set is the authority only against roots-blind + // writers: an empty incoming set may mean "rebuilt without knowing + // about multi-root", so the persisted set wins there. A non-empty + // incoming set is a deliberate live-owner mutation (a `/cd` primary + // swap, or a snapshot stamped from the loaded session) and must + // survive the merge - overwriting it would durably revert the swap + // and resurrect abandoned directories on the next resume. + if metadata.workspace_roots.is_empty() { + metadata.workspace_roots = persisted.workspace_roots; + } true } @@ -1681,9 +1705,10 @@ impl SessionManager { mutator: SessionMutator, ) -> std::io::Result { let title = normalize_session_title(title)?; - if mutator == SessionMutator::External && is_live_session(id) { - return Err(live_session_conflict(id)); - } + let _ = mutator; // retained in the API shape; the live guard is retired (round-20 B20-3) + // Round-20 B20-3: no live-session guard (see set_session_archived) — + // the External lane cannot coexist with the interactive surface that + // populates the registry. let mut session = self.load_session(id)?; if session.metadata.title == title { return Ok(session.metadata); @@ -2219,6 +2244,7 @@ pub fn create_saved_session_with_id_and_mode( model_provider: default_model_provider(), model_provider_id: None, workspace: workspace.to_path_buf(), + workspace_roots: Vec::new(), mode: mode.map(str::to_string), cost: SessionCostSnapshot::default(), parent_session_id: None, @@ -2638,6 +2664,134 @@ mod tests { ); } + #[test] + fn workspace_roots_default_for_legacy_sessions_and_round_trip() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager"); + let workspace = tmp.path().join("ws"); + let messages = vec![make_test_message("user", "hi")]; + let session = create_saved_session_with_id_and_mode( + "roots-session".to_string(), + &messages, + "deepseek-v4-flash", + &workspace, + 0, + None, + None, + ); + + // A session written before multi-root support has no workspace_roots + // key; it must load as an empty (single-root) set, and a roots-less + // session must keep that key off the wire. + let mut value = serde_json::to_value(&session).expect("serialize session"); + let metadata = value.get_mut("metadata").expect("metadata object"); + assert!(metadata.get("workspace_roots").is_none()); + let metadata = metadata.take(); + let mut metadata: SessionMetadata = + serde_json::from_value(metadata).expect("legacy metadata decodes"); + assert!(metadata.workspace_roots.is_empty()); + + // Roots survive a save/load round-trip byte-faithfully. + metadata.workspace_roots = vec![workspace.clone(), tmp.path().join("shared")]; + let mut session = session; + session.metadata = metadata; + manager.save_session(&session).expect("save session"); + let loaded = manager.load_session("roots-session").expect("load session"); + assert_eq!( + loaded.metadata.workspace_roots, + vec![workspace, tmp.path().join("shared")] + ); + } + + #[test] + fn workspace_switch_survives_autosave_merge_and_restart() { + // /cd round trip: disk holds the pre-switch state; the live owner + // swaps the primary (/A -> /C, roots [/A,/B] -> [/C,/B]); the + // autosave stamps the live set and merges against disk; the + // restart+resume re-normalizes. The abandoned directory must not + // resurrect as a writable root. + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager"); + let workspace = tmp.path().join("a"); + let messages = vec![make_test_message("user", "hi")]; + let mut session = create_saved_session_with_id_and_mode( + "switch-session".to_string(), + &messages, + "deepseek-v4-flash", + &workspace, + 0, + None, + Some("agent"), + ); + session.metadata.workspace_roots = vec![workspace.clone(), tmp.path().join("b")]; + manager + .save_session(&session) + .expect("save pre-switch session"); + + // The /cd swap in memory: new primary, old primary leaves the set. + let mut live = manager + .load_session("switch-session") + .expect("load for switch"); + let new_workspace = tmp.path().join("c"); + live.metadata.workspace = new_workspace.clone(); + live.metadata.workspace_roots = vec![new_workspace.clone(), tmp.path().join("b")]; + + // Autosave: stamp the live set, then merge against disk exactly as + // build_session_snapshot does. + live.metadata.total_tokens = 5; + assert!(manager.merge_persisted_lifecycle(&mut live.metadata)); + manager.save_session(&live).expect("autosave"); + + // Restart + resume: reload, then re-normalize the way the engine + // resolves roots on resume. + let resumed = manager.load_session("switch-session").expect("reload"); + assert_eq!(resumed.metadata.workspace, new_workspace); + assert_eq!( + resumed.metadata.workspace_roots, + vec![new_workspace, tmp.path().join("b")], + "the abandoned directory must not resurrect as a writable root" + ); + } + + #[test] + fn merge_persisted_lifecycle_restores_persisted_workspace_roots() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager"); + let workspace = tmp.path().join("ws"); + let messages = vec![make_test_message("user", "hi")]; + let mut session = create_saved_session_with_id_and_mode( + "merge-roots-session".to_string(), + &messages, + "deepseek-v4-flash", + &workspace, + 0, + None, + Some("agent"), + ); + session.metadata.workspace_roots = vec![workspace.clone(), tmp.path().join("shared")]; + manager.save_session(&session).expect("save session"); + + // A host without multi-root awareness rebuilds the metadata through + // the historical constructor and would rewrite the file with an + // empty root set; the lifecycle merge must restore the persisted + // set instead of letting that rewrite erase it. + let mut rewritten = create_saved_session_with_id_and_mode( + session.metadata.id.clone(), + &messages, + "deepseek-v4-flash", + &workspace, + 0, + None, + Some("agent"), + ); + assert!(rewritten.metadata.workspace_roots.is_empty()); + assert!(manager.merge_persisted_lifecycle(&mut rewritten.metadata)); + assert_eq!( + rewritten.metadata.workspace_roots, + vec![workspace, tmp.path().join("shared")] + ); + } + /// Coverage state round-trips with the money it qualifies, and a session /// written before coverage existed is detected as *unknown* rather than being /// read as a complete total covering zero turns (#4318). @@ -2864,6 +3018,7 @@ mod tests { model_provider: "deepseek".to_string(), model_provider_id: None, workspace: workspace.to_path_buf(), + workspace_roots: Vec::new(), mode: None, cost: SessionCostSnapshot::default(), parent_session_id: None, @@ -2905,6 +3060,7 @@ mod tests { model_provider: "deepseek".to_string(), model_provider_id: None, workspace: workspace.to_path_buf(), + workspace_roots: Vec::new(), mode: Some("yolo".to_string()), cost: SessionCostSnapshot::default(), parent_session_id: None, @@ -4832,4 +4988,103 @@ mod tests { "unexpected error: {err}" ); } + + /// The live-session claim is judged on the trimmed id — the same + /// normalized value `set_live_session` stores — so a padded id cannot + /// read as a stranger to the guard while every store path + /// (`validated_session_id`) reads it as the owner. + #[test] + fn live_session_claim_matches_the_trimmed_query() { + let _lock = crate::shell_dispatcher::test_env_lock::lock_test_env(); + set_live_session(Some(" sess-pad ")); + assert!(is_live_session("sess-pad")); + assert!( + is_live_session(" sess-pad "), + "a padded id must hit the claim the trimmed id would hit" + ); + assert!(!is_live_session("sess-other")); + set_live_session(None); + assert!(!is_live_session(" sess-pad ")); + } + + /// A poisoned claim lock means ownership cannot be determined, so the + /// answer fails closed: the session counts as live, so the orphan + /// reclamation keep-chain skips it rather than deleting a directory a + /// maybe-running surface still owns. (The write-conflict guard that + /// used to be the other fail-closed consumer was retired in round-20 + /// B20-3; stale wording corrected round-24 P3.) + #[test] + fn live_session_claim_fails_closed_on_a_poisoned_lock() { + let _lock = crate::shell_dispatcher::test_env_lock::lock_test_env(); + set_live_session(Some("sess-poison")); + // Poison the lock by panicking while holding its write guard, then + // clear the poison in the same test — the process-global lock is + // shared with every other test in this binary, and only the env lock + // above keeps the poisoned window single-threaded. + let _ = std::panic::catch_unwind(|| { + let _guard = live_sessions().write(); + panic!("poison the live-session claim lock"); + }); + assert!( + live_sessions().read().is_err(), + "the lock must actually be poisoned for this pin to mean anything" + ); + assert!( + is_live_session("sess-poison"), + "poisoned: the known claim must still read as live" + ); + assert!( + is_live_session("sess-unknown"), + "poisoned: an unknown id must also read as live (fail closed)" + ); + live_sessions().clear_poison(); + set_live_session(None); + assert!(!is_live_session("sess-unknown")); + } +} + +#[test] +fn reclaim_keeps_a_live_claimed_session_dir() { + // Round-21 should-fix 5 (the keystone for B20-3's registry-retention + // decision): the retired HTTP guard left the registry one real + // consumer — the orphan-dir reclaim must keep a directory whose id + // is claimed live by the interactive surface, or that keep would be + // dead code. Hermetic under ENV_LOCK + PINVOU3_HOME. + let _lock = crate::shell_dispatcher::test_env_lock::lock_test_env(); + let prev_home = std::env::var("PINVOU3_HOME").ok(); + let home = + std::env::temp_dir().join(format!("pinvou3-reclaim-live-home-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + // SAFETY: platform::paths::tests::ENV_LOCK held; env writes are serialized. + unsafe { std::env::set_var("PINVOU3_HOME", &home) }; + let manager = SessionManager::new(home.join("sessions")).expect("session manager"); + + // An orphan session directory in the exact shape the runtime mints. + let id = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + let dir = home.join("sessions").join(id); + std::fs::create_dir_all(&dir).unwrap(); + + // Unclaimed: the orphan is reclaimed. + manager.reclaim_orphaned_session_dirs(); + assert!(!dir.exists(), "an unclaimed orphan is reclaimed"); + + // Re-create it and claim it live: the reclaim must keep it. + std::fs::create_dir_all(&dir).unwrap(); + set_live_session(Some(id)); + manager.reclaim_orphaned_session_dirs(); + assert!(dir.exists(), "a live-claimed orphan survives the reclaim"); + + set_live_session(None); + manager.reclaim_orphaned_session_dirs(); + assert!(!dir.exists(), "releasing the claim re-opens the reclaim"); + + // SAFETY: ENV_LOCK held for the whole test; restoring the caller's + // environment. + unsafe { + match prev_home { + Some(home) => std::env::set_var("PINVOU3_HOME", home), + None => std::env::remove_var("PINVOU3_HOME"), + } + } + let _ = std::fs::remove_dir_all(&home); } diff --git a/crates/tui/src/session_projection.rs b/crates/tui/src/session_projection.rs index 46bf85a89b..72c0ee9a94 100644 --- a/crates/tui/src/session_projection.rs +++ b/crates/tui/src/session_projection.rs @@ -339,6 +339,7 @@ mod tests { model_provider: "deepseek".to_string(), model_provider_id: None, workspace: PathBuf::from(workspace), + workspace_roots: Vec::new(), mode: Some("agent".to_string()), cost: Default::default(), parent_session_id: None, diff --git a/crates/tui/src/shell_dispatcher.rs b/crates/tui/src/shell_dispatcher.rs index a90058f1b6..426d351886 100644 --- a/crates/tui/src/shell_dispatcher.rs +++ b/crates/tui/src/shell_dispatcher.rs @@ -739,7 +739,7 @@ mod tests { .as_chunks::<2>() .0 .iter() - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .map(|pair| u16::from_le_bytes(*pair)) .collect(); let text = String::from_utf16(&units).expect("payload is UTF-16LE"); assert!(text.contains("Write-Output '中文-ok'"), "{text}"); diff --git a/crates/tui/src/snapshot/mod.rs b/crates/tui/src/snapshot/mod.rs index 81344c37d3..4d62b17324 100644 --- a/crates/tui/src/snapshot/mod.rs +++ b/crates/tui/src/snapshot/mod.rs @@ -51,8 +51,250 @@ pub use prune::{DEFAULT_MAX_AGE, prune_older_than}; /// Maximum snapshots kept per workspace side-repo. Oldest are pruned /// after each new snapshot to cap disk usage (#1112). pub const DEFAULT_MAX_SNAPSHOTS: usize = 50; + +/// Honesty clause for revert/undo reports when the session root set extends +/// beyond the primary workspace: the snapshot side-repo is rooted at the +/// primary, so a restore rolls back only the primary while attached-root +/// writes persist. Appended verbatim so single-root reports stay +/// byte-identical (the clause is simply never added there). Owned by the +/// command contract since round-45 (single source shared with the portable +/// `/restore` handler); re-exported so the model/API lanes keep one stable +/// spelling. +pub use codewhale_command_contract::ATTACHED_ROOTS_NOT_REVERTED_NOTE; + +/// Round-43 P2-5: the localized form for human-facing TUI surfaces (the +/// model/API lanes keep the stable English constant above). +pub fn attached_roots_not_reverted_note(locale: crate::localization::Locale) -> String { + crate::localization::tr( + locale, + crate::localization::MessageId::AttachedRootsNotRevertedNote, + ) + .into_owned() +} + +/// Whether a restore from the workspace snapshot repo covers only the +/// primary root — true exactly when the normalized root set has an entry +/// OUTSIDE the primary tree. Revert/undo reports must carry +/// [`ATTACHED_ROOTS_NOT_REVERTED_NOTE`] in that case instead of implying a +/// full rollback. +/// +/// A root nested under the primary does NOT count +/// (review #484/CodeWhale round-22 N22-3): `validate_workspace_roots` +/// explicitly allows attached roots under the primary, and the side repo's +/// work-tree IS the primary tree, so `repo.restore` reverts those writes +/// with the primary — claiming they "were not rolled back" would tell the +/// user the opposite of what happened. +/// +/// Round-23 B23-1 + round-24 B24-1: BOTH spellings of every side are judged. +/// The lexically normalized forms catch `..`-spelled roots (`/ws/../shared` +/// collapses outside the primary); the canonical-or-raw forms (the +/// `ToolContext::boundary_roots` idiom) catch inward-symlink roots +/// (`/ws/link → /elsewhere`) that lexically nest under the primary while +/// every consumer canonicalizes them outside — writes through such a root +/// persist past the primary-rooted restore, so withholding the note for +/// either class was fail-unsafe (and an earlier doc asserted the opposite +/// of the code). The clause fires when EITHER form lands outside the +/// primary; over-disclosing the boundary is the safe side. +pub fn restore_covers_primary_only( + workspace: &std::path::Path, + workspace_roots: &[std::path::PathBuf], +) -> bool { + let workspace_lexical = codewhale_core::normalize_path_lexically(workspace); + let workspace_canonical = codewhale_core::normalize_path_lexically( + &workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()), + ); + codewhale_core::normalize_workspace_roots(workspace, workspace_roots) + .iter() + .skip(1) + .any(|root| { + // Round-43 P2-2: coverage is a property of the SNAPSHOT, not of + // directory containment — the side repo's built-in excludes + // (vendor/, node_modules/, …) keep a nested attached root's + // files OUT of the snapshot, and a nested repository records + // only a mode-160000 gitlink, so neither reverts with the + // primary even though both sit inside the primary tree. The + // disclosure fires for those shapes too (fail-safe direction: + // over-disclosing the boundary). + if nested_root_escapes_snapshot_coverage(workspace, root) { + return true; + } + let root_lexical = codewhale_core::normalize_path_lexically(root); + let root_canonical = codewhale_core::normalize_path_lexically( + &root.canonicalize().unwrap_or_else(|_| root.clone()), + ); + !root_lexical.starts_with(&workspace_lexical) + || !root_canonical.starts_with(&workspace_canonical) + }) +} + +/// Round-43 P2-2: true when `root` nests under the primary lexically but +/// its CONTENT will not revert with a primary-rooted restore — the snapshot +/// repo's built-in excludes drop it, or it is (or sits under) a nested +/// repository the snapshot records only as a gitlink. Both shapes leave the +/// attached root's writes persisting past the restore, so the disclosure +/// must fire exactly as for an out-of-tree root. +fn nested_root_escapes_snapshot_coverage( + workspace: &std::path::Path, + root: &std::path::Path, +) -> bool { + /// The snapshot repo's built-in exclude list (repo.rs BUILTIN_EXCLUDES): + /// a nested attached root at or under one of these directories is not + /// captured by the snapshot at all. + const SNAPSHOT_EXCLUDED_DIRS: &[&str] = &[ + "node_modules", + "target", + "dist", + "build", + ".build", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".parcel-cache", + "vendor", + ".cargo", + ".rustup", + ".npm", + ".bun", + ".yarn", + ".pnpm-store", + ".cache", + ".venv", + "venv", + ".tox", + "__pycache__", + ]; + + let rel = root.strip_prefix(workspace).ok(); + let Some(rel) = rel else { + return false; + }; + // A nested repository boundary: the attached root (or any ancestor under + // the primary up to it) carrying a .git entry records as a gitlink in + // the primary's snapshot — its files never enter the snapshot tree. + let mut probe = workspace.to_path_buf(); + for component in rel.components() { + probe.push(component); + if probe.join(".git").exists() { + return true; + } + } + // The excluded-directory shape: the root itself is one of the built-in + // excludes (vendor/...) or sits under one (vendor/sub). + let mut under_excluded = false; + for component in rel.components() { + let name = component.as_os_str().to_string_lossy(); + under_excluded = under_excluded || SNAPSHOT_EXCLUDED_DIRS.contains(&name.as_ref()); + if under_excluded { + break; + } + } + under_excluded +} #[allow(unused_imports)] pub use repo::{ DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT, Snapshot, SnapshotId, SnapshotRepo, estimate_workspace_size_bounded, }; + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn nested_under_primary_attached_roots_are_reverted_with_the_primary() { + // Round-22 N22-3: the side repo's work-tree IS the primary tree, so a + // root nested under it (validate_workspace_roots explicitly allows + // those) is rolled back by the restore — the boundary note must not + // claim otherwise. + let workspace = PathBuf::from("/ws"); + assert!(restore_covers_primary_only( + &workspace, + &[PathBuf::from("/elsewhere")], + )); + assert!(!restore_covers_primary_only( + &workspace, + &[PathBuf::from("/ws/nested")], + )); + assert!(!restore_covers_primary_only( + &workspace, + &[PathBuf::from("/ws/nested/deeper")], + )); + // A root that contains the primary (`/`, a legacy-row ancestor) is + // outside the reverted tree's spelling — its writes outside the + // primary persist. A root equal to the primary dedups away in the + // normalizer, like the empty set. + assert!(restore_covers_primary_only( + &workspace, + &[PathBuf::from("/")], + )); + assert!(!restore_covers_primary_only( + &workspace, + &[PathBuf::from("/ws")], + )); + assert!(!restore_covers_primary_only(&workspace, &[])); + } + + #[cfg(unix)] + #[test] + fn inward_symlink_root_fires_the_boundary_note() { + // Round-24 B24-1: lexical normalization cannot see through an inward + // symlink — `/ws/link → /elsewhere` lexically nests under `/ws` + // while every consumer canonicalizes it outside, so the + // lexical-only predicate withheld the boundary note while writes + // through the root persisted past the primary-rooted restore. The + // canonical leg (raw fallback for roots that do not resolve) must + // fire it; a genuinely nested plain root must stay silent. + let base = tempfile::tempdir().expect("tempdir"); + let workspace = base.path().join("ws"); + std::fs::create_dir_all(&workspace).expect("mkdir ws"); + let elsewhere = base.path().join("elsewhere"); + std::fs::create_dir_all(&elsewhere).expect("mkdir elsewhere"); + let link = workspace.join("link"); + std::os::unix::fs::symlink(&elsewhere, &link).expect("symlink"); + assert!( + restore_covers_primary_only(&workspace, std::slice::from_ref(&link)), + "an inward-symlink root must fire the boundary note" + ); + // A plain root nested under the primary keeps the old behavior even + // though the primary itself now also canonicalizes. A28-4: the dir + // must EXIST first — a missing target canonicalizes to the raw + // /var spelling while the primary canonicalizes to /private/var, + // mixing spellings and spuriously firing the note on stock macOS. + let nested_plain = workspace.join("nested"); + std::fs::create_dir_all(&nested_plain).expect("mkdir nested"); + assert!(!restore_covers_primary_only(&workspace, &[nested_plain])); + // A symlink INSIDE the primary pointing at a nested directory stays + // inside on both spellings — no note. + let nested = workspace.join("nested-real"); + std::fs::create_dir_all(&nested).expect("mkdir nested"); + let inner_link = workspace.join("inner-link"); + std::os::unix::fs::symlink(&nested, &inner_link).expect("symlink"); + assert!(!restore_covers_primary_only(&workspace, &[inner_link])); + + // Round-43 P2-2: coverage follows the SNAPSHOT, not directory + // containment — a vendor/ nested root (built-in exclude) and a + // nested repository (gitlink-only) do not revert with the primary, + // so the disclosure fires; an ordinary tracked nested root does + // not (the plain-root case above). + let vendor = workspace.join("vendor"); + std::fs::create_dir_all(&vendor).expect("mkdir vendor"); + assert!( + restore_covers_primary_only(&workspace, std::slice::from_ref(&vendor)), + "a vendor/ nested root is excluded from the snapshot — its writes persist" + ); + let nested_repo = workspace.join("child-repo"); + std::fs::create_dir_all(nested_repo.join(".git")).expect("nested repo .git"); + assert!( + restore_covers_primary_only(&workspace, std::slice::from_ref(&nested_repo)), + "a nested repository records only a gitlink — its files never revert" + ); + assert!( + restore_covers_primary_only(&workspace, &[nested_repo.join("sub")]), + "a root under a nested repository boundary escapes too" + ); + } +} diff --git a/crates/tui/src/tools/apply_patch.rs b/crates/tui/src/tools/apply_patch.rs index ecfb7c5397..0528a7c590 100644 --- a/crates/tui/src/tools/apply_patch.rs +++ b/crates/tui/src/tools/apply_patch.rs @@ -555,9 +555,23 @@ fn verify_patch_expected_hash( /// This deliberately stops before workspace resolution or file reads. It is /// suitable for policy checks, audit logs, diagnostics hooks, and future undo /// planning that must know the target files before mutation. +/// +/// The `path` alias spellings (`file_path`/`filePath`) are folded exactly +/// like `execute` folds them before reading the top-level override (review +/// #484/CodeWhale round-24 B24-2): every plan-time consumer of this summary +/// (ask rules, the in-workspace write carve-out, auto-review, resource +/// claims, the work-surface activity line) judges the RAW input, so an +/// alias-spelled override used to be invisible here while execution folded +/// it — the judged files and the written file could differ, and a patch +/// with innocuous headers plus a `filePath` override landed its write on +/// the override target past every plan-time gate. `execute` folds before +/// calling the plan fn directly, so folding again here is a no-op for that +/// lane; alias-conflict errors surface identically to execute time. pub fn preflight_apply_patch(input: &Value) -> Result { - let normalized = normalize_apply_patch_input(input)?; - Ok(preflight_apply_patch_plan(input, normalized)?.summary) + let mut folded = input.clone(); + apply_param_aliases(&mut folded, PATH_ALIASES, "apply_patch")?; + let normalized = normalize_apply_patch_input(&folded)?; + Ok(preflight_apply_patch_plan(&folded, normalized)?.summary) } fn preflight_apply_patch_plan( @@ -1679,6 +1693,47 @@ mod tests { assert_eq!(preflight.path_override.as_deref(), Some("src/lib.rs")); } + #[test] + fn test_preflight_apply_patch_folds_alias_path_override() { + // Round-24 B24-2: the alias-spelled top-level override is folded + // exactly like execute folds it, so the plan-time gates that judge + // raw input see the file the write will actually land on — with an + // innocuous patch header, the override (not the header) must be the + // preflight's touched file. + let patch = r"@@ -1,2 +1,2 @@ + old +-value ++new-value +"; + + for alias in ["file_path", "filePath"] { + let preflight = preflight_apply_patch(&json!({ + alias: ".git/hooks/pre-commit", + "patch": patch, + // A decoy header must not become the judged target. + })) + .expect("preflight"); + assert_eq!( + preflight.touched_files, + vec![".git/hooks/pre-commit"], + "alias `{alias}` override must be visible to preflight" + ); + assert_eq!( + preflight.path_override.as_deref(), + Some(".git/hooks/pre-commit") + ); + } + + // Conflicting alias + canonical spellings fail exactly like + // execute time, instead of silently judging one of them. + let conflict = preflight_apply_patch(&json!({ + "path": "a.rs", + "filePath": "b.rs", + "patch": patch, + })); + assert!(conflict.is_err(), "alias/canonical disagreement must fail"); + } + #[test] fn test_preflight_apply_patch_multi_file_create_and_delete() { let patch = r"diff --git a/new.rs b/new.rs diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index 69c411ccea..987750617f 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -27,7 +27,7 @@ //! | Tool | Grouping key | //! |---------------|------------------------------------------| //! | `apply_patch` | `patch:` | -//! | shell tools | `shell:` | +//! | shell tools | `shell:` (+ `@cwd:` when the call carries one) | //! | `fetch_url` | `net:` | //! | everything else| `tool::` | //! @@ -37,7 +37,6 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use crate::command_safety::classify_command; -use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input}; /// The fingerprint of a tool call — stable enough to match repeated /// calls but specific enough to avoid privilege confusion. @@ -80,12 +79,31 @@ pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> Approva /// "approve for session" decision covers later invocations that differ only /// by flags. Denials must keep using the exact [`build_approval_key`]. #[must_use] -pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey { +pub fn build_approval_grouping_key( + tool_name: &str, + input: &serde_json::Value, + workspace: &std::path::Path, +) -> ApprovalKey { let tool_name = crate::tools::canonical_action::canonical_action_alias(tool_name, input); let fingerprint = match tool_name { "apply_patch" => { + // B27-3 + A28-3: key on the REAL parser's plan — + // `preflight_apply_patch`, the same alias-folded judgment every + // plan-time gate uses. The round-26 ad-hoc scan (a) keyed the + // top-level override for replace-forms where execution never + // reads it (an approved patch grant then auto-approved an + // arbitrary replace-write under the same decoy override), and + // (b) collected only `+++ b/` headers, keying `+++ a/`-spelled, + // bare, timestamped, and /dev/null-delete sections into one + // shared `no_files` family that covers arbitrary targets. + // Round-37 M37-6: the touched-path hash alone had NO root + // component, so a patch grant approved under one primary rode a + // `/workspace` move and auto-approved the same relative paths + // under the new root. Bind the family to the primary the grant + // was judged under. let paths_hash = hash_patch_paths(input); - format!("patch:{paths_hash}") + let root_tag = workspace.display().to_string(); + format!("patch:{paths_hash}@root:{}:{root_tag}", root_tag.len()) } "exec_shell" | "task_shell_start" @@ -94,7 +112,45 @@ pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) - | "exec_wait" | "exec_interact" => { let prefix = command_prefix(input); - format!("shell:{prefix}") + // The approval side judges the resolved effective cwd (the same + // value exec rule matching sees), so the grant is keyed to the + // command family AND the cwd operand: a grant approved at the + // session workspace must not silently cover the same command + // redirected into another root. + // Round-37 M37-6: the operand is RESOLVED against the current + // primary before keying — the raw spelling is + // workspace-relative, so a grant approved for `cwd: "sub"` in + // one root used to cover the same raw operand after a + // `/workspace` move even though execution then landed in the + // new root's `sub`. + match shell_cwd_operand(input) { + // Length-prefix the operand (round-20 B20-2): the raw + // `prefix@cwd:` concatenation is non-injective — model-craftable + // spellings of prefix/cwd collide across the boundary. + // Round-29/30 B2: length-prefix BOTH sides — the prefix + // comes from classify_command's positional fallback and can + // itself contain "@cwd::", which let {"git foo" @ cwd + // "a@cwd:1:b"} collide with {"git@cwd:9:a & payload" @ + // cwd "b"} (the review's dynamic probe). + Some(cwd) => { + let resolved = codewhale_core::resolve_operand_cwd(workspace, cwd) + .to_string_lossy() + .into_owned(); + format!( + "shell:{}:{prefix}@cwd:{}:{resolved}", + prefix.len(), + resolved.len() + ) + } + // Round-32 B32-2: length-prefix the operand-less arm too — + // classify_command's fallback prefix is any single token, so + // a forged `2:sh@cwd:1:/` command string produced the same + // key as a genuine operand-carrying `sh` grant (the + // review's dynamic probe: one approve-for-session on the + // odd spelling auto-approved the whole `sh` family at any + // absolute cwd). + None => format!("shell:{}:{prefix}", prefix.len()), + } } "fetch_url" | "web.fetch" | "web_fetch" => { let host = parse_host(input); @@ -128,43 +184,70 @@ fn command_prefix(input: &serde_json::Value) -> String { classify_command(&tokens) } -/// Hash the sorted set of file paths referenced by a patch input. +/// Return the exec `cwd`/`working_dir` operand when the call carries one — +/// the value the approval side resolves and judges as the effective cwd. +fn shell_cwd_operand(input: &serde_json::Value) -> Option<&str> { + // Round-20 B20-2: mirror the check-side and execution field semantics — + // `get("cwd")` returning `Some(Value::Null)` used to block the + // `working_dir` fallback, keying the grant to the no-operand family + // while the check/exec treated the same call as redirected to + // `working_dir`. Skip Null (and non-string) values like they do. + ["cwd", "working_dir"] + .iter() + .find_map(|name| input.get(name).and_then(Value::as_str)) + .filter(|cwd| !cwd.is_empty()) +} + +/// Hash the write targets of a patch input, taken from the REAL parser's +/// plan (`preflight_apply_patch`): the top-level override keys ONLY in the +/// patch form (execution's PathOverride-wins semantics; replace-form top +/// levels are decoys), otherwise the touched file set keys as parsed — +/// every accepted `+++` spelling (`a/`/`b/` prefixes, bare, tab timestamps) +/// normalizes to the execution target, and deletions key under a separate +/// delete marker so a delete grant never covers writes (B27-3/A28-3). fn hash_patch_paths(input: &serde_json::Value) -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; - let mut paths: Vec<&str> = Vec::new(); - - match normalize_apply_patch_input(input) { - Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => { - for change in entries { - if let Some(path) = change.get("path").and_then(|v| v.as_str()) { - paths.push(path); - } + let mut folded = input.clone(); + if super::file::apply_param_aliases(&mut folded, super::file::PATH_ALIASES, "apply_patch") + .is_err() + { + // Alias conflict: execution fails the call too; a distinct family + // keeps the key from silently matching a folded spelling. + return "alias_conflict".to_string(); + } + match super::apply_patch::preflight_apply_patch(&folded) { + Ok(plan) => { + // Round-29/30 B2: domain-separate every hashed item — a bare + // `str` marker and a touched-file name feed the IDENTICAL byte + // stream, so hash("override", P) == hash(touched ["override", + // P]) and one approved override patch could auto-approve a + // differently-shaped replace patch (dynamically reproduced in + // the round-29 review). Tag each item class and length-prefix + // every string so no spelling of one class impersonates another. + let mut hasher = DefaultHasher::new(); + fn feed(tag: u8, value: &str, hasher: &mut DefaultHasher) { + tag.hash(hasher); + value.len().hash(hasher); + value.hash(hasher); } - } - Ok(NormalizedApplyPatchInput::Patch(patch_text)) => { - for line in patch_text.lines() { - if let Some(rest) = line.strip_prefix("+++ b/") { - paths.push(rest.trim()); + if let Some(override_path) = plan.path_override.as_deref() { + feed(1, "override", &mut hasher); + feed(2, override_path, &mut hasher); + } else { + for path in &plan.touched_files { + feed(3, path, &mut hasher); + } + for path in &plan.deletes { + feed(4, "delete", &mut hasher); + feed(5, path, &mut hasher); } } + format!("{:x}", hasher.finish()) } - Err(_) => {} - } - - paths.sort(); - paths.dedup(); - - if paths.is_empty() { - return "no_files".to_string(); - } - - let mut hasher = DefaultHasher::new(); - for path in &paths { - path.hash(&mut hasher); + Err(_) => "unparseable".to_string(), } - format!("{:x}", hasher.finish()) } /// Parse the host portion from a URL input. @@ -262,6 +345,12 @@ fn push_canonical_json(value: &Value, out: &mut String) { #[cfg(test)] mod tests { use super::*; + + /// Tests anchor operand resolution at a fixed primary (round-37 M37-6); + /// comparisons stay valid because both sides share the anchor. + fn gkey(tool: &str, input: &serde_json::Value) -> ApprovalKey { + build_approval_grouping_key(tool, input, std::path::Path::new("/repo")) + } use serde_json::json; #[test] @@ -285,11 +374,54 @@ mod tests { assert_ne!(key_a, key_b); } + /// Round-37 M37-6 pins: the grouping key resolves the cwd operand + /// against the CURRENT primary and binds the patch family to it, so a + /// session grant approved in one root cannot ride a `/workspace` move. + #[test] + fn grouping_key_rekeys_a_relative_operand_under_a_moved_workspace() { + let call = serde_json::json!({"command": "sh build", "cwd": "sub"}); + let under_a = + build_approval_grouping_key("exec_shell", &call, std::path::Path::new("/repo-a")); + let under_b = + build_approval_grouping_key("exec_shell", &call, std::path::Path::new("/repo-b")); + assert_ne!( + under_a, under_b, + "the same raw operand re-resolves under the new primary — a grant must not cross roots" + ); + // An absolute operand resolves to itself under either anchor: the + // root binding intentionally does NOT quarantine cross-root absolute + // redirects that were approved as such. + let absolute = serde_json::json!({"command": "sh build", "cwd": "/shared"}); + let abs_a = + build_approval_grouping_key("exec_shell", &absolute, std::path::Path::new("/repo-a")); + let abs_b = + build_approval_grouping_key("exec_shell", &absolute, std::path::Path::new("/repo-b")); + assert_eq!( + abs_a, abs_b, + "an explicitly approved absolute redirect stays one family" + ); + } + + #[test] + fn grouping_key_binds_the_patch_family_to_the_primary_root() { + let patch = serde_json::json!({"replace": [{"path": "a.rs", "content": "x"}]}); + let under_a = + build_approval_grouping_key("apply_patch", &patch, std::path::Path::new("/repo-a")); + let under_b = + build_approval_grouping_key("apply_patch", &patch, std::path::Path::new("/repo-b")); + assert_ne!( + under_a, under_b, + "a patch grant approved under one primary must not cover the same relative paths after /workspace" + ); + let same = + build_approval_grouping_key("apply_patch", &patch, std::path::Path::new("/repo-a")); + assert_eq!(under_a, same); + } + #[test] fn grouping_key_collapses_shell_flag_variants() { - let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"})); - let key_b = - build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"})); + let key_a = gkey("exec_shell", &json!({"command": "cargo build"})); + let key_b = gkey("exec_shell", &json!({"command": "cargo build --release"})); assert_eq!( key_a, key_b, "approving a command family must cover later flag variants" @@ -303,11 +435,11 @@ mod tests { // approves the echo kind once and later variants of the same reviewed // tool must not re-prompt. R2's shell command-family scoping is // untouched — this is the MCP arm only. - let key_a = build_approval_grouping_key( + let key_a = gkey( "mcp_plugin-4-demo-local_echo", &json!({"text": "acceptance", "hang": false}), ); - let key_b = build_approval_grouping_key( + let key_b = gkey( "mcp_plugin-4-demo-local_echo", &json!({"text": "acceptance", "hang": true}), ); @@ -315,7 +447,7 @@ mod tests { key_a, key_b, "a reviewed MCP kind grant covers argument variants of that tool" ); - let key_c = build_approval_grouping_key("mcp_plugin-4-demo-local_kick", &json!({"x": 1})); + let key_c = gkey("mcp_plugin-4-demo-local_kick", &json!({"x": 1})); assert_ne!(key_a, key_c, "a different MCP tool is a different kind"); // The exact-call key stays per-arguments so denials still suppress // only exact retries. @@ -332,18 +464,87 @@ mod tests { #[test] fn grouping_key_still_separates_distinct_commands() { - let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "git status"})); - let key_b = build_approval_grouping_key("exec_shell", &json!({"command": "git push"})); + let key_a = gkey("exec_shell", &json!({"command": "git status"})); + let key_b = gkey("exec_shell", &json!({"command": "git push"})); assert_ne!(key_a, key_b); } + /// Round-29/30 B2 pin: the patch marker and touched-file name spaces + /// are domain-separated — an approved override-form grant must never + /// auto-approve a replace-form patch whose touched list is literally + /// ["override", ] (the review's dynamic collision). + #[test] + fn grouping_key_separates_override_form_from_listed_override_name() { + let target = "/work/.env"; + let override_form = serde_json::json!({ + "path": target, + "patch": "@@ +-a ++b +" + }); + let listed_form = serde_json::json!({ + "replace": [ + { "path": "override", "content": "x" }, + { "path": target, "content": "y" } + ] + }); + let a = gkey("apply_patch", &override_form); + let b = gkey("apply_patch", &listed_form); + assert_ne!( + a, b, + "the override marker and a touched file named 'override' must not collide" + ); + } + + /// Round-32 B32-2 pin: the operand-less (None-cwd) arm is + /// length-prefixed — a forged single-token command containing the + /// `shell::@cwd::` shape must not collide with a + /// genuine operand-carrying key. + #[test] + fn grouping_key_separates_operandless_forgery_from_real_operand_key() { + let forged = serde_json::json!({ + "command": "2:sh@cwd:1:/" + }); + let genuine = serde_json::json!({ + "command": "sh -c 'payload'", + "cwd": "/" + }); + let a = gkey("exec_shell", &forged); + let b = gkey("exec_shell", &genuine); + assert_ne!( + a, b, + "an operand-less spelling must not forge an operand-carrying key" + ); + } + + /// Round-29/30 B2 pin (shell side): a prefix containing the "@cwd::" + /// spelling must not collide with a genuine cwd-carrying key. + #[test] + fn grouping_key_separates_embedded_cwd_spelling_from_real_operand() { + let weird_cwd = serde_json::json!({ + "command": "git foo", + "cwd": "a@cwd:1:b" + }); + let payload_prefix = serde_json::json!({ + "command": "git@cwd:9:a & payload", + "cwd": "b" + }); + let a = gkey("exec_shell", &weird_cwd); + let b = gkey("exec_shell", &payload_prefix); + assert_ne!( + a, b, + "an embedded '@cwd:' spelling in either operand must not forge the other key" + ); + } + #[test] fn grouping_key_collapses_patch_body_for_same_path() { - let key_a = build_approval_grouping_key( + let key_a = gkey( "apply_patch", &json!({"replace": [{"path": "a.rs", "content": "x"}]}), ); - let key_b = build_approval_grouping_key( + let key_b = gkey( "apply_patch", &json!({"replace": [{"path": "a.rs", "content": "y"}]}), ); @@ -353,13 +554,116 @@ mod tests { ); } + #[test] + fn grouping_key_rekeys_on_the_top_level_path_override() { + // Round-26 M26-3: execution's PathOverride wins over the payload + // headers (they become decoys), so the grouping key must cover the + // override — the same patch body under two different targets used + // to share one grant key, letting a session-approved family + // auto-approve a redirect to an arbitrary file (the B20-2 shell + // class, on the patch arm). Alias spellings fold first, exactly + // like preflight/execute. + let patch = "@@ -1,2 +1,2 @@\n old\n-value\n+new-value\n"; + let decoy = gkey("apply_patch", &json!({"patch": patch})); + for alias in ["path", "file_path", "filePath"] { + let redirected = gkey( + "apply_patch", + &json!({alias: ".git/hooks/pre-commit", "patch": patch}), + ); + assert_ne!( + redirected, decoy, + "an {alias} override must re-key the grant family away from the header set" + ); + } + // The same override target under different alias spellings is ONE + // family (the fold runs before hashing). + let canonical = gkey( + "apply_patch", + &json!({"path": ".git/hooks/pre-commit", "patch": patch}), + ); + let alias_spellings = gkey( + "apply_patch", + &json!({"filePath": ".git/hooks/pre-commit", "patch": patch}), + ); + assert_eq!( + canonical, alias_spellings, + "alias spellings of the same override collapse to one family" + ); + + // B27-3: in the REPLACE form the top-level path is a decoy execution + // never reads — the key must follow the entries, so an approved + // patch-form override grant cannot cover a replace-write hiding + // behind the same decoy. + let replace_decoy = gkey( + "apply_patch", + &json!({"path": ".git/hooks/pre-commit", "replace": [{"path": "notes.txt", "content": "x"}]}), + ); + let replace_plain = gkey( + "apply_patch", + &json!({"replace": [{"path": "notes.txt", "content": "x"}]}), + ); + assert_eq!( + replace_decoy, replace_plain, + "a replace-form top-level path is a decoy and must not key the family" + ); + assert_ne!( + replace_plain, + gkey( + "apply_patch", + &json!({"replace": [{"path": "other.txt", "content": "x"}]}), + ), + "different replace targets are different families" + ); + + // A28-3: header spellings the old ad-hoc scan missed (`+++ a/`, bare + // `+++ x`) key to the REAL target, and a single-file grant must not + // cover a two-file patch whose second section rides such a spelling. + // `+++ a/x`-spelled and `+++ b/x`-spelled writes of the same target + // are ONE family (the parser normalizes both to x) — the old ad-hoc + // `+++ b/`-only scan keyed the a-spelling into the shared `no_files` + // family, whose grant then covered arbitrary targets (A28-3). + let b_spelled = gkey( + "apply_patch", + &json!({"patch": "--- a/.env\n+++ b/.env\n@@ -1,1 +1,1 @@\n-old\n+new\n"}), + ); + let a_spelled = gkey( + "apply_patch", + &json!({"patch": "--- b/.env\n+++ a/.env\n@@ -1,1 +1,1 @@\n-old\n+new\n"}), + ); + assert_eq!( + b_spelled, a_spelled, + "header-prefix spellings of the same target collapse to one family" + ); + assert_ne!( + b_spelled, + gkey( + "apply_patch", + &json!({"patch": "--- a/other.txt\n+++ b/other.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"}), + ), + "different targets stay different families" + ); + // Deletions key under a delete marker: a delete grant covers no write. + let deletion = gkey( + "apply_patch", + &json!({"patch": "--- a/old.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone\n"}), + ); + let write_same_path = gkey( + "apply_patch", + &json!({"patch": "--- a/old.txt\n+++ b/old.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"}), + ); + assert_ne!( + deletion, write_same_path, + "a deletion keys apart from a write of the same path" + ); + } + #[test] fn grouping_key_treats_replace_and_legacy_changes_as_the_same_path_set() { - let canonical = build_approval_grouping_key( + let canonical = gkey( "apply_patch", &json!({"replace": [{"path": "a.rs", "content": "new"}]}), ); - let legacy = build_approval_grouping_key( + let legacy = gkey( "apply_patch", &json!({"changes": [{"path": "a.rs", "content": "new"}]}), ); @@ -374,12 +678,60 @@ mod tests { build_approval_key("exec_shell", &json!({"command": "cargo build --release"})); assert_ne!(exact_a, exact_b, "denials must remain exact-call scoped"); - let group_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"})); - let group_b = - build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"})); + let group_a = gkey("exec_shell", &json!({"command": "cargo build"})); + let group_b = gkey("exec_shell", &json!({"command": "cargo build --release"})); assert_eq!(group_a, group_b, "approvals must group by command family"); } + #[test] + fn shell_grouping_key_rekeys_on_the_cwd_operand() { + let at_workspace = gkey("exec_shell", &json!({"command": "git status"})); + let redirected = gkey( + "exec_shell", + &json!({"command": "git status", "cwd": "/attached/repo"}), + ); + assert_ne!( + at_workspace, redirected, + "a grant approved at the session workspace must not cover the same command redirected into another root" + ); + + let same_redirect = gkey( + "exec_shell", + &json!({"command": "git status -s", "cwd": "/attached/repo"}), + ); + assert_eq!( + redirected, same_redirect, + "the same command family in the same cwd stays one grant" + ); + + let via_working_dir = gkey( + "exec_shell", + &json!({"command": "git status", "working_dir": "/attached/repo"}), + ); + assert_eq!( + redirected, via_working_dir, + "cwd and working_dir are the same operand for the grant key" + ); + + // Round-20 B20-2: `cwd: null` must not block the `working_dir` + // fallback — the null spelling used to key the NO-OPERAND family + // while the check and execution treated the same call as redirected, + // letting a session-approved plain command run in the attached root + // unprompted. + let null_cwd_with_working_dir = gkey( + "exec_shell", + &json!({"command": "git status", "cwd": null, "working_dir": "/attached/repo"}), + ); + assert_eq!( + redirected, null_cwd_with_working_dir, + "a null cwd plus working_dir keys as the redirected operand" + ); + assert_ne!( + at_workspace, null_cwd_with_working_dir, + "the redirected spelling must not collide with the no-operand family" + ); + } + #[test] fn patch_keys_differ_by_path() { let key_a = build_approval_key( diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index d75e421491..ac0d9cf346 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -145,11 +145,11 @@ pub(super) const EXPECTED_HASH_DESCRIPTION: &str = "The `content_hash` from a pr /// value is an error rather than a coin flip, and any parameter that is not a /// known synonym still fails validation. The #5209 guarantee — no fabricated /// "Replaced 1 occurrence" for an edit that never landed — is unchanged. -pub(super) struct ParamAlias { +pub(crate) struct ParamAlias { /// Spelling a model might emit. - alias: &'static str, + pub(crate) alias: &'static str, /// Parameter this tool implements. - canonical: &'static str, + pub(crate) canonical: &'static str, } const fn alias(alias: &'static str, canonical: &'static str) -> ParamAlias { @@ -159,7 +159,7 @@ const fn alias(alias: &'static str, canonical: &'static str) -> ParamAlias { /// Path spellings shared by every file action. `path` is CodeWhale's /// canonical name and the most common one in the field, but `file_path` is /// widespread enough in training data to be worth accepting everywhere. -pub(super) const PATH_ALIASES: &[ParamAlias] = +pub(crate) const PATH_ALIASES: &[ParamAlias] = &[alias("file_path", "path"), alias("filePath", "path")]; /// Edit-specific spellings. Ordered most- to least-common. @@ -238,6 +238,29 @@ pub(super) fn apply_param_aliases( Ok(()) } +/// Read the canonical `path` parameter with every accepted alias spelling, +/// in `apply_param_aliases` fold order: the canonical key wins, then the +/// aliases in declaration order, empty strings skipped. +/// +/// Plan-time gates — repo law, persisted ask/allow/deny rules, the +/// in-workspace write carve-out, Auto-Review — run before execution folds +/// `PATH_ALIASES` onto `path` (the default `ToolSpec::prepare` passes input +/// through unchanged), so each must consult this helper instead of reading +/// `path` alone: a `file_path`- or `filePath`-spelled write was otherwise +/// invisible to every one of them at once (review #484/CodeWhale round-22 +/// B22-2). +pub(crate) fn path_param_value(input: &Value) -> Option { + std::iter::once("path") + .chain(PATH_ALIASES.iter().map(|alias| alias.alias)) + .find_map(|key| { + input + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + // === Per-action parameter contracts === /// The parameter contract for one `File` action. diff --git a/crates/tui/src/tools/revert_turn.rs b/crates/tui/src/tools/revert_turn.rs index f981d9043b..041111fa67 100644 --- a/crates/tui/src/tools/revert_turn.rs +++ b/crates/tui/src/tools/revert_turn.rs @@ -35,7 +35,11 @@ impl ToolSpec for RevertTurnTool { Use when the user explicitly asks to undo, revert, or roll back the most recent edits. \ `turn_offset` is 1-based: 1 reverts the most recent turn, 2 reverts the previous one, \ and so on (max 50). Conversation history is NOT modified — only working-tree files are \ - restored from the side-git snapshot repo." + restored from the side-git snapshot repo. Snapshots cover the primary workspace root \ + only — attached workspace roots are reverted only when the snapshot covers + them (an ordinary nested root tracked by the snapshot reverts with + the primary; excluded directories such as vendor/ and nested + repositories do not — see the boundary note)." } fn input_schema(&self) -> Value { @@ -73,6 +77,8 @@ impl ToolSpec for RevertTurnTool { } let workspace = context.workspace.clone(); + let primary_only = + crate::snapshot::restore_covers_primary_only(&workspace, &context.workspace_roots); let label = format!("revert_turn(offset={offset})"); let session = context.state_namespace.clone(); let result = tokio::task::spawn_blocking(move || -> Result { @@ -115,11 +121,20 @@ impl ToolSpec for RevertTurnTool { } repo.restore(&target.id) .map_err(|e| format!("Restore failed: {e}"))?; - Ok(format!( - "{label}: restored '{}' ({}). Workspace files reverted; conversation unchanged.", - target.label, - short_sha(target.id.as_str()), - )) + Ok(if primary_only { + format!( + "{label}: restored '{}' ({}). {} Conversation unchanged.", + target.label, + short_sha(target.id.as_str()), + crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE, + ) + } else { + format!( + "{label}: restored '{}' ({}). Workspace files reverted; conversation unchanged.", + target.label, + short_sha(target.id.as_str()), + ) + }) }) .await .map_err(|e| ToolError::execution_failed(format!("revert_turn join failed: {e}")))?; @@ -191,6 +206,59 @@ mod tests { let content = std::fs::read_to_string(workspace.join("a.txt")).unwrap(); assert_eq!(content, "original"); + + // Single-root report stays byte-identical: no boundary clause. + assert!( + r.content + .contains("Workspace files reverted; conversation unchanged."), + "{}", + r.content + ); + assert!( + !r.content + .contains(crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE), + "{}", + r.content + ); + } + + #[tokio::test] + async fn revert_turn_with_attached_roots_names_rollback_boundary() { + // Snapshots are primary-bound (M15-3): with attached roots in the + // session set the report must not claim a full rollback — attached + // root writes persist after the revert. + let tmp = tempdir().unwrap(); + let workspace = tmp.path().join("ws"); + let attached = tmp.path().join("attached"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&attached).unwrap(); + let _guard = scoped_home(tmp.path()); + + let repo = SnapshotRepo::open_or_init(&workspace).unwrap(); + std::fs::write(workspace.join("a.txt"), b"original").unwrap(); + repo.snapshot_with_session("pre-turn:1", Some("workspace")) + .unwrap(); + std::fs::write(workspace.join("a.txt"), b"modified").unwrap(); + + let tool = RevertTurnTool; + let ctx = ToolContext::new(workspace.clone()).with_workspace_roots(vec![attached.clone()]); + let r = tool.execute(json!({}), &ctx).await.expect("execute"); + assert!(r.success, "expected success: {r:?}"); + assert_eq!( + std::fs::read_to_string(workspace.join("a.txt")).unwrap(), + "original" + ); + assert!( + r.content + .contains(crate::snapshot::ATTACHED_ROOTS_NOT_REVERTED_NOTE), + "{}", + r.content + ); + assert!( + !r.content.contains("Workspace files reverted"), + "{}", + r.content + ); } #[tokio::test] @@ -268,4 +336,23 @@ mod tests { "current" ); } + + #[test] + fn description_names_the_rollback_boundary_unconditionally() { + // Round-26 M26-4d: the boundary clause in the tool DESCRIPTION is an + // unconditional literal — every session's schema (single-root ones + // included) differs from base, so the description face needs its own + // pin, matching the response-face pins. + // Round-37 M37-1: the description is MODEL-FACING — review + // meta-text must never ride it onto the wire. + let description = RevertTurnTool::description(&RevertTurnTool); + assert!( + description.contains("attached workspace roots"), + "the description must keep naming the rollback boundary: {description}" + ); + assert!( + !description.contains("round-") && !description.contains("recorded"), + "review meta-text must not ship to the model: {description}" + ); + } } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 048a5893ce..c93becb7b8 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -2141,7 +2141,7 @@ impl ShellManager { origin_tool_call_id: Option, origin_turn_id: Option, work_lifecycle: Option, - readonly_workspace: Option<&std::path::Path>, + readonly_roots: Option<&[PathBuf]>, persist_pending: bool, timeout_bounds_ms: (u64, u64), ) -> Result { @@ -2157,7 +2157,7 @@ impl ShellManager { let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone()); // Create command spec and prepare sandboxed environment - let spec = if let Some(workspace) = readonly_workspace { + let spec = if let Some(roots) = readonly_roots { if command.contains('|') { // An agent read-only pipeline: every segment was admitted by // `is_agent_readonly_shell_command` (no separators, redirects, @@ -2168,7 +2168,7 @@ impl ShellManager { CommandSpec::shell(&piped, work_dir.clone(), Duration::from_millis(timeout_ms)) } else { let (program, args) = hardened_readonly_argv(command)?; - let program = resolve_readonly_program(&program, workspace)?; + let program = resolve_readonly_program(&program, roots)?; CommandSpec::program( program .to_str() @@ -4020,9 +4020,39 @@ fn hardened_readonly_argv(command: &str) -> Result<(String, Vec)> { Ok((program, argv)) } +/// Canonical boundary set for the read-only Scout shell: every declared +/// workspace root (primary first), canonicalized. Attached roots carry the +/// same trust weight as the primary — they are agent-writable, so a program +/// or PATH entry under any of them is untrusted, while operands under them +/// were already admitted by the roots-aware `resolve_path`. Roots that do not +/// resolve on disk cannot contain anything and drop out: fail-closed for +/// operand admission, and a non-existent root can shadow neither a PATH entry +/// nor an executable. +fn canonical_readonly_roots(roots: &[PathBuf]) -> std::io::Result> { + let Some((primary, extra)) = roots.split_first() else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "read-only boundary requires at least the primary root", + )); + }; + let mut canonical = vec![primary.canonicalize()?]; + // Round-24 P3 perf: the Vec `contains` made the dedup O(N²) in the + // root-set size — a set keeps the order-preserving first-wins dedup + // linear. Behavior identical (first canonical spelling wins). + let mut seen: std::collections::HashSet = canonical.iter().cloned().collect(); + for root in extra { + if let Ok(resolved) = root.canonicalize() + && seen.insert(resolved.clone()) + { + canonical.push(resolved); + } + } + Ok(canonical) +} + fn enforce_readonly_workspace_operands( command: &str, - workspace: &std::path::Path, + roots: &[PathBuf], effective_cwd: &std::path::Path, ) -> Result<(), ToolError> { let argv = shell_words::split(&normalize_windows_command_paths(command)).map_err(|error| { @@ -4035,7 +4065,7 @@ fn enforce_readonly_workspace_operands( // is pinned and evaluated separately by the network-policy guard. return Ok(()); } - let workspace = workspace.canonicalize().map_err(|error| { + let roots = canonical_readonly_roots(roots).map_err(|error| { ToolError::execution_failed(format!( "Could not resolve the Scout workspace before shell dispatch: {error}" )) @@ -4045,7 +4075,7 @@ fn enforce_readonly_workspace_operands( "Could not prove the read-only shell working directory stays in the workspace: {error}" )) })?; - if !effective_cwd.starts_with(&workspace) { + if !roots.iter().any(|root| effective_cwd.starts_with(root)) { return Err(ToolError::permission_denied( "[shell.readonly.cwd.outside_workspace] Read-only Scout shell working directory resolves outside the workspace.", )); @@ -4093,7 +4123,7 @@ fn enforce_readonly_workspace_operands( "[shell.readonly.operand.unresolved] Could not prove absolute read-only operand {value:?} stays inside the workspace because it could not be resolved: {error}" )) })?; - if !resolved.starts_with(&workspace) { + if !roots.iter().any(|root| resolved.starts_with(root)) { return Err(ToolError::permission_denied(format!( "[shell.readonly.operand.outside_workspace] Read-only Scout shell operand {value:?} resolves outside the workspace. Use the bounded File read/search actions for project evidence." ))); @@ -4121,7 +4151,7 @@ fn enforce_readonly_workspace_operands( "[shell.readonly.operand.unresolved] Could not prove read-only operand {value:?} stays in the workspace: {error}" )) })?; - if !resolved.starts_with(&workspace) { + if !roots.iter().any(|root| resolved.starts_with(root)) { return Err(ToolError::permission_denied(format!( "[shell.readonly.operand.outside_workspace] Read-only Scout shell operand {value:?} resolves outside the workspace. Use the bounded File read/search actions for project evidence." ))); @@ -4132,43 +4162,43 @@ fn enforce_readonly_workspace_operands( } fn readonly_sanitized_path_from( - workspace: &std::path::Path, + roots: &[PathBuf], path: &std::ffi::OsStr, ) -> Option { - let workspace = workspace.canonicalize().ok()?; + let roots = canonical_readonly_roots(roots).ok()?; let safe = std::env::split_paths(path).filter_map(|entry| { if !entry.is_absolute() { return None; } let resolved = entry.canonicalize().ok()?; - (!resolved.starts_with(&workspace)).then_some(resolved) + (!roots.iter().any(|root| resolved.starts_with(root))).then_some(resolved) }); std::env::join_paths(safe).ok() } -fn readonly_sanitized_path(workspace: &std::path::Path) -> Option { +fn readonly_sanitized_path(roots: &[PathBuf]) -> Option { let path = std::env::var_os("PATH")?; - readonly_sanitized_path_from(workspace, &path).map(|value| value.to_string_lossy().into_owned()) + readonly_sanitized_path_from(roots, &path).map(|value| value.to_string_lossy().into_owned()) } -fn resolve_readonly_program(program: &str, workspace: &std::path::Path) -> Result { +fn resolve_readonly_program(program: &str, roots: &[PathBuf]) -> Result { let path = std::env::var_os("PATH") .ok_or_else(|| anyhow!("no executable search path is configured"))?; - resolve_readonly_program_from_path(program, workspace, &path) + resolve_readonly_program_from_path(program, roots, &path) } fn resolve_readonly_program_from_path( program: &str, - workspace: &std::path::Path, + roots: &[PathBuf], path: &std::ffi::OsStr, ) -> Result { - let workspace = workspace.canonicalize()?; + let roots = canonical_readonly_roots(roots)?; if std::path::Path::new(program).components().count() != 1 { return Err(anyhow!( "read-only command must name a bare allowlisted executable" )); } - let safe_path = readonly_sanitized_path_from(&workspace, path).ok_or_else(|| { + let safe_path = readonly_sanitized_path_from(&roots, path).ok_or_else(|| { anyhow!("no trusted executable search path remains outside the workspace") })?; let names = if cfg!(windows) { @@ -4190,7 +4220,7 @@ fn resolve_readonly_program_from_path( } } let resolved = candidate.canonicalize()?; - if resolved.is_absolute() && !resolved.starts_with(&workspace) { + if resolved.is_absolute() && !roots.iter().any(|root| resolved.starts_with(root)) { return Ok(resolved); } } @@ -4288,6 +4318,12 @@ async fn execute_foreground_via_background( let timeout_ms = timeout_ms.map(|timeout| timeout.clamp(timeout_bounds_ms.0, timeout_bounds_ms.1)); let spawn_timeout_ms = timeout_ms.unwrap_or(timeout_bounds_ms.1); + // The read-only launch boundary is the full declared root set (primary + // first), so program resolution and PATH sanitization distrust every + // agent-writable root, not just the primary. + let readonly_roots = direct_argv.then(|| { + codewhale_core::normalize_workspace_roots(&context.workspace, &context.workspace_roots) + }); let spawned = { let mut manager = context .shell_manager @@ -4310,7 +4346,7 @@ async fn execute_foreground_via_background( context.origin_tool_call_id.clone(), context.origin_turn_id.clone(), lifecycle, - direct_argv.then_some(context.workspace.as_path()), + readonly_roots.as_deref(), false, timeout_bounds_ms, )? @@ -5057,19 +5093,25 @@ impl ToolSpec for BashTool { // shared ShellManager's parent-workspace default_workspace. None => Some(context.workspace.display().to_string()), }; - if matches!(context.shell_policy, ShellPolicy::ReadOnly) { + let read_only_shell = matches!(context.shell_policy, ShellPolicy::ReadOnly); + // The read-only boundary is the full declared root set, not just the + // primary: attached roots are agent-writable (untrusted for programs + // and PATH) yet legitimate operand/cwd targets. + let readonly_roots = read_only_shell.then(|| { + codewhale_core::normalize_workspace_roots(&context.workspace, &context.workspace_roots) + }); + if let Some(roots) = readonly_roots.as_deref() { let effective_cwd = working_dir .as_deref() .map(std::path::Path::new) .unwrap_or(&context.workspace); - enforce_readonly_workspace_operands(command, &context.workspace, effective_cwd)?; + enforce_readonly_workspace_operands(command, roots, effective_cwd)?; } // #456 — collect env from any configured `shell_env` hooks. Runs // synchronously, captures stdout, parses `KEY=VAL` lines, audit-logs // the keys (never the values). Empty / no-op when no hook is // configured. - let read_only_shell = matches!(context.shell_policy, ShellPolicy::ReadOnly); let mut extra_env = if read_only_shell { // shell_env hooks are arbitrary operator-configured processes. // They cannot run inside the evidence-only execution boundary. @@ -5124,7 +5166,7 @@ impl ToolSpec for BashTool { inert_git_helper.to_string(), ); extra_env.insert("GIT_ATTR_NOSYSTEM".to_string(), "1".to_string()); - if let Some(path) = readonly_sanitized_path(&context.workspace) { + if let Some(path) = readonly_sanitized_path(readonly_roots.as_deref().unwrap_or(&[])) { extra_env.insert("PATH".to_string(), path); } extra_env.insert("GIT_CONFIG_COUNT".to_string(), "3".to_string()); diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 9f5bb83e56..62b82fe04c 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -951,6 +951,7 @@ fn readonly_program_resolution_ignores_workspace_shadow_executables() { let workspace = tempdir().expect("workspace"); let trusted = tempdir().expect("trusted bin"); let path = std::env::join_paths([workspace.path(), trusted.path()]).expect("test PATH"); + let roots = [workspace.path().to_path_buf()]; for program in ["git", "gh", "rg"] { let file = if cfg!(windows) { @@ -970,12 +971,55 @@ fn readonly_program_resolution_ignores_workspace_shadow_executables() { } } let resolved = - resolve_readonly_program_from_path(program, workspace.path(), &path).expect("resolved"); + resolve_readonly_program_from_path(program, &roots, &path).expect("resolved"); assert_eq!(resolved, trusted.path().join(file).canonicalize().unwrap()); assert!(resolved.is_absolute() && !resolved.starts_with(workspace.path())); } } +/// B14-1: an attached root is agent-writable, so an executable planted inside +/// it must shadow nothing and its PATH entries must be stripped — exactly +/// like the primary root. Judging only the primary here fails open. +#[cfg(any(unix, windows))] +#[test] +fn forkguard_workspace_roots_readonly_shell_distrusts_attached_root_programs() { + let workspace = tempdir().expect("workspace"); + let attached = tempdir().expect("attached root"); + let trusted = tempdir().expect("trusted bin"); + let path = std::env::join_paths([workspace.path(), attached.path(), trusted.path()]) + .expect("test PATH"); + let roots = [ + workspace.path().to_path_buf(), + attached.path().to_path_buf(), + ]; + + let file = if cfg!(windows) { "git.exe" } else { "git" }; + for directory in [workspace.path(), attached.path(), trusted.path()] { + let executable = directory.join(file); + std::fs::write(&executable, b"fixture").expect("fixture executable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mut permissions = executable.metadata().unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + } + } + + let resolved = resolve_readonly_program_from_path("git", &roots, &path).expect("resolved"); + assert_eq!(resolved, trusted.path().join(file).canonicalize().unwrap()); + + // A program that exists only inside an attached root fails closed. + let attached_only = std::env::join_paths([attached.path()]).expect("attached-only PATH"); + resolve_readonly_program_from_path("git", &roots, &attached_only) + .expect_err("a program inside an attached root must not be trusted"); + + // PATH entries under attached roots are stripped from the sanitized set. + let sanitized = readonly_sanitized_path_from(&roots, &path).expect("sanitized PATH"); + let kept: Vec<_> = std::env::split_paths(&sanitized).collect(); + assert_eq!(kept, vec![trusted.path().canonicalize().unwrap()]); +} + #[test] fn readonly_child_env_removes_git_and_github_redirects() { let mut command = std::process::Command::new("unused"); @@ -1015,8 +1059,9 @@ fn readonly_operands_are_workspace_bounded_and_symlink_aware() { let outside = tempdir().expect("outside"); std::fs::write(workspace.path().join("inside.txt"), "inside").expect("inside file"); std::fs::write(outside.path().join("secret.txt"), "secret").expect("outside file"); + let roots = [workspace.path().to_path_buf()]; - enforce_readonly_workspace_operands("cat inside.txt", workspace.path(), workspace.path()) + enforce_readonly_workspace_operands("cat inside.txt", &roots, workspace.path()) .expect("in-workspace operand"); let inside_absolute = workspace .path() @@ -1025,7 +1070,7 @@ fn readonly_operands_are_workspace_bounded_and_symlink_aware() { .expect("canonical inside file"); enforce_readonly_workspace_operands( &format!("cat {}", inside_absolute.display()), - workspace.path(), + &roots, workspace.path(), ) .expect("absolute in-workspace operand"); @@ -1037,7 +1082,7 @@ fn readonly_operands_are_workspace_bounded_and_symlink_aware() { .expect("canonical outside file"); let error = enforce_readonly_workspace_operands( &format!("cat {}", outside_absolute.display()), - workspace.path(), + &roots, workspace.path(), ) .expect_err("absolute outside operand must fail") @@ -1052,10 +1097,9 @@ fn readonly_operands_are_workspace_bounded_and_symlink_aware() { r"cat C:\secret", r"cat \\server\share\secret", ] { - let error = - enforce_readonly_workspace_operands(command, workspace.path(), workspace.path()) - .expect_err("out-of-workspace operand must fail") - .to_string(); + let error = enforce_readonly_workspace_operands(command, &roots, workspace.path()) + .expect_err("out-of-workspace operand must fail") + .to_string(); assert!(error.contains("inside the workspace"), "{command}: {error}"); } @@ -1066,13 +1110,10 @@ fn readonly_operands_are_workspace_bounded_and_symlink_aware() { workspace.path().join("secret-link"), ) .expect("outside symlink"); - let error = enforce_readonly_workspace_operands( - "cat secret-link", - workspace.path(), - workspace.path(), - ) - .expect_err("symlink escape must fail") - .to_string(); + let error = + enforce_readonly_workspace_operands("cat secret-link", &roots, workspace.path()) + .expect_err("symlink escape must fail") + .to_string(); assert!(error.contains("resolves outside"), "{error}"); let subdir = workspace.path().join("subdir"); @@ -1082,11 +1123,66 @@ fn readonly_operands_are_workspace_bounded_and_symlink_aware() { subdir.join("secret-link"), ) .expect("cwd-relative outside symlink"); - enforce_readonly_workspace_operands("cat secret-link", workspace.path(), &subdir) + enforce_readonly_workspace_operands("cat secret-link", &roots, &subdir) .expect_err("operands must resolve relative to the effective cwd"); } } +/// B14-1: the operand/cwd gate judges against the full declared root set. +/// Operands and working directories under an attached root were already +/// admitted by the roots-aware `resolve_path`; refusing them here would +/// contradict the session's own declaration, while anything outside every +/// root stays refused. +#[test] +fn forkguard_workspace_roots_readonly_shell_operands_span_attached_roots() { + let workspace = tempdir().expect("workspace"); + let attached = tempdir().expect("attached root"); + let outside = tempdir().expect("outside"); + std::fs::write(attached.path().join("shared.txt"), "shared").expect("attached file"); + std::fs::write(outside.path().join("secret.txt"), "secret").expect("outside file"); + let roots = [ + workspace.path().to_path_buf(), + attached.path().to_path_buf(), + ]; + + // The working directory may sit under an attached root, and relative + // operands resolve there. + enforce_readonly_workspace_operands("cat shared.txt", &roots, attached.path()) + .expect("operand under an attached root"); + + let attached_absolute = attached + .path() + .join("shared.txt") + .canonicalize() + .expect("canonical attached file"); + enforce_readonly_workspace_operands( + &format!("cat {}", attached_absolute.display()), + &roots, + workspace.path(), + ) + .expect("absolute operand under an attached root"); + + // A directory outside every declared root stays refused. + let outside_absolute = outside + .path() + .join("secret.txt") + .canonicalize() + .expect("canonical outside file"); + let error = enforce_readonly_workspace_operands( + &format!("cat {}", outside_absolute.display()), + &roots, + workspace.path(), + ) + .expect_err("operand outside every root must fail") + .to_string(); + assert!(error.contains("operand.outside_workspace"), "{error}"); + + // A working directory outside every root stays refused even when the + // operand itself sits under one. + enforce_readonly_workspace_operands("cat shared.txt", &roots, outside.path()) + .expect_err("cwd outside every root must fail"); +} + #[test] fn windows_verbatim_and_drive_operands_survive_posix_split() { // `shell_words` splits with POSIX backslash-escaping, which silently eats diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 179f5833b9..d5d5fede59 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use std::fs; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::SystemTime; @@ -603,6 +603,22 @@ pub struct ToolExecutionState { pub(crate) tool_authority: Option>, /// Whether to allow paths outside workspace pub trust_mode: bool, + /// Additional workspace roots tools may touch; `workspace` is always the + /// primary root. Empty means the historical single-root boundary. + pub workspace_roots: Vec, + /// Round-24 P3 perf memo for `boundary_roots()`: the normalized set plus + /// each root's canonical spelling, canonicalizing once per context + /// lifetime instead of once per `resolve_path` call (several judgments + /// ride every tool call). Invariant: `workspace` and `workspace_roots` + /// change ONLY through [`ToolContext::rebase_roots`] (which swaps this + /// Arc for a fresh one) or at construction — clones share the Arc, which + /// is sound only while the pair stays identical. Round-26 B26-1 closed + /// the regression this comment's earlier "no in-place mutation" survey + /// missed: the worktree spawn/resume sites and the per-turn live context + /// used to assign the fields in place on a clone, so a parent-filled + /// memo kept judging the CHILD's file-tool containment against the + /// PARENT boundary (fail-open for worktree isolation). + pub(crate) boundary_roots_cache: std::sync::Arc>>, /// Current sandbox policy #[allow(dead_code)] pub sandbox_policy: SandboxPolicy, @@ -779,6 +795,8 @@ impl ToolContext { origin_turn_id: None, tool_authority, trust_mode, + workspace_roots: Vec::new(), + boundary_roots_cache: std::sync::Arc::new(std::sync::OnceLock::new()), sandbox_policy: SandboxPolicy::None, notes_path: notes_path.into(), mcp_config_path: mcp_config_path.into(), @@ -834,6 +852,37 @@ impl ToolContext { self } + /// Attach the session's additional workspace roots. The primary root + /// stays `workspace`; boundary checks accept a path contained in any + /// root. An empty set keeps the historical single-root boundary. + #[must_use] + pub fn with_workspace_roots(mut self, workspace_roots: Vec) -> Self { + self.workspace_roots = workspace_roots; + // Fresh memo: the boundary must never judge the previous set's + // canonical spellings (see the field's invariant note). + self.boundary_roots_cache = std::sync::Arc::new(std::sync::OnceLock::new()); + self + } + + /// Replace the (primary workspace, attached roots) pair on a LIVE + /// context and refresh the boundary memo atomically (round-26 B26-1). + /// + /// Cloned contexts share the parent's memo `Arc`; assigning the two + /// fields in place used to leave that shared memo filled with the + /// PARENT's boundary, so the child's file-tool containment judged + /// against the parent workspace plus its attached roots — for a + /// worktree child (whose boundary is the worktree alone) that passed + /// containment for the entire parent checkout: fail-open for exactly + /// the isolation the worktree contract names. Every site that moves a + /// context onto a different primary or root set MUST go through here; + /// the memo swap is not optional. Callers that change the roots derive + /// them into the sandbox policy separately (`rederive_sandbox_policy_roots`). + pub(crate) fn rebase_roots(&mut self, workspace: PathBuf, workspace_roots: Vec) { + self.workspace = workspace; + self.workspace_roots = workspace_roots; + self.boundary_roots_cache = std::sync::Arc::new(std::sync::OnceLock::new()); + } + /// Attach durable runtime services to tools. #[must_use] pub fn with_runtime_services(mut self, runtime: RuntimeToolServices) -> Self { @@ -1058,6 +1107,27 @@ impl ToolContext { /// let path = ctx.resolve_path("README.md")?; /// # Ok::<(), crate::tools::spec::ToolError>(()) /// ``` + /// Boundary roots for escape checks: the primary workspace followed by + /// any additional roots, each paired with its canonical (or raw fallback) + /// form. With no additional roots configured this is exactly the primary + /// root, so single-root sessions take the historical code path. + fn boundary_roots(&self) -> &[(PathBuf, PathBuf)] { + // Round-24 P3 perf: memoized per context (roots are lifetime- + // immutable, see the cache field's invariant note). The canonicalize + // chain per root used to run on every resolve_path call — several + // per tool turn — which on a 64-root session was hundreds of + // realpath syscalls per turn for an unchanged set. + self.boundary_roots_cache.get_or_init(|| { + codewhale_core::normalize_workspace_roots(&self.workspace, &self.workspace_roots) + .into_iter() + .map(|root| { + let canonical = root.canonicalize().unwrap_or_else(|_| root.clone()); + (root, canonical) + }) + .collect() + }) + } + pub fn resolve_path(&self, raw: &str) -> Result { let candidate = if std::path::Path::new(raw).is_absolute() { PathBuf::from(raw) @@ -1071,33 +1141,30 @@ impl ToolContext { return Ok(candidate.canonicalize().unwrap_or(candidate)); } - // Try to canonicalize the workspace - let workspace_canonical = self - .workspace - .canonicalize() - .unwrap_or_else(|_| self.workspace.clone()); + let boundary_roots = self.boundary_roots(); + let candidate_normalized = normalize_path(&candidate); // When follow_symlinks is enabled, check the non-canonical (symlink) - // path against the workspace first. A symlink inside the workspace - // that resolves outside is allowed — the symlink itself is the gate. + // path against each root first. A symlink inside a root that resolves + // outside is allowed — the symlink itself is the gate. if self.follow_symlinks { - let candidate_normalized = normalize_path(&candidate); - let workspace_normalized = normalize_path(&self.workspace); - let workspace_canonical_normalized = normalize_path(&workspace_canonical); - - if candidate_normalized.starts_with(&workspace_normalized) - || candidate_normalized.starts_with(&workspace_canonical_normalized) - { - // The symlink (or plain path) is inside the workspace. - // Return the canonicalized target so file I/O works correctly. - if candidate.exists() { - return Ok(candidate.canonicalize().unwrap_or(candidate)); + for (root, root_canonical) in boundary_roots { + let root_normalized = normalize_path(root); + let root_canonical_normalized = normalize_path(root_canonical); + if candidate_normalized.starts_with(&root_normalized) + || candidate_normalized.starts_with(&root_canonical_normalized) + { + // The symlink (or plain path) is inside this root. + // Return the canonicalized target so file I/O works correctly. + if candidate.exists() { + return Ok(candidate.canonicalize().unwrap_or(candidate)); + } + // Non-existent path: canonicalize the deepest existing ancestor + return self.resolve_nonexistent_path(candidate, root_canonical); } - // Non-existent path: canonicalize the deepest existing ancestor - return self.resolve_nonexistent_path(candidate, &workspace_canonical); } - // Path is outside workspace even before resolving symlinks. + // Path is outside every root even before resolving symlinks. // Fall through to the standard escape check. } @@ -1105,23 +1172,21 @@ impl ToolContext { // This handles symlinks like /var -> /private/var on macOS let candidate_canonical = candidate .canonicalize() - .unwrap_or_else(|_| normalize_path(&candidate)); - let workspace_normalized = normalize_path(&workspace_canonical); - - // Check if the candidate is under the workspace (comparing canonical paths) - if !candidate_canonical.starts_with(&workspace_normalized) { - // Also try with non-canonical workspace for cases where workspace itself - // hasn't been canonicalized yet - let workspace_plain = normalize_path(&self.workspace); - let candidate_normalized = normalize_path(&candidate); - if !candidate_normalized.starts_with(&workspace_plain) - && !self.is_trusted_external_path(&candidate_canonical) - && !self.is_trusted_external_path(&candidate_normalized) - { - return Err(ToolError::PathEscape { - path: candidate_canonical, - }); - } + .unwrap_or_else(|_| candidate_normalized.clone()); + + // The candidate must sit under at least one root (comparing canonical + // paths, then plain paths for roots that do not canonicalize yet). + let contained = boundary_roots.iter().any(|(root, root_canonical)| { + candidate_canonical.starts_with(normalize_path(root_canonical)) + || candidate_normalized.starts_with(normalize_path(root)) + }); + if !contained + && !self.is_trusted_external_path(&candidate_canonical) + && !self.is_trusted_external_path(&candidate_normalized) + { + return Err(ToolError::PathEscape { + path: candidate_canonical, + }); } // For existing paths, use canonicalize directly @@ -1134,21 +1199,40 @@ impl ToolContext { )) })?; - if !canonical.starts_with(&workspace_canonical) - && !self.is_trusted_external_path(&canonical) - { + let under_root = boundary_roots + .iter() + .any(|(_, root_canonical)| canonical.starts_with(root_canonical)); + if !under_root && !self.is_trusted_external_path(&canonical) { return Err(ToolError::PathEscape { path: canonical }); } return Ok(canonical); } - self.resolve_nonexistent_path(candidate, &workspace_canonical) + // Non-existent path: resolve against the containing root's boundary; + // `resolve_nonexistent_path` re-checks trusted external paths itself. + let boundary = boundary_roots + .iter() + .find(|(root, root_canonical)| { + candidate_canonical.starts_with(normalize_path(root_canonical)) + || candidate_normalized.starts_with(normalize_path(root)) + }) + .map(|(_, root_canonical)| root_canonical.clone()) + .unwrap_or_else(|| { + self.workspace + .canonicalize() + .unwrap_or_else(|_| self.workspace.clone()) + }); + self.resolve_nonexistent_path(candidate, &boundary) } /// Resolve a non-existent path by canonicalizing its deepest existing /// ancestor and validating the result is under the workspace or a - /// trusted external path. + /// trusted external path. Round-45 R45-4: the walk itself is delegated + /// to the single core implementation (`codewhale_core::walk_deepest_existing`, + /// DeepestAncestor fallback) so this lane can never drift from the + /// intake gate's copy again; the local `normalize_path` layer stays + /// because the containment comparisons below consume its spellings. fn resolve_nonexistent_path( &self, candidate: PathBuf, @@ -1156,39 +1240,15 @@ impl ToolContext { ) -> Result { let workspace_normalized = normalize_path(workspace_canonical); let workspace_plain = normalize_path(&self.workspace); - let mut existing_ancestor = candidate.clone(); - let mut suffix_parts: Vec = Vec::new(); - - while !existing_ancestor.exists() { - if let Some(file_name) = existing_ancestor.file_name() { - suffix_parts.push(file_name.to_owned()); - } - match existing_ancestor.parent() { - Some(parent) if !parent.as_os_str().is_empty() => { - existing_ancestor = parent.to_path_buf(); - } - _ => { - // No existing parent found; fall back to simple check - break; - } - } - } - let ancestor_normalized = normalize_path(&existing_ancestor); - - let canonical_ancestor = if existing_ancestor.exists() { - existing_ancestor - .canonicalize() - .unwrap_or(existing_ancestor) - } else { - existing_ancestor - }; - - // Rebuild the full path from canonicalized ancestor - let mut canonical = canonical_ancestor; - for part in suffix_parts.into_iter().rev() { - canonical.push(part); - } - let canonical = normalize_path(&canonical); + let walked = codewhale_core::walk_deepest_existing( + &candidate, + codewhale_core::DeepestExistingFallback::DeepestAncestor, + ); + // The leniency fast path compares the RAW walked spelling (a + // workspace-local symlink name) against the raw workspace — the + // canonicalized ancestor feeds the composed containment path only. + let ancestor_normalized = normalize_path(&walked.existing); + let canonical = normalize_path(&walked.compose()); if self.follow_symlinks && (ancestor_normalized.starts_with(&workspace_plain) @@ -1197,11 +1257,22 @@ impl ToolContext { return Ok(canonical); } - // Validate it's under workspace, OR is under a user-trusted external - // path (`/trust add ` from the slash command, persisted in - // `~/.deepseek/workspace-trust.json`). + // Validate it's under workspace, under ANY declared root, OR under a + // user-trusted external path (`/trust add ` from the slash + // command, persisted in `~/.deepseek/workspace-trust.json`). + // Round-43 P2-1: a new target reached through an ALIAS spelling of + // another declared root (primary/link → attached-root/existing, then + // creating link/new.txt) must resolve like the direct spelling — the + // deepest-existing-ancestor walk lands the rebuilt path under the + // attached root, and the containment check accepts every declared + // root's canonical form, not only the workspace pair. + let under_any_declared_root = self + .boundary_roots() + .iter() + .any(|(_, root_canonical)| canonical.starts_with(root_canonical)); if !canonical.starts_with(workspace_canonical) && !canonical.starts_with(&workspace_normalized) + && !under_any_declared_root && !self.is_trusted_external_path(&canonical) { return Err(ToolError::PathEscape { path: canonical }); @@ -1319,48 +1390,13 @@ pub async fn lsp_diagnostics_for_paths(context: &ToolContext, paths: &[PathBuf]) render_blocks(&blocks) } +/// The tools boundary's landing normalizer: clamps a `..` at the filesystem +/// root, keeps a `..` a relative spelling cannot pop. Delegates to the shared +/// core implementation so the judgment lanes (repo law, the judged exec cwd) +/// normalize identically by construction instead of by copy +/// (review #484/CodeWhale round-22 B22-5). pub(crate) fn normalize_path(path: &Path) -> PathBuf { - let mut prefix: Option = None; - let mut is_root = false; - let mut stack: Vec = Vec::new(); - - for component in path.components() { - match component { - Component::Prefix(prefix_component) => { - prefix = Some(prefix_component.as_os_str().to_owned()); - } - Component::RootDir => { - is_root = true; - } - Component::CurDir => {} - Component::ParentDir => { - let parent = Component::ParentDir.as_os_str(); - if let Some(last) = stack.pop() { - if last == parent { - stack.push(last); - stack.push(parent.to_owned()); - } - } else if !is_root { - stack.push(parent.to_owned()); - } - } - Component::Normal(part) => { - stack.push(part.to_owned()); - } - } - } - - let mut normalized = PathBuf::new(); - if let Some(prefix) = prefix { - normalized.push(prefix); - } - if is_root { - normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR)); - } - for part in stack { - normalized.push(part); - } - normalized + codewhale_core::normalize_path_lexically(path) } /// The core trait that all tools must implement. diff --git a/crates/tui/src/tools/spec/tests.rs b/crates/tui/src/tools/spec/tests.rs index 120e86a91f..b56a47e5c0 100644 --- a/crates/tui/src/tools/spec/tests.rs +++ b/crates/tui/src/tools/spec/tests.rs @@ -4,6 +4,45 @@ use serde_json::json; use std::os::unix::fs::symlink; use tempfile::tempdir; +#[test] +#[cfg(unix)] +fn new_target_through_an_alias_of_another_declared_root_resolves() { + // Round-43 P2-1: primary/link is a symlink into the attached root + // (a declared root). Creating link/new.txt — a target that does not + // exist yet, spelled through the alias — must resolve like the + // direct attached-root/new.txt spelling: the deepest-existing + // walk lands under the attached root and the containment check + // accepts every declared root, not only the workspace pair. + let temp = tempdir().expect("tempdir"); + let primary = temp.path().join("primary"); + let attached = temp.path().join("attached"); + std::fs::create_dir_all(&primary).expect("primary"); + std::fs::create_dir_all(&attached).expect("attached root"); + let alias = primary.join("link"); + symlink(&attached, &alias).expect("alias into the attached root"); + + let context = ToolContext::new(primary.clone()) + .with_workspace_roots(vec![primary.clone(), attached.clone()]); + let resolved = context + .resolve_path(alias.join("new.txt").to_str().expect("utf8")) + .expect("a new target inside a declared root resolves through the alias spelling"); + let normalized = normalize_path(&resolved); + // R44-2: the expected prefix canonicalizes through the same walk — on + // macOS the tempdir's /var spelling resolves to /private/var and + // comparing a canonical result against the raw spelling fails. + let attached_real = attached.canonicalize().unwrap_or_else(|_| attached.clone()); + assert!( + normalized.starts_with(normalize_path(&attached_real)), + "the alias-spelled creation resolves under the attached root: {normalized:?}" + ); + + // The direct spelling resolves to the same containment. + let direct = context + .resolve_path(attached.join("new.txt").to_str().expect("utf8")) + .expect("the direct spelling resolves"); + assert_eq!(normalize_path(&direct), normalized); +} + #[test] fn test_tool_result_success() { let result = ToolResult::success("hello"); @@ -74,6 +113,144 @@ fn test_tool_context_resolve_path_normalizes_parent() { assert!(result.is_ok()); } +#[test] +fn test_tool_context_resolve_path_allows_additional_workspace_roots() { + let workspace = tempdir().expect("workspace tempdir"); + let shared = tempdir().expect("shared root tempdir"); + std::fs::write(shared.path().join("lib.rs"), "// shared\n").expect("write"); + let ctx = ToolContext::new(workspace.path().to_path_buf()) + .with_workspace_roots(vec![shared.path().to_path_buf()]); + + // Existing and not-yet-existing files under an additional root resolve. + let existing = ctx + .resolve_path(shared.path().join("lib.rs").to_string_lossy().as_ref()) + .expect("existing file under additional root"); + assert!(existing.ends_with("lib.rs")); + let created = ctx + .resolve_path( + shared + .path() + .join("new/dir/file.rs") + .to_string_lossy() + .as_ref(), + ) + .expect("new file under additional root"); + assert!(created.ends_with("file.rs")); + + // A genuine escape is still denied, and the same path is denied when the + // root set is empty (the historical single-root boundary). + let escape = ctx.resolve_path("/etc/passwd"); + assert!(matches!(escape, Err(ToolError::PathEscape { .. }))); + let single_root = ToolContext::new(workspace.path().to_path_buf()); + let denied = single_root.resolve_path(shared.path().join("lib.rs").to_string_lossy().as_ref()); + assert!(matches!(denied, Err(ToolError::PathEscape { .. }))); +} + +#[test] +fn test_tool_context_resolve_path_empty_string_root_stays_fail_closed() { + // Regression pin: an empty-string root accepted at intake used to reach + // boundary_roots() as ("", "") — Path::starts_with("") is true for every + // path, so both containment checks passed and read_file (approval Auto, + // no prompt in any posture) could read arbitrary filesystem paths. + let workspace = tempdir().expect("workspace tempdir"); + let ctx = ToolContext::new(workspace.path().to_path_buf()) + .with_workspace_roots(vec![PathBuf::from("")]); + + let escape = ctx.resolve_path("/etc/passwd"); + assert!( + matches!(escape, Err(ToolError::PathEscape { .. })), + "an empty-string declared root must not null containment" + ); + // The primary root still works: the filter drops the poison entry, not + // the set. + let inside = workspace.path().join("ok.txt"); + std::fs::write(&inside, "ok").expect("write"); + assert!(ctx.resolve_path(inside.to_string_lossy().as_ref()).is_ok()); +} + +#[test] +fn forkguard_workspace_roots_resolve_path_spans_attached_roots() { + let workspace = tempdir().expect("workspace tempdir"); + let attached = tempdir().expect("attached root tempdir"); + std::fs::write(attached.path().join("lib.rs"), "// attached\n").expect("write"); + let ctx = ToolContext::new(workspace.path().to_path_buf()) + .with_workspace_roots(vec![attached.path().to_path_buf()]); + + // Attached root: existing and not-yet-existing targets resolve. + assert!( + ctx.resolve_path(attached.path().join("lib.rs").to_string_lossy().as_ref()) + .is_ok() + ); + assert!( + ctx.resolve_path( + attached + .path() + .join("new/file.rs") + .to_string_lossy() + .as_ref() + ) + .is_ok() + ); + + // A path outside every root still fails closed. + assert!(matches!( + ctx.resolve_path("/etc/passwd"), + Err(ToolError::PathEscape { .. }) + )); + + // Empty root set = the exact historical single-root boundary: the same + // attached-root path is rejected. + let single_root = ToolContext::new(workspace.path().to_path_buf()); + assert!(matches!( + single_root.resolve_path(attached.path().join("lib.rs").to_string_lossy().as_ref()), + Err(ToolError::PathEscape { .. }) + )); +} + +#[test] +fn forkguard_workspace_roots_relative_target_resolves_against_primary_root() { + // Execution-layer pin: a relative target joins onto the PRIMARY root + // even when attached roots exist (ToolContext::resolve_path), never + // onto an attached root that happens to carry the same name. The + // approval layers rely on exactly this join. + let workspace = tempdir().expect("workspace tempdir"); + let attached = tempdir().expect("attached root tempdir"); + std::fs::write(workspace.path().join("note.txt"), "primary").expect("write"); + std::fs::write(attached.path().join("note.txt"), "attached").expect("write"); + let ctx = ToolContext::new(workspace.path().to_path_buf()) + .with_workspace_roots(vec![attached.path().to_path_buf()]); + + let resolved = ctx.resolve_path("note.txt").expect("resolve"); + assert_eq!( + resolved, + workspace + .path() + .join("note.txt") + .canonicalize() + .expect("canonical"), + "a relative target must resolve against the primary root" + ); +} + +#[cfg(unix)] +#[test] +fn test_tool_context_resolve_path_follow_symlinks_spans_additional_roots() { + let workspace = tempdir().expect("workspace tempdir"); + let shared = tempdir().expect("shared root tempdir"); + let outside = tempdir().expect("outside tempdir"); + symlink(outside.path(), shared.path().join("link-out")).expect("symlink"); + let ctx = ToolContext::new(workspace.path().to_path_buf()) + .with_workspace_roots(vec![shared.path().to_path_buf()]) + .with_follow_symlinks(true); + + // In follow-symlinks mode the symlink's location is the gate; a link + // inside an additional root is accepted just like one in the primary. + let resolved = ctx + .resolve_path(shared.path().join("link-out").to_string_lossy().as_ref()) + .expect("symlink inside additional root"); + assert!(resolved.ends_with("link-out") || resolved == outside.path().canonicalize().unwrap()); +} + #[test] fn test_tool_context_trust_mode() { let tmp = tempdir().expect("tempdir"); @@ -526,3 +703,79 @@ fn test_approval_requirement_default() { let level = ApprovalRequirement::default(); assert_eq!(level, ApprovalRequirement::Auto); } + +#[test] +fn rebased_roots_drop_the_parent_filled_boundary_memo() { + // Round-26 B26-1: a cloned context shares the parent's boundary memo + // Arc. The worktree spawn/resume sites used to clear/replace the root + // pair IN PLACE, so a parent that had already called resolve_path + // (filling the memo with the PARENT's boundary) left the child judging + // file-tool containment against the parent workspace plus its attached + // roots — the child could read the whole parent checkout through the + // exact isolation the worktree contract promises. `rebase_roots` swaps + // the pair AND the memo; this pin fills the parent memo first, exactly + // the shape the production probes showed was falsely covered before. + let base = tempfile::tempdir().expect("base tempdir"); + let parent_ws = base.path().join("parent-ws"); + std::fs::create_dir_all(&parent_ws).expect("parent dir"); + let attached = base.path().join("attached"); + std::fs::create_dir_all(&attached).expect("attached dir"); + let secret = attached.join("secret.txt"); + std::fs::write(&secret, "x").expect("secret"); + + // Parent: primary + attached root; one resolve_path FILLS the memo. + let parent = ToolContext::new(parent_ws.clone()).with_workspace_roots(vec![attached.clone()]); + let ok = parent + .resolve_path(secret.to_string_lossy().as_ref()) + .expect("the attached root admits the path for the parent"); + // M27-1: resolve_path canonicalizes its return — compare against the + // canonical spelling so the pin survives a symlinked temp root (macOS + // /var vs /private/var), instead of aborting before the isolation + // assertions on every stock macOS box. + let attached_canonical = attached.canonicalize().expect("canonical attached"); + assert!(ok.starts_with(&attached_canonical)); + + // Child: a clone of the parent runtime's context, rebased onto a fresh + // worktree (the production spawn/resume shape). + let worktree = base.path().join("wt"); + std::fs::create_dir_all(&worktree).expect("worktree dir"); + let mut child = parent.clone(); + child.rebase_roots(worktree.clone(), Vec::new()); + + let inside = worktree.join("file.txt"); + std::fs::write(&inside, "x").expect("inside file"); + assert!( + child + .resolve_path(inside.to_string_lossy().as_ref()) + .is_ok(), + "the rebased child still resolves inside its own worktree" + ); + assert!( + matches!( + child.resolve_path(secret.to_string_lossy().as_ref()), + Err(ToolError::PathEscape { .. }) + ), + "the parent-filled memo must NOT survive the rebase: the parent's attached root is outside the child boundary" + ); + assert!( + matches!( + child.resolve_path( + parent_ws + .join("src") + .join("lib.rs") + .to_string_lossy() + .as_ref() + ), + Err(ToolError::PathEscape { .. }) + ), + "the parent's own workspace is outside the worktree child's boundary" + ); + + // Control: the untouched parent still admits the attached root (the + // rebase did not mutate the shared-clone parent's judgment). + assert!( + parent + .resolve_path(secret.to_string_lossy().as_ref()) + .is_ok() + ); +} diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 18f03dae1d..cc9a107193 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -3353,6 +3353,17 @@ pub struct SubAgentManager { pending_follow_ups: HashMap>, /// Test/observability: agent ids that received a live wake via followup. woken_agents: HashMap, + /// Test/observability: the workspace root set each spawned child was + /// launched with, keyed by child agent id. `ToolContext::boundary_roots` + /// and the gate's sandbox policy both materialize from this set. + #[cfg(test)] + spawned_workspace_roots: HashMap>, + /// Test/observability: the exec-lane sandbox policy each spawned child + /// was launched with, keyed by child agent id. The policy is cloned from + /// the parent, so a worktree child must re-derive it from its cleared + /// root set or the parent's attached roots stay writable. + #[cfg(test)] + spawned_sandbox_policies: HashMap>, /// Agent ids whose handle-store entries should be evicted on the next async /// drain. Populated by `cleanup()` when an agent record is retired; drained /// by async callers that hold the `HandleStore` lock (#3885). @@ -3475,6 +3486,10 @@ impl SubAgentManager { queued_mail: HashMap::new(), pending_follow_ups: HashMap::new(), woken_agents: HashMap::new(), + #[cfg(test)] + spawned_workspace_roots: HashMap::new(), + #[cfg(test)] + spawned_sandbox_policies: HashMap::new(), pending_handle_evictions: Vec::new(), resume_targets: HashMap::new(), child_approvals: HashMap::new(), @@ -5825,6 +5840,7 @@ impl SubAgentManager { fork_context, workspace, claim, + recorded_worktree_isolation, preserved_profile, child_route, ) = { @@ -5867,13 +5883,26 @@ impl SubAgentManager { // stays inside the coordination ledger with the original bounded // scope instead of inheriting the caller's unchecked write surface. // The ledger claim is already namespaced and carries the isolation - // flag; both are passed through to the spawn seam. + // flag; both are passed through to the spawn seam. The claim is + // not the isolation authority, though — it can be released or + // never have existed for a worktree child — so the worker + // record's launch manifest is read below as the durable fallback. let claim = self .coordination .write_claims .iter() .find(|record| record.claim.owner == agent_id) .map(|record| (record.claim.clone(), record.isolated_worktree)); + // The spec's launch manifest durably pins that this child was + // spawned into an isolated worktree, independent of the claim + // lifecycle. Keying isolation on the claim alone resumed a + // released or claim-less worktree child in the caller's full + // root set — wider than the spawn it continues. + let recorded_worktree_isolation = self + .worker_records + .get(&agent_id) + .and_then(|record| record.spec.launch_manifest.as_ref()) + .map(|manifest| manifest.worktree); // Preserve the interrupted child's runtime posture (read_only / // denied tools / shell) instead of rebuilding from the caller's // role, which could widen the resumed child's authority. @@ -5894,6 +5923,7 @@ impl SubAgentManager { agent.fork_context, agent.workspace.clone(), claim, + recorded_worktree_isolation, preserved_profile, child_route, ) @@ -5910,10 +5940,34 @@ impl SubAgentManager { )); } let runtime = runtime.background_runtime(); + // The live claim's flag wins (it is the freshest isolation truth); + // the launch manifest covers the released/never-claimed legs. + let isolated_worktree = claim + .as_ref() + .map(|(_, isolated)| *isolated) + .or(recorded_worktree_isolation) + .unwrap_or(false); // Resume in the interrupted child's workspace, not the caller's // (worktree/cwd children must not resume in the parent directory). let mut runtime = runtime; - runtime.context.workspace = workspace; + if isolated_worktree { + // Same isolation rule as a fresh worktree spawn: a worktree + // child's boundary is the worktree alone, so the parent's + // attached roots do not carry over into the resumed child + // (neither `boundary_roots` nor the gate's sandbox policy may + // resolve or write outside the worktree). Re-derive the cloned + // sandbox policy as well — the exec lane consumes it verbatim. + // `rebase_roots` (round-26 B26-1) also drops the parent-shared + // boundary memo with the pair swap. + runtime.context.rebase_roots(workspace, Vec::new()); + rederive_sandbox_policy_roots(&mut runtime.context); + } else { + // A cwd-resumed child keeps the carried root set; the memo still + // refreshes because the primary moved (the boundary normalizes + // the set against the new primary). + let carried = runtime.context.workspace_roots.clone(); + runtime.context.rebase_roots(workspace, carried); + } let options = SubAgentSpawnOptions { name: None, // the old session name stays owned by the terminal record model: Some(model), @@ -5922,10 +5976,7 @@ impl SubAgentManager { nickname: None, fork_context, write_claim: claim.as_ref().map(|(claim, _)| claim.clone()), - isolated_worktree: claim - .as_ref() - .map(|(_, isolated)| *isolated) - .unwrap_or(false), + isolated_worktree, claim_pre_namespaced: claim.is_some(), preserve_runtime_profile: preserved_profile, ..Default::default() @@ -6730,6 +6781,14 @@ impl SubAgentManager { } let launch_gate = (runtime.spawn_depth == 1).then(|| self.launch_gate.clone()); + #[cfg(test)] + self.spawned_workspace_roots + .insert(agent_id.clone(), runtime.context.workspace_roots.clone()); + #[cfg(test)] + self.spawned_sandbox_policies.insert( + agent_id.clone(), + runtime.context.elevated_sandbox_policy.clone(), + ); let task = SubAgentTask { manager_handle, runtime, @@ -9301,6 +9360,24 @@ async fn wait_result_payload( Ok(tool_result) } +/// Re-derive the exec lane's writable roots after a worktree clear site +/// resets `context.workspace_roots`. A child runtime clones the parent's +/// context wholesale — including `elevated_sandbox_policy`, which the engine +/// built over the parent's full root set — so without this rebuild the exec +/// lane (`shell.rs` policy override → `WorkspaceWrite::get_writable_roots`) +/// would still treat the parent's attached roots as writable while the file +/// lane's `boundary_roots` no longer resolves there. Only the WorkspaceWrite +/// face carries a root set to re-derive; the other postures hold none. +fn rederive_sandbox_policy_roots(context: &mut ToolContext) { + let cleared = + codewhale_core::normalize_workspace_roots(&context.workspace, &context.workspace_roots); + if let Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { writable_roots, .. }) = + context.elevated_sandbox_policy.as_mut() + { + *writable_roots = cleared; + } +} + async fn spawn_subagent_from_input( input: Value, manager: SharedSubAgentManager, @@ -9409,7 +9486,30 @@ async fn spawn_subagent_from_input( spawn_request.max_depth, ); if let Some(workspace) = child_workspace { - child_runtime.context.workspace = workspace.clone(); + if spawn_request.worktree.is_some() { + // A worktree child is an isolation boundary, not a wider + // session: its boundary is the worktree alone, so the parent's + // attached roots do not carry over (at base a worktree child + // could only resolve inside its worktree). The cloned sandbox + // policy must be re-derived too — it was built over the parent's + // full root set, and the exec lane consumes it verbatim. + // `rebase_roots` (round-26 B26-1): the pair swap MUST also drop + // the parent-shared boundary memo, or the child's file-tool + // containment keeps judging against the parent's boundary. + child_runtime + .context + .rebase_roots(workspace.clone(), Vec::new()); + rederive_sandbox_policy_roots(&mut child_runtime.context); + } else { + // An explicit `cwd:` swap without a worktree is non-isolating and + // keeps the parent's root set (disclosed in the PR description) — + // but the memo still refreshes: the boundary normalizes the + // carried set against the NEW primary. + let carried = child_runtime.context.workspace_roots.clone(); + child_runtime + .context + .rebase_roots(workspace.clone(), carried); + } // A worktree child gets a distinct workspace-scoped plugin catalog. // Reusing the parent's registry here would leak workspace plugins (and // their authority receipts) across the exact isolation boundary the @@ -14603,6 +14703,7 @@ impl SubAgentToolRegistry { approval_mode, workspace_trusted, Some(&workspace), + &self.gate_runtime.context.workspace_roots, ); let (decision, _audit) = auto_review_plan_decision_for_context( &self.gate_runtime.auto_review_policy, @@ -14983,6 +15084,7 @@ impl SubAgentToolRegistry { } crate::core::authority::paths_within_workspace_write_carve_out( &self.registry.context().workspace, + &self.registry.context().workspace_roots, &raw_mutation_target_paths(name, input), ) } @@ -15561,6 +15663,7 @@ impl SubAgentToolRegistry { name, &input, &self.registry.context().workspace, + &self.registry.context().workspace_roots, crate::tui::approval::ApprovalMode::Auto, ) .or_else(|| { @@ -15569,6 +15672,7 @@ impl SubAgentToolRegistry { name, &input, &self.registry.context().workspace, + &self.registry.context().workspace_roots, crate::tui::approval::ApprovalMode::Auto, ) }); @@ -15959,11 +16063,17 @@ fn raw_mutation_target_paths(name: &str, input: &Value) -> Vec { .map(|preflight| preflight.touched_files) .unwrap_or_default(); } - input - .get("path") - .or_else(|| input.get("output_path")) - .and_then(Value::as_str) - .map(|path| vec![path.to_string()]) + // Round-40 minor 4: alias-aware like the session-side collectors + // (B22-2's own doctrine) — an aliased spelling must not under-report + // the mutation target. + crate::tools::file::path_param_value(input) + .or_else(|| { + input + .get("output_path") + .and_then(Value::as_str) + .map(str::to_string) + }) + .map(|path| vec![path]) .unwrap_or_default() } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 70e81d349c..4d58ebaa3f 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -20785,6 +20785,260 @@ async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { ); } +/// The resume lane must honor the same isolation rule as a fresh worktree +/// spawn: a worktree child's boundary is the worktree alone, so a multi-root +/// parent's attached roots must not re-widen the resumed child. Both +/// enforcement surfaces (`ToolContext::boundary_roots` and the gate's +/// per-turn sandbox policy) materialize from the child's +/// `(workspace, workspace_roots)` pair — and the exec lane's cloned +/// `elevated_sandbox_policy` must be re-derived from that pair too, or the +/// parent's attached roots would stay writable through the policy override. +#[tokio::test] +async fn resume_of_isolated_worktree_child_keeps_the_worktree_as_its_only_root() { + let tmp = tempdir().unwrap(); + let parent_workspace = tmp.path().join("parent"); + let attached_root = tmp.path().join("attached"); + let worktree = tmp.path().join("worktree"); + for dir in [&parent_workspace, &attached_root, &worktree] { + std::fs::create_dir_all(dir).unwrap(); + } + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4); + let (agent_id, _handle) = { + let mut guard = manager.write().await; + let (agent_id, handle) = guard.insert_test_interrupted_continuable_agent( + "paused_worktree_child", + &worktree, + vec![Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "prior work".to_string(), + cache_control: None, + }], + }], + ); + // The interrupted child held an isolated-worktree write claim. + guard + .coordination + .register_claim( + WriteScopeClaim { + owner: agent_id.clone(), + roots: vec![".".to_string()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + true, + |_| false, + ) + .expect("isolated worktree claim"); + (agent_id, handle) + }; + let mut runtime = stub_runtime(); + runtime.manager = Arc::clone(&manager); + // Multi-root parent: its primary workspace plus an attached root, with + // the exec-lane policy built over that full set (as the engine builds it + // for the parent's turn). + runtime.context.workspace = parent_workspace.clone(); + runtime.context.workspace_roots = vec![parent_workspace.clone(), attached_root.clone()]; + runtime.context.elevated_sandbox_policy = Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { + writable_roots: codewhale_core::normalize_workspace_roots( + &parent_workspace, + &runtime.context.workspace_roots, + ), + network_access: false, + exclude_tmpdir: false, + exclude_slash_tmp: false, + }); + + let resumed = { + let mut guard = manager.write().await; + guard + .resume_from_checkpoint(Arc::clone(&manager), runtime, &agent_id, "continue") + .expect("resume ok") + }; + + let guard = manager.read().await; + let child_roots = guard + .spawned_workspace_roots + .get(&resumed.agent_id) + .expect("the spawn seam captured the resumed child's root set"); + assert!( + child_roots.is_empty(), + "an isolated worktree child must not carry the parent's attached roots: {child_roots:?}" + ); + let boundary = codewhale_core::normalize_workspace_roots(&worktree, child_roots); + assert_eq!( + boundary, + vec![worktree.clone()], + "the resumed child's boundary materializes as the worktree alone" + ); + let child_policy = guard + .spawned_sandbox_policies + .get(&resumed.agent_id) + .expect("the spawn seam captured the resumed child's sandbox policy") + .as_ref() + .expect("the resumed child inherited the parent's exec-lane policy"); + assert_eq!( + child_policy, + &crate::sandbox::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![worktree], + network_access: false, + exclude_tmpdir: false, + exclude_slash_tmp: false, + }, + "the exec lane's writable set must be re-derived from the cleared \ + root set — the parent's attached root must not stay writable" + ); +} + +/// Isolation must not key on the write claim's existence: a claim can be +/// released or never created for a worktree child. The worker record's +/// launch manifest durably pins the worktree spawn, so the claim-less resume +/// still comes back with the worktree as its only root instead of the +/// caller's full set. +#[tokio::test] +async fn claim_less_resume_of_a_recorded_worktree_child_stays_isolated() { + let tmp = tempdir().unwrap(); + let parent_workspace = tmp.path().join("parent"); + let attached_root = tmp.path().join("attached"); + let worktree = tmp.path().join("worktree"); + for dir in [&parent_workspace, &attached_root, &worktree] { + std::fs::create_dir_all(dir).unwrap(); + } + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4); + let agent_id = { + let mut guard = manager.write().await; + let (agent_id, _handle) = guard.insert_test_interrupted_continuable_agent( + "released_worktree_child", + &worktree, + vec![Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "prior work".to_string(), + cache_control: None, + }], + }], + ); + // The claim has been released (or never existed) — no ledger record + // remains. The worker record still carries the worktree spawn. + let mut spec = make_worker_spec(&agent_id, worktree.clone()); + spec.agent_type = FleetRole::Builder; + spec.runtime_profile = WorkerRuntimeProfile::for_role(FleetRole::Builder); + spec.launch_manifest = Some(ChildLaunchManifest { + owner_session: "root".to_string(), + child_id: agent_id.clone(), + profile: spec.runtime_profile.clone(), + prompt: spec.objective.clone(), + cwd: Some(worktree.display().to_string()), + worktree: true, + writable_roots: Vec::new(), + writable_files: Vec::new(), + coordination_contracts: Vec::new(), + expected_artifact: None, + token_budget: None, + resume_identity: Some(agent_id.clone()), + generation: 1, + resume_from_agent_id: None, + }); + guard.register_worker(spec); + agent_id + }; + let mut runtime = stub_runtime(); + runtime.manager = Arc::clone(&manager); + runtime.context.workspace = parent_workspace.clone(); + runtime.context.workspace_roots = vec![parent_workspace.clone(), attached_root.clone()]; + runtime.context.elevated_sandbox_policy = Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { + writable_roots: codewhale_core::normalize_workspace_roots( + &parent_workspace, + &runtime.context.workspace_roots, + ), + network_access: false, + exclude_tmpdir: false, + exclude_slash_tmp: false, + }); + + let resumed = { + let mut guard = manager.write().await; + guard + .resume_from_checkpoint(Arc::clone(&manager), runtime, &agent_id, "continue") + .expect("resume ok") + }; + + let guard = manager.read().await; + let child_roots = guard + .spawned_workspace_roots + .get(&resumed.agent_id) + .expect("the spawn seam captured the resumed child's root set"); + assert!( + child_roots.is_empty(), + "a claim-less worktree child must not inherit the caller's root set: {child_roots:?}" + ); + let child_policy = guard + .spawned_sandbox_policies + .get(&resumed.agent_id) + .expect("the spawn seam captured the resumed child's sandbox policy") + .as_ref() + .expect("the resumed child inherited the parent's exec-lane policy"); + assert_eq!( + child_policy, + &crate::sandbox::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![worktree], + network_access: false, + exclude_tmpdir: false, + exclude_slash_tmp: false, + }, + "a claim-less worktree child must not inherit the caller's writable roots" + ); +} + +/// The rebuild idiom shared by both worktree clear sites: after the roots set +/// is cleared, the WorkspaceWrite face of the cloned policy re-derives from +/// the child's `(workspace, workspace_roots)` pair — the worktree alone — and +/// postures that carry no root set pass through untouched. +#[test] +fn rederive_sandbox_policy_roots_confines_the_exec_lane_to_the_cleared_set() { + let tmp = tempdir().unwrap(); + let parent_workspace = tmp.path().join("parent"); + let attached_root = tmp.path().join("attached"); + let worktree = tmp.path().join("worktree"); + for dir in [&parent_workspace, &attached_root, &worktree] { + std::fs::create_dir_all(dir).unwrap(); + } + + // The shape a worktree clear site leaves behind: the child's workspace is + // the worktree, the roots set is cleared, and the policy is still the one + // cloned from the parent over the parent's full root set. + let mut context = ToolContext::new(worktree.clone()); + context.workspace_roots = Vec::new(); + context.elevated_sandbox_policy = Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![parent_workspace, attached_root], + network_access: true, + exclude_tmpdir: false, + exclude_slash_tmp: false, + }); + rederive_sandbox_policy_roots(&mut context); + assert_eq!( + context.elevated_sandbox_policy, + Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![worktree], + network_access: true, + exclude_tmpdir: false, + exclude_slash_tmp: false, + }), + "the parent's attached roots must not survive the re-derivation" + ); + + // Root-less postures have no writable set to re-derive and pass through. + for policy in [ + crate::sandbox::SandboxPolicy::ReadOnly, + crate::sandbox::SandboxPolicy::DangerFullAccess, + ] { + let mut context = ToolContext::new(tmp.path().join("child")); + context.elevated_sandbox_policy = Some(policy.clone()); + rederive_sandbox_policy_roots(&mut context); + assert_eq!(context.elevated_sandbox_policy, Some(policy)); + } +} + #[test] fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 2cf029b474..71a492a495 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1576,6 +1576,11 @@ pub struct App { /// Last effective thinking receipt for the most recently accepted route. pub(crate) last_effective_reasoning_effort: Option, pub workspace: PathBuf, + /// Additional accessible roots carried by the current session. The TUI + /// has no multi-root UI; this only preserves roots another host (Runtime + /// API, exec) persisted on the session, so a resume or re-sync neither + /// drops nor invents them. + pub workspace_roots: Vec, /// Effective `[workflow]` table for this session (`/workflow settings`). pub workflow_config: codewhale_config::WorkflowConfigToml, /// Effective `[goal] max_continuations` backstop; `0` means unlimited. diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index 2d55dd7236..222ac27363 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -829,6 +829,7 @@ impl App { reasoning_effort_preference, last_effective_reasoning_effort: None, workspace, + workspace_roots: Vec::new(), workflow_config: config.workflow_config(), goal_max_continuations: config.goal_max_continuations(), goal_continuation_waiting: false, diff --git a/crates/tui/src/tui/app/types.rs b/crates/tui/src/tui/app/types.rs index 688e7bf92a..431f746696 100644 --- a/crates/tui/src/tui/app/types.rs +++ b/crates/tui/src/tui/app/types.rs @@ -959,6 +959,7 @@ pub enum AppAction { system_prompt: Option, model: String, workspace: PathBuf, + workspace_roots: Vec, mode: AppMode, }, OpenConfigView, diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index c9899c57ba..89f0a74eb8 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -182,7 +182,8 @@ impl ApprovalRequest { let category = get_tool_category_for_call(tool_name, params); let risk = classify_risk(tool_name, category, params); let approval_grouping_key = - crate::tools::approval_cache::build_approval_grouping_key(tool_name, params).0; + crate::tools::approval_cache::build_approval_grouping_key(tool_name, params, workspace) + .0; let persistent_ask_rules = build_persistent_ask_rules(semantic_tool_name, params, workspace); let persistent_allow_rules = if classify_stakes(tool_name, category, risk, params) @@ -321,6 +322,20 @@ fn param_preview(params: &Value, keys: &[&str], max_len: usize) -> Option return Some(truncate_string_value(text, max_len)), Value::Number(number) => return Some(number.to_string()), @@ -526,7 +541,12 @@ fn build_prominent_details( value: command, }); } - if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 96) { + // The preview keys must mirror the operand readers exactly — + // the shell tool and the approval cache key both read + // ["cwd", "working_dir"] (round-37 M37-9): a `working_dir` + // redirect invisible on the card would let the user approve a + // grant keyed by an operand they never saw. + if let Some(workdir) = param_preview(params, &["cwd", "working_dir"], 96) { details.push(ApprovalDetail { label: "Dir".to_string(), value: workdir, diff --git a/crates/tui/src/tui/approval/elevation.rs b/crates/tui/src/tui/approval/elevation.rs index 239fe7d0d1..64a1e0321b 100644 --- a/crates/tui/src/tui/approval/elevation.rs +++ b/crates/tui/src/tui/approval/elevation.rs @@ -29,13 +29,24 @@ pub enum ElevationOption { } impl ElevationOption { - /// Convert to a sandbox policy. - pub fn to_policy(&self, base_cwd: &Path) -> SandboxPolicy { + /// Convert to a sandbox policy. `workspace_roots` is the session's live + /// root set, materialized with the per-turn policy's + /// `normalize_workspace_roots` idiom: the retry must keep every attached + /// root writable, or a denied call that legitimately writes one would be + /// denied again and nudge the user toward Full Access. + pub fn to_policy(&self, base_cwd: &Path, workspace_roots: &[PathBuf]) -> SandboxPolicy { match self { - ElevationOption::WithNetwork => SandboxPolicy::workspace_with_network(), + ElevationOption::WithNetwork => SandboxPolicy::workspace_with_roots( + codewhale_core::normalize_workspace_roots(base_cwd, workspace_roots), + true, + ), ElevationOption::WithWriteAccess(paths) => { let mut roots = paths.clone(); - roots.push(base_cwd.to_path_buf()); + for root in codewhale_core::normalize_workspace_roots(base_cwd, workspace_roots) { + if !roots.contains(&root) { + roots.push(root); + } + } SandboxPolicy::workspace_with_roots(roots, false) } ElevationOption::FullAccess => SandboxPolicy::DangerFullAccess, diff --git a/crates/tui/src/tui/approval/tests.rs b/crates/tui/src/tui/approval/tests.rs index 0c2c8bd5f1..938cf102c8 100644 --- a/crates/tui/src/tui/approval/tests.rs +++ b/crates/tui/src/tui/approval/tests.rs @@ -195,6 +195,54 @@ fn risk_dangerous_shell_command_stays_destructive() { ); } +#[test] +fn approval_card_previews_the_working_dir_redirect() { + // Round-37 M37-9: the card's Dir preview must read the SAME operand + // keys the shell tool and the approval cache key read (["cwd", + // "working_dir"]). The old ["workdir", "cwd"] preview left a + // `working_dir` redirect invisible on the card while the grant it + // approved was keyed by that very operand. + let details = build_prominent_details( + "exec_shell", + ToolCategory::Shell, + &json!({ + "command": "cargo test", + "working_dir": "/repo/subdir" + }), + ); + let dir = details + .iter() + .find(|detail| detail.label == "Dir") + .expect("a working_dir redirect must be visible on the card"); + assert_eq!(dir.value, "/repo/subdir"); + + // The legacy `cwd` spelling stays previewed too. + let details = build_prominent_details( + "exec_shell", + ToolCategory::Shell, + &json!({"command": "cargo test", "cwd": "/repo/other"}), + ); + let dir = details + .iter() + .find(|detail| detail.label == "Dir") + .expect("the cwd spelling stays previewed"); + assert_eq!(dir.value, "/repo/other"); + + // Round-38 minor: `cwd: null` does not shadow the working_dir operand + // with a literal "null" — the preview skips Null like the key and + // execution sides. + let details = build_prominent_details( + "exec_shell", + ToolCategory::Shell, + &json!({"command": "cargo test", "cwd": null, "working_dir": "/repo/redirect"}), + ); + let dir = details + .iter() + .find(|detail| detail.label == "Dir") + .expect("the working_dir operand must be the visible one"); + assert_eq!(dir.value, "/repo/redirect"); +} + // ======================================================================== // ApprovalRequest Tests // ======================================================================== @@ -2243,7 +2291,7 @@ fn test_elevation_render_zh_hant_has_translated_copy() { fn test_elevation_option_to_policy() { let cwd = PathBuf::from("/tmp/test"); - let policy = ElevationOption::WithNetwork.to_policy(&cwd); + let policy = ElevationOption::WithNetwork.to_policy(&cwd, &[]); assert!(matches!( policy, SandboxPolicy::WorkspaceWrite { @@ -2252,14 +2300,52 @@ fn test_elevation_option_to_policy() { } )); - let policy = ElevationOption::FullAccess.to_policy(&cwd); + let policy = ElevationOption::FullAccess.to_policy(&cwd, &[]); assert!(matches!(policy, SandboxPolicy::DangerFullAccess)); let paths = vec![PathBuf::from("/tmp/test/src")]; - let policy = ElevationOption::WithWriteAccess(paths).to_policy(&cwd); + let policy = ElevationOption::WithWriteAccess(paths).to_policy(&cwd, &[]); assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. })); } +/// The retry policy must materialize the session's live root set (round-15): +/// a denied call retried WithNetwork keeps every attached root writable, +/// matching the per-turn policy instead of shrinking to the bare cwd. +#[test] +fn elevation_retry_policy_keeps_the_sessions_attached_roots() { + let cwd = PathBuf::from("/work/primary"); + let attached = PathBuf::from("/work/attached"); + let session_roots = vec![cwd.clone(), attached.clone()]; + + let SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + .. + } = ElevationOption::WithNetwork.to_policy(&cwd, &session_roots) + else { + panic!("WithNetwork stays workspace-write"); + }; + assert!(network_access); + assert_eq!(writable_roots, session_roots); + + let extra = PathBuf::from("/work/extra"); + let SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + .. + } = ElevationOption::WithWriteAccess(vec![extra.clone()]).to_policy(&cwd, &session_roots) + else { + panic!("WithWriteAccess stays workspace-write"); + }; + assert!(!network_access); + for root in [&extra, &cwd, &attached] { + assert!( + writable_roots.contains(root), + "retry must keep {root:?} writable: {writable_roots:?}" + ); + } +} + // ======================================================================== // ElevationRequest Tests // ======================================================================== diff --git a/crates/tui/src/tui/auto_review.rs b/crates/tui/src/tui/auto_review.rs index 3af136db78..f80afcc3c0 100644 --- a/crates/tui/src/tui/auto_review.rs +++ b/crates/tui/src/tui/auto_review.rs @@ -194,6 +194,7 @@ impl<'a> AutoReviewContext<'a> { approval_mode: ApprovalMode, workspace_trusted: bool, workspace: Option<&std::path::Path>, + workspace_roots: &[std::path::PathBuf], ) -> Self { let category = get_tool_category_for_call(tool_name, params); let risk = classify_risk(tool_name, category, params); @@ -212,7 +213,9 @@ impl<'a> AutoReviewContext<'a> { .zip(file_write_target_paths(tool_name, params)) .is_some_and(|(workspace, paths)| { crate::core::authority::paths_within_workspace_write_carve_out( - workspace, &paths, + workspace, + workspace_roots, + &paths, ) }), } @@ -386,12 +389,13 @@ fn file_write_target_paths(tool_name: &str, input: &Value) -> Option let canonical = crate::tools::canonical_action::canonical_action_alias(tool_name, input); Some(match canonical { "write_file" | "edit_file" => vec![ - input - .get("path") - .and_then(Value::as_str) - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(str::to_string)?, + // Raw spelling, untrimmed: the carve-out must judge exactly the + // path execution resolves (`ToolContext::resolve_path` joins the + // raw string onto the workspace). Alias spellings + // (`file_path`/`filePath`) included: execution folds them onto + // `path` only at execute time, so reading `path` alone judged + // nothing for an alias-spelled write (round-22 B22-2). + crate::tools::file::path_param_value(input)?, ], "apply_patch" => { crate::tools::apply_patch::preflight_apply_patch(input) @@ -1024,7 +1028,15 @@ mod tests { run_origin: RunOrigin, approval_mode: ApprovalMode, ) -> AutoReviewContext<'_> { - AutoReviewContext::from_tool_call(tool_name, ¶ms, run_origin, approval_mode, true, None) + AutoReviewContext::from_tool_call( + tool_name, + ¶ms, + run_origin, + approval_mode, + true, + None, + &[], + ) } fn assert_safety_gate(decision: &AutoReviewDecision) { @@ -1081,6 +1093,7 @@ mod tests { ApprovalMode::Auto, true, None, + &[], ); let decision = policy.evaluate(&ctx); @@ -1089,6 +1102,75 @@ mod tests { assert_eq!(decision.rule_id.as_deref(), Some("no-rm")); } + #[test] + fn write_targets_bounded_spans_attached_workspace_roots() { + // Pin for the bounded-write plumbing: the declared root set must + // reach the carve-out check. Every other caller here passes + // `None, &[]`, so a mutation dropping the set inside the context + // builder only shows up as an attached-root-dependent outcome. + let workspace = tempfile::tempdir().expect("workspace tempdir"); + let attached = tempfile::tempdir().expect("attached root tempdir"); + std::fs::create_dir(workspace.path().join(".git")).expect("git marker"); + std::fs::create_dir(attached.path().join(".git")).expect("git marker"); + let target = attached.path().join("src/a.rs"); + + let ctx = AutoReviewContext::from_tool_call( + "write_file", + &json!({ "path": target.to_string_lossy() }), + RunOrigin::Interactive, + ApprovalMode::Auto, + true, + Some(workspace.path()), + &[attached.path().to_path_buf()], + ); + assert!( + ctx.write_targets_bounded, + "a write target under an attached git root is bounded only while the root set reaches the carve-out" + ); + + // Control: the same call with the historical empty root set is not + // bounded. + let ctx = AutoReviewContext::from_tool_call( + "write_file", + &json!({ "path": target.to_string_lossy() }), + RunOrigin::Interactive, + ApprovalMode::Auto, + true, + Some(workspace.path()), + &[], + ); + assert!(!ctx.write_targets_bounded); + } + + #[test] + #[allow(non_snake_case)] + fn filePath_spelled_write_reaches_the_auto_review_carve_out() { + // Round-22 B22-2: execution folds the camelCase `filePath` alias onto + // `path` only at execute time, so Auto-Review's target collector read + // `path` alone and an alias-spelled write had no bounded targets — + // an unbounded write reached review. The alias must extract the same + // target. + let workspace = tempfile::tempdir().expect("workspace tempdir"); + let attached = tempfile::tempdir().expect("attached root tempdir"); + std::fs::create_dir(workspace.path().join(".git")).expect("git marker"); + std::fs::create_dir(attached.path().join(".git")).expect("git marker"); + let target = attached.path().join("src/a.rs"); + + let ctx = AutoReviewContext::from_tool_call( + "write_file", + &json!({ "filePath": target.to_string_lossy() }), + RunOrigin::Interactive, + ApprovalMode::Auto, + true, + Some(workspace.path()), + &[attached.path().to_path_buf()], + ); + assert!( + ctx.write_targets_bounded, + "a filePath-spelled write under an attached git root must be judged" + ); + } + #[test] fn safety_floor_holds_publish_before_allow_rules() { let policy = AutoReviewPolicy { @@ -1508,6 +1590,7 @@ mod tests { ApprovalMode::Suggest, true, None, + &[], ); let decision = policy.evaluate(&ctx); @@ -1563,6 +1646,7 @@ mod tests { ApprovalMode::Auto, true, None, + &[], ); assert_eq!(context.tool_name, tool_name); assert_eq!(context.category, category, "{tool_name}"); @@ -1630,6 +1714,7 @@ mod tests { ApprovalMode::Auto, true, None, + &[], ); let text = build_reviewer_context( &ctx, diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 60a32549eb..3acc767635 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -1263,6 +1263,7 @@ mod tests { model_provider: "deepseek".to_string(), model_provider_id: None, workspace: std::path::PathBuf::from("/tmp"), + workspace_roots: Vec::new(), mode: Some("agent".to_string()), cost: crate::session_manager::SessionCostSnapshot::default(), parent_session_id: None, diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index 4a4ec1c022..4083a6dee0 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -1101,6 +1101,7 @@ pub(crate) async fn apply_provider_fallback_switch( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; @@ -1215,6 +1216,19 @@ pub(crate) async fn apply_command_result( return Ok(false); } }; + // The persisted metadata is the only roots source in the TUI + // (no multi-root UI): seed the app state only after every + // fallible restore step has succeeded, so a failed load + // cannot leave the *current* session's engine inheriting a + // foreign root set through the next routine re-sync. Seeded + // under the RECORD primary (B27-1/B27-2): the restore has + // already re-homed the app there, so this is a pure load and + // the launch directory never enters the set. + crate::tui::ui::session_state::seed_loaded_workspace_roots_into( + app, + &session.metadata.workspace, + &session.metadata.workspace_roots, + ); sync_runtime_workspace_state(task_manager, app.workspace.clone()).await; if respawn { let _ = engine_handle.send(Op::Shutdown).await; @@ -1236,6 +1250,7 @@ pub(crate) async fn apply_command_result( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; @@ -1254,6 +1269,13 @@ pub(crate) async fn apply_command_result( content: success_message.clone(), }); app.status_message = Some(success_message); + // The imported record's root set arrived from another host; + // name it in the transcript beside the load receipt. + if let Some(notice) = + workspace_roots_notice(app.ui_locale, &app.workspace, &app.workspace_roots) + { + app.add_message(HistoryCell::System { content: notice }); + } // A loaded session is the working screen. The launch card's // recent rows reach here through `/resume`-shaped dispatch; // leaving the launch stage visible over the restored @@ -1267,20 +1289,54 @@ pub(crate) async fn apply_command_result( system_prompt, model, workspace, + workspace_roots, mode, } => { let mut session_id = session_id; let is_full_reset = messages.is_empty() && system_prompt.is_none(); + let previous_session_id = app.current_session_id.clone(); if is_full_reset && session_id.is_none() { let new_session_id = uuid::Uuid::new_v4().to_string(); app.current_session_id = Some(new_session_id.clone()); session_id = Some(new_session_id); } + // Round-37 M37-6: a session grant/denial belongs to the + // conversation it was approved in. A fork, a full reset, or + // a different session's load installs a NEW conversation — + // even over the same workspace — so the sets reset here too + // (a workspace move clears them inside + // `apply_workspace_runtime_state`). Same-session re-syncs + // keep their grants. + if is_full_reset || session_id != previous_session_id { + app.approval_session_approved.clear(); + app.approval_session_denied.clear(); + } let workspace_changed = task_manager.default_workspace().await != workspace; if workspace_changed { apply_workspace_runtime_state(app, config, workspace.clone()); sync_runtime_workspace_state(task_manager, workspace.clone()).await; } + // The action is the roots authority for this transition (a + // fork carries the parent's set, a new session carries an + // empty one). Record it in the same step as the workspace + // above: the provider restore below can fail and return + // early, and leaving the previous session's set paired with + // the new workspace would make every later re-sync send a + // workspace whose primary root is the old directory. + let previous_workspace_roots = app.workspace_roots.clone(); + app.workspace_roots = workspace_roots.clone(); + // Round-37 M37-8: a SyncSession that INSTALLS a different + // root set (a fork carrying the parent's set, a loaded + // conversation) discloses it like every other loading face — + // the bare "Forked " receipt must not be the only trace + // of a silently swapped live set. The notice renders only for + // a set with additional roots. + if workspace_roots != previous_workspace_roots + && let Some(notice) = + workspace_roots_notice(app.ui_locale, &app.workspace, &app.workspace_roots) + { + app.add_message(HistoryCell::System { content: notice }); + } let provider_changed = config.api_provider() != app.api_provider || config.provider_identity_for(config.api_provider()) != app.provider_identity_for_persistence(); @@ -1327,6 +1383,7 @@ pub(crate) async fn apply_command_result( system_prompt_override: false, model, workspace, + workspace_roots, mode, }) .await; @@ -1404,6 +1461,7 @@ pub(crate) async fn apply_command_result( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; @@ -2291,6 +2349,7 @@ pub(crate) async fn apply_command_result( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; @@ -2427,6 +2486,14 @@ fn edit_project_hooks_from_tui(terminal: &mut AppTerminal, app: &mut App, config pub(crate) fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) { app.workspace = workspace.clone(); + // Round-37 M37-6: session approval grants/denials are keyed to the + // primary the approval was judged under (shell operand resolution and + // the patch family both bind the root). The whole set is stale the + // moment the workspace moves — an approved `cwd: "sub"` family or patch + // path set must not auto-approve the same spellings re-resolved under + // the new root. + app.approval_session_approved.clear(); + app.approval_session_denied.clear(); app.coordination_detail = None; app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace); for error in crate::commands::user_registry::install_plugin_registry( @@ -3301,6 +3368,24 @@ pub(crate) fn apply_loaded_session_with_goal( "runtime work is active; wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work before switching sessions".to_string(), ); } + if session.metadata.workspace.as_os_str().is_empty() + || !session.metadata.workspace.is_absolute() + { + // A legacy or hand-edited record with an empty or RELATIVE + // workspace would seed the vacuous containment root + // (`starts_with("")` accepts every path; a relative primary leaves + // every file tool answering PathEscape forever). Refuse the restore + // like any other invalid saved state — the same rule ACP + // session/load and (since round-41 M41-1) the REST resume-thread + // lane apply (round-42 minor 9). Round-45 R45-3: user-facing copy + // ships as a localized MessageId (it surfaces through "Failed to + // restore session: {err}"), not hardcoded English. + return Err(crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::SessionRestoreWorkspaceInvalid, + ) + .into_owned()); + } if let Some(goal) = goal { goal.validate() .map_err(|error| format!("saved session goal is invalid: {error}"))?; diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index ad74f31a55..346fb2f111 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -652,6 +652,28 @@ pub async fn run_tui( Ok(goal) => { match apply_loaded_session_with_goal(&mut app, config, &saved, goal.as_ref()) { Ok(()) => { + // The engine below is built and synced from App + // state: without this seed, a multi-root session + // resumed from the CLI runs single-root for the + // whole process lifetime. Seeded under the RECORD + // primary (B27-1/B27-2): the restore re-homes the + // app onto it, so this is a pure load and the + // launch directory never enters the set. + crate::tui::ui::session_state::seed_loaded_workspace_roots_into( + &mut app, + &saved.metadata.workspace, + &saved.metadata.workspace_roots, + ); + // Name the inherited set in the transcript: the + // roots arrived from another host and no header + // chrome reports them. + if let Some(notice) = workspace_roots_notice( + app.ui_locale, + &app.workspace, + &app.workspace_roots, + ) { + app.add_message(HistoryCell::System { content: notice }); + } app.status_message = Some(format!( "Resumed session: {}", crate::session_manager::truncate_id(&saved.metadata.id) @@ -796,6 +818,7 @@ pub async fn run_tui( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; @@ -1211,6 +1234,7 @@ async fn submit_decided_composer_input( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; @@ -2757,6 +2781,13 @@ pub(crate) async fn run_event_loop( next = %session_id, "engine session id diverged from the host session id" ); + // Round-40 minor 8: a different conversation id + // with an UNCHANGED workspace skipped the + // workspace-move clear — grants belong to the + // conversation they were approved in (round-37 + // M37-6's rule), so clear them here too. + app.approval_session_approved.clear(); + app.approval_session_denied.clear(); } app.current_session_id = Some(session_id.clone()); if app.last_known_goal_state.is_some() @@ -3744,6 +3775,7 @@ pub(crate) async fn run_event_loop( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; diff --git a/crates/tui/src/tui/ui/frame.rs b/crates/tui/src/tui/ui/frame.rs index f80e8fa1fd..ba23cff821 100644 --- a/crates/tui/src/tui/ui/frame.rs +++ b/crates/tui/src/tui/ui/frame.rs @@ -713,6 +713,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig { model: app.model.clone(), active_route_limits: app.active_route_limits, workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), // The App owns the session id (claimed before the Runtime store lock // and used for every checkpoint/autosave); the engine adopts it so the // engine conversation and the persisted session are the same record. @@ -908,6 +909,18 @@ pub(crate) fn build_session_snapshot( Some(app.mode.as_setting()), ) }; + // Autosave must rewrite the roots the session runs with, not an empty + // set: an erased `workspace_roots` here durably degrades a multi-root + // session on disk. Round-38 M38-2: the stamp runs through the SAME + // sanitize the load faces use — a live set that somehow carries poison + // (the seed strips it; this is the belt) is stripped here instead of + // laundered onto disk, and the drop is logged for the record. + let (sanitized_roots, dropped_roots) = + codewhale_core::sanitize_workspace_roots(&app.workspace, &app.workspace_roots); + if dropped_roots > 0 { + tracing::warn!("autosave dropped {dropped_roots} workspace root(s) that failed the intake"); + } + session.metadata.workspace_roots = sanitized_roots; let computed_title = session.metadata.title.clone(); if let Some(cached) = app .current_session_metadata @@ -971,15 +984,16 @@ pub(crate) fn build_session_snapshot( session.last_auto_route = app.auto_route_for_persistence(); session.window_title.clone_from(&app.window_title); app.current_session_metadata = Some(session.metadata.clone()); - // Claim ownership of this session for the process. From here on the - // Runtime API refuses external renames/archives of it with a typed 409 - // rather than writing something the next snapshot would revert. + // Claim ownership of this session for the process. The registry's + // remaining consumer is the orphan-reclamation keep-chain: a live + // owner's session directory is never swept while the TUI holds it. // // Claiming here rather than at each of the ten `current_session_id` // assignment sites is deliberate: this is the function that establishes // "the TUI holds the authoritative copy", which is exactly the condition - // the conflict protects. A session that has never been snapshotted has no - // in-memory state to lose, so leaving it unclaimed is correct, not a gap. + // the keep-chain protects. A session that has never been snapshotted has + // no in-memory state to lose, so leaving it unclaimed is correct, not a + // gap. crate::session_manager::set_live_session(Some(&session.metadata.id)); Ok(session) } @@ -2037,6 +2051,32 @@ mod tests { use super::{register_info_interaction_targets, render_info_row, short_title_truncate}; use ratatui::{Terminal, backend::TestBackend}; + /// The autosave chokepoint is a roots writer like any other: an erased + /// `workspace_roots` here durably degrades a multi-root session on disk, + /// so the stamp needs a behavior test of its own. + #[test] + fn autosave_snapshot_stamps_the_live_root_set() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("work"); + let attached = tmp.path().join("attached"); + std::fs::create_dir_all(&workspace).expect("workspace dir"); + std::fs::create_dir_all(&attached).expect("attached dir"); + let mut app = crate::test_support::test_app_with_options( + crate::test_support::test_tui_options(&workspace), + ); + app.workspace_roots = vec![workspace.clone(), attached.clone()]; + + let manager = crate::session_manager::SessionManager::new(tmp.path().join("sessions")) + .expect("manager"); + let session = super::build_session_snapshot(&mut app, &manager).expect("snapshot"); + + assert_eq!( + session.metadata.workspace_roots, + vec![workspace, attached], + "the autosave chokepoint must persist the live set, not an empty one" + ); + } + /// Chrome that answers a click must also answer the pointer, or the app /// teaches people that pointing at things does not work here. #[test] diff --git a/crates/tui/src/tui/ui/handlers.rs b/crates/tui/src/tui/ui/handlers.rs index f4fcea405d..f5a3163fb2 100644 --- a/crates/tui/src/tui/ui/handlers.rs +++ b/crates/tui/src/tui/ui/handlers.rs @@ -1088,21 +1088,21 @@ pub(crate) async fn handle_view_events( app.add_message(HistoryCell::System { content: format!("Retrying {tool_name} with network access enabled"), }); - let policy = option.to_policy(&app.workspace); + let policy = option.to_policy(&app.workspace, &app.workspace_roots); let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; } ElevationOption::WithWriteAccess(_) => { app.add_message(HistoryCell::System { content: format!("Retrying {tool_name} with write access enabled"), }); - let policy = option.to_policy(&app.workspace); + let policy = option.to_policy(&app.workspace, &app.workspace_roots); let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; } ElevationOption::FullAccess => { app.add_message(HistoryCell::System { content: format!("Retrying {tool_name} with full access (no sandbox)"), }); - let policy = option.to_policy(&app.workspace); + let policy = option.to_policy(&app.workspace, &app.workspace_roots); let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; } } @@ -1163,6 +1163,21 @@ pub(crate) async fn handle_view_events( continue; } }; + // Seed only after the fallible restore succeeded: the + // persisted metadata is the target session's roots, + // and the respawned engine plus every re-sync read + // this field. Without it, switching sessions either + // leaks the previous session's set into this one or + // silently strips this session's persisted set. + // Seeded under the RECORD primary (B27-1/B27-2): the + // restore re-homes the app onto it, so this is a pure + // load and the previous session's directory never + // enters the set. + crate::tui::ui::session_state::seed_loaded_workspace_roots_into( + app, + &session.metadata.workspace, + &session.metadata.workspace_roots, + ); sync_runtime_workspace_state(task_manager, app.workspace.clone()).await; if respawn { let _ = engine_handle.send(Op::Shutdown).await; @@ -1185,6 +1200,7 @@ pub(crate) async fn handle_view_events( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; @@ -1205,6 +1221,17 @@ pub(crate) async fn handle_view_events( content: loaded_message.clone(), }); app.status_message = Some(loaded_message); + // The picker can cross workspaces and the loaded + // session may carry roots the header never showed; + // name them in the transcript (durable, like the + // receipt above) rather than a transient toast. + if let Some(notice) = workspace_roots_notice( + app.ui_locale, + &app.workspace, + &app.workspace_roots, + ) { + app.add_message(HistoryCell::System { content: notice }); + } app.launch.visible = false; app.launch.status = None; } @@ -2216,6 +2243,7 @@ pub(crate) async fn handle_view_events( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; diff --git a/crates/tui/src/tui/ui/provider_routes.rs b/crates/tui/src/tui/ui/provider_routes.rs index 4a67c9f15f..a653e33774 100644 --- a/crates/tui/src/tui/ui/provider_routes.rs +++ b/crates/tui/src/tui/ui/provider_routes.rs @@ -741,6 +741,7 @@ pub(crate) async fn switch_provider( system_prompt_override: false, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; diff --git a/crates/tui/src/tui/ui/session_state.rs b/crates/tui/src/tui/ui/session_state.rs index 7c2ad636e5..b5bb88abf5 100644 --- a/crates/tui/src/tui/ui/session_state.rs +++ b/crates/tui/src/tui/ui/session_state.rs @@ -588,6 +588,9 @@ pub(crate) fn begin_launch_session( let session_id = uuid::Uuid::new_v4().to_string(); app.current_session_id = Some(session_id.clone()); app.current_session_metadata = None; + // A new session starts single-root: never inherit the previous + // session's additional roots. + app.workspace_roots = Vec::new(); app.session_title = Some(app.tr(MessageId::SessionsNewSessionTitle).into_owned()); app.launch.visible = false; app.launch.status = None; @@ -598,6 +601,7 @@ pub(crate) fn begin_launch_session( system_prompt: None, model: app.model.clone(), workspace: app.workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) } @@ -609,6 +613,194 @@ pub(crate) async fn sync_runtime_workspace_state( task_manager.set_default_workspace(workspace).await; } +/// The load/resume faces' root-set seed (round-26 M26-2, corrected by +/// round-27 B27-1/B27-2). The TUI restore path RE-HOMES the app onto the +/// record's own primary (`apply_workspace_runtime_state` runs inside +/// `apply_loaded_session_*`), so by the time the engine consumes the set +/// this is a PURE LOAD in the core lane's terms — the tolerant normalizer +/// under the RECORD primary is the correct semantics, legacy and +/// hand-edited rows stay loadable, and the LAUNCH directory never enters +/// the set. The round-26 "moved primary validates" shape seeded against +/// the pre-restore `app.workspace` instead: the launch directory became an +/// undeclared writable root (B27-1, persisted by the next autosave) and +/// primary-led rows loaded from a subdirectory were falsely rejected by +/// the ancestor rule because the record primary was not stripped first +/// (B27-2 — core's real move arm, `/cd` and `/fork` all strip it). A +/// genuine primary MOVE keeps using the core lane's own validating path +/// (`resolve_resume_roots`), which strips the old primary before +/// validating; no TUI face moves the primary. +/// Round-38 M38-2 — explicit tolerance decision: tolerant here means +/// STRIP-AND-WARN, not arm-verbatim. The seed drops the entries the +/// validating intake refuses (a filesystem root, an ancestor of the +/// record primary) via [`codewhale_core::sanitize_workspace_roots`]; the +/// caller surfaces [`note_sanitized_loaded_roots`] so the drop is named. +/// Arming them was the M15-2 empty-string-root class through another +/// lane: the in-process file tools' containment boundary IS the root set, +/// so a live `/` contained every path on disk. +pub(crate) fn seed_loaded_workspace_roots( + record_primary: &std::path::Path, + persisted_roots: &[std::path::PathBuf], +) -> (Vec, usize) { + codewhale_core::sanitize_workspace_roots(record_primary, persisted_roots) +} + +/// The assignment-shaped wrapper every load face uses (round-38 M38-2): +/// seed the sanitized set onto the App and name the drop when one happened. +pub(crate) fn seed_loaded_workspace_roots_into( + app: &mut crate::tui::app::App, + record_primary: &std::path::Path, + persisted_roots: &[std::path::PathBuf], +) { + let (clean, dropped) = seed_loaded_workspace_roots(record_primary, persisted_roots); + app.workspace_roots = clean; + note_sanitized_loaded_roots(app, dropped); +} + +/// The strip-and-warn receipt for a loaded row whose set carried entries +/// the validating intake refuses (round-38 M38-2): the load stays tolerant +/// — the session must open so its owner can repair it — but the drop is +/// named in the transcript instead of silently arming a narrower set. +pub(crate) fn note_sanitized_loaded_roots(app: &mut crate::tui::app::App, dropped: usize) { + if dropped == 0 { + return; + } + // Round-39 minor 1: localized (all 15 packs), no literal runs of + // folded-source spaces, and an actionable remedy — the dropped classes + // are refused by every intake and the next autosave persists the + // reduced set, so "repair the record outside the TUI" was not + // actionable; re-attaching through /workspace is. + let notice = app + .tr(crate::localization::MessageId::SanitizedLoadedRoots) + .replace("{count}", &dropped.to_string()); + app.add_message(HistoryCell::System { + content: notice.clone(), + }); + app.status_message = Some(notice); +} + +/// One-line human disclosure for a session that carries accessible roots +/// beside its primary workspace, capped like the model-facing +/// `Accessible folders:` line. The turn-meta envelope is model-facing only +/// and the TUI has no multi-root UI, so without this a session whose roots +/// were attached once (or inherited through resume or a bare fork) re-enters +/// with zero indication that writes under an attached root are governed by +/// this session's policy — and under the default Ask posture an attached git +/// root's writes are carve-out modal-free. +pub(crate) fn workspace_roots_notice( + locale: crate::localization::Locale, + workspace: &Path, + roots: &[PathBuf], +) -> Option { + const MAX_LISTED_ROOTS: usize = 5; + // Round-39 minor 7: the shared lexical strip — a `..`-spelled copy of + // the primary is the head, not an attached root, in the notice too. + let stripped = codewhale_core::strip_workspace_roots_head(workspace, roots); + let additional: Vec<&PathBuf> = stripped.iter().take(MAX_LISTED_ROOTS).collect(); + if additional.is_empty() { + return None; + } + let remainder = stripped.len().saturating_sub(additional.len()); + let listed = additional + .into_iter() + .map(|root| root.display().to_string()) + .collect::>() + .join(", "); + let mut roots_value = listed; + if remainder > 0 { + roots_value.push_str( + &tr(locale, MessageId::WorkspaceRootsRemainder) + .replace("{count}", &remainder.to_string()), + ); + } + Some( + tr(locale, MessageId::WorkspaceRootsNotice) + .replace("{workspace}", &workspace.display().to_string()) + .replace("{roots}", &roots_value), + ) +} + +/// The `/cd` persistence receipt, typed so the closing-status classifier can +/// tell a degraded success from a real failure instead of treating any +/// receipt as an error (round-17: the actor-unavailable arm used to promote +/// to a sticky Error toast even though the direct save had landed). +#[derive(Debug)] +enum WorkspaceSwitchReceipt { + /// The direct save landed; only the actor enqueue failed and the next + /// autosave re-persists the snapshot. A warning note, not an error. + Degraded(String), + /// The swap is not durably recorded (save failed, or the snapshot itself + /// could not be built). The user must see this as a failure. + /// Accuracy caveat (round-24 P3): the save-failed/actor-QUEUED arm also + /// lands here, and that arm DOES durably converge — the actor holds the + /// post-switch snapshot and its latest-wins coalescing drops any stale + /// pre-switch write; it is classified as Failure (not Degraded) because + /// the direct disk authority missed, so the restart-order guarantee + /// depends on the actor alone until the next persist. + Failure(String), +} + +impl WorkspaceSwitchReceipt { + fn text(&self) -> &str { + match self { + Self::Degraded(text) | Self::Failure(text) => text, + } + } +} + +/// Persist the post-switch snapshot through both durability paths and +/// return exactly what happened. The direct save is the immediate disk +/// authority; the actor enqueue heals the reorder window (a queued +/// pre-switch snapshot is coalesced away by this fresher one). The status +/// must never claim a persistence that did not happen: when both paths fail +/// the next restart would resurrect the pre-switch workspace/root set, and +/// that has to be the visible failure. The caller assigns the receipt LAST — +/// the closing "Workspace: X" line must not clobber it (round-14 M-1). +fn persist_workspace_switch_snapshot( + locale: crate::localization::Locale, + manager: &SessionManager, + snapshot: crate::session_manager::SavedSession, +) -> Option { + let save_error = manager.save_session(&snapshot).err(); + // The direct save bypasses the persistence actor, so a pre-`/cd` + // snapshot already queued there (an autosave that fired moments ago) + // could still land after it and revert the root swap on disk. Enqueue + // the post-switch snapshot: the actor's latest-wins coalescing drops + // the queued stale one, and even a stale write already in flight is + // followed by this fresher record of the swapped set. + let queued = persistence_actor::try_persist(PersistRequest::SessionSnapshot(snapshot)); + workspace_switch_persistence_message( + locale, + save_error.as_ref().map(|err| err.to_string()), + queued, + ) +} + +/// The status receipt for the two `/cd` persistence paths, from the actual +/// outcomes: a save error plus an unavailable actor means NEITHER path +/// persisted the swap, and the message must say so — a "persisted" claim +/// there would hide a restart resurrecting the pre-switch workspace/root +/// set. A landed save with a dead actor is a degraded success (`Degraded`), +/// not a failure. +fn workspace_switch_persistence_message( + locale: crate::localization::Locale, + save_error: Option, + queued: bool, +) -> Option { + let message: Option = match (&save_error, queued) { + (None, true) => None, + (None, false) => Some(WorkspaceSwitchReceipt::Degraded( + tr(locale, MessageId::WorkspaceSwitchPersistedActorUnavailable).into_owned(), + )), + (Some(error), true) => Some(WorkspaceSwitchReceipt::Failure( + tr(locale, MessageId::WorkspaceSwitchSaveFailedActorQueued).replace("{error}", error), + )), + (Some(error), false) => Some(WorkspaceSwitchReceipt::Failure( + tr(locale, MessageId::WorkspaceSwitchPersistFailed).replace("{error}", error), + )), + }; + message +} + pub(crate) async fn switch_workspace( app: &mut App, engine_handle: &mut EngineHandle, @@ -616,12 +808,9 @@ pub(crate) async fn switch_workspace( config: &Config, workspace: PathBuf, ) { - if app.is_loading { - app.status_message = - Some("Cannot switch workspace while a request is running.".to_string()); - app.add_message(HistoryCell::System { - content: "Cannot switch workspace while a request is running.".to_string(), - }); + if let Some(message) = workspace_switch_blocked_message(app) { + app.status_message = Some(message.clone()); + app.add_message(HistoryCell::System { content: message }); return; } @@ -630,9 +819,57 @@ pub(crate) async fn switch_workspace( return; } + // Primary-swap semantics, matching the runtime PATCH workspace-only + // branch and `resolve_resume_roots`: the previous directory leaves the + // root set (it stopped being the session's directory), the new + // workspace takes the primary slot, and additional roots survive + // re-normalized against the new primary. + // Round-20 should-fix 1: the runtime PATCH workspace-only branch and + // `resolve_resume_roots` reject a re-based set whose entries become an + // ancestor of (or a super-root for) the new primary — persisting one + // here would strand a later bare fork/resume-move with a hard error. + // Validate BEFORE the swap and refuse the move with the same rule. + // Round-38 M38-3: the carried set strips the OLD head through the + // shared lexical primitive — a `..`-spelled copy of the old primary + // must leave the set with the primary itself, or the abandoned + // directory stays writable after the move. + let carried: Vec = + codewhale_core::strip_workspace_roots_head(&app.workspace, &app.workspace_roots); + let re_based = match codewhale_core::validate_workspace_roots(&workspace, &carried) { + Ok(roots) => roots, + Err(err) => { + // Round-24 P3 copy fix: the old guidance ("re-declare the roots + // after switching") named a producer the TUI does not have — + // no command attaches or detaches roots — and the switch was + // refused, so there is nothing to re-declare "after". The + // actionable exits are a new session in that directory or + // editing the session's root set outside the TUI. + // Round-37 M37-8: the refusal is localized like its sibling + // messages (this flow's every other message ships in all 15 + // packs). + let message = app + .tr(MessageId::WorkspaceSwitchSetRefused) + .replace("{workspace}", &workspace.display().to_string()) + .replace("{reason}", &format!("{err:#}")); + app.status_message = Some(message.clone()); + app.add_message(HistoryCell::System { content: message }); + return; + } + }; + let carried_additional = re_based.len() > 1; + app.workspace = workspace.clone(); + app.workspace_roots = re_based; + apply_workspace_runtime_state(app, config, workspace.clone()); sync_runtime_workspace_state(task_manager, workspace.clone()).await; + // Persist the primary swap immediately (the same pattern as the fork + // paths): the autosave merge treats disk as the authority against an + // empty incoming set, so without a direct save the stale pre-`/cd` + // set would be rewritten on disk and resurrect the old directory as a + // writable root on the next resume. + let persist_receipt = persist_workspace_switch_receipt(app); + let _ = engine_handle.send(Op::Shutdown).await; let engine_config = build_engine_config(app, config); *engine_handle = spawn_tui_engine(engine_config, config); @@ -645,6 +882,7 @@ pub(crate) async fn switch_workspace( system_prompt_override: false, model: app.model.clone(), workspace: workspace.clone(), + workspace_roots: app.workspace_roots.clone(), mode: app.mode, }) .await; @@ -653,7 +891,97 @@ pub(crate) async fn switch_workspace( app.add_message(HistoryCell::System { content: format!("Switched workspace to {}", workspace.display()), }); - app.status_message = Some(format!("Workspace: {}", workspace.display())); + // Round-24 P3 display fix: a switch that RE-BASED surviving roots + // changes what the sandbox governs, exactly like the /resume and /load + // entries that already disclose the set — without this notice the + // carried roots silently changed their anchor (or were dropped by the + // re-normalization) with no in-band disclosure. + if carried_additional + && let Some(notice) = + workspace_roots_notice(app.ui_locale, &workspace, &app.workspace_roots) + { + app.add_message(HistoryCell::System { content: notice }); + } + // The receipt rides the closing line instead of being assigned earlier: + // an unconditional "Workspace: X" assignment after the persist block used + // to clobber every failure receipt it exists to disclose (round-14 M-1). + apply_workspace_switch_closing_status(app, &workspace, persist_receipt); +} + +/// The `/cd` transition guard, aligned with `/clear`: an idle compaction or +/// a queued task blocks the switch just like a running request, because the +/// switch persists the new root set and shuts the engine down — mid-compaction +/// that loses the compaction result. Like `/clear`'s busy message, the wording +/// is generic ("runtime work busy") and localized: naming "a request" would +/// misdescribe the compaction/purge/queued-task legs (round-17). +fn workspace_switch_blocked_message(app: &App) -> Option { + app.session_transition_blocked() + .then(|| tr(app.ui_locale, MessageId::WorkspaceSwitchBusy).into_owned()) +} + +/// The closing `/cd` status line: the persistence receipt (when any) is +/// composed into the success line, so the success text can never silently +/// replace a failure disclosure. +fn workspace_switch_closing_status(workspace: &Path, receipt: Option) -> String { + match receipt { + Some(receipt) => format!("Workspace: {} — {receipt}", workspace.display()), + None => format!("Workspace: {}", workspace.display()), + } +} + +/// Set the closing `/cd` status line and surface the persistence receipt at +/// the severity it actually is: a real failure promotes to a sticky error +/// with a typed level, while a degraded success (save landed, actor down) is +/// a typed warning toast. The `status_message` -> toast sync classifies by +/// sniffing English keywords, so a localized receipt would otherwise degrade +/// to an ephemeral Info toast. Marking the line as seen keeps that sync from +/// re-adding the same text as a second, misclassified toast. +fn apply_workspace_switch_closing_status( + app: &mut App, + workspace: &Path, + receipt: Option, +) { + let closing = workspace_switch_closing_status( + workspace, + receipt.as_ref().map(|receipt| receipt.text().to_string()), + ); + app.status_message = Some(closing.clone()); + match receipt { + Some(WorkspaceSwitchReceipt::Failure(_)) => { + app.set_sticky_status(closing.clone(), StatusToastLevel::Error, None); + app.last_status_message_seen = Some(closing); + } + Some(WorkspaceSwitchReceipt::Degraded(_)) => { + app.push_status_toast(closing.clone(), StatusToastLevel::Warning, Some(8_000)); + app.last_status_message_seen = Some(closing); + } + None => {} + } +} + +/// The `/cd` persist step, returning the user-visible receipt (if any). Only +/// an existing session has a record to swap: with no current session, +/// `build_session_snapshot` would mint AND durably save an empty +/// "New Session" orphan on every bare-prompt `/cd` (round-14 M-3), so the +/// step is skipped entirely there. +fn persist_workspace_switch_receipt(app: &mut App) -> Option { + // Only an existing session has a record to swap: with no current + // session, `build_session_snapshot` would mint AND durably save an empty + // "New Session" orphan on every bare-prompt `/cd` (round-14 M-3). + app.current_session_id.as_ref()?; + match SessionManager::default_location() { + Ok(manager) => match crate::tui::ui::frame::build_session_snapshot(app, &manager) { + Ok(snapshot) => persist_workspace_switch_snapshot(app.ui_locale, &manager, snapshot), + Err(err) => Some(WorkspaceSwitchReceipt::Failure( + tr(app.ui_locale, MessageId::WorkspaceSwitchSnapshotFailed) + .replace("{error}", &err.to_string()), + )), + }, + Err(err) => Some(WorkspaceSwitchReceipt::Failure( + tr(app.ui_locale, MessageId::WorkspaceSwitchSessionsDirFailed) + .replace("{error}", &err.to_string()), + )), + } } pub(crate) fn restore_failed_immediate_submit( @@ -1199,3 +1527,396 @@ mod launch_resume_tests { ); } } + +#[cfg(test)] +mod workspace_switch_persistence_tests { + use super::*; + + fn message_for(save_error: Option, queued: bool) -> Option { + workspace_switch_persistence_message(crate::localization::Locale::En, save_error, queued) + .map(|receipt| receipt.text().to_string()) + } + + /// The round-13 blocker: when the direct save AND the actor enqueue both + /// fail, neither path persisted the swap — the receipt must say so + /// instead of claiming persistence while a restart would resurrect the + /// pre-switch workspace/root set. + #[test] + fn double_failure_reports_failure_never_persistence() { + let message = message_for(Some("disk full".to_string()), false) + .expect("both paths failed; a receipt is required"); + assert!( + message.contains("Failed to persist workspace switch"), + "{message}" + ); + assert!(message.contains("disk full"), "{message}"); + assert!( + !message.to_ascii_lowercase().contains("persisted, but"), + "the double-failure receipt must not claim persistence: {message}" + ); + + // The actor-unavailable leg (save succeeded) is the only one allowed + // to say "persisted". + let saved_but_unqueued = + message_for(None, false).expect("save ok, actor down; a receipt is required"); + assert!( + saved_but_unqueued.contains("persisted"), + "{saved_but_unqueued}" + ); + + // Save failed but the actor holds the snapshot: no "persisted" claim + // about the direct save, and the actor leg is named. + let queued_anyway = message_for(Some("read-only fs".to_string()), true) + .expect("save failed; a receipt is required"); + assert!( + queued_anyway.contains("Direct workspace-switch save failed"), + "{queued_anyway}" + ); + assert!( + !queued_anyway + .to_ascii_lowercase() + .contains("persisted, but"), + "{queued_anyway}" + ); + + // Both paths succeeded: no receipt (the switch message follows). + assert_eq!(message_for(None, true), None); + } + + /// Round-17: the receipt is typed by outcome — the actor-unavailable leg + /// is a degraded success (the direct save landed), only save/snapshot + /// failures are `Failure`. The classifier keys off this type, not off + /// "is there a receipt". + #[test] + fn receipt_type_distinguishes_degraded_success_from_failure() { + assert!(matches!( + workspace_switch_persistence_message(crate::localization::Locale::En, None, false), + Some(WorkspaceSwitchReceipt::Degraded(_)) + )); + assert!(matches!( + workspace_switch_persistence_message( + crate::localization::Locale::En, + Some("disk full".to_string()), + true, + ), + Some(WorkspaceSwitchReceipt::Failure(_)) + )); + assert!(matches!( + workspace_switch_persistence_message( + crate::localization::Locale::En, + Some("disk full".to_string()), + false, + ), + Some(WorkspaceSwitchReceipt::Failure(_)) + )); + assert!( + workspace_switch_persistence_message(crate::localization::Locale::En, None, true) + .is_none() + ); + } + + /// The receipt is user-visible prose: a non-English locale renders its + /// own pack, never the English template. + #[test] + fn double_failure_receipt_is_localized() { + let message = workspace_switch_persistence_message( + crate::localization::Locale::Ja, + Some("disk full".to_string()), + false, + ) + .expect("receipt"); + let message = message.text(); + assert!(message.contains("永続化"), "{message}"); + assert!( + !message.contains("Failed to persist workspace switch"), + "{message}" + ); + } + + /// M-1 wiring: the closing status line composes the receipt into the + /// success text instead of overwriting it, so a double persistence + /// failure can never surface as a bare "Workspace: X". + #[test] + fn closing_status_carries_the_receipt() { + let workspace = Path::new("/tmp/ws"); + let bare = workspace_switch_closing_status(workspace, None); + assert_eq!(bare, "Workspace: /tmp/ws"); + + let with_receipt = workspace_switch_closing_status( + workspace, + Some("Failed to persist workspace switch: disk full".to_string()), + ); + assert!( + with_receipt.contains("Workspace: /tmp/ws"), + "{with_receipt}" + ); + assert!( + with_receipt.contains("Failed to persist workspace switch"), + "the receipt must survive the closing line: {with_receipt}" + ); + } + + /// Round-15: the failure receipt must surface as a sticky Error toast via + /// its typed level, in every locale — the keyword classifier only sniffs + /// English, so a localized receipt would otherwise degrade to an + /// ephemeral Info toast. Round-17: the classification is by receipt TYPE, + /// so the degraded-success arm (save landed, actor down) must NOT promote + /// as a sticky Error — it is a warning toast. + #[test] + fn failure_receipt_promotes_as_typed_sticky_error_in_any_locale() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::new( + crate::test_support::test_tui_options(dir.path()), + &Config::default(), + ); + let receipt = tr( + crate::localization::Locale::Ja, + MessageId::WorkspaceSwitchPersistFailed, + ) + .replace("{error}", "disk full"); + apply_workspace_switch_closing_status( + &mut app, + Path::new("/tmp/ws"), + Some(WorkspaceSwitchReceipt::Failure(receipt)), + ); + let sticky = app.sticky_status.as_ref().expect("sticky error toast"); + assert_eq!(sticky.level, StatusToastLevel::Error); + assert!( + sticky.ttl_ms.is_some(), + "sticky errors stay TTL-capped, not permanent chrome" + ); + assert_eq!( + app.last_status_message_seen, app.status_message, + "the typed promotion replaces the keyword-classified re-toast" + ); + + // The degraded-success arm: the direct save landed and only the + // actor enqueue failed, so a sticky Error would cry failure over a + // persisted switch. It surfaces as a typed Warning toast instead. + let mut app = App::new( + crate::test_support::test_tui_options(dir.path()), + &Config::default(), + ); + let note = tr( + crate::localization::Locale::Ja, + MessageId::WorkspaceSwitchPersistedActorUnavailable, + ) + .into_owned(); + apply_workspace_switch_closing_status( + &mut app, + Path::new("/tmp/ws"), + Some(WorkspaceSwitchReceipt::Degraded(note)), + ); + assert!( + app.sticky_status.is_none(), + "a degraded success must not promote as a sticky error: {:?}", + app.sticky_status + ); + let toast = app + .status_toasts + .back() + .expect("a degraded success still raises a toast"); + assert_eq!(toast.level, StatusToastLevel::Warning); + assert_eq!( + app.last_status_message_seen, app.status_message, + "the typed toast replaces the keyword-classified re-toast" + ); + + let mut app = App::new( + crate::test_support::test_tui_options(dir.path()), + &Config::default(), + ); + apply_workspace_switch_closing_status(&mut app, Path::new("/tmp/ws"), None); + assert!( + app.sticky_status.is_none(), + "a plain success stays an untyped status line" + ); + } + + /// Round-15: `/cd` shares the `/clear` transition guard — an idle + /// compaction (no request running) must block the switch, because the + /// switch persists the new root set and shuts the engine down + /// mid-compaction, losing the result. + #[test] + fn cd_guard_blocks_idle_compaction_like_clear() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::new( + crate::test_support::test_tui_options(dir.path()), + &Config::default(), + ); + app.is_compacting = true; + assert!(!app.is_loading, "idle compaction: no request running"); + assert!(workspace_switch_blocked_message(&app).is_some()); + + app.is_compacting = false; + assert!(workspace_switch_blocked_message(&app).is_none()); + } + + /// Round-17: the blocked message is localized prose that names the real + /// blocker class. The old hardcoded literal blamed "a request running" + /// on every leg — including idle compaction, where no request is running. + #[test] + fn cd_blocked_message_is_localized_and_names_no_running_request() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::new( + crate::test_support::test_tui_options(dir.path()), + &Config::default(), + ); + app.is_compacting = true; + + let message = workspace_switch_blocked_message(&app).expect("blocked"); + assert_eq!( + message, + tr( + crate::localization::Locale::En, + MessageId::WorkspaceSwitchBusy, + ), + "the message comes from the locale pack, not a literal" + ); + assert!( + !message.to_ascii_lowercase().contains("request"), + "an idle compaction must not be told a request is running: {message}" + ); + + app.ui_locale = crate::localization::Locale::Ja; + let message = workspace_switch_blocked_message(&app).expect("blocked"); + assert!(message.contains("ワークスペース"), "{message}"); + assert!(!message.contains("Workspace unchanged"), "{message}"); + } + + /// M-3: a bare-prompt `/cd` has no session record to swap; the persist + /// step must not mint (and durably save) an empty "New Session" orphan. + #[test] + fn bare_cd_persists_nothing_and_mints_no_orphan() { + let dir = tempfile::tempdir().expect("tempdir"); + let options = crate::test_support::test_tui_options(dir.path()); + let mut app = App::new(options, &Config::default()); + assert!(app.current_session_id.is_none(), "fresh TUI has no session"); + + let receipt = persist_workspace_switch_receipt(&mut app); + assert!(receipt.is_none(), "no session, no receipt, no disk write"); + assert!( + app.current_session_id.is_none() && app.current_session_metadata.is_none(), + "the persist step must not mint a session on a bare /cd" + ); + } + + /// M-2: the two `/cd` failure strings are typed MessageIds rendered from + /// every pack, never hardcoded English. + #[test] + fn cd_failure_strings_are_localized() { + for locale in [ + crate::localization::Locale::En, + crate::localization::Locale::Ja, + crate::localization::Locale::ZhHans, + ] { + let snapshot = tr(locale, MessageId::WorkspaceSwitchSnapshotFailed); + let sessions_dir = tr(locale, MessageId::WorkspaceSwitchSessionsDirFailed); + assert!(snapshot.contains("{error}"), "{snapshot}"); + assert!(sessions_dir.contains("{error}"), "{sessions_dir}"); + } + let ja = tr( + crate::localization::Locale::Ja, + MessageId::WorkspaceSwitchSnapshotFailed, + ); + assert!(!ja.contains("Failed to snapshot"), "{ja}"); + } +} + +#[cfg(test)] +mod seed_loaded_workspace_roots_tests { + use super::seed_loaded_workspace_roots; + + #[test] + fn seeds_under_the_record_primary_and_never_the_launch_directory() { + // Round-27 B27-1 regression pin: the round-26 shape seeded against + // the PRE-restore app.workspace, so a record loaded from a different + // directory ended up with the LAUNCH directory materialized as an + // undeclared writable root (persisted by the next autosave). The + // restore re-homes the app onto the record primary, so the seed + // anchors there — the launch directory can never enter the set. + let record_primary = std::path::PathBuf::from("/work/project"); + let launch_dir = std::path::PathBuf::from("/home/alice/elsewhere"); + let carried = vec![ + std::path::PathBuf::from("/work/project"), + std::path::PathBuf::from("/home/alice"), + ]; + let (seeded, dropped) = seed_loaded_workspace_roots(&record_primary, &carried); + assert_eq!(seeded, carried); + assert_eq!(dropped, 0, "a clean row seeds whole"); + assert!( + !seeded.contains(&launch_dir), + "the launch directory must never be seeded as a root: {seeded:?}" + ); + // The launch directory being an ANCESTOR of nothing relevant is not + // consulted at all — no move arm exists on the TUI faces (B27-2: + // primary-led rows load from any directory without false refusal). + let (primary_led, dropped) = seed_loaded_workspace_roots( + &record_primary, + &[ + std::path::PathBuf::from("/work/project"), + std::path::PathBuf::from("/srv/data"), + ], + ); + assert_eq!( + primary_led, + vec![ + std::path::PathBuf::from("/work/project"), + std::path::PathBuf::from("/srv/data"), + ], + "a primary-led row is a pure load from any directory (B27-2)" + ); + assert_eq!(dropped, 0); + + // Round-38 M38-2: the tolerance decision is STRIP-AND-WARN. A + // legacy/hand-edited row carrying the filesystem root (or a primary + // ancestor) must not arm — the in-process file tools' containment + // boundary IS the root set, so a live `/` contained every path on + // disk. The seed drops the poison and reports the count; the load + // itself stays tolerant (the session must open to be repairable). + let (stripped, dropped) = seed_loaded_workspace_roots( + &record_primary, + &[ + std::path::PathBuf::from("/work/project"), + std::path::PathBuf::from("/"), + std::path::PathBuf::from("/work"), + ], + ); + assert_eq!( + stripped, + vec![std::path::PathBuf::from("/work/project")], + "the fs-root and the primary-ancestor entries drop, the clean head stays" + ); + assert_eq!(dropped, 2, "both poison entries are named in the receipt"); + + // Round-39 minors 2+3: the receipt also counts the shape + // normalizer's own drops (a relative spelling) and its cap + // truncation — a silently-truncated over-cap row reports the + // truncation instead of loading quietly at 65. + let (_, dropped) = seed_loaded_workspace_roots( + &record_primary, + &[ + std::path::PathBuf::from("/work/project"), + std::path::PathBuf::from("relative/root"), + std::path::PathBuf::from("/srv/data"), + ], + ); + assert_eq!( + dropped, 1, + "a relative entry dropped by the shape normalizer is counted" + ); + + let mut over_cap = vec![std::path::PathBuf::from("/work/project")]; + over_cap.extend( + (0..codewhale_core::MAX_WORKSPACE_ROOTS + 6) + .map(|i| std::path::PathBuf::from(format!("/srv/r{i}"))), + ); + let (armed, dropped) = seed_loaded_workspace_roots(&record_primary, &over_cap); + assert_eq!( + armed.len(), + codewhale_core::MAX_WORKSPACE_ROOTS + 1, + "the tolerant load truncates at the cap" + ); + assert_eq!(dropped, 6, "the cap truncation is counted, not silent"); + } +} diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4a190c174e..209a7fdc84 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3869,6 +3869,93 @@ fn create_test_app() -> App { app } +#[test] +fn autosave_snapshot_sanitizes_a_poisoned_live_root_set() { + // Round-39 minor 11: the autosave belt's non-identity case — a live set + // carrying poison (which the seed strips; this is the defense in depth) + // is sanitized onto the persisted snapshot instead of laundered. + let tmpdir = tempfile::TempDir::new().unwrap(); + let mut app = create_test_app(); + app.workspace = tmpdir.path().join("ws"); + std::fs::create_dir_all(&app.workspace).unwrap(); + let keep = tmpdir.path().join("keep"); + std::fs::create_dir_all(&keep).unwrap(); + app.workspace_roots = vec![ + app.workspace.clone(), + std::path::PathBuf::from("/"), + keep.clone(), + ]; + + let manager = + crate::session_manager::SessionManager::new(tmpdir.path().join("sessions")).unwrap(); + let snapshot = + super::frame::build_session_snapshot(&mut app, &manager).expect("snapshot builds"); + + assert_eq!( + snapshot.metadata.workspace_roots, + vec![app.workspace.clone(), keep], + "the belt strips the poison from the persisted snapshot" + ); +} + +#[test] +fn sanitized_roots_receipt_is_localized_and_names_the_count() { + // Round-39 minor 1: the user-visible half of strip-and-warn — the + // receipt renders the localized message with the count substituted, + // carries no folded-source space runs, and names the actionable remedy + // (/workspace re-attach), not the unactionable "repair outside the + // TUI" (the next autosave persists the reduced set regardless). + let mut app = create_test_app(); + super::session_state::note_sanitized_loaded_roots(&mut app, 3); + let message = app.status_message.expect("receipt surfaced"); + assert!(message.contains('3'), "the count is substituted: {message}"); + assert!( + !message.contains(" "), + "no folded-source space runs ship in the receipt: {message:?}" + ); + assert!( + !message.contains("/workspace"), + "the remedy must not name a producer the TUI does not have: {message}" + ); + assert!( + message.contains("reload") || message.contains("Reload"), + "the remedy names the reload path that actually drops the refused roots: {message}" + ); + + // Zero drops stay silent. + let mut app = create_test_app(); + super::session_state::note_sanitized_loaded_roots(&mut app, 0); + assert!(app.status_message.is_none(), "a clean load gets no receipt"); +} + +#[test] +fn workspace_move_and_conversation_change_clear_session_approval_sets() { + // Round-37 M37-6: session grants/denials are keyed to the primary the + // approval was judged under (operand resolution + patch root binding), + // so installing a different workspace or a different conversation must + // drop the stale set instead of letting it auto-approve the same + // spellings re-resolved under the new root. + let mut app = create_test_app(); + app.approval_session_approved + .insert("shell:2:sh@cwd:8:/old/sub".to_string()); + app.approval_session_denied + .insert("file:edit_file:abc".to_string()); + assert_eq!(app.approval_session_approved.len(), 1); + assert_eq!(app.approval_session_denied.len(), 1); + + let config = crate::config::Config::default(); + let moved = std::env::temp_dir().join(format!("m37-6-move-{}", std::process::id())); + super::apply::apply_workspace_runtime_state(&mut app, &config, moved); + assert!( + app.approval_session_approved.is_empty(), + "a workspace move orphans every root-bound grant" + ); + assert!( + app.approval_session_denied.is_empty(), + "the denial receipts move with the same anchor" + ); +} + #[test] fn runtime_chat_short_circuits_inline_voice_and_cache_actions_before_ui_mutation() { let mut app = create_test_app(); @@ -6598,6 +6685,7 @@ fn saved_session_with_messages(messages: Vec) -> SavedSession { model_provider: "deepseek".to_string(), model_provider_id: None, workspace: PathBuf::from("/tmp/resume-recovery"), + workspace_roots: Vec::new(), mode: Some("yolo".to_string()), cost: crate::session_manager::SessionCostSnapshot::default(), parent_session_id: None, @@ -6640,6 +6728,43 @@ fn named_custom_session_config(name: &str, base_url: &str, model: &str) -> Confi } } +#[test] +fn apply_loaded_session_refuses_a_relative_workspace_with_localized_copy() { + // Round-45 R45-3: the refusal surfaces through "Failed to restore + // session: {err}", so it is user-facing copy and must ride the + // localized MessageId — asserting on a non-English locale also pins + // that no hardcoded English leak survives. The localization lookup + // needs the larger debug-build stack (R45-1/P2-8), so the body runs on + // an explicitly sized thread. + std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(|| { + let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::Ja; + let mut session = saved_session_with_messages(Vec::new()); + session.metadata.workspace = std::path::PathBuf::from("relative/ws"); + let err = apply_loaded_session(&mut app, &mut Config::default(), &session) + .expect_err("a relative saved workspace must be refused"); + assert_eq!( + err, + crate::localization::tr( + crate::localization::Locale::Ja, + crate::localization::MessageId::SessionRestoreWorkspaceInvalid + ) + ); + assert_ne!( + err, + crate::localization::tr( + crate::localization::Locale::En, + crate::localization::MessageId::SessionRestoreWorkspaceInvalid + ) + ); + }) + .expect("spawn localized-refusal test thread") + .join() + .expect("localized-refusal test thread panicked"); +} + #[test] fn apply_loaded_session_keeps_submitted_user_tail_in_history() { let mut app = create_test_app(); @@ -17609,6 +17734,14 @@ fn session_snapshot_and_resume_round_trip_work_state() { let manager = crate::session_manager::SessionManager::new(tmp.path().join("sessions")).expect("manager"); let mut app = create_test_app(); + // Round-42 minor 9: absolute fixture workspace (the restore lane + // refuses relative primaries now). + app.workspace = std::env::temp_dir() + .join(format!("snapshot-roundtrip-{}", std::process::id())) + .canonicalize() + .unwrap_or_else(|_| { + std::env::temp_dir().join(format!("snapshot-roundtrip-{}", std::process::id())) + }); app.api_messages.push(text_message("user", "keep my work")); app.api_messages .push(text_message("assistant", "continuity captured")); @@ -17825,6 +17958,28 @@ fn legacy_session_without_work_state_clears_previous_todo_on_load() { assert_eq!(app.work_state_snapshot().expect("snapshot"), None); } +#[test] +fn empty_workspace_session_restore_is_rejected_and_leaves_current_session_intact() { + // Regression pin: a legacy or hand-edited record with `workspace: ""` + // used to resume unchallenged, seeding the vacuous containment root + // (`starts_with("")` accepts every path) into the restored session. + let mut app = create_test_app(); + app.api_messages + .push(text_message("user", "current conversation")); + app.current_session_id = Some("current-session".to_string()); + let mut session = saved_session_with_messages(vec![text_message("user", "legacy")]); + session.metadata.workspace = PathBuf::new(); + + let err = apply_loaded_session(&mut app, &mut Config::default(), &session).unwrap_err(); + + assert!( + err.contains("workspace must be a non-empty absolute path"), + "{err}" + ); + assert_eq!(app.api_messages.len(), 1); + assert_eq!(app.current_session_id.as_deref(), Some("current-session")); +} + #[test] fn contended_work_restore_leaves_current_session_wholly_unchanged() { let mut app = create_test_app(); @@ -18425,6 +18580,9 @@ fn auto_route_receipt_survives_session_snapshot_and_restore() { let tmp = tempfile::tempdir().expect("tempdir"); let manager = crate::session_manager::SessionManager::new(tmp.path().join("sessions")).expect("manager"); + // Round-42 minor 9: the restore lane refuses relative primaries now — + // pin the app to the absolute fixture workspace real sessions carry. + let workspace = tmp.path().canonicalize().expect("canonical workspace"); let receipt = crate::model_routing::AutoRouteReceipt { tier: crate::model_routing::AutoRouteTier::Strong, pair: crate::model_routing::AutoRoutePair { @@ -18438,6 +18596,7 @@ fn auto_route_receipt_survives_session_snapshot_and_restore() { ), }; let mut app = create_test_app(); + app.workspace = workspace.clone(); app.set_model_selection("auto".to_string()); app.last_effective_provider = Some(ApiProvider::Zai); app.last_effective_provider_identity = Some("zai".to_string()); @@ -20862,6 +21021,7 @@ async fn approval_decision_persists_ask_rules_to_permissions_file() { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::OnFailure, sandbox_mode: None, + workspace_roots: Vec::new(), }) .expect("check persisted runtime policy"); assert!(decision.requires_approval); @@ -20918,6 +21078,7 @@ async fn approval_decision_persists_exact_workspace_allow_rule() { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest, sandbox_mode: None, + workspace_roots: Vec::new(), }) .expect("check persisted allow"); assert_eq!( @@ -20935,6 +21096,7 @@ async fn approval_decision_persists_exact_workspace_allow_rule() { path: None, ask_for_approval: codewhale_execpolicy::AskForApproval::OnRequest, sandbox_mode: None, + workspace_roots: Vec::new(), }) .expect("check expanded command"); assert!(expanded.requires_approval); diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index 04c90b40fe..7f8ae1670b 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -847,6 +847,7 @@ fn filesystem_scope_notice(app: &App) -> Option> { app.approval_mode, app.configured_sandbox_mode.as_deref(), &app.workspace, + &app.workspace_roots, crate::core::authority::SandboxNetworkAccess::from_config(app.configured_sandbox_network), ); // A policy is an intent; enforcement needs a backend. On default Linux diff --git a/docs/AUTHORIZATION_ORDER.md b/docs/AUTHORIZATION_ORDER.md index 5322de1f89..cb67e280d8 100644 --- a/docs/AUTHORIZATION_ORDER.md +++ b/docs/AUTHORIZATION_ORDER.md @@ -39,7 +39,21 @@ a hold that the layer actually produced. There is no project-local permission-rule source today. An optional `workspace` field scopes one user rule to a repository; it does not create a project overlay. `/permissions` reports that source, matcher, scope, and whether the -scope applies to the current workspace. +scope covers the current session. In a multi-root session a scoped ASK/DENY rule is +eligible against the accessible roots inside its scope (subtree containment, +matching by the normalized scope spelling); a scoped ALLOW matches its scope +EXACTLY (never a subdirectory — the widening direction stays closed); an +UNSCOPED rule spans the whole set. The action carried by a rule scoped to the SESSION PRIMARY stays +primary-root-scoped, so such an `allow` never auto-approves a write under an +attached root — but an `allow` scoped to an attached root itself auto-approves +calls judged at that root, including a `cwd:`-operand redirect landing there +(the judged-cwd walk; widening auto-approval remains the wrong direction and +the primary-scoped narrowing holds). One exception, inherited from the +single-root base: a rule spelling an ABSOLUTE path falls back to matching the +call's original spelling exactly, root-independently, so such a rule can +approve a write under an attached root — while scoped `ask`/`deny` apply to +every root inside their scope (the same containment the execution-policy +engine applies). The execution-policy engine evaluates matching rules as follows: diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 07c7e5cef5..58f0a53f08 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -521,6 +521,11 @@ a TLS or verified transport boundary. - `GET /v1/sessions/summary?…` (same query params; projected row shape) - `GET /v1/sessions/{id}` (add `?peek=true&entries=12` for a bounded, redacted read-only peek instead of the full transcript) +- `POST /v1/sessions` (`{ "thread_id": string, "title"?: string }` — save a + thread's stored turns as a new session) +- `PUT /v1/sessions` (`{ "thread_id"?: string, "session_id"?: string }` — + snapshot a thread's live engine state; a `session_id` overwrites that + session in place) - `PATCH /v1/sessions/{id}` (`{ "title"?: string, "archived"?: bool }`) - `DELETE /v1/sessions/{id}` - `POST /v1/sessions/{id}/resume-thread` @@ -560,9 +565,12 @@ archive notion. While a session is open in an interactive Codewhale process, that process holds the authoritative copy in memory and rewrites the whole document on its next -autosave. `PATCH` therefore fails closed on it with `409 Conflict` rather than -writing something that would be silently reverted. Change it in the terminal -instead. A standalone `codewhale web` holds nothing open and is never blocked. +autosave. **Retracted (round-20 B20-3):** the `409 Conflict` guard on +`PATCH` and `PUT /v1/sessions` was removed — the process-local live-session +registry cannot coexist with the runtime HTTP server in any shipped +topology, so the conflict was unobservable and the guard is gone. Same- +process writers converge by last-write-wins at the store layer; change a +session open in the terminal from the terminal. `GET /v1/sessions/{id}?peek=true` returns a bounded, redacted, read-only view instead of the transcript: at most 12 entries of at most 400 characters each @@ -590,10 +598,72 @@ provider, model, workspace, and permission fields: "model_provider": "openai-codex", "model": "gpt-5.6", "reasoning_effort": "high", - "allowed_tools": ["read_file", "search"] + "allowed_tools": ["read_file", "search"], + "workspace_roots": ["/Users/you/projects/codewhale", "/Users/you/projects/shared"] } ``` +`workspace_roots` (added by the multi-root workspace theme) declares the +thread's full accessible root set: the first entry is the primary root (the +thread's `workspace`) and the rest are attached roots whose writes the +thread's sandbox policy, write carve-out, and repo law also govern. Two +server-side qualifications apply to "first entry" (round-39 B39-4): when +the request omits `workspace`, the server fills in its own workspace, +which takes the primary slot and demotes the client-sent first entry to an +attached root; and when `workspace` IS present and `workspace_roots[0]` +differs from it, the same silent demotion applies — the `workspace` field +is always the primary, and the declared first entry rides as an attached +root. Omitting +the field yields the historical single-root thread: the stored set is +`[workspace]`, serialized as a one-element array (see the ThreadRecord note +under "Runtime data model"). `PATCH +/v1/threads/{id}` accepts the same field to reshape a live thread (empty +array clears back to the bare workspace); a change while a turn is active is +rejected. `POST /v1/threads/{id}/fork` and `POST /v1/threads/{id}/resume` +take no request body: both always inherit the thread's stored set, and a +client posting `workspace_roots` to either gets plain inheritance — reshape +through `PATCH` instead. + +Resuming or forking a thread id that does not exist answers `404 Not Found` +(the stdio `thread/resume` / `thread/fork` methods answer the typed +`thread_not_found` error, `-32004`) instead of a success envelope carrying +`status: "missing"`, so a stale id no longer hides behind a 200. Declared +`workspace_roots` are validated at intake rather than silently reshaped: a +root that is not an absolute path (a `~/shared` spelling is refused, not +dropped), a root that normalizes to the filesystem root (`/`, `/..`), and a +root that is an ancestor of the primary workspace (its parent directory) +all answer `400 Bad Request` with the reason — each of those would widen +the per-turn sandbox past what the request declared. A root that sits under +the primary is accepted, and an explicit empty array still clears back to +the bare workspace. A declared set is additionally capped at **64 entries** +(`MAX_WORKSPACE_ROOTS`): an over-cap declaration answers `400` instead of +being truncated. The ancestor and filesystem-root rejections judge the +path the filesystem resolves (round-37 M37-3): an EXISTING declared root is +canonicalized before the comparison — and a NOT-YET-MATERIALIZED primary +resolves through its deepest existing ancestor before comparing, so a +symlink spelling — and, on case-insensitive filesystems, a case-folded +spelling — of the primary's ancestor is rejected on both mixed-existence +shapes; a declared root that does not exist yet keeps the lexical +judgment (materialization stays the delivery lane's job; the same-disk +TOCTOU is the disclosed inherent one). Disclosed residuals +(round-41): the comparisons fold ASCII case only (non-Unicode), matching the +block-only law contract's safe direction; and on Windows an SMB/UNC alias +spelling of the primary's existing ancestor (`\\localhost\C$\…` for a +`C:\…` primary, share reachable) canonicalizes to the UNC form the +drive-letter comparison never unifies, so the ancestor rejection is defeated +for that class — exploitability is tempered (a share of the ancestor +directory; `C$` needs admin), and exec-policy scope matching treats the UNC +operand as POSIX-rooted, so primary-scoped rules do not span the alias +either. + +Stored rows persist the cwd-first full form (primary first, up to 64 +additional entries — 65 stored). Every resume/fork/load face that +re-declares a stored row through the validating intake strips the stored +head first (`validate_stored_workspace_roots`), so a legal full-load row +round-trips whole; a client replaying a stored row's `workspace_roots` as +a fresh declaration must strip the primary itself, or a full-load row +answers `400` on the cap. + `reasoning_effort` uses the canonical Runtime vocabulary (`auto`, `off`, `low`, `medium`, `high`, `xhigh`, `ultra`, or `max`; documented compatibility aliases are accepted and persisted canonically). `allowed_tools` is a @@ -779,6 +849,22 @@ the side repo actually knows — including entries beyond the default listing window — exactly (full id, case-sensitive); an unknown or malformed id returns `404`, and a non-matching id is never passed to any git command. +When the snapshot's owning thread declares a root outside the primary +workspace, the restore response grows a `boundary` object naming what the +rollback does not cover: `attached_roots_not_reverted` (always `true`) and +`note` (human-readable text). Single-root restores keep the legacy bare +shape above — no `boundary` key. + +```json +{ + "restored": "", + "boundary": { + "attached_roots_not_reverted": true, + "note": "Only the primary workspace was reverted; attached workspace roots were not rolled back." + } +} +``` + ```json [ { @@ -1016,7 +1102,19 @@ The runtime uses a durable Thread/Turn/Item lifecycle. - **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`, `model_provider` (generic kind), `model_provider_id` (optional exact configured - route), `workspace`, `mode`, `task_id`, `system_prompt`, `latest_turn_id`, + route), `workspace`, `workspace_roots` (the full accessible root set, + primary first; normalization prepends the workspace only when it is a + non-empty ABSOLUTE path, so an absolute-workspace thread created through + `POST /v1/threads` serializes at least one element — a single-root + thread reads `"workspace_roots": [""]` — while a relative + workspace collapses the set to empty and the key is omitted. NOTE + (round-39 B39-4): `POST /v1/threads` rejects only an EMPTY workspace — + a fresh relative-workspace row is mintable through this API today (the + inline guard records that "requires it to be absolute" overclaims), so + the collapsed shape is NOT legacy-only; any row whose normalized set is + empty — including every row persisted before the field existed — omits + the key entirely), `mode`, + `task_id`, `system_prompt`, `latest_turn_id`, `latest_response_bookmark`, `archived` - **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed| failed|interrupted|canceled`), `effective_provider`, `effective_model`, diff --git a/docs/SANDBOX.md b/docs/SANDBOX.md index 2aa216bd2b..f995ade7d6 100644 --- a/docs/SANDBOX.md +++ b/docs/SANDBOX.md @@ -77,8 +77,10 @@ read-only, applied last so they can narrow a policy-writable path) and directories are never honored). Missing paths are skipped silently. That gives the child a read-only root view. For `workspace-write`, every safe, -existing policy root is mounted read-write: the working directory, configured -additional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git +existing policy root is mounted read-write: the working directory, the +session's configured additional roots (for a multi-root thread this is the +attached `workspace_roots` beside the primary workspace; for a single-root +thread none), `/tmp` and `TMPDIR` unless excluded, and verified Git worktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are remounted read-only after their writable parent. Missing paths, non-directory paths, and `/` are not promoted to writable mounts.