diff --git a/.yoi/workflow/multi-agent-workflow.md b/.yoi/workflow/multi-agent-workflow.md index 9dc7ff5f..d9e8bbab 100644 --- a/.yoi/workflow/multi-agent-workflow.md +++ b/.yoi/workflow/multi-agent-workflow.md @@ -119,7 +119,7 @@ reviewer には coder の実装方針ではなく、この intent packet と dif - child worktree path / branch - 対象 ticket path - intent packet - - Bash は必ず child worktree に `cd` すること + - SpawnPod の `cwd` は child worktree に設定すること(`cwd` は process/tool default cwd であり、scope/authority ではない) - main workspace の `TODO.md` / `tickets/` / `docs/report/` / `.yoi` は編集しないこと - child worktree 内の tracked `.yoi` project records は実装対象に必要な branch-local artifacts/dossiers として編集してよいが、`.yoi/memory` や local/runtime/secret-like files は作らないこと - active orchestration progress と最終 review/approval/close は main workspace の責任として残すこと diff --git a/.yoi/workflow/worktree-workflow.md b/.yoi/workflow/worktree-workflow.md index 999bbf9f..593f0d67 100644 --- a/.yoi/workflow/worktree-workflow.md +++ b/.yoi/workflow/worktree-workflow.md @@ -116,7 +116,7 @@ fi ## Pod へ渡す scope -Pod を使う場合、Pod の cwd は main workspace のままになる。必ず作業対象が child worktree であることを明示し、Bash 実行時は毎回 `cd /.worktree/ && ...` させる。 +Pod を使う場合、coder Pod の SpawnPod `cwd` は child worktree に設定する。`cwd` は child process/tool default cwd だけを変え、runtime workspace root・project record root・scope/authority は変えないため、下の明示 scope は別途渡す。 coder Pod 推奨 scope: diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index e094c300..0ffdbb3a 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -549,7 +549,7 @@ fn append_orchestrator_agent_routing_guidance(out: &mut String) { out.push_str("- Create worktrees or spawn coder/reviewer Pods only after `workflow_state = inprogress` is already recorded and accepted. If the Ticket is still queued and unblocked, record `queued -> inprogress` before any worktree/SpawnPod side effect.\n"); out.push_str("- Use `worktree-workflow` for the mechanical worktree plan: create `.worktree/`, keep tracked `.yoi` project records visible in the child worktree, exclude `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths, and keep active orchestration progress plus final review/approval/close in the main workspace unless explicitly designed otherwise.\n"); out.push_str("- Use `multi-agent-workflow` for the sibling loop: coder and reviewer are siblings under this Orchestrator; coder gets narrow write scope to the child worktree; reviewer is read-only by default.\n"); - out.push_str("- Give the coder an intent packet that distinguishes binding decisions/invariants, implementation latitude, escalation conditions, child worktree/branch, validation commands, and report expectations; require Bash commands to `cd` into the child worktree, prohibit editing main-workspace `.yoi`/Ticket/workflow/docs records, and prohibit creating generated memory/local/runtime/secret-like files in the child worktree.\n"); + out.push_str("- Give the coder an intent packet that distinguishes binding decisions/invariants, implementation latitude, escalation conditions, child worktree/branch, validation commands, and report expectations; set SpawnPod `cwd` to the child worktree while delegating explicit scope separately, prohibit editing main-workspace `.yoi`/Ticket/workflow/docs records, and prohibit creating generated memory/local/runtime/secret-like files in the child worktree.\n"); out.push_str("- Give the reviewer the recorded Ticket intent, binding decisions/invariants, implementation latitude, acceptance criteria, explicit escalation conditions, diff/commits, validation evidence, and blocker/non-blocker criteria; reviewer judgment is against recorded requirements and decisions, not unrecorded preferred tactics. Keep branch-local reviewer verdicts in the review report or merge-ready dossier rather than recording them as final main-branch Ticket approval.\n"); out.push_str("- Ticket thread progress may record worktree plan, coder delegated/completed/blocked, reviewer delegated, blocker/fix-loop summaries, and merge-ready dossier pointer; do not merge, close, or record final main approval in this routing/branch-review phase.\n"); out.push_str("- Stop at a merge-ready dossier for `orchestrator-merge-completion` containing Ticket id/slug, branch/worktree, commits, intent/invariant check, implementation summary, coder/reviewer Pods, blockers fixed or rejected findings with reasons, validation performed, residual risks, dirty state, and parent/human decision needs if any.\n"); diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index f0540b5a..f0390316 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -18,7 +18,7 @@ use manifest::{ CompactionConfigPartial, DelegationScope, FileUploadLimitsPartial, Permission, PermissionConfigPartial, PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery, ProfileError, ProfileRegistry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, - ProfileSelector, ScopeConfig, ScopeRule, SessionConfigPartial, SharedScope, + ProfileSelector, Scope, ScopeConfig, ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig, }; use serde::Deserialize; @@ -52,6 +52,11 @@ struct SpawnPodInput { /// Instruction-file reference (e.g. `$yoi/default`, `$user/my-agent`). #[serde(default)] instruction: Option, + /// Child process/tool working directory. This is not the runtime workspace + /// root and grants no filesystem authority. When omitted, the spawned Pod + /// starts in the spawner's current working directory. + #[serde(default)] + cwd: Option, /// First message sent to the spawned Pod via `Method::Run`. task: String, /// Allow rules delegated to the spawned Pod. Must be a subset of the @@ -304,6 +309,7 @@ impl Tool for SpawnPodTool { let scope_allow = parse_scope(&input.scope)?; self.validate_delegation_scope(&scope_allow)?; + let child_cwd = validate_spawn_cwd(input.cwd.as_deref(), &scope_allow, &self.spawner_pwd)?; let spawn_selector = parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| { @@ -349,7 +355,12 @@ impl Tool for SpawnPodTool { // entry on exit. let start_outcome = self - .exec_child(&input.name, &spawn_config_json, &predicted_socket) + .exec_child( + &input.name, + &spawn_config_json, + &predicted_socket, + &child_cwd, + ) .await; if let Err(e) = start_outcome { self.release_reservation(&lock_path, &input.name); @@ -422,6 +433,7 @@ impl SpawnPodTool { pod_name: &str, spawn_config_json: &str, predicted_socket: &Path, + child_cwd: &Path, ) -> Result<(), ToolError> { let runtime_command = match &self.runtime_command { Some(command) => command.clone(), @@ -458,7 +470,7 @@ impl SpawnPodTool { .arg(&self.callback_socket) .arg("--spawn-config-json") .arg(spawn_config_json) - .current_dir(&self.spawner_pwd) + .current_dir(child_cwd) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::from(stderr_file)) @@ -531,6 +543,60 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result, ToolError> { .collect() } +fn validate_spawn_cwd( + cwd: Option<&Path>, + scope_allow: &[ScopeRule], + default_cwd: &Path, +) -> Result { + let Some(cwd) = cwd else { + return Ok(default_cwd.to_path_buf()); + }; + if !cwd.is_absolute() { + return Err(ToolError::InvalidArgument(format!( + "SpawnPod.cwd must be absolute: {}", + cwd.display() + ))); + } + let metadata = std::fs::metadata(cwd).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ToolError::InvalidArgument(format!("SpawnPod.cwd does not exist: {}", cwd.display())) + } else { + ToolError::InvalidArgument(format!( + "SpawnPod.cwd is not usable: {}: {e}", + cwd.display() + )) + } + })?; + if !metadata.is_dir() { + return Err(ToolError::InvalidArgument(format!( + "SpawnPod.cwd must be a directory: {}", + cwd.display() + ))); + } + let canonical = std::fs::canonicalize(cwd).map_err(|e| { + ToolError::InvalidArgument(format!( + "SpawnPod.cwd is not usable: {}: {e}", + cwd.display() + )) + })?; + let child_scope = Scope::from_config(&ScopeConfig { + allow: scope_allow.to_vec(), + deny: Vec::new(), + }) + .map_err(|e| { + ToolError::InvalidArgument(format!( + "requested child scope cannot validate SpawnPod.cwd: {e}" + )) + })?; + if !child_scope.is_readable(&canonical) { + return Err(ToolError::InvalidArgument(format!( + "SpawnPod.cwd {} is outside the child's delegated readable scope; cwd grants no authority, so add an explicit read or write scope rule covering it", + cwd.display() + ))); + } + Ok(canonical) +} + /// Serialise the internal manifest config that gets handed to the child /// Pod runtime process via the hidden `--spawn-config-json` flag. /// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the @@ -912,6 +978,63 @@ mod tests { } } + #[test] + fn spawn_pod_input_schema_includes_optional_cwd() { + let schema = serde_json::to_value(schemars::schema_for!(SpawnPodInput)).unwrap(); + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("schema properties"); + assert!(properties.contains_key("cwd"), "schema: {schema}"); + let required = schema + .get("required") + .and_then(serde_json::Value::as_array) + .expect("schema required list"); + assert!( + !required.iter().any(|value| value.as_str() == Some("cwd")), + "cwd must remain optional: {schema}" + ); + } + + #[test] + fn spawn_pod_validate_cwd_requires_absolute_existing_directory_in_child_scope() { + let root = TempDir::new().unwrap(); + let child_cwd = root.path().join("child"); + std::fs::create_dir(&child_cwd).unwrap(); + let file_path = root.path().join("file.txt"); + std::fs::write(&file_path, "not a dir").unwrap(); + let outside = TempDir::new().unwrap(); + let missing = root.path().join("missing"); + let rules = vec![abs_rule(root.path(), Permission::Write)]; + + assert_eq!( + validate_spawn_cwd(None, &rules, root.path()).unwrap(), + root.path() + ); + assert_eq!( + validate_spawn_cwd(Some(&child_cwd), &rules, root.path()).unwrap(), + std::fs::canonicalize(&child_cwd).unwrap() + ); + + for (cwd, expected) in [ + (Path::new("relative"), "must be absolute"), + (missing.as_path(), "does not exist"), + (file_path.as_path(), "must be a directory"), + ( + outside.path(), + "outside the child's delegated readable scope", + ), + ] { + let err = validate_spawn_cwd(Some(cwd), &rules, root.path()).unwrap_err(); + match err { + ToolError::InvalidArgument(message) => { + assert!(message.contains(expected), "{message}") + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + } + } + fn parent_manifest(root: &Path, deny: Option<&Path>) -> PodManifest { PodManifestConfig { pod: PodMetaConfig { diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index 522b45ef..40036e1a 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -145,6 +145,30 @@ fn mock_runtime_command() -> PodRuntimeCommand { PodRuntimeCommand::new(which_true(), Vec::new()) } +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(), + ], + ) +} + +async fn read_recorded_pwd(output_path: &Path) -> String { + for _ in 0..50 { + if let Ok(content) = std::fs::read_to_string(output_path) { + return content.trim_end().to_string(); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!( + "runtime command did not record pwd at {}", + output_path.display() + ); +} + /// `/bin/true` only exists on FHS-compliant systems. Resolve it via PATH /// so the tests work regardless of distro. fn which_true() -> String { @@ -160,6 +184,19 @@ fn which_true() -> String { "/bin/true".into() } +fn which_sh() -> String { + for dir in std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect::>()) + .unwrap_or_default() + { + let candidate = dir.join("sh"); + if candidate.is_file() { + return candidate.to_string_lossy().into_owned(); + } + } + "/bin/sh".into() +} + /// Tests don't exercise the model — they intercept the spawned /// child via a mock socket — but `spawn_pod_tool` needs a value to /// embed in the overlay TOML. Any well-formed `ModelManifest` works. @@ -231,6 +268,108 @@ fn clear_env() { } } +#[tokio::test] +async fn spawn_pod_runs_child_process_in_provided_cwd() { + let _env = EnvGuard::acquire(); + + let allow_root = TempDir::new().unwrap(); + let child_cwd = allow_root.path().join("child-cwd"); + std::fs::create_dir(&child_cwd).unwrap(); + let script = allow_root.path().join("record-pwd.sh"); + let output_path = allow_root.path().join("pwd.txt"); + let (_tmp, runtime_base, spawner_socket, spawner_rd) = + setup_spawner("root", allow_root.path()).await; + + let (_predicted_socket, listener) = bind_mock_pod_socket(&runtime_base, "child-cwd").await; + let received = accept_one_method(listener); + + let registry = SpawnedPodRegistry::new(spawner_rd); + let def = spawn_pod_tool_with_runtime_command( + "root".into(), + spawner_socket, + runtime_base, + allow_root.path().to_path_buf(), + registry, + None, + dummy_manifest(allow_root.path()), + shared_scope_for(allow_root.path()), + builtin_prompts(), + cwd_recording_runtime_command(&script, &output_path), + ); + let (_meta, tool) = def(); + + let input = json!({ + "name": "child-cwd", + "task": "hello", + "profile": "inherit", + "cwd": child_cwd.to_str().unwrap(), + "scope": [{ + "target": allow_root.path().to_str().unwrap(), + "permission": "write" + }] + }) + .to_string(); + + 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() + ); + + clear_env(); +} + +#[tokio::test] +async fn spawn_pod_omitted_cwd_preserves_spawner_pwd() { + let _env = EnvGuard::acquire(); + + let allow_root = TempDir::new().unwrap(); + let script = allow_root.path().join("record-pwd.sh"); + let output_path = allow_root.path().join("pwd.txt"); + let (_tmp, runtime_base, spawner_socket, spawner_rd) = + setup_spawner("root", allow_root.path()).await; + + let (_predicted_socket, listener) = + bind_mock_pod_socket(&runtime_base, "child-default-cwd").await; + let received = accept_one_method(listener); + + let registry = SpawnedPodRegistry::new(spawner_rd); + let def = spawn_pod_tool_with_runtime_command( + "root".into(), + spawner_socket, + runtime_base, + allow_root.path().to_path_buf(), + registry, + None, + dummy_manifest(allow_root.path()), + shared_scope_for(allow_root.path()), + builtin_prompts(), + cwd_recording_runtime_command(&script, &output_path), + ); + let (_meta, tool) = def(); + + let input = json!({ + "name": "child-default-cwd", + "task": "hello", + "profile": "inherit", + "scope": [{ + "target": allow_root.path().to_str().unwrap(), + "permission": "write" + }] + }) + .to_string(); + + 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() + ); + + clear_env(); +} + #[tokio::test] async fn spawn_pod_delegates_scope_and_sends_run() { let _env = EnvGuard::acquire(); diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml index 55de66d5..466d9587 100644 --- a/resources/prompts/internal.toml +++ b/resources/prompts/internal.toml @@ -71,6 +71,8 @@ pod_orchestration_guidance_section = "{% include \"$yoi/common/pod-orchestration spawn_pod_tool_description = """\ Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path. +Optional `cwd`: when provided, it is the child process/tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. + Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Pod, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SpawnPod. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SpawnPod scope. Default profile: {{ default_profile }}