feat: migrate Claude Code compatibility through v2.1.207

This commit is contained in:
抱月 2026-07-13 03:34:04 +08:00
parent 1923d7df13
commit 14dfb7ae5e
12 changed files with 899 additions and 72 deletions

View File

@ -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/<id>/state.json`, `jobs/<id>/tmp/`, `daemon/roster.json`, and `daemon.log`. `claw agents`, `claw agents --json [--all] [--cwd <path>]`, `claw --bg`, `claw --bg --exec`, `claw attach/logs/stop/kill/respawn/rm <id>`, 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.

View File

@ -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 <question>` 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.

View File

@ -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: <https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md>
- 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 <workspace> && <read command> >/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.2022.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.

View File

@ -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)

View File

@ -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");

View File

@ -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 {

View File

@ -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<bool>,
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<RuntimeWorkflowCon
"merged settings",
)?
.unwrap_or(true),
dynamic_workflow_size: parse_dynamic_workflow_size(optional_string(
object,
"dynamicWorkflowSize",
"merged settings",
)?)?,
})
}
fn parse_dynamic_workflow_size(value: Option<&str>) -> Result<DynamicWorkflowSize, ConfigError> {
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<RuntimeHookConfig, ConfigError> {
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<usize>,
@ -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<String, JsonValue>,
context: &str,
) -> Result<Option<u64>, 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<String, JsonValue>) -> &'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<String, JsonValue>,
context: &str,
) -> Result<Option<String>, 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<String, JsonValue>,
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::<Vec<_>>()
.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());

View File

@ -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] = &[

View File

@ -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,

View File

@ -1953,7 +1953,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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<Vec<String>> {
"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 {

View File

@ -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,
}
}

View File

@ -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<ToolSpec> {
"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<ResolvedWorkflowScrip
};
if name == "deep-research" {
let script = builtin_deep_research_script(args.as_ref());
let script = builtin_deep_research_script(args.as_ref(), workflow_size_for_current_dir());
return Ok(ResolvedWorkflowScript {
name,
description: Some(String::from(
@ -2409,6 +2411,8 @@ where
let script_path = persist_workflow_script_for_run(&store_dir, &run_id, &workflow)?;
let manifest_file = store_dir.join(format!("{run_id}.json"));
let calls = parse_workflow_agent_calls(&workflow.script);
let workflow_size = workflow_size_for_current_dir();
let (recommended_min, recommended_max) = workflow_size.recommended_agent_range();
let mut agents = Vec::new();
for (index, call) in calls.into_iter().enumerate() {
@ -2419,7 +2423,7 @@ where
let manifest = execute_agent_with_spawn(
AgentInput {
description: description.clone(),
prompt: workflow_agent_prompt(&workflow, &call),
prompt: workflow_agent_prompt(&workflow, &call, workflow_size),
subagent_type: call
.agent_type
.or_else(|| Some(String::from("general-purpose"))),
@ -2428,6 +2432,8 @@ where
effort: call.effort,
run_in_background: Some(true),
isolation: call.isolation,
workflow_run_id: Some(run_id.clone()),
workflow_name: Some(workflow.name.clone()),
},
spawn_fn,
)?;
@ -2458,6 +2464,13 @@ where
)
};
let telemetry_attributes = BTreeMap::from([
("workflow.run_id".to_string(), Value::String(run_id.clone())),
(
"workflow.name".to_string(),
Value::String(workflow.name.clone()),
),
]);
let run = WorkflowRunOutput {
run_id,
kind: String::from("workflow"),
@ -2470,24 +2483,45 @@ where
created_at,
agents,
args: workflow.args,
workflow_size: workflow_size.as_str().to_string(),
recommended_agent_range: [recommended_min, recommended_max],
telemetry_attributes,
message,
};
write_workflow_run_manifest(&run)?;
Ok(run)
}
fn workflow_agent_prompt(workflow: &ResolvedWorkflowScript, call: &WorkflowAgentCall) -> 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<PathBuf, String> {
if raw.starts_with("\\\\") {
return Err(format!(
@ -2661,13 +2695,64 @@ fn parse_workflow_meta_string(script: &str, key: &str) -> Option<String> {
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<String> {
@ -2938,28 +3023,240 @@ fn format_workflow_detail_report(value: &Value) -> String {
#[allow(clippy::needless_pass_by_value)]
fn run_monitor(input: MonitorInput) -> Result<String, String> {
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<String> = 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<Vec<u8>, 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<String, String> {
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<PathBuf, String> {
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 <workspace-path> &&` 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<String>,
run_in_background: Option<bool>,
isolation: Option<String>,
#[serde(skip)]
workflow_run_id: Option<String>,
#[serde(skip)]
workflow_name: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -4196,9 +4532,23 @@ struct WorkflowRunOutput {
agents: Vec<WorkflowAgentRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
args: Option<Value>,
#[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<String, Value>,
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<String>,
pattern: Option<String>,
timeout_ms: Option<u64>,
interval_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
@ -4442,6 +4793,12 @@ struct AgentOutput {
current_blocker: Option<LaneEventBlocker>,
#[serde(rename = "derivedState")]
derived_state: String,
#[serde(
rename = "telemetryAttributes",
default,
skip_serializing_if = "BTreeMap::is_empty"
)]
telemetry_attributes: BTreeMap<String, Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
@ -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();