feat: migrate Claude Code compatibility through v2.1.207
This commit is contained in:
parent
1923d7df13
commit
14dfb7ae5e
10
PARITY.md
10
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/<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.
|
||||
|
|
|
|||
8
USAGE.md
8
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 <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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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.
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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] = &[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue