diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index 0ffdbb3a..1749dcf6 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -566,7 +566,7 @@ fn append_orchestrator_agent_routing_guidance(out: &mut String) { fn append_coder_agent_routing_guidance(out: &mut String) { out.push_str("\nCoder worktree routing guidance:\n"); - out.push_str("- Implement only in the provided child worktree/branch. Use `cd ` before Bash commands and do not edit main-workspace `.yoi`, Ticket, workflow, docs, or memory records; child-worktree `.yoi` project records may be visible when they are part of the branch.\n"); + out.push_str("- Implement only in the provided child worktree/branch. SpawnPod should set `cwd` to that worktree so Bash/tool defaults already start there; do not treat `cwd` as authority, and do not edit main-workspace `.yoi`, Ticket, workflow, docs, or memory records; child-worktree `.yoi` project records may be visible when they are part of the branch.\n"); out.push_str("- Do not create `.yoi/memory`, local/runtime state, logs, locks, caches, sockets, or secret-like files in the child worktree.\n"); out.push_str("- Treat the intent packet, binding decisions/invariants, implementation latitude, validation expectations, and report expectations as the contract. Investigate and choose local tactics only within the recorded implementation latitude; escalate to Orchestrator rather than expanding scope when design, permission, history, prompt-context, dependency, or Ticket-boundary questions appear.\n"); out.push_str("- Report worktree path, branch, commits/status, changed files, implementation summary, validation run, unresolved notes, and whether the branch is ready for external review. Do not merge, push, close Tickets, or delete worktrees.\n"); diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 20c24444..439d8bfe 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -493,6 +493,7 @@ where // below so the worker borrow doesn't conflict with reads on `pod`. let scope_handle = pod.scope().clone(); let pwd = pod.pwd().to_path_buf(); + let workspace_root = pod.workspace_root().to_path_buf(); let task_feature = pod.task_feature(); let session_id_for_usage = pod.segment_id().to_string(); let memory_config = pod.manifest().memory.clone(); @@ -523,7 +524,9 @@ where let mut feature_registry = FeatureRegistryBuilder::new(); feature_registry.add_module(task_feature); - feature_registry.add_module(crate::feature::builtin::ticket_tools_feature(&pwd)); + feature_registry.add_module(crate::feature::builtin::ticket_tools_feature( + &workspace_root, + )); let _feature_install_report = pod.install_features(feature_registry); let worker = pod.worker_mut(); @@ -534,7 +537,7 @@ where // their built-in linter. Companion deny rules on the generic CRUD // scope were already applied during `Pod::from_manifest`. if let Some(mem) = memory_config.as_ref() { - let layout = memory::WorkspaceLayout::resolve(mem, &pwd); + let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root); let query_cfg = memory::tool::QueryConfig::from(mem); worker.register_tool(memory::tool::read_tool_with_usage( layout.clone(), @@ -554,6 +557,7 @@ where spawner_name.clone(), spawner_socket, runtime_base.clone(), + workspace_root.clone(), pwd.clone(), spawned_registry.clone(), self_parent_socket, diff --git a/crates/pod/src/entrypoint.rs b/crates/pod/src/entrypoint.rs index b7750370..5106d46f 100644 --- a/crates/pod/src/entrypoint.rs +++ b/crates/pod/src/entrypoint.rs @@ -28,6 +28,12 @@ struct Cli { #[arg(long, value_name = "PATH")] workspace: Option, + /// Internal spawned child process/tool working directory. This is separate + /// from `--workspace`; adopted Pods use `--workspace` for runtime context + /// and this path for tool defaults. + #[arg(long, value_name = "PATH", requires = "adopt", hide = true)] + tool_cwd: Option, + /// Manifest TOML to use directly as a one-file compatibility/debug input. /// This bypasses profile discovery but still applies builtin defaults and /// the same required-field validation boundary. @@ -96,6 +102,17 @@ fn runtime_workspace_root(cli: &Cli) -> Result { } } +fn runtime_tool_cwd(cli: &Cli, workspace_root: &Path) -> Result { + let raw = cli.tool_cwd.as_deref().unwrap_or(workspace_root); + let path = if raw.is_absolute() { + raw.to_path_buf() + } else { + workspace_root.join(raw) + }; + std::fs::canonicalize(&path) + .map_err(|e| format!("failed to resolve tool cwd {}: {e}", path.display())) +} + fn runtime_pod_name(cli: &Cli, workspace_root: &Path) -> String { cli.pod .as_deref() @@ -350,8 +367,33 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { return ExitCode::FAILURE; } }; - match Pod::from_manifest_spawned(manifest, store, loader, callback).await { - Ok(p) => p, + let tool_cwd = match runtime_tool_cwd(&cli, &workspace_root) { + Ok(path) => path, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + }; + match Pod::from_manifest_spawned_with_context( + manifest, + store, + loader, + callback, + workspace_root.clone(), + tool_cwd.clone(), + ) + .await + { + Ok(p) => { + if let Err(e) = std::env::set_current_dir(&tool_cwd) { + eprintln!( + "error: failed to enter tool cwd {}: {e}", + tool_cwd.display() + ); + return ExitCode::FAILURE; + } + p + } Err(e) => { eprintln!("error: failed to create spawned pod: {e}"); return ExitCode::FAILURE; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index a20e9cf1..b11b82a6 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -231,8 +231,11 @@ pub struct Pod { /// `segment_id` and append tally. `self.segment_id()` is a thin /// wrapper over `segment_state.segment_id()`. segment_state: Arc, - /// Absolute working directory of the Pod. + /// Absolute tool/process working directory of the Pod. pwd: PathBuf, + /// Absolute runtime workspace root used for project records, workflow, + /// memory, Ticket config, Profile context, and spawned-child inheritance. + workspace_root: PathBuf, /// Shared, atomically-swappable view of the Pod's resolved scope. /// Cloned out to `ScopedFs` instances (builtin tools, fs_view, /// compact worker) so scope updates propagate to every consumer @@ -417,6 +420,7 @@ impl Pod { pod_metadata_writer: None, segment_state: self.segment_state.clone(), pwd: self.pwd.clone(), + workspace_root: self.workspace_root.clone(), scope: self.scope.clone(), delegation_scope: self.delegation_scope.clone(), hook_builder: HookRegistryBuilder::new(), @@ -597,6 +601,7 @@ impl Pod { store, pod_metadata_writer: None, segment_state: SegmentState::new(session_id, segment_id, 0), + workspace_root: pwd.clone(), pwd, scope: SharedScope::new(scope), delegation_scope, @@ -695,11 +700,17 @@ impl Pod { &self.manifest } - /// The Pod's working directory. + /// The Pod's tool/process working directory. pub fn pwd(&self) -> &Path { &self.pwd } + /// The Pod's runtime workspace root. This stays separate from `pwd` for + /// spawned children whose SpawnPod `cwd` only changes tool defaults. + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + /// The Pod's directory scope, as a shared atomically-swappable /// handle. Clone it to share scope state with another consumer /// (e.g. a tool that needs to mutate scope dynamically). @@ -1239,7 +1250,7 @@ impl Pod { .map(|d| d.name) .collect() }; - let agents_md_read = read_agents_md(&self.pwd); + let agents_md_read = read_agents_md(&self.workspace_root); for warning in agents_md_read.warnings { if let Some(n) = alerter.as_ref() { n.alert(AlertLevel::Warn, AlertSource::AgentsMd, warning); @@ -2784,7 +2795,7 @@ impl Pod { // `Some(0)` means disabled, same as `None`. Otherwise the // `tokens_since >= 0` comparison would fire on every post-run. let Some(threshold) = memory_cfg.extract_threshold.filter(|n| *n > 0) else { - let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.pwd); + let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.workspace_root); let model = memory_cfg .extract_model .as_ref() @@ -2814,7 +2825,7 @@ impl Pod { .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { - let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.pwd); + let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.workspace_root); let model = memory_cfg .extract_model .as_ref() @@ -2870,7 +2881,7 @@ impl Pod { ) -> Result { use memory::extract; - let layout = memory::WorkspaceLayout::resolve(memory_cfg, &self.pwd); + let layout = memory::WorkspaceLayout::resolve(memory_cfg, &self.workspace_root); let model = memory_cfg .extract_model .as_ref() @@ -3193,7 +3204,7 @@ impl Pod { let files_threshold = memory_cfg.consolidation_threshold_files.filter(|n| *n > 0); let bytes_threshold = memory_cfg.consolidation_threshold_bytes.filter(|n| *n > 0); if files_threshold.is_none() && bytes_threshold.is_none() { - let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.pwd); + let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.workspace_root); let model = memory_cfg .consolidation_model .as_ref() @@ -3221,7 +3232,7 @@ impl Pod { .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { - let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.pwd); + let layout = memory::WorkspaceLayout::resolve(&memory_cfg, &self.workspace_root); let model = memory_cfg .consolidation_model .as_ref() @@ -3274,7 +3285,7 @@ impl Pod { ) -> Result { use memory::consolidate; - let layout = memory::WorkspaceLayout::resolve(memory_cfg, &self.pwd); + let layout = memory::WorkspaceLayout::resolve(memory_cfg, &self.workspace_root); let model = memory_cfg .consolidation_model .as_ref() @@ -3730,6 +3741,7 @@ where pod_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, 0), pwd: common.pwd, + workspace_root: common.workspace_root, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -3784,7 +3796,34 @@ where loader: PromptLoader, callback_socket: PathBuf, ) -> Result { - let mut common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?; + let pwd = current_pwd()?; + Self::from_manifest_spawned_with_context( + manifest, + store, + loader, + callback_socket, + pwd.clone(), + pwd, + ) + .await + } + + pub async fn from_manifest_spawned_with_context( + manifest: PodManifest, + store: St, + loader: PromptLoader, + callback_socket: PathBuf, + workspace_root: PathBuf, + tool_cwd: PathBuf, + ) -> Result { + let mut common = prepare_pod_common_with_context( + &manifest, + &loader, + /* parse_template */ true, + workspace_root, + tool_cwd, + manifest.scope.clone(), + )?; let skill_shadows = std::mem::take(&mut common.skill_shadows); // A spawned child starts its own conversation, so it mints a @@ -3809,6 +3848,7 @@ where pod_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, 0), pwd: common.pwd, + workspace_root: common.workspace_root, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -3987,6 +4027,7 @@ where pod_metadata_writer, segment_state: SegmentState::new(session_id, segment_id, state.entries_count), pwd: common.pwd, + workspace_root: common.workspace_root, scope: SharedScope::new(common.scope), delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -4610,11 +4651,13 @@ pub enum PodError { } /// Bundle of resources that every high-level Pod constructor needs: -/// pwd, scope, an LLM client, the prompt catalog, and (optionally) a -/// parsed system-prompt template. Built once by [`prepare_pod_common`] -/// from the resolved manifest and then split into Pod fields. +/// tool pwd, runtime workspace root, scope, an LLM client, the prompt catalog, +/// and (optionally) a parsed system-prompt template. Built once by +/// [`prepare_pod_common`] from the resolved manifest and then split into Pod +/// fields. struct PodCommon { pwd: PathBuf, + workspace_root: PathBuf, scope: Scope, delegation_scope: DelegationScope, client: Box, @@ -4705,8 +4748,9 @@ fn prepare_pod_common( parse_template: bool, ) -> Result { let pwd = current_pwd()?; - let scope = build_scope_with_memory(manifest, &pwd)?; - prepare_pod_common_from_scope(manifest, loader, parse_template, pwd, scope) + let workspace_root = pwd.clone(); + let scope = build_scope_with_memory(manifest, &workspace_root)?; + prepare_pod_common_from_scope(manifest, loader, parse_template, workspace_root, pwd, scope) } fn prepare_pod_common_with_scope( @@ -4716,17 +4760,45 @@ fn prepare_pod_common_with_scope( scope_config: ScopeConfig, ) -> Result { let pwd = current_pwd()?; + let workspace_root = pwd.clone(); let scope = Scope::from_config(&scope_config).map_err(PodError::Scope)?; - prepare_pod_common_from_scope(manifest, loader, parse_template, pwd, scope) + prepare_pod_common_from_scope(manifest, loader, parse_template, workspace_root, pwd, scope) +} + +fn prepare_pod_common_with_context( + manifest: &PodManifest, + loader: &PromptLoader, + parse_template: bool, + workspace_root: PathBuf, + pwd: PathBuf, + scope_config: ScopeConfig, +) -> Result { + let workspace_root = + std::fs::canonicalize(&workspace_root).map_err(|source| PodError::InvalidPwd { + pwd: workspace_root.clone(), + source, + })?; + let pwd = std::fs::canonicalize(&pwd).map_err(|source| PodError::InvalidPwd { + pwd: pwd.clone(), + source, + })?; + let scope = Scope::from_config(&scope_config).map_err(PodError::Scope)?; + prepare_pod_common_from_scope(manifest, loader, parse_template, workspace_root, pwd, scope) } fn prepare_pod_common_from_scope( manifest: &PodManifest, loader: &PromptLoader, parse_template: bool, + workspace_root: PathBuf, pwd: PathBuf, scope: Scope, ) -> Result { + if !scope.is_readable(&workspace_root) { + return Err(PodError::PwdOutsideScope { + pwd: workspace_root, + }); + } if !scope.is_readable(&pwd) { return Err(PodError::PwdOutsideScope { pwd }); } @@ -4738,7 +4810,7 @@ fn prepare_pod_common_from_scope( let memory_layout = manifest .memory .as_ref() - .map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd)); + .map(|mem| memory::WorkspaceLayout::resolve(mem, &workspace_root)); let mut workflow_registry = match memory_layout.as_ref() { Some(layout) => workflow_crate::load_workflows(layout).map_err(PodError::WorkflowLoad)?, None => workflow_crate::WorkflowRegistry::empty(), @@ -4756,6 +4828,7 @@ fn prepare_pod_common_from_scope( Ok(PodCommon { pwd, + workspace_root, scope, delegation_scope, client, @@ -4861,6 +4934,70 @@ fn current_pwd() -> Result { .map_err(|source| PodError::InvalidPwd { pwd: cwd, source }) } +#[cfg(test)] +mod spawned_context_tests { + use super::*; + + #[test] + fn spawn_pod_context_keeps_workspace_root_separate_from_tool_pwd() { + let tmp = tempfile::tempdir().unwrap(); + let workspace_root = tmp.path().join("workspace-root"); + let tool_cwd = tmp.path().join("child-worktree"); + std::fs::create_dir_all(&workspace_root).unwrap(); + std::fs::create_dir_all(&tool_cwd).unwrap(); + + let mut manifest = minimal_manifest_for_context_test(&workspace_root, &tool_cwd); + manifest.memory = Some(manifest::MemoryConfig::default()); + let common = prepare_pod_common_with_context( + &manifest, + &PromptLoader::builtins_only(), + false, + workspace_root.clone(), + tool_cwd.clone(), + manifest.scope.clone(), + ) + .unwrap(); + + assert_eq!( + common.workspace_root, + workspace_root.canonicalize().unwrap() + ); + assert_eq!(common.pwd, tool_cwd.canonicalize().unwrap()); + assert_eq!( + common.memory_layout.as_ref().unwrap().root(), + workspace_root.canonicalize().unwrap() + ); + } + + fn minimal_manifest_for_context_test(workspace_root: &Path, tool_cwd: &Path) -> PodManifest { + let toml_str = format!( + r#" +[pod] +name = "spawn-context-test" + +[model] +scheme = "anthropic" +model_id = "claude-sonnet-4-20250514" + +[worker] + +[[scope.allow]] +target = "{}" +permission = "read" + +[[scope.allow]] +target = "{}" +permission = "write" +"#, + workspace_root.display(), + tool_cwd.display() + ); + let mut manifest = PodManifest::from_toml(&toml_str).unwrap(); + manifest.model.auth = Some(manifest::AuthRef::None); + manifest + } +} + #[cfg(test)] mod memory_worker_event_tests { use super::*; diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index f0390316..642c78ba 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -224,8 +224,11 @@ pub struct SpawnPodTool { /// Root of the `$XDG_RUNTIME_DIR/yoi/` tree, used to predict /// the spawned Pod's socket path before the child has bound it. runtime_base: PathBuf, - /// Directory the spawned Pod should run in when the LLM did not - /// override it. Defaults to the spawner's pwd — see module docs. + /// Inherited runtime workspace root for Profile/project/Ticket/workflow/ + /// memory context. SpawnPod `cwd` must not affect this value. + workspace_root: PathBuf, + /// Directory the spawned Pod's tools should use when the LLM did not + /// override it. Defaults to the spawner's tool pwd. spawner_pwd: PathBuf, /// Optional typed runtime command injected by tests. Production resolves /// the runtime command from `std::env::current_exe()` at launch time. @@ -266,6 +269,7 @@ impl SpawnPodTool { spawner_name: String, callback_socket: PathBuf, runtime_base: PathBuf, + workspace_root: PathBuf, spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, @@ -279,6 +283,7 @@ impl SpawnPodTool { spawner_name, callback_socket, runtime_base, + workspace_root, spawner_pwd, runtime_command, registry, @@ -470,7 +475,11 @@ impl SpawnPodTool { .arg(&self.callback_socket) .arg("--spawn-config-json") .arg(spawn_config_json) - .current_dir(child_cwd) + .arg("--workspace") + .arg(&self.workspace_root) + .arg("--tool-cwd") + .arg(child_cwd) + .current_dir(&self.workspace_root) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::from(stderr_file)) @@ -602,9 +611,8 @@ fn validate_spawn_cwd( /// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the /// internal handoff shape. /// -/// The child's working directory is set separately via -/// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is -/// not part of the manifest. +/// The child's tool working directory is carried separately through +/// the child runtime entrypoint; it is not part of the manifest. impl SpawnPodTool { fn build_spawn_config_json( &self, @@ -616,7 +624,7 @@ impl SpawnPodTool { build_spawn_config_json_for_profile( &self.spawner_manifest, &self.available_profiles, - &self.spawner_pwd, + &self.workspace_root, name, instruction_override, scope_allow, @@ -628,7 +636,7 @@ impl SpawnPodTool { fn build_spawn_config_json_for_profile( spawner_manifest: &PodManifest, available_profiles: &AvailableProfiles, - spawner_pwd: &Path, + workspace_root: &Path, name: &str, instruction_override: Option<&str>, scope_allow: &[ScopeRule], @@ -650,7 +658,7 @@ fn build_spawn_config_json_for_profile( SpawnProfileSelector::Inherit => unreachable!(), }; let resolved = ProfileResolver::new() - .with_workspace_base(spawner_pwd) + .with_workspace_base(workspace_root) .resolve_from_registry( &profile_selector, registry, @@ -867,6 +875,7 @@ pub fn spawn_pod_tool( spawner_name: String, callback_socket: PathBuf, runtime_base: PathBuf, + workspace_root: PathBuf, spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, @@ -878,6 +887,7 @@ pub fn spawn_pod_tool( spawner_name, callback_socket, runtime_base, + workspace_root, spawner_pwd, registry, parent_socket, @@ -893,6 +903,7 @@ pub fn spawn_pod_tool_with_runtime_command( spawner_name: String, callback_socket: PathBuf, runtime_base: PathBuf, + workspace_root: PathBuf, spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, @@ -905,6 +916,7 @@ pub fn spawn_pod_tool_with_runtime_command( spawner_name, callback_socket, runtime_base, + workspace_root, spawner_pwd, registry, parent_socket, @@ -919,6 +931,7 @@ fn spawn_pod_tool_impl( spawner_name: String, callback_socket: PathBuf, runtime_base: PathBuf, + workspace_root: PathBuf, spawner_pwd: PathBuf, registry: Arc, parent_socket: Option, @@ -930,7 +943,7 @@ fn spawn_pod_tool_impl( Arc::new(move || { let schema = schemars::schema_for!(SpawnPodInput); let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); - let available_profiles = AvailableProfiles::discover(&spawner_pwd); + let available_profiles = AvailableProfiles::discover(&workspace_root); let description = prompts .spawn_pod_tool_description( &available_profiles.compact_list(), @@ -950,6 +963,7 @@ fn spawn_pod_tool_impl( spawner_name.clone(), callback_socket.clone(), runtime_base.clone(), + workspace_root.clone(), spawner_pwd.clone(), registry.clone(), parent_socket.clone(), diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index 40036e1a..82f7c10a 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -146,25 +146,26 @@ fn mock_runtime_command() -> PodRuntimeCommand { } fn cwd_recording_runtime_command(script_path: &Path, output_path: &Path) -> PodRuntimeCommand { - std::fs::write(script_path, "pwd > \"$1\"\n").unwrap(); - PodRuntimeCommand::new( - which_sh(), - vec![ - script_path.as_os_str().to_os_string(), - output_path.as_os_str().to_os_string(), - ], + let output = output_path.display(); + std::fs::write( + script_path, + format!( + "tmp=\"{output}.tmp\"\npwd > \"$tmp\"\nprintf '%s\\n' \"$@\" >> \"$tmp\"\nmv \"$tmp\" \"{output}\"\n" + ), ) + .unwrap(); + PodRuntimeCommand::new(which_sh(), vec![script_path.as_os_str().to_os_string()]) } -async fn read_recorded_pwd(output_path: &Path) -> String { +async fn read_recorded_runtime_invocation(output_path: &Path) -> Vec { for _ in 0..50 { if let Ok(content) = std::fs::read_to_string(output_path) { - return content.trim_end().to_string(); + return content.lines().map(str::to_owned).collect(); } tokio::time::sleep(std::time::Duration::from_millis(10)).await; } panic!( - "runtime command did not record pwd at {}", + "runtime command did not record invocation at {}", output_path.display() ); } @@ -269,7 +270,7 @@ fn clear_env() { } #[tokio::test] -async fn spawn_pod_runs_child_process_in_provided_cwd() { +async fn spawn_pod_launches_runtime_in_workspace_and_passes_tool_cwd() { let _env = EnvGuard::acquire(); let allow_root = TempDir::new().unwrap(); @@ -289,6 +290,7 @@ async fn spawn_pod_runs_child_process_in_provided_cwd() { spawner_socket, runtime_base, allow_root.path().to_path_buf(), + allow_root.path().to_path_buf(), registry, None, dummy_manifest(allow_root.path()), @@ -312,9 +314,19 @@ async fn spawn_pod_runs_child_process_in_provided_cwd() { tool.execute(&input).await.unwrap(); assert!(matches!(received.await.unwrap(), Some(Method::Run { .. }))); - assert_eq!( - read_recorded_pwd(&output_path).await, - child_cwd.to_str().unwrap() + let invocation = read_recorded_runtime_invocation(&output_path).await; + assert_eq!(invocation[0], allow_root.path().to_str().unwrap()); + assert!( + invocation + .windows(2) + .any(|pair| pair[0] == "--workspace" && pair[1] == allow_root.path().to_str().unwrap()), + "invocation should carry inherited workspace root: {invocation:?}" + ); + assert!( + invocation + .windows(2) + .any(|pair| pair[0] == "--tool-cwd" && pair[1] == child_cwd.to_str().unwrap()), + "invocation should carry tool cwd separately: {invocation:?}" ); clear_env(); @@ -340,6 +352,7 @@ async fn spawn_pod_omitted_cwd_preserves_spawner_pwd() { spawner_socket, runtime_base, allow_root.path().to_path_buf(), + allow_root.path().to_path_buf(), registry, None, dummy_manifest(allow_root.path()), @@ -362,9 +375,13 @@ async fn spawn_pod_omitted_cwd_preserves_spawner_pwd() { tool.execute(&input).await.unwrap(); assert!(matches!(received.await.unwrap(), Some(Method::Run { .. }))); - assert_eq!( - read_recorded_pwd(&output_path).await, - allow_root.path().to_str().unwrap() + let invocation = read_recorded_runtime_invocation(&output_path).await; + assert_eq!(invocation[0], allow_root.path().to_str().unwrap()); + assert!( + invocation + .windows(2) + .any(|pair| pair[0] == "--tool-cwd" && pair[1] == allow_root.path().to_str().unwrap()), + "omitted cwd should preserve spawner pwd as tool cwd: {invocation:?}" ); clear_env(); @@ -388,6 +405,7 @@ async fn spawn_pod_delegates_scope_and_sends_run() { spawner_socket.clone(), runtime_base.clone(), allow_root.path().to_path_buf(), + allow_root.path().to_path_buf(), registry, None, dummy_manifest(allow_root.path()), @@ -480,6 +498,7 @@ async fn spawn_pod_requires_explicit_delegation_even_with_direct_scope() { spawner_socket, runtime_base, allow_root.path().to_path_buf(), + allow_root.path().to_path_buf(), registry, None, manifest, @@ -546,6 +565,7 @@ async fn spawn_pod_rejects_child_non_recursive_scope_under_parent_non_recursive_ spawner_socket, runtime_base, allow_root.path().to_path_buf(), + allow_root.path().to_path_buf(), registry, None, manifest, @@ -597,6 +617,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() { spawner_socket, runtime_base, allow_root.path().to_path_buf(), + allow_root.path().to_path_buf(), registry, None, dummy_manifest(allow_root.path()), @@ -670,6 +691,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() { spawner_socket, runtime_base, allow_root.path().to_path_buf(), + allow_root.path().to_path_buf(), registry, None, dummy_manifest(allow_root.path()),