diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index bd6010662..ee28531c5 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -1278,6 +1278,8 @@ pub enum RunnerConfigErrorField { SkillsRoots, #[serde(rename = "instructions.files")] InstructionsFiles, + #[serde(rename = "policy.managed_worktree_root")] + ManagedWorktreeRoot, #[serde(rename = "shell.max_persistent_shells")] ShellMaxPersistentShells, #[serde(rename = "shell.persistent_shell_idle_timeout_secs")] diff --git a/crates/webcodex-runner/src/main_tests.rs b/crates/webcodex-runner/src/main_tests.rs index fb862ae73..bfad9a670 100644 --- a/crates/webcodex-runner/src/main_tests.rs +++ b/crates/webcodex-runner/src/main_tests.rs @@ -2008,6 +2008,89 @@ fn concurrent_managed_worktree_bootstraps_choose_distinct_runner_paths() { assert_eq!(load_runner_project_summaries_from_dir(®istry).len(), 3); } +#[test] +fn managed_worktree_uses_configured_central_root_without_changing_identity() { + let tmp = tempfile::tempdir().unwrap(); + let other_root = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + let central = tmp.path().join("central"); + let registry = tmp.path().join("project-registry"); + seed_managed_worktree_repo(&source); + std::fs::create_dir(¢ral).unwrap(); + let central = central.canonicalize().unwrap(); + register_managed_source_project(®istry, &source); + let mut policy = project_policy(tmp.path()); + policy.allowed_roots = vec![ + other_root.path().canonicalize().unwrap(), + tmp.path().canonicalize().unwrap(), + ]; + policy.managed_worktree_root = Some(central.clone()); + let request = managed_worktree_request( + &source, + serde_json::Value::Null, + "77777777-7777-4777-8777-777777777777", + None, + ); + + let result = project_ok(handle_prepare_managed_worktree( + &policy, ®istry, &request, + )); + let path = PathBuf::from(result["path"].as_str().unwrap()); + assert_eq!(path.parent(), Some(central.as_path())); + assert_eq!(result["outcome"], "managed_worktree_created"); + assert_eq!(result["managed"], true); +} + +#[test] +fn managed_worktree_resume_revalidates_current_root_without_retargeting_old_path() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + let central = tmp.path().join("central"); + let replacement = tmp.path().join("replacement"); + let registry = tmp.path().join("project-registry"); + seed_managed_worktree_repo(&source); + std::fs::create_dir(¢ral).unwrap(); + std::fs::create_dir(&replacement).unwrap(); + register_managed_source_project(®istry, &source); + + let mut initial_policy = project_policy(tmp.path()); + initial_policy.managed_worktree_root = Some(central.canonicalize().unwrap()); + let request = managed_worktree_request( + &source, + serde_json::Value::Null, + "88888888-8888-4888-8888-888888888888", + None, + ); + let created = project_ok(handle_prepare_managed_worktree( + &initial_policy, + ®istry, + &request, + )); + let old_path = created["path"].clone(); + + let mut reloaded_policy = project_policy(tmp.path()); + reloaded_policy.managed_worktree_root = Some(replacement.canonicalize().unwrap()); + let resume = managed_worktree_request( + &source, + serde_json::Value::Null, + "99999999-9999-4999-8999-999999999999", + created["agent_project_id"].as_str(), + ); + let resumed = project_ok(handle_prepare_managed_worktree( + &reloaded_policy, + ®istry, + &resume, + )); + assert_eq!(resumed["outcome"], "managed_worktree_recovered"); + assert_eq!(resumed["path"], old_path); + assert_eq!( + Path::new(resumed["path"].as_str().unwrap()) + .parent() + .unwrap(), + central.canonicalize().unwrap() + ); +} + #[test] fn managed_worktree_git_source_without_registered_project_does_not_infer_lineage() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/webcodex-runner/src/main_tests/config_reload.rs b/crates/webcodex-runner/src/main_tests/config_reload.rs index 49aa0afce..da9b8a818 100644 --- a/crates/webcodex-runner/src/main_tests/config_reload.rs +++ b/crates/webcodex-runner/src/main_tests/config_reload.rs @@ -235,6 +235,121 @@ fn instruction_files_do_not_expand_project_allowed_roots() { assert_eq!(config.instructions.files, vec![instruction_file]); } +#[test] +fn managed_worktree_root_is_hot_reloadable_and_rejects_invalid_values() { + let (tmp, path, runtime) = reload_fixture(); + // An explicit temp root is portable: "/" is not the Temp directory's + // drive on Windows and must not authorize the configured worktree root. + let allowed_root = format!( + "policy.allowed_roots = [{:?}]", + tmp.path().canonicalize().unwrap().to_string_lossy() + ); + let central = tmp.path().join("central-worktrees"); + std::fs::create_dir(¢ral).unwrap(); + let central = central.canonicalize().unwrap(); + let valid = reload_toml( + "oe", + None, + 60, + 1024, + "sh", + "native", + false, + "claude", + "project_search_generation_1", + ) + .replace("policy.allowed_roots = [\"/\"]", &allowed_root) + .replace( + "policy.max_output_bytes = 1024", + &format!( + "policy.max_output_bytes = 1024\npolicy.managed_worktree_root = {:?}", + central.to_string_lossy() + ), + ); + std::fs::write(&path, valid).unwrap(); + let checked = runtime.check_config(); + assert_eq!(checked.valid, Some(true)); + assert!(!checked.restart_required); + assert_eq!(runtime.reload().generation, 2); + assert_eq!( + runtime.snapshot().policy.managed_worktree_root, + Some(central.clone()) + ); + + std::fs::write( + &path, + reload_toml( + "oe", + None, + 60, + 1024, + "sh", + "native", + false, + "claude", + "project_search_generation_2", + ) + .replace("policy.allowed_roots = [\"/\"]", &allowed_root) + .replace( + "policy.max_output_bytes = 1024", + &format!( + "policy.max_output_bytes = 1024\npolicy.managed_worktree_root = {:?}", + tmp.path().join("missing").to_string_lossy() + ), + ), + ) + .unwrap(); + let rejected = runtime.check_config(); + assert_eq!(rejected.valid, Some(false)); + assert_eq!( + rejected.error_field, + Some(runner_protocol::RunnerConfigErrorField::ManagedWorktreeRoot) + ); + assert_eq!( + rejected.error_reason, + Some(runner_protocol::RunnerConfigErrorReason::InvalidPath) + ); + assert_eq!( + runtime.snapshot().policy.managed_worktree_root, + Some(central) + ); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let target = tmp.path().join("real-central"); + let redirected = tmp.path().join("redirected-central"); + std::fs::create_dir(&target).unwrap(); + symlink(&target, &redirected).unwrap(); + let candidate = reload_toml( + "oe", + None, + 60, + 1024, + "sh", + "native", + false, + "claude", + "project_search_generation_3", + ) + .replace("policy.allowed_roots = [\"/\"]", &allowed_root) + .replace( + "policy.max_output_bytes = 1024", + &format!( + "policy.max_output_bytes = 1024\npolicy.managed_worktree_root = {:?}", + redirected.to_string_lossy() + ), + ); + std::fs::write(&path, candidate).unwrap(); + let checked = runtime.check_config(); + assert_eq!(checked.valid, Some(false)); + assert_eq!( + checked.error_field, + Some(runner_protocol::RunnerConfigErrorField::ManagedWorktreeRoot) + ); + } +} + #[test] fn invalid_instruction_paths_are_structured_config_diagnostics() { let (_tmp, path, runtime) = reload_fixture(); diff --git a/crates/webcodex-runner/src/webcodex_runner/config.rs b/crates/webcodex-runner/src/webcodex_runner/config.rs index af8ac6749..2caa47c74 100644 --- a/crates/webcodex-runner/src/webcodex_runner/config.rs +++ b/crates/webcodex-runner/src/webcodex_runner/config.rs @@ -54,12 +54,51 @@ pub(crate) struct InstructionsConfig { pub(crate) files: Vec, } +fn validate_managed_worktree_root_config(policy: &RunnerPolicy) -> Result<(), String> { + let Some(path) = policy.managed_worktree_root.as_ref() else { + return Ok(()); + }; + if !path.is_absolute() || webcodex_runner_config::paths::project_path_has_parent_traversal(path) + { + return Err( + "policy.managed_worktree_root must be an absolute path without parent traversal" + .to_string(), + ); + } + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + format!("policy.managed_worktree_root must be an existing directory: {error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err( + "policy.managed_worktree_root must be an existing non-redirected directory".to_string(), + ); + } + let canonical = std::fs::canonicalize(path).map_err(|error| { + format!("policy.managed_worktree_root cannot be canonicalized: {error}") + })?; + if !webcodex_runner_config::paths::paths_equal(path, &canonical) { + return Err( + "policy.managed_worktree_root must not contain redirected path components".to_string(), + ); + } + let roots = + webcodex_runner_config::paths::canonicalize_usable_allowed_roots(&policy.allowed_roots); + if !roots + .iter() + .any(|root| webcodex_runner_config::paths::path_is_within(&canonical, root)) + { + return Err( + "policy.managed_worktree_root must be inside effective allowed_roots".to_string(), + ); + } + Ok(()) +} + #[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] pub(crate) struct SkillsConfig { #[serde(default)] pub(crate) roots: Vec, } - #[derive(Debug, Clone, Deserialize)] pub(crate) struct RunnerConfig { pub(crate) server_url: String, @@ -375,6 +414,11 @@ pub(crate) struct RunnerPolicy { pub(crate) allow_cwd_anywhere: bool, #[serde(default)] pub(crate) allowed_roots: Vec, + /// Optional operator-selected central namespace for new managed + /// worktrees. It is validated against the effective roots at config load + /// and revalidated against the live filesystem before each operation. + #[serde(default)] + pub(crate) managed_worktree_root: Option, #[serde(default = "default_max_timeout_secs")] pub(crate) max_timeout_secs: u64, /// Per-stream Runner capture/presentation policy for stdout and stderr. @@ -389,6 +433,7 @@ impl Default for RunnerPolicy { allow_raw_shell: true, allow_cwd_anywhere: false, allowed_roots: Vec::new(), + managed_worktree_root: None, max_timeout_secs: DEFAULT_MAX_TIMEOUT_SECS, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, } @@ -1072,6 +1117,12 @@ fn reload_error_diagnostic( Some(RunnerConfigErrorReason::InvalidPath), ); } + if error.starts_with("policy.managed_worktree_root") { + return ( + Some(RunnerConfigErrorField::ManagedWorktreeRoot), + Some(RunnerConfigErrorReason::InvalidPath), + ); + } OUT_OF_RANGE_FIELDS .iter() .find_map(|(prefix, field)| { @@ -1649,6 +1700,7 @@ pub(crate) fn load_config_with_server_url( let effective = effective_allowed_roots(&cfg.policy.allowed_roots, cfg.policy.allow_cwd_anywhere)?; cfg.policy.allowed_roots = effective; + validate_managed_worktree_root_config(&cfg.policy)?; if cfg.project_registry_dir.is_none() { cfg.project_registry_dir = Some(default_project_registry_dir()?); } diff --git a/crates/webcodex-runner/src/webcodex_runner/persistent_shell/tests/unix.rs b/crates/webcodex-runner/src/webcodex_runner/persistent_shell/tests/unix.rs index ea38ad2c6..7b026660c 100644 --- a/crates/webcodex-runner/src/webcodex_runner/persistent_shell/tests/unix.rs +++ b/crates/webcodex-runner/src/webcodex_runner/persistent_shell/tests/unix.rs @@ -63,6 +63,7 @@ fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, RunnerPolicy) { allow_raw_shell: true, allow_cwd_anywhere: false, allowed_roots: vec![project.clone()], + managed_worktree_root: None, max_timeout_secs: 30, max_output_bytes: 16 * 1024, }; diff --git a/crates/webcodex-runner/src/webcodex_runner/persistent_shell/tests/windows.rs b/crates/webcodex-runner/src/webcodex_runner/persistent_shell/tests/windows.rs index c0a8d493d..6cd0dbe0e 100644 --- a/crates/webcodex-runner/src/webcodex_runner/persistent_shell/tests/windows.rs +++ b/crates/webcodex-runner/src/webcodex_runner/persistent_shell/tests/windows.rs @@ -104,6 +104,7 @@ fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, RunnerPolicy) { allow_raw_shell: true, allow_cwd_anywhere: false, allowed_roots: vec![project.clone()], + managed_worktree_root: None, max_timeout_secs: 30, max_output_bytes: 16 * 1024, }; diff --git a/crates/webcodex-runner/src/webcodex_runner/projects/managed_worktree.rs b/crates/webcodex-runner/src/webcodex_runner/projects/managed_worktree.rs index 3d25efc70..c3bce453b 100644 --- a/crates/webcodex-runner/src/webcodex_runner/projects/managed_worktree.rs +++ b/crates/webcodex-runner/src/webcodex_runner/projects/managed_worktree.rs @@ -129,6 +129,10 @@ fn managed_worktree_namespace_candidate( policy: &RunnerPolicy, source: &Path, ) -> Result { + if policy.managed_worktree_root.is_some() { + let root = validate_configured_managed_worktree_root(policy, Some(source))?; + return Ok(root); + } let mut roots = webcodex_runner_config::paths::canonicalize_usable_allowed_roots(&policy.allowed_roots); roots.sort_by_key(|root| std::cmp::Reverse(root.components().count())); @@ -136,6 +140,7 @@ fn managed_worktree_namespace_candidate( if !webcodex_runner_config::paths::path_is_within(source, &root) { continue; } + let candidate = root.join(MANAGED_WORKTREE_NAMESPACE); if webcodex_runner_config::paths::path_is_within(&candidate, source) { continue; @@ -153,6 +158,29 @@ fn managed_worktree_namespace_candidate( Err("managed_worktree_root_unavailable") } +fn validate_configured_managed_worktree_root( + policy: &RunnerPolicy, + source: Option<&Path>, +) -> Result { + let Some(configured) = policy.managed_worktree_root.as_ref() else { + return Err("managed_worktree_root_unavailable"); + }; + let metadata = + std::fs::symlink_metadata(configured).map_err(|_| "managed_worktree_root_unavailable")?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("managed_worktree_root_unsafe"); + } + let root = + canonicalize_existing(configured).map_err(|_| "managed_worktree_root_unavailable")?; + if !paths_equal(&root, configured) + || source.is_some_and(|source| webcodex_runner_config::paths::path_is_within(&root, source)) + || validate_project_path_policy(policy, &root).is_err() + { + return Err("managed_worktree_root_unsafe"); + } + Ok(root) +} + /// Materialize the Runner-owned managed-worktree namespace inside the current /// Runner filesystem policy. The caller never selects this destination, but the /// resulting Project must remain usable by the ordinary file/shell/process paths, @@ -161,6 +189,9 @@ fn ensure_managed_worktree_root( policy: &RunnerPolicy, source: &Path, ) -> Result { + if policy.managed_worktree_root.is_some() { + return managed_worktree_namespace_candidate(policy, source); + } let candidate = managed_worktree_namespace_candidate(policy, source)?; match std::fs::symlink_metadata(&candidate) { Ok(metadata) => { @@ -335,6 +366,18 @@ fn resume_managed_worktree( requested_base_ref: Option<&str>, resume_project_id: &str, ) -> CommandResult { + if policy.managed_worktree_root.is_some() + && validate_configured_managed_worktree_root(policy, Some(source_root)).is_err() + { + return managed_worktree_error( + start, + "managed_worktree_root_unsafe", + false, + requested_base_ref, + None, + Some(source_dirty), + ); + } if validate_project_op_id(resume_project_id).is_err() { return managed_worktree_error( start, diff --git a/docs/RUNNER.md b/docs/RUNNER.md index bbf0935c4..fcba6f8b7 100644 --- a/docs/RUNNER.md +++ b/docs/RUNNER.md @@ -118,13 +118,21 @@ or created: [policy] allow_cwd_anywhere = false allowed_roots = ["/root/git"] +# Optional central namespace for newly created managed worktrees. +managed_worktree_root = "/srv/webcodex-worktrees" ``` Managed-worktree bootstrap uses the same boundary. The Runner may derive a private managed namespace so the model never supplies a destination path, but that namespace and the resulting Project still have to fit the effective -`allowed_roots` policy (unless `allow_cwd_anywhere=true`). A Project selector or -Project ref does not widen this filesystem authority. +`allowed_roots` policy (unless `allow_cwd_anywhere=true`). To centralize new +worktrees across multiple allowed roots, set `managed_worktree_root` to an +absolute, existing, non-symbolic-link directory inside the effective +`allowed_roots` and outside each source checkout. Invalid values reject config +check/reload and never fall back to the derived namespace. The value is +hot-reloadable; it affects only future creation and revalidates on creation and +resume. Existing managed worktrees are never moved or retargeted. A Project +selector or Project ref does not widen this filesystem authority. ### Registering projects at runtime diff --git a/docs/RUNNER.zh-CN.md b/docs/RUNNER.zh-CN.md index 52aac2ae4..5f406e30e 100644 --- a/docs/RUNNER.zh-CN.md +++ b/docs/RUNNER.zh-CN.md @@ -99,8 +99,19 @@ Runner policy 中的 `allowed_roots` 控制项目可以在哪里注册或创建 [policy] allow_cwd_anywhere = false allowed_roots = ["/root/git"] +# 新创建的 managed worktree 可选的集中目录。 +managed_worktree_root = "/srv/webcodex-worktrees" ``` +Managed-worktree bootstrap 使用相同的边界。Runner 可以派生私有 managed +namespace,使模型无需提供目标路径;但该 namespace 与最终 Project 仍必须符合有效的 +`allowed_roots` policy(除非 `allow_cwd_anywhere=true`)。如果多个 allowed root 需要 +集中放置新 worktree,可将 `managed_worktree_root` 设置为有效 allowed root 内的绝对、 +已存在且不含符号链接/重定向的目录,并且不能位于 source checkout 内。配置检查或 reload +遇到无效值会直接拒绝,绝不会静默回退到派生 namespace。该值支持 hot reload,只影响今后 +的创建,并会在创建和 resume 时重新验证;已有 managed worktree 不会移动或静默改向。 +Project selector 或 Project ref 不会扩大此文件系统 authority。 + ### 运行时注册项目 runtime 工具 `register_project` 与 `create_project` 让客户端在在线 Runner 上