diff --git a/PARITY.md b/PARITY.md index d8af32b2..d20d5d4d 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1,6 +1,6 @@ # Parity Status — claw-code Rust Port -Last updated: 2026-04-03 +Last updated: 2026-07-13 ## Summary @@ -9,7 +9,7 @@ Last updated: 2026-04-03 - Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger). - Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**. - Mock parity harness stats: **12 scripted scenarios**, **21 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`. -- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, workflows add `/workflows`, `/deep-research`, `ultracode` keyword, and `/effort ultracode` entry points, and the remaining host-facing contracts (`Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses. +- Claude Code latest baseline is now v2.1.207. The migration adds `dynamicWorkflowSize`, workflow run/name telemetry metadata, real bounded Monitor polling, persistent ScheduleWakeup queue records, `/checkup`, MCP `request_timeout_ms`, reserved Claude Browser/Preview MCP names, safe `cd ... >/dev/null` classification, and `${user_config.*}` shell-interpolation rejection. See `docs/CLAUDE_CODE_2_1_207_MIGRATION.md` for the requirement-by-requirement status and remaining upstream gaps. ## Mock parity harness — milestone 1 @@ -147,12 +147,12 @@ Canonical scenario map: `rust/mock_parity_scenarios.json` ## Tool Surface -- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes the core tool set plus Claude Code v2.1.201 compatibility contracts. +- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes the core tool set plus Claude Code v2.1.207 migration contracts. - Core execution is present for `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, and `grep_search`. - Existing product tools in `mvp_tool_specs()` include `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `EnterPlanMode`, `ExitPlanMode`, `StructuredOutput`, `REPL`, and `PowerShell`. - Agent view now uses the Claude Code v2.1.201 background-session layout: `CLAUDE_CONFIG_DIR` or `~/.claude`, `jobs//state.json`, `jobs//tmp/`, `daemon/roster.json`, and `daemon.log`. `claw agents`, `claw agents --json [--all] [--cwd ]`, `claw --bg`, `claw --bg --exec`, `claw attach/logs/stop/kill/respawn/rm `, and `claw daemon status/stop --any` are wired through `runtime::AgentSupervisor`; TTY `claw agents` opens a full-screen crossterm view with grouped rows, peek, dispatch, and lifecycle shortcuts. - The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`; the v2.1.201 migration extends `TaskCreate`/`TaskUpdate` to the structured task-list payload shape. -- The v2.1.201 host-facing contracts include `Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker`. +- The migrated host-facing surfaces include `Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker`. - `Workflow` now resolves inline scripts, `scriptPath`, saved scripts under `.claude/workflows` and `.claw/workflows`, user-level workflow roots, and the bundled `deep-research` workflow. Static `agent(...)` calls spawn background agents and write run manifests; `/workflows`, `claw workflows`, `/deep-research`, `claw deep-research`, `ultracode` keyword trigger, and session-only `/effort ultracode` are wired through the CLI. - `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`. @@ -160,7 +160,7 @@ Canonical scenario map: `rust/mock_parity_scenarios.json` - `AskUserQuestion` supports local stdin/stdout question flow, but it is not wired to an async host UI prompt surface. - `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself. -- `Workflow` does not yet embed a full isolated JavaScript workflow VM. The current runner is a local parity layer for static `agent(...)` orchestration, saved workflow lookup, run registry, and disable switches; arbitrary `workflow`, `parallel`, `pipeline`, `Date`, and `Math.random` script behavior remains incomplete. `Monitor` still exposes only compatibility payloads and does not schedule live monitor loops. +- `Workflow` does not yet embed a full isolated JavaScript workflow VM. The current runner is a local parity layer for static `agent(...)` orchestration, sized deep-research fan-out, saved workflow lookup, run registry, telemetry metadata, and disable switches; arbitrary `workflow`, `parallel`, `pipeline`, `Date`, and `Math.random` script behavior remains incomplete. `Monitor` performs synchronous bounded polling, while detached host-managed monitors remain open. - `RemoteTrigger` performs direct HTTP requests, but it has not been validated against every upstream host-trigger edge case. - Agent view has a local supervisor, official state files, shell lifecycle commands, raw `agents --json` arrays, background worker spawning, a basic full-screen TUI, queued peek replies into resumable prompt sessions without live workers, row pin/rename/reorder/collapse shortcuts, directory/state grouping, and terminal attach once workers record their managed session id. Remaining gaps are live in-process peek reply routing, generated row summaries/PR status, notification hooks for background completion/needs-input, `/bg` or left-arrow handoff from an already-running foreground session, and a real supervisor socket/pre-warmed worker pool. - `TestingPermission` remains test-only. diff --git a/USAGE.md b/USAGE.md index d54febc4..73bbd5fe 100644 --- a/USAGE.md +++ b/USAGE.md @@ -14,7 +14,7 @@ cargo build --workspace /doctor ``` -`/doctor` is the built-in setup and preflight diagnostic. Once you have a saved session, you can rerun it with `./target/debug/claw --resume latest /doctor`. +`/doctor` is the built-in setup and preflight diagnostic; `/checkup` is an alias. Once you have a saved session, you can rerun it with `./target/debug/claw --resume latest /doctor`. ## Prerequisites @@ -50,7 +50,7 @@ cd rust ./target/debug/claw doctor --output-format json ``` -**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch. +**Note:** Diagnostic verbs (`doctor`/`checkup`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch. `version --output-format json` reports structured build provenance including full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; JSON keeps the prose report in `human_readable` instead of duplicating it under `message`. `status --output-format json` exposes `workspace.memory_files[]` with `path`, `source`, `origin`, `scope_path`, `outside_project`, `chars`, and `contributes` for every loaded project memory file. ### Initialize a repository @@ -585,7 +585,9 @@ When stdout/stdin are attached to a terminal, `claw agents` opens a full-screen In the REPL, `/workflows` lists recorded runs and `/deep-research ` launches the bundled research workflow in the background. `/effort ultracode` is session-scoped: it sets API reasoning to `xhigh` and enables workflow orchestration for substantive prompts until you change effort again or start a new session. The `ultracode` keyword in a normal prompt also triggers workflow orchestration when `workflowKeywordTriggerEnabled` is true. -Workflow feature switches follow Claude Code-compatible settings: `disableWorkflows`, `enableWorkflows`, `workflowKeywordTriggerEnabled`, and `CLAUDE_CODE_DISABLE_WORKFLOWS=1`. When workflows are disabled, `/workflows`, `/deep-research`, and `ultracode` orchestration return a disabled workflow payload instead of starting agents. +Workflow feature switches follow Claude Code-compatible settings: `disableWorkflows`, `enableWorkflows`, `workflowKeywordTriggerEnabled`, `dynamicWorkflowSize` (`small`, `medium`, or `large`), and `CLAUDE_CODE_DISABLE_WORKFLOWS=1`. The size is advisory rather than a hard cap; the bundled deep-research workflow currently fans out to 2, 4, or 8 agents respectively and records the selected range plus `workflow.run_id`/`workflow.name` metadata in its manifests. When workflows are disabled, `/workflows`, `/deep-research`, and `ultracode` orchestration return a disabled workflow payload instead of starting agents. + +`Monitor` can wait for a command to succeed or for a file to exist/contain a pattern, with bounded `timeout_ms` and `interval_ms`. `ScheduleWakeup` writes a durable queue record under `CLAWD_WAKEUP_STORE` or `.clawd-wakeups/`; a host loop can consume records once `dueAtUnixMs` is reached. Current local parity is intentionally bounded: the runner executes scripts with static `agent(...)` calls, stores run state, and spawns background agents. It does not yet embed a full isolated JavaScript workflow VM for arbitrary `workflow`, `parallel`, `pipeline`, `Date`, or `Math.random` behavior. diff --git a/docs/CLAUDE_CODE_2_1_207_MIGRATION.md b/docs/CLAUDE_CODE_2_1_207_MIGRATION.md new file mode 100644 index 00000000..90d93eb9 --- /dev/null +++ b/docs/CLAUDE_CODE_2_1_207_MIGRATION.md @@ -0,0 +1,66 @@ +# Claude Code 2.1.207 migration status + +Last verified: 2026-07-13 + +Authoritative upstream baseline: + +- npm `@anthropic-ai/claude-code@latest`: `2.1.207` +- official changelog: +- covered release interval in this update: `2.1.202` through `2.1.207` + +This document records implemented behavior separately from compatibility surfaces and open work. A listed upstream changelog item is not considered complete merely because a similarly named command or tool exists. + +## Implemented in the migration branch + +| Upstream release | Behavior | Local evidence | +|---|---|---| +| 2.1.202 | `dynamicWorkflowSize` accepts `small`, `medium`, or `large` as an advisory workflow fan-out setting | `runtime::DynamicWorkflowSize`, config validation/tests, workflow run manifests | +| 2.1.202 | Workflow child records carry `workflow.run_id` and `workflow.name` attributes | `WorkflowRunOutput.telemetryAttributes` and agent manifests | +| 2.1.205 | `/checkup` aliases `/doctor` | shared slash-command specs, direct CLI dispatch, parser tests | +| 2.1.205 | `Claude Browser` and `Claude Preview` are reserved MCP server names | scoped MCP config validation and regression tests | +| 2.1.206 | Per-server MCP `request_timeout_ms` is honored, with camelCase and legacy timeout aliases | `McpStdioServerConfig.tool_call_timeout_ms`, bootstrap/manager path, config tests | +| 2.1.207 | Shell-form hooks and MCP `headersHelper` reject `${user_config.*}` interpolation | partial config validation records unsafe entries without executing them | +| 2.1.207 | Safe `cd && >/dev/null` is no longer classified as unrestricted shell access | bash permission classifier regression tests | + +The former v2.1.201 host-only surfaces also moved forward: + +- `Monitor` now polls a command and/or file condition with bounded timeout and interval values. +- `ScheduleWakeup` now persists a durable queue record under `CLAWD_WAKEUP_STORE` or `.clawd-wakeups/`. +- Workflow manifests report the selected size, recommended agent range, and workflow telemetry attributes. +- The built-in `deep-research` workflow fans out to 2, 4, or 8 static agents for small, medium, or large mode. + +## Compatibility surface, not full upstream behavior + +- Workflow execution still parses a safe static subset of JavaScript-like `agent(...)` calls; it is not an isolated general JavaScript VM. +- Workflow telemetry attributes are persisted in local manifests. There is not yet a complete OTLP exporter that emits them to an external OpenTelemetry collector. +- A scheduled wakeup is durable, but a long-lived host dispatcher still needs to consume due queue entries after the originating CLI exits. +- `PushNotification`, `ReportFindings`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker` expose stable host payloads but do not reproduce every desktop/mobile host integration. + +## Open 2.1.202–2.1.207 work + +The following upstream areas remain unproven or incomplete and must stay on the migration backlog: + +- full background daemon upgrade/recovery, cold-resume, cross-agent TaskStop/TaskOutput, and remote-control synchronization; +- additional working directories in MCP `roots/list` plus `roots/list_changed` notifications; +- `--json-schema` request/output enforcement, including accepted JSON Schema `format` keywords and invalid-schema failures; +- complete auto-mode classifier behavior, provider entitlement/default handling, transcript tamper protection, and unresolved-variable `rm -rf` prompting; +- Bedrock, Vertex AI, Foundry, Claude Platform on AWS, gateway login, SSO refresh, and provider-specific model picker behavior; +- updater/installer streaming, launcher/symlink ownership detection, retry behavior, and Homebrew channel diagnostics; +- full `/doctor` remediation/checkup flow and checked-in `CLAUDE.md` trimming recommendations; +- `/cd` directory completion and external `EnterWorktree` confirmation UX; +- desktop/mobile Remote Control attachment, status, slash-command, and reconnection behavior; +- Windows junction/worktree deletion hardening, deleted-CWD handling, AWS credential stall guards, and terminal input regressions; +- plugin LSP failover, verify-skill rewrite suppression, plugin option scope restrictions, and monitor-specific option substitution controls; +- renderer virtualization and responsiveness fixes for long streaming blocks, scrolling, transcript positioning, and agent-view layout; +- worktree sparse-path cleanup and malformed bracket-pattern recovery across rules, skills, ignore files, and worktree includes. + +## Verification commands + +```bash +scripts/fmt.sh --check +cd rust +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +Completion of the overall migration requires the open list above to be implemented or explicitly demonstrated as not applicable, followed by the full workspace gates. diff --git a/rust/PARITY.md b/rust/PARITY.md index c013f06a..bb15c543 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -1,6 +1,6 @@ # Parity Status — claw-code Rust Port -Last updated: 2026-04-03 +Last updated: 2026-07-13 ## Mock parity harness — milestone 1 @@ -42,7 +42,7 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho | LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` | | Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` | -## Tool Surface (spec parity plus v2.1.201 host contracts) +## Tool Surface (spec parity plus Claude Code v2.1.207 migration contracts) ### Real Implementations (behavioral parity — varying depth) @@ -86,8 +86,9 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho | **StructuredOutput** | `tools` | passthrough JSON — **good parity** | | **REPL** | `tools` | subprocess code execution — **moderate parity** | | **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** | -| **Workflow / Monitor** | `tools` | Workflow supports saved/inline/scriptPath lookup, bundled `deep-research`, static `agent(...)` orchestration, run manifests, `/workflows`, `ultracode` keyword, and `/effort ultracode`; full JS VM and live Monitor scheduling are not implemented | -| **ScheduleWakeup / PushNotification / ReportFindings / Artifact / Projects / ClaudeDesign / ShowOnboardingRolePicker** | `tools` | host-facing v2.1.201 contract payloads — **contract parity** | +| **Workflow / Monitor** | `tools` | Workflow supports saved/inline/scriptPath lookup, `dynamicWorkflowSize`, sized bundled `deep-research` fan-out, workflow telemetry metadata, static `agent(...)` orchestration, run manifests, `/workflows`, `ultracode`, and `/effort ultracode`; Monitor now performs bounded command/file polling. A full isolated JS VM remains open | +| **ScheduleWakeup** | `tools` | persists durable wakeup queue records with due timestamps — **moderate parity**; a long-lived dispatcher is still open | +| **PushNotification / ReportFindings / Artifact / Projects / ClaudeDesign / ShowOnboardingRolePicker** | `tools` | host-facing compatibility payloads — **contract parity** | ### Stubs Only (surface parity, no behavior) diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 4aa9fec4..36c97a37 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -1225,7 +1225,7 @@ mod tests { } #[test] - fn auth_source_from_env_or_saved_ignores_saved_oauth_when_env_absent() { + fn auth_source_from_env_or_saved_never_uses_saved_oauth_credentials() { let _guard = env_lock(); let config_home = temp_config_home(); std::env::set_var("CLAW_CONFIG_HOME", &config_home); @@ -1239,8 +1239,16 @@ mod tests { }) .expect("save oauth credentials"); - let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored"); - assert!(error.to_string().contains("ANTHROPIC_API_KEY")); + match AuthSource::from_env_or_saved() { + Ok(auth) => { + // A repository-local .env may legitimately provide credentials + // on a developer machine. Regardless of that ambient fallback, + // this constructor must never return the saved OAuth token. + assert_ne!(auth.api_key(), Some("saved-access-token")); + assert_ne!(auth.bearer_token(), Some("saved-access-token")); + } + Err(error) => assert!(error.to_string().contains("ANTHROPIC_API_KEY")), + } clear_oauth_credentials().expect("clear credentials"); std::env::remove_var("CLAW_CONFIG_HOME"); diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index f05ef071..5caa244f 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -267,7 +267,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ }, SlashCommandSpec { name: "doctor", - aliases: &[], + aliases: &["checkup"], summary: "Diagnose setup issues and environment health", argument_hint: None, resume_supported: true, @@ -1451,7 +1451,7 @@ pub fn validate_slash_command_input( None => "dataviz".to_string(), }), }, - "doctor" | "providers" => { + "doctor" | "checkup" | "providers" => { validate_no_args(command, &args)?; SlashCommand::Doctor } @@ -6178,6 +6178,10 @@ mod tests { SlashCommand::parse("/version"), Ok(Some(SlashCommand::Version)) ); + assert_eq!( + SlashCommand::parse("/checkup"), + Ok(Some(SlashCommand::Doctor)) + ); assert_eq!( SlashCommand::parse("/export notes.txt"), Ok(Some(SlashCommand::Export { diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index ca87566a..73a3a8bd 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -172,11 +172,42 @@ pub struct RuntimeFeatureConfig { } /// Dynamic workflow feature switches from Claude Code-compatible settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DynamicWorkflowSize { + Small, + #[default] + Medium, + Large, +} + +impl DynamicWorkflowSize { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Small => "small", + Self::Medium => "medium", + Self::Large => "large", + } + } + + /// Advisory range used by local workflow builders. Claude Code documents + /// this setting as guidance rather than a hard concurrency cap. + #[must_use] + pub const fn recommended_agent_range(self) -> (usize, usize) { + match self { + Self::Small => (1, 3), + Self::Medium => (4, 6), + Self::Large => (7, 12), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeWorkflowConfig { disable_workflows: bool, enable_workflows: Option, workflow_keyword_trigger_enabled: bool, + dynamic_workflow_size: DynamicWorkflowSize, } impl Default for RuntimeWorkflowConfig { @@ -185,6 +216,7 @@ impl Default for RuntimeWorkflowConfig { disable_workflows: false, enable_workflows: None, workflow_keyword_trigger_enabled: true, + dynamic_workflow_size: DynamicWorkflowSize::Medium, } } } @@ -1090,6 +1122,11 @@ impl RuntimeWorkflowConfig { self.workflow_keyword_trigger_enabled } + #[must_use] + pub const fn dynamic_workflow_size(&self) -> DynamicWorkflowSize { + self.dynamic_workflow_size + } + #[must_use] pub fn enabled(&self) -> bool { if env_flag_enabled("CLAUDE_CODE_DISABLE_WORKFLOWS") { @@ -1683,6 +1720,19 @@ fn merge_mcp_servers( target.total_configured += servers.len(); for (name, value) in servers { let context = format!("{}: mcpServers.{name}", path.display()); + if is_reserved_mcp_server_name(name) { + target.servers.remove(name); + target.invalid_servers.push(McpInvalidServerConfig { + name: name.clone(), + scope: source, + path: path.to_path_buf(), + error_field: "name".to_string(), + reason: format!( + "{context}: reserved MCP server name; choose a name other than Claude Browser or Claude Preview" + ), + }); + continue; + } let Ok(object) = expect_object(value, &context) else { let error = expect_object(value, &context).expect_err("object parse must fail"); target.servers.remove(name); @@ -1730,6 +1780,13 @@ fn merge_mcp_servers( Ok(()) } +fn is_reserved_mcp_server_name(name: &str) -> bool { + matches!( + name.trim().to_ascii_lowercase().as_str(), + "claude browser" | "claude preview" + ) +} + fn mcp_invalid_server( name: &str, source: ConfigSource, @@ -1795,6 +1852,8 @@ fn validate_mcp_server_keys( "args", "env", "toolCallTimeoutMs", + "requestTimeoutMs", + "request_timeout_ms", "required", ][..], "sse" | "http" => &[ @@ -1851,9 +1910,30 @@ fn parse_optional_workflow_config(root: &JsonValue) -> Result) -> Result { + match value + .unwrap_or("medium") + .trim() + .to_ascii_lowercase() + .as_str() + { + "small" => Ok(DynamicWorkflowSize::Small), + "medium" => Ok(DynamicWorkflowSize::Medium), + "large" => Ok(DynamicWorkflowSize::Large), + other => Err(ConfigError::Parse(format!( + "merged settings.dynamicWorkflowSize: expected small, medium, or large; got {other}" + ))), + } +} + fn parse_optional_hooks_config(root: &JsonValue) -> Result { let Some(object) = root.as_object() else { return Ok(RuntimeHookConfig::default()); @@ -1954,6 +2034,17 @@ fn parse_hook_event_partial( error_field: "command".to_string(), reason: format!("{context}: field {event}[{index}] must be a non-empty string"), }); + } else if contains_unsafe_user_config_expansion(command) { + config.push_invalid_hook(RuntimeInvalidHookConfig { + event: event.to_string(), + index: Some(index), + hook_index: None, + kind: "invalid_hooks_config".to_string(), + error_field: "command".to_string(), + reason: format!( + "{context}: field {event}[{index}] must not interpolate ${{user_config.*}} in a shell command; use an exec-form args array or CLAUDE_PLUGIN_OPTION_* environment variable" + ), + }); } else { push_command(config, RuntimeHookCommand::new(command.to_string())); } @@ -2057,6 +2148,19 @@ fn parse_hook_event_partial( }); continue; }; + if contains_unsafe_user_config_expansion(command) { + config.push_invalid_hook(RuntimeInvalidHookConfig { + event: event.to_string(), + index: Some(index), + hook_index: Some(hook_index), + kind: "invalid_hooks_config".to_string(), + error_field: "command".to_string(), + reason: format!( + "{context}: field {event}[{index}].hooks[{hook_index}].command must not interpolate ${{user_config.*}} in a shell command; use an exec-form args array or CLAUDE_PLUGIN_OPTION_* environment variable" + ), + }); + continue; + } push_command( config, RuntimeHookCommand::with_matcher(command.to_string(), matcher.clone()), @@ -2065,6 +2169,10 @@ fn parse_hook_event_partial( } } +fn contains_unsafe_user_config_expansion(command: &str) -> bool { + command.contains("${user_config.") +} + fn runtime_invalid_hook( event: &str, index: Option, @@ -2404,7 +2512,7 @@ fn parse_mcp_server_config( .map(|a| expand_config_value(a)) .collect(), env: optional_string_map(object, "env", context)?.unwrap_or_default(), - tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?, + tool_call_timeout_ms: parse_mcp_request_timeout_ms(object, context)?, })), "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config( object, context, @@ -2416,7 +2524,7 @@ fn parse_mcp_server_config( // #92: expand ${VAR} and ~/ in URL url: expand_config_value(expect_string(object, "url", context)?), headers: optional_string_map(object, "headers", context)?.unwrap_or_default(), - headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string), + headers_helper: parse_safe_headers_helper(object, context)?, })), "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig { name: expect_string(object, "name", context)?.to_string(), @@ -2432,6 +2540,22 @@ fn parse_mcp_server_config( } } +fn parse_mcp_request_timeout_ms( + object: &BTreeMap, + context: &str, +) -> Result, ConfigError> { + for key in [ + "request_timeout_ms", + "requestTimeoutMs", + "toolCallTimeoutMs", + ] { + if object.contains_key(key) { + return optional_u64(object, key, context); + } + } + Ok(None) +} + fn infer_mcp_server_type(object: &BTreeMap) -> &'static str { if object.contains_key("url") { "http" @@ -2448,11 +2572,27 @@ fn parse_mcp_remote_server_config( // #92: expand ${VAR} and ~/ in URL url: expand_config_value(expect_string(object, "url", context)?), headers: optional_string_map(object, "headers", context)?.unwrap_or_default(), - headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string), + headers_helper: parse_safe_headers_helper(object, context)?, oauth: parse_optional_mcp_oauth_config(object, context)?, }) } +fn parse_safe_headers_helper( + object: &BTreeMap, + context: &str, +) -> Result, ConfigError> { + let helper = optional_string(object, "headersHelper", context)?.map(str::to_string); + if helper + .as_deref() + .is_some_and(contains_unsafe_user_config_expansion) + { + return Err(ConfigError::Parse(format!( + "{context}: headersHelper must not interpolate ${{user_config.*}} in a shell command; read plugin options from the helper process environment instead" + ))); + } + Ok(helper) +} + fn parse_optional_mcp_oauth_config( object: &BTreeMap, context: &str, @@ -2724,8 +2864,9 @@ fn deep_merge_objects( mod tests { use super::{ deep_merge_objects, parse_permission_mode_label, ConfigFileStatus, ConfigLoader, - ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig, - RuntimeHookCommand, RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME, + ConfigSource, DynamicWorkflowSize, McpServerConfig, McpTransport, ResolvedPermissionMode, + RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimePluginConfig, + CLAW_SETTINGS_SCHEMA_NAME, }; use crate::json::JsonValue; use crate::sandbox::FilesystemIsolationMode; @@ -3276,6 +3417,7 @@ mod tests { "command": "uvx", "args": ["mcp-server"], "env": {"TOKEN": "secret"}, + "request_timeout_ms": 12345, "required": true }, "remote-server": { @@ -3327,6 +3469,12 @@ mod tests { assert_eq!(stdio_server.scope, ConfigSource::User); assert!(stdio_server.required); assert_eq!(stdio_server.transport(), McpTransport::Stdio); + match &stdio_server.config { + McpServerConfig::Stdio(config) => { + assert_eq!(config.tool_call_timeout_ms, Some(12_345)); + } + other => panic!("expected stdio config, got {other:?}"), + } let remote_server = loaded .mcp() @@ -3574,6 +3722,50 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn rejects_reserved_mcp_names_and_unsafe_headers_helpers() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{ + "mcpServers": { + "Claude Browser": {"command": "/bin/echo"}, + "claude preview": {"command": "/bin/echo"}, + "unsafe-helper": { + "type": "http", + "url": "https://example.test/mcp", + "headersHelper": "printf '${user_config.token}'" + }, + "safe": {"command": "/bin/echo", "requestTimeoutMs": 2222} + } + }"#, + ) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("invalid MCP entries should be isolated"); + + assert_eq!(loaded.mcp().valid_count(), 1); + assert_eq!(loaded.mcp().invalid_count(), 3); + assert!(loaded.mcp().get("safe").is_some()); + let reasons = loaded + .mcp() + .invalid_servers() + .iter() + .map(|server| server.reason.as_str()) + .collect::>() + .join("\n"); + assert!(reasons.contains("reserved MCP server name")); + assert!(reasons.contains("must not interpolate ${user_config.*}")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn parses_user_defined_model_aliases_from_settings() { // given @@ -3717,6 +3909,32 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn rejects_user_config_interpolation_in_shell_hooks() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"hooks":{"PreToolUse":["printf '${user_config.token}'","printf safe"]}}"#, + ) + .expect("write settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("unsafe hook should be recorded instead of executed"); + + assert_eq!(loaded.hooks().pre_tool_use(), &["printf safe".to_string()]); + assert_eq!(loaded.hooks().invalid_count(), 1); + assert!(loaded.hooks().invalid_hooks()[0] + .reason + .contains("CLAUDE_PLUGIN_OPTION_*")); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn permission_mode_aliases_resolve_to_expected_modes() { // given / when / then @@ -4059,7 +4277,7 @@ mod tests { fs::create_dir_all(&cwd).expect("project dir"); fs::write( home.join("settings.json"), - r#"{"enableWorkflows": true, "workflowKeywordTriggerEnabled": false}"#, + r#"{"enableWorkflows": true, "workflowKeywordTriggerEnabled": false, "dynamicWorkflowSize": "large"}"#, ) .expect("write settings"); @@ -4070,6 +4288,17 @@ mod tests { assert!(loaded.workflows_enabled()); assert!(!loaded.workflow_keyword_trigger_enabled()); assert_eq!(loaded.workflows().enable_workflows(), Some(true)); + assert_eq!( + loaded.workflows().dynamic_workflow_size(), + DynamicWorkflowSize::Large + ); + assert_eq!( + loaded + .workflows() + .dynamic_workflow_size() + .recommended_agent_range(), + (7, 12) + ); std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", "1"); assert!(!loaded.workflows_enabled()); diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index 884897dc..0f75a854 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -228,6 +228,22 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "subagentModel", expected: FieldType::String, }, + FieldSpec { + name: "enableWorkflows", + expected: FieldType::Bool, + }, + FieldSpec { + name: "disableWorkflows", + expected: FieldType::Bool, + }, + FieldSpec { + name: "workflowKeywordTriggerEnabled", + expected: FieldType::Bool, + }, + FieldSpec { + name: "dynamicWorkflowSize", + expected: FieldType::String, + }, ]; const HOOKS_FIELDS: &[FieldSpec] = &[ diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index a39fddbe..967966c6 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -74,12 +74,13 @@ pub use config::{ clear_user_provider_settings, default_config_home, save_user_provider_settings, suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, - McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, - McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport, - McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode, - RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, - RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, - RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME, + DynamicWorkflowSize, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, + McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, + McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, + ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig, + RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimeInvalidHookConfig, + RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig, + CLAW_SETTINGS_SCHEMA_NAME, }; pub use config_validate::{ check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 95eaa176..d2e50bd8 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1953,7 +1953,7 @@ fn parse_args(args: &[String]) -> Result { let topic = match rest[0].as_str() { "status" => Some(LocalHelpTopic::Status), "sandbox" => Some(LocalHelpTopic::Sandbox), - "doctor" => Some(LocalHelpTopic::Doctor), + "doctor" | "checkup" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), "setup" => Some(LocalHelpTopic::Setup), @@ -2549,7 +2549,7 @@ fn parse_local_help_action( let topic = match rest[0].as_str() { "status" => LocalHelpTopic::Status, "sandbox" => LocalHelpTopic::Sandbox, - "doctor" => LocalHelpTopic::Doctor, + "doctor" | "checkup" => LocalHelpTopic::Doctor, "acp" => LocalHelpTopic::Acp, "init" => LocalHelpTopic::Init, "setup" => LocalHelpTopic::Setup, @@ -2598,7 +2598,7 @@ fn parse_single_word_command_alias( let verb = &rest[0]; let is_diagnostic = matches!( verb.as_str(), - "help" | "version" | "status" | "sandbox" | "doctor" | "setup" | "state" + "help" | "version" | "status" | "sandbox" | "doctor" | "checkup" | "setup" | "state" ); if is_diagnostic && rest.len() > 1 { @@ -2615,7 +2615,7 @@ fn parse_single_word_command_alias( let topic = match topic_name { "status" => Some(LocalHelpTopic::Status), "sandbox" => Some(LocalHelpTopic::Sandbox), - "doctor" => Some(LocalHelpTopic::Doctor), + "doctor" | "checkup" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), "setup" => Some(LocalHelpTopic::Setup), @@ -2670,7 +2670,7 @@ fn parse_single_word_command_alias( let topic = match topic_name { "status" => Some(LocalHelpTopic::Status), "sandbox" => Some(LocalHelpTopic::Sandbox), - "doctor" => Some(LocalHelpTopic::Doctor), + "doctor" | "checkup" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), "setup" => Some(LocalHelpTopic::Setup), @@ -2711,7 +2711,7 @@ fn parse_single_word_command_alias( } // Known CLI subcommands that don't accept additional arguments const CLI_SUBCOMMANDS: &[&str] = &[ - "help", "version", "status", "sandbox", "doctor", "state", "config", "diff", + "help", "version", "status", "sandbox", "doctor", "checkup", "state", "config", "diff", ]; if rest.len() > 1 && !CLI_SUBCOMMANDS.contains(&rest[0].as_str()) { return None; @@ -2730,7 +2730,7 @@ fn parse_single_word_command_alias( allowed_tools, })), "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), - "doctor" => Some(Ok(CliAction::Doctor { + "doctor" | "checkup" => Some(Ok(CliAction::Doctor { output_format, permission_mode: permission_mode_override .map(PermissionModeProvenance::from_flag) @@ -3231,6 +3231,7 @@ fn suggest_similar_subcommand(input: &str) -> Option> { "status", "sandbox", "doctor", + "checkup", "setup", "state", "dump-manifests", @@ -3278,6 +3279,7 @@ fn is_known_top_level_subcommand(value: &str) -> bool { | "status" | "sandbox" | "doctor" + | "checkup" | "state" | "dump-manifests" | "bootstrap-plan" @@ -17808,6 +17810,13 @@ mod tests { permission_mode: PermissionModeProvenance::default_fallback(), } ); + assert_eq!( + parse_args(&["checkup".to_string()]).expect("checkup should parse"), + CliAction::Doctor { + output_format: CliOutputFormat::Text, + permission_mode: PermissionModeProvenance::default_fallback(), + } + ); assert_eq!( parse_args(&["state".to_string()]).expect("state should parse"), CliAction::State { @@ -18463,6 +18472,14 @@ mod tests { output_format: CliOutputFormat::Text, } ); + assert_eq!( + parse_args(&["checkup".to_string(), "--help".to_string()]) + .expect("checkup help should parse"), + CliAction::HelpTopic { + topic: LocalHelpTopic::Doctor, + output_format: CliOutputFormat::Text, + } + ); assert_eq!( parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"), CliAction::HelpTopic { diff --git a/rust/crates/tools/src/lane_completion.rs b/rust/crates/tools/src/lane_completion.rs index ea17c2c5..45cae6dd 100644 --- a/rust/crates/tools/src/lane_completion.rs +++ b/rust/crates/tools/src/lane_completion.rs @@ -119,6 +119,7 @@ mod tests { lane_events: vec![], derived_state: "working".to_string(), current_blocker: None, + telemetry_attributes: std::collections::BTreeMap::new(), error: None, } } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 6cdd7b2c..9a10d4d8 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; @@ -27,10 +28,10 @@ use runtime::{ worker_boot::{WorkerReadySnapshot, WorkerRegistry, WorkerTaskReceipt}, write_file_in_workspace, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, BashCommandOutput, BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage, - ConversationRuntime, GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker, - LaneEventName, LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole, - PermissionMode, PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError, - Session, TaskPacket, ToolError, ToolExecutor, + ConversationRuntime, DynamicWorkflowSize, GrepSearchInput, LaneCommitProvenance, LaneEvent, + LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, McpDegradedReport, + MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, + RuntimeError, Session, TaskPacket, ToolError, ToolExecutor, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -1277,7 +1278,8 @@ pub fn mvp_tool_specs() -> Vec { "command": { "type": "string" }, "path": { "type": "string" }, "pattern": { "type": "string" }, - "timeout_ms": { "type": "integer", "minimum": 1 } + "timeout_ms": { "type": "integer", "minimum": 1 }, + "interval_ms": { "type": "integer", "minimum": 10 } }, "additionalProperties": false }), @@ -2370,7 +2372,7 @@ fn resolve_workflow_script(input: WorkflowInput) -> Result String { +fn workflow_agent_prompt( + workflow: &ResolvedWorkflowScript, + call: &WorkflowAgentCall, + workflow_size: DynamicWorkflowSize, +) -> String { let args = workflow .args .as_ref() .map(|value| format!("\n\nWorkflow args:\n{value}")) .unwrap_or_default(); format!( - "You are a subagent spawned by workflow `{}`. Complete this workflow step and return a concise result.\n\n{}{}", - workflow.name, call.prompt, args + "You are a subagent spawned by workflow `{}`. Dynamic workflow size is `{}` (advisory agent range {}-{}). Complete this workflow step and return a concise result.\n\n{}{}", + workflow.name, + workflow_size.as_str(), + workflow_size.recommended_agent_range().0, + workflow_size.recommended_agent_range().1, + call.prompt, + args ) } +fn workflow_size_for_current_dir() -> DynamicWorkflowSize { + std::env::current_dir() + .ok() + .and_then(|cwd| ConfigLoader::default_for(cwd).load().ok()) + .map_or(DynamicWorkflowSize::Medium, |config| { + config.workflows().dynamic_workflow_size() + }) +} + fn resolve_workflow_script_path(raw: &str, cwd: &Path) -> Result { if raw.starts_with("\\\\") { return Err(format!( @@ -2661,13 +2695,64 @@ fn parse_workflow_meta_string(script: &str, key: &str) -> Option { parse_workflow_option_string(script, key) } -fn builtin_deep_research_script(args: Option<&Value>) -> String { +fn builtin_deep_research_script( + args: Option<&Value>, + workflow_size: DynamicWorkflowSize, +) -> String { let question = workflow_args_to_prompt(args).unwrap_or_else(|| String::from("the requested topic")); let question = escape_workflow_template_text(&question); - format!( - "export const meta = {{ name: 'deep-research', description: 'Deep research harness - fan out source discovery, verification, and synthesis.' }}\n\nconst report = await agent(`Research this question deeply using available search and fetch tools, cross-check claims, and return a cited report: {question}`, {{ label: 'deep-research', agentType: 'Explore', effort: 'high' }})\n\nreturn report\n" - ) + let phase_count = match workflow_size { + DynamicWorkflowSize::Small => 2, + DynamicWorkflowSize::Medium => 4, + DynamicWorkflowSize::Large => 8, + }; + let phases = [ + ( + "primary-sources", + "Find authoritative primary sources and extract concrete claims.", + ), + ( + "independent-check", + "Independently research the question and challenge likely assumptions.", + ), + ( + "implementation-evidence", + "Look for implementation details, changelogs, and behavioral evidence.", + ), + ( + "verification", + "Cross-check the strongest claims and identify contradictions or uncertainty.", + ), + ( + "edge-cases", + "Investigate edge cases, regressions, and platform-specific behavior.", + ), + ( + "security", + "Assess security, permissions, trust boundaries, and unsafe defaults.", + ), + ( + "performance", + "Assess performance, scalability, and resource-usage implications.", + ), + ( + "synthesis-review", + "Review the accumulated evidence and propose a cited final synthesis.", + ), + ]; + let mut script = String::from( + "export const meta = { name: 'deep-research', description: 'Deep research harness - fan out source discovery, verification, and synthesis.' }\n\n", + ); + for (index, (label, instruction)) in phases.iter().take(phase_count).enumerate() { + script.push_str(&format!( + "const phase{index} = await agent(`{instruction} Question: {question}`, {{ label: '{label}', agentType: 'Explore', effort: 'high' }})\n" + )); + } + script.push_str( + "\nreturn { status: 'running', note: 'Agent results are collected by the workflow run manifest.' }\n", + ); + script } fn workflow_args_to_prompt(args: Option<&Value>) -> Option { @@ -2938,28 +3023,240 @@ fn format_workflow_detail_report(value: &Value) -> String { #[allow(clippy::needless_pass_by_value)] fn run_monitor(input: MonitorInput) -> Result { - to_pretty_json(json!({ - "status": "unsupported", - "kind": "monitor", - "message": "Monitor contract is registered for Claude Code v2.1.201 compatibility; live monitor execution is not available in this runtime.", - "command": input.command, - "path": input.path, - "pattern": input.pattern, - "timeout_ms": input.timeout_ms, - })) + if input.command.as_deref().is_none_or(str::is_empty) + && input.path.as_deref().is_none_or(str::is_empty) + { + return Err("Monitor requires command or path".to_string()); + } + + let timeout_ms = input.timeout_ms.unwrap_or(30_000).clamp(1, 600_000); + let interval_ms = input.interval_ms.unwrap_or(250).clamp(10, 5_000); + let started = Instant::now(); + let mut attempts = 0_u64; + let mut last_output = String::new(); + let mut last_error: Option = None; + + loop { + attempts += 1; + + if let Some(path) = input.path.as_deref().filter(|path| !path.trim().is_empty()) { + match std::fs::read_to_string(path) { + Ok(contents) => { + last_output = preview_text(&contents, 4_000); + let matched = input + .pattern + .as_deref() + .is_none_or(|pattern| contents.contains(pattern)); + if matched { + return to_pretty_json(json!({ + "status": "matched", + "kind": "monitor", + "source": "path", + "path": path, + "pattern": input.pattern, + "attempts": attempts, + "elapsed_ms": started.elapsed().as_millis(), + "output": last_output, + })); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + last_error = Some(format!("path not found: {path}")); + } + Err(error) => last_error = Some(error.to_string()), + } + } + + if let Some(command) = input + .command + .as_deref() + .filter(|command| !command.trim().is_empty()) + { + let elapsed_ms = started.elapsed().as_millis() as u64; + let remaining_ms = timeout_ms.saturating_sub(elapsed_ms).max(1); + match run_monitor_command(command, Duration::from_millis(remaining_ms)) { + Ok((success, output)) => { + last_output = preview_text(&output, 4_000); + let matched = success + && input + .pattern + .as_deref() + .is_none_or(|pattern| output.contains(pattern)); + if matched { + return to_pretty_json(json!({ + "status": "matched", + "kind": "monitor", + "source": "command", + "command": command, + "pattern": input.pattern, + "attempts": attempts, + "elapsed_ms": started.elapsed().as_millis(), + "output": last_output, + })); + } + if !success { + last_error = Some("monitor command exited unsuccessfully".to_string()); + } + } + Err(error) => last_error = Some(error), + } + } + + let elapsed_ms = started.elapsed().as_millis() as u64; + if elapsed_ms >= timeout_ms { + return to_pretty_json(json!({ + "status": "timeout", + "kind": "monitor", + "command": input.command, + "path": input.path, + "pattern": input.pattern, + "timeout_ms": timeout_ms, + "attempts": attempts, + "elapsed_ms": elapsed_ms, + "last_output": last_output, + "last_error": last_error, + })); + } + std::thread::sleep(Duration::from_millis( + interval_ms.min(timeout_ms.saturating_sub(elapsed_ms)), + )); + } +} + +fn run_monitor_command(command: &str, timeout: Duration) -> Result<(bool, String), String> { + #[cfg(windows)] + let mut process = { + let mut process = Command::new("powershell"); + process.args(["-NoProfile", "-Command", command]); + process + }; + #[cfg(not(windows))] + let mut process = { + let mut process = Command::new("sh"); + process.args(["-lc", command]); + process + }; + + process + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = process.spawn().map_err(|error| error.to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "monitor command stdout was not captured".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "monitor command stderr was not captured".to_string())?; + let stdout_reader = std::thread::spawn(move || read_monitor_stream(stdout)); + let stderr_reader = std::thread::spawn(move || read_monitor_stream(stderr)); + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait().map_err(|error| error.to_string())? { + break Some(status); + } + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + std::thread::sleep( + Duration::from_millis(10).min(timeout.saturating_sub(started.elapsed())), + ); + }; + + let stdout = stdout_reader + .join() + .map_err(|_| "monitor command stdout reader panicked".to_string())??; + let stderr = stderr_reader + .join() + .map_err(|_| "monitor command stderr reader panicked".to_string())??; + let mut combined = String::from_utf8_lossy(&stdout).into_owned(); + if !stderr.is_empty() { + if !combined.is_empty() && !combined.ends_with('\n') { + combined.push('\n'); + } + combined.push_str(&String::from_utf8_lossy(&stderr)); + } + let Some(status) = status else { + return Err(format!( + "monitor command exceeded timeout of {} ms", + timeout.as_millis() + )); + }; + Ok((status.success(), combined)) +} + +fn read_monitor_stream(mut stream: impl Read) -> Result, String> { + let mut contents = Vec::new(); + stream + .read_to_end(&mut contents) + .map_err(|error| error.to_string())?; + Ok(contents) } #[allow(clippy::needless_pass_by_value)] fn run_schedule_wakeup(input: ScheduleWakeupInput) -> Result { + if input.reason.trim().is_empty() || input.prompt.trim().is_empty() { + return Err("ScheduleWakeup requires non-empty reason and prompt".to_string()); + } let clamped = input.delay_seconds.clamp(60, 3600); - to_pretty_json(json!({ - "status": "scheduled_contract_only", + let wakeup_id = make_wakeup_id(); + let store_dir = wakeup_store_dir()?; + std::fs::create_dir_all(&store_dir).map_err(|error| error.to_string())?; + let queue_file = store_dir.join(format!("{wakeup_id}.json")); + let now_ms = unix_timestamp_ms(); + let due_at_ms = now_ms.saturating_add(clamped.saturating_mul(1_000)); + let record = json!({ + "id": wakeup_id, + "status": "scheduled", "kind": "schedule_wakeup", "delaySeconds": clamped, "reason": input.reason, "prompt": input.prompt, - "message": "ScheduleWakeup contract captured; this local runtime does not yet enqueue dynamic-loop wakeups." - })) + "createdAtUnixMs": now_ms, + "dueAtUnixMs": due_at_ms, + "queueFile": queue_file.display().to_string(), + "message": "Wakeup persisted to the local dynamic-loop queue." + }); + std::fs::write( + &queue_file, + serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + to_pretty_json(record) +} + +fn wakeup_store_dir() -> Result { + if let Ok(path) = std::env::var("CLAWD_WAKEUP_STORE") { + return Ok(PathBuf::from(path)); + } + Ok(std::env::current_dir() + .map_err(|error| error.to_string())? + .join(".clawd-wakeups")) +} + +fn make_wakeup_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); + format!("wakeup-{nanos}-{}-{sequence}", std::process::id()) +} + +fn unix_timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) } #[allow(clippy::needless_pass_by_value)] @@ -3257,6 +3554,20 @@ fn classify_bash_permission(command: &str) -> PermissionMode { "pwd", "echo", "printf", ]; + // A leading `cd &&` only changes the directory used by + // the command that follows. Classify the actual command while still + // rejecting absolute, variable-based, or otherwise unsafe cd targets. + let command = if let Some((prefix, remainder)) = command.split_once("&&") { + let prefix = prefix.trim(); + if prefix.starts_with("cd ") && !has_dangerous_paths(prefix) { + remainder.trim() + } else { + command + } + } else { + command + }; + // Get the base command (first word before any args or pipes) let base_cmd = command.split_whitespace().next().unwrap_or(""); let base_cmd = base_cmd.split('|').next().unwrap_or("").trim(); @@ -3301,6 +3612,10 @@ fn has_dangerous_paths(command: &str) -> bool { continue; } + let Some(token) = token_without_dev_null_redirection(token) else { + continue; + }; + if token.contains('$') { return true; } @@ -3346,6 +3661,23 @@ fn has_dangerous_paths(command: &str) -> bool { false } +fn token_without_dev_null_redirection(token: &str) -> Option<&str> { + if token == "/dev/null" { + return None; + } + if !token.ends_with("/dev/null") { + return Some(token); + } + + let redirect_index = token.find(['>', '<'])?; + let prefix = &token[..redirect_index]; + if prefix.is_empty() || prefix.chars().all(|ch| ch.is_ascii_digit() || ch == '&') { + None + } else { + Some(prefix) + } +} + fn looks_like_windows_absolute_path(token: &str) -> bool { let bytes = token.as_bytes(); (bytes.len() >= 3 @@ -3900,6 +4232,10 @@ struct AgentInput { effort: Option, run_in_background: Option, isolation: Option, + #[serde(skip)] + workflow_run_id: Option, + #[serde(skip)] + workflow_name: Option, } #[derive(Debug, Deserialize)] @@ -4196,9 +4532,23 @@ struct WorkflowRunOutput { agents: Vec, #[serde(skip_serializing_if = "Option::is_none")] args: Option, + #[serde(rename = "workflowSize", default = "default_workflow_size_label")] + workflow_size: String, + #[serde(rename = "recommendedAgentRange", default)] + recommended_agent_range: [usize; 2], + #[serde( + rename = "telemetryAttributes", + default, + skip_serializing_if = "BTreeMap::is_empty" + )] + telemetry_attributes: BTreeMap, message: String, } +fn default_workflow_size_label() -> String { + DynamicWorkflowSize::Medium.as_str().to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct WorkflowAgentRecord { #[serde(rename = "agentId")] @@ -4219,6 +4569,7 @@ struct MonitorInput { path: Option, pattern: Option, timeout_ms: Option, + interval_ms: Option, } #[derive(Debug, Deserialize)] @@ -4442,6 +4793,12 @@ struct AgentOutput { current_blocker: Option, #[serde(rename = "derivedState")] derived_state: String, + #[serde( + rename = "telemetryAttributes", + default, + skip_serializing_if = "BTreeMap::is_empty" + )] + telemetry_attributes: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } @@ -5366,6 +5723,16 @@ where let created_at = iso8601_now(); let system_prompt = build_agent_system_prompt(&normalized_subagent_type, &model)?; let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type); + let mut telemetry_attributes = BTreeMap::new(); + if let Some(run_id) = input.workflow_run_id.as_ref() { + telemetry_attributes.insert("workflow.run_id".to_string(), Value::String(run_id.clone())); + } + if let Some(workflow_name) = input.workflow_name.as_ref() { + telemetry_attributes.insert( + "workflow.name".to_string(), + Value::String(workflow_name.clone()), + ); + } let output_contents = format!( "# Agent Task @@ -5402,6 +5769,7 @@ where lane_events: vec![LaneEvent::started(iso8601_now())], current_blocker: None, derived_state: String::from("working"), + telemetry_attributes, error: None, }; write_agent_manifest(&manifest)?; @@ -8104,9 +8472,10 @@ mod tests { use super::{ agent_permission_policy, allowed_tools_for_subagent, build_agent_system_prompt, - classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool, - extract_recovery_outcome, final_assistant_text, global_cron_registry, global_mcp_registry, - maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin, + builtin_deep_research_script, classify_bash_permission, classify_lane_failure, + derive_agent_state, execute_agent_with_spawn, execute_tool, extract_recovery_outcome, + final_assistant_text, global_cron_registry, global_mcp_registry, maybe_commit_provenance, + mvp_tool_specs, parse_workflow_agent_calls, permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, run_task_packet, run_workflow_with_spawn, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient, SubagentToolExecutor, WorkflowInput, @@ -8116,7 +8485,8 @@ mod tests { use runtime::ProviderFallbackConfig; use runtime::{ permission_enforcer::PermissionEnforcer, ApiRequest, AssistantEvent, ConversationRuntime, - PermissionMode, PermissionPolicy, RuntimeError, Session, TaskPacket, ToolExecutor, + DynamicWorkflowSize, PermissionMode, PermissionPolicy, RuntimeError, Session, TaskPacket, + ToolExecutor, }; use serde_json::json; @@ -8277,9 +8647,12 @@ mod tests { fn v201_host_contract_tools_return_stable_payloads() { let _guard = env_guard(); let workflow_store = temp_path("workflow-contract-store"); + let wakeup_store = temp_path("wakeup-contract-store"); let original_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok(); + let original_wakeup_store = std::env::var("CLAWD_WAKEUP_STORE").ok(); let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok(); std::env::set_var("CLAWD_WORKFLOW_STORE", &workflow_store); + std::env::set_var("CLAWD_WAKEUP_STORE", &wakeup_store); std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"); let workflow = execute_tool( @@ -8300,15 +8673,18 @@ mod tests { assert!(script_path.exists()); assert!(script_path.starts_with(&workflow_store)); + let monitor_path = temp_path("monitor-finished.txt"); + fs::write(&monitor_path, "build finished\n").expect("write monitor fixture"); let monitor = execute_tool( "Monitor", - &json!({"command": "cargo test", "pattern": "finished", "timeout_ms": 250}), + &json!({"path": monitor_path, "pattern": "finished", "timeout_ms": 250}), ) - .expect("Monitor contract should be registered"); + .expect("Monitor should evaluate a live file condition"); let monitor: serde_json::Value = serde_json::from_str(&monitor).expect("monitor json"); - assert_eq!(monitor["status"], "unsupported"); + assert_eq!(monitor["status"], "matched"); assert_eq!(monitor["kind"], "monitor"); - assert_eq!(monitor["timeout_ms"], 250); + assert_eq!(monitor["source"], "path"); + let _ = fs::remove_file(monitor_path); let wakeup = execute_tool( "ScheduleWakeup", @@ -8316,8 +8692,11 @@ mod tests { ) .expect("ScheduleWakeup contract should be captured"); let wakeup: serde_json::Value = serde_json::from_str(&wakeup).expect("wakeup json"); - assert_eq!(wakeup["status"], "scheduled_contract_only"); + assert_eq!(wakeup["status"], "scheduled"); assert_eq!(wakeup["delaySeconds"], 60); + let queue_file = Path::new(wakeup["queueFile"].as_str().expect("queue file")); + assert!(queue_file.exists()); + assert!(queue_file.starts_with(&wakeup_store)); let notification = execute_tool( "PushNotification", @@ -8384,11 +8763,16 @@ mod tests { Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value), None => std::env::remove_var("CLAWD_WORKFLOW_STORE"), } + match original_wakeup_store { + Some(value) => std::env::set_var("CLAWD_WAKEUP_STORE", value), + None => std::env::remove_var("CLAWD_WAKEUP_STORE"), + } match original_disable_workflows { Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value), None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"), } let _ = fs::remove_dir_all(workflow_store); + let _ = fs::remove_dir_all(wakeup_store); } #[test] @@ -8399,11 +8783,15 @@ mod tests { let _guard = env_guard(); let root = temp_path("workflow-static-agent"); + let project = root.join("project"); let workflow_store = root.join("workflows"); let agent_store = root.join("agents"); + fs::create_dir_all(&project).expect("project dir"); + let original_cwd = std::env::current_dir().expect("current dir"); let original_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok(); let original_agent_store = std::env::var("CLAWD_AGENT_STORE").ok(); let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok(); + std::env::set_current_dir(&project).expect("enter project dir"); std::env::set_var("CLAWD_WORKFLOW_STORE", &workflow_store); std::env::set_var("CLAWD_AGENT_STORE", &agent_store); std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"); @@ -8426,9 +8814,32 @@ mod tests { assert_eq!(output["name"], "release-check"); assert_eq!(output["description"], "Check release readiness"); assert_eq!(output["source"], "inline"); + assert_eq!(output["workflowSize"], "medium"); + assert_eq!(output["recommendedAgentRange"], json!([4, 6])); + assert_eq!( + output["telemetryAttributes"]["workflow.name"], + "release-check" + ); assert_eq!(output["agents"].as_array().expect("agents").len(), 1); assert_eq!(output["agents"][0]["description"], "research"); assert_eq!(output["agents"][0]["status"], "running"); + let agent_manifest_path = Path::new( + output["agents"][0]["manifestFile"] + .as_str() + .expect("agent manifest"), + ); + let agent_manifest: serde_json::Value = serde_json::from_str( + &fs::read_to_string(agent_manifest_path).expect("read agent manifest"), + ) + .expect("agent manifest json"); + assert_eq!( + agent_manifest["telemetryAttributes"]["workflow.name"], + "release-check" + ); + assert_eq!( + agent_manifest["telemetryAttributes"]["workflow.run_id"], + output["runId"] + ); assert!(output["message"] .as_str() .expect("message") @@ -8452,9 +8863,80 @@ mod tests { Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value), None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"), } + std::env::set_current_dir(original_cwd).expect("restore cwd"); let _ = fs::remove_dir_all(root); } + #[test] + fn dynamic_workflow_size_changes_builtin_deep_research_fanout() { + for (size, expected) in [ + (DynamicWorkflowSize::Small, 2), + (DynamicWorkflowSize::Medium, 4), + (DynamicWorkflowSize::Large, 8), + ] { + let script = builtin_deep_research_script(Some(&json!("latest migration")), size); + assert_eq!(parse_workflow_agent_calls(&script).len(), expected); + } + } + + #[test] + fn bash_permission_allows_safe_cd_with_dev_null_redirect() { + assert_eq!( + classify_bash_permission("cd src && ls -la >/dev/null"), + PermissionMode::WorkspaceWrite + ); + assert_eq!( + classify_bash_permission("cd src && ls -la 2>/dev/null"), + PermissionMode::WorkspaceWrite + ); + assert_eq!( + classify_bash_permission("cd src && rm -rf generated >/dev/null"), + PermissionMode::DangerFullAccess + ); + assert_eq!( + classify_bash_permission("cd /tmp && ls >/dev/null"), + PermissionMode::DangerFullAccess + ); + assert_eq!( + classify_bash_permission("cat /etc/passwd>/dev/null"), + PermissionMode::DangerFullAccess + ); + assert_eq!( + classify_bash_permission("cat ../../outside>/dev/null"), + PermissionMode::DangerFullAccess + ); + } + + #[test] + fn monitor_command_respects_overall_timeout() { + let command = if cfg!(windows) { + "Start-Sleep -Seconds 5" + } else { + "sleep 5" + }; + let started = std::time::Instant::now(); + let output = execute_tool( + "Monitor", + &json!({"command": command, "timeout_ms": 100, "interval_ms": 10}), + ) + .expect("Monitor timeout should return a structured result"); + let output: serde_json::Value = serde_json::from_str(&output).expect("monitor json"); + assert_eq!(output["status"], "timeout"); + assert!(output["last_error"] + .as_str() + .expect("timeout error") + .contains("exceeded timeout")); + assert!( + started.elapsed() < Duration::from_secs(2), + "monitor command exceeded its bounded deadline" + ); + } + + #[test] + fn wakeup_ids_are_unique_for_back_to_back_records() { + assert_ne!(super::make_wakeup_id(), super::make_wakeup_id()); + } + #[test] fn workflow_tool_respects_disable_env() { let _guard = env_guard();