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
|
# Parity Status — claw-code Rust Port
|
||||||
|
|
||||||
Last updated: 2026-04-03
|
Last updated: 2026-07-13
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@ Last updated: 2026-04-03
|
||||||
- Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger).
|
- 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**.
|
- 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`.
|
- 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
|
## Mock parity harness — milestone 1
|
||||||
|
|
||||||
|
|
@ -147,12 +147,12 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
|
||||||
|
|
||||||
## Tool Surface
|
## 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`.
|
- 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`.
|
- 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.
|
- 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 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.
|
- `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()`.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- 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.
|
- `TestingPermission` remains test-only.
|
||||||
|
|
|
||||||
8
USAGE.md
8
USAGE.md
|
|
@ -14,7 +14,7 @@ cargo build --workspace
|
||||||
/doctor
|
/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
|
## Prerequisites
|
||||||
|
|
||||||
|
|
@ -50,7 +50,7 @@ cd rust
|
||||||
./target/debug/claw doctor --output-format json
|
./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.
|
`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
|
### 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.
|
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.
|
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
|
# Parity Status — claw-code Rust Port
|
||||||
|
|
||||||
Last updated: 2026-04-03
|
Last updated: 2026-07-13
|
||||||
|
|
||||||
## Mock parity harness — milestone 1
|
## 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` |
|
| LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` |
|
||||||
| Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` |
|
| 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)
|
### 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** |
|
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
|
||||||
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
|
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
|
||||||
| **PowerShell** | `tools` | Windows PowerShell 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 |
|
| **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 / PushNotification / ReportFindings / Artifact / Projects / ClaudeDesign / ShowOnboardingRolePicker** | `tools` | host-facing v2.1.201 contract payloads — **contract parity** |
|
| **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)
|
### Stubs Only (surface parity, no behavior)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1225,7 +1225,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 _guard = env_lock();
|
||||||
let config_home = temp_config_home();
|
let config_home = temp_config_home();
|
||||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
|
|
@ -1239,8 +1239,16 @@ mod tests {
|
||||||
})
|
})
|
||||||
.expect("save oauth credentials");
|
.expect("save oauth credentials");
|
||||||
|
|
||||||
let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored");
|
match AuthSource::from_env_or_saved() {
|
||||||
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
|
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");
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||||
},
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "doctor",
|
name: "doctor",
|
||||||
aliases: &[],
|
aliases: &["checkup"],
|
||||||
summary: "Diagnose setup issues and environment health",
|
summary: "Diagnose setup issues and environment health",
|
||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
|
|
@ -1451,7 +1451,7 @@ pub fn validate_slash_command_input(
|
||||||
None => "dataviz".to_string(),
|
None => "dataviz".to_string(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
"doctor" | "providers" => {
|
"doctor" | "checkup" | "providers" => {
|
||||||
validate_no_args(command, &args)?;
|
validate_no_args(command, &args)?;
|
||||||
SlashCommand::Doctor
|
SlashCommand::Doctor
|
||||||
}
|
}
|
||||||
|
|
@ -6178,6 +6178,10 @@ mod tests {
|
||||||
SlashCommand::parse("/version"),
|
SlashCommand::parse("/version"),
|
||||||
Ok(Some(SlashCommand::Version))
|
Ok(Some(SlashCommand::Version))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/checkup"),
|
||||||
|
Ok(Some(SlashCommand::Doctor))
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/export notes.txt"),
|
SlashCommand::parse("/export notes.txt"),
|
||||||
Ok(Some(SlashCommand::Export {
|
Ok(Some(SlashCommand::Export {
|
||||||
|
|
|
||||||
|
|
@ -172,11 +172,42 @@ pub struct RuntimeFeatureConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dynamic workflow feature switches from Claude Code-compatible settings.
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RuntimeWorkflowConfig {
|
pub struct RuntimeWorkflowConfig {
|
||||||
disable_workflows: bool,
|
disable_workflows: bool,
|
||||||
enable_workflows: Option<bool>,
|
enable_workflows: Option<bool>,
|
||||||
workflow_keyword_trigger_enabled: bool,
|
workflow_keyword_trigger_enabled: bool,
|
||||||
|
dynamic_workflow_size: DynamicWorkflowSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RuntimeWorkflowConfig {
|
impl Default for RuntimeWorkflowConfig {
|
||||||
|
|
@ -185,6 +216,7 @@ impl Default for RuntimeWorkflowConfig {
|
||||||
disable_workflows: false,
|
disable_workflows: false,
|
||||||
enable_workflows: None,
|
enable_workflows: None,
|
||||||
workflow_keyword_trigger_enabled: true,
|
workflow_keyword_trigger_enabled: true,
|
||||||
|
dynamic_workflow_size: DynamicWorkflowSize::Medium,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1090,6 +1122,11 @@ impl RuntimeWorkflowConfig {
|
||||||
self.workflow_keyword_trigger_enabled
|
self.workflow_keyword_trigger_enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn dynamic_workflow_size(&self) -> DynamicWorkflowSize {
|
||||||
|
self.dynamic_workflow_size
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn enabled(&self) -> bool {
|
pub fn enabled(&self) -> bool {
|
||||||
if env_flag_enabled("CLAUDE_CODE_DISABLE_WORKFLOWS") {
|
if env_flag_enabled("CLAUDE_CODE_DISABLE_WORKFLOWS") {
|
||||||
|
|
@ -1683,6 +1720,19 @@ fn merge_mcp_servers(
|
||||||
target.total_configured += servers.len();
|
target.total_configured += servers.len();
|
||||||
for (name, value) in servers {
|
for (name, value) in servers {
|
||||||
let context = format!("{}: mcpServers.{name}", path.display());
|
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 Ok(object) = expect_object(value, &context) else {
|
||||||
let error = expect_object(value, &context).expect_err("object parse must fail");
|
let error = expect_object(value, &context).expect_err("object parse must fail");
|
||||||
target.servers.remove(name);
|
target.servers.remove(name);
|
||||||
|
|
@ -1730,6 +1780,13 @@ fn merge_mcp_servers(
|
||||||
Ok(())
|
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(
|
fn mcp_invalid_server(
|
||||||
name: &str,
|
name: &str,
|
||||||
source: ConfigSource,
|
source: ConfigSource,
|
||||||
|
|
@ -1795,6 +1852,8 @@ fn validate_mcp_server_keys(
|
||||||
"args",
|
"args",
|
||||||
"env",
|
"env",
|
||||||
"toolCallTimeoutMs",
|
"toolCallTimeoutMs",
|
||||||
|
"requestTimeoutMs",
|
||||||
|
"request_timeout_ms",
|
||||||
"required",
|
"required",
|
||||||
][..],
|
][..],
|
||||||
"sse" | "http" => &[
|
"sse" | "http" => &[
|
||||||
|
|
@ -1851,9 +1910,30 @@ fn parse_optional_workflow_config(root: &JsonValue) -> Result<RuntimeWorkflowCon
|
||||||
"merged settings",
|
"merged settings",
|
||||||
)?
|
)?
|
||||||
.unwrap_or(true),
|
.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> {
|
fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
|
||||||
let Some(object) = root.as_object() else {
|
let Some(object) = root.as_object() else {
|
||||||
return Ok(RuntimeHookConfig::default());
|
return Ok(RuntimeHookConfig::default());
|
||||||
|
|
@ -1954,6 +2034,17 @@ fn parse_hook_event_partial(
|
||||||
error_field: "command".to_string(),
|
error_field: "command".to_string(),
|
||||||
reason: format!("{context}: field {event}[{index}] must be a non-empty 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 {
|
} else {
|
||||||
push_command(config, RuntimeHookCommand::new(command.to_string()));
|
push_command(config, RuntimeHookCommand::new(command.to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -2057,6 +2148,19 @@ fn parse_hook_event_partial(
|
||||||
});
|
});
|
||||||
continue;
|
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(
|
push_command(
|
||||||
config,
|
config,
|
||||||
RuntimeHookCommand::with_matcher(command.to_string(), matcher.clone()),
|
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(
|
fn runtime_invalid_hook(
|
||||||
event: &str,
|
event: &str,
|
||||||
index: Option<usize>,
|
index: Option<usize>,
|
||||||
|
|
@ -2404,7 +2512,7 @@ fn parse_mcp_server_config(
|
||||||
.map(|a| expand_config_value(a))
|
.map(|a| expand_config_value(a))
|
||||||
.collect(),
|
.collect(),
|
||||||
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
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(
|
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
|
||||||
object, context,
|
object, context,
|
||||||
|
|
@ -2416,7 +2524,7 @@ fn parse_mcp_server_config(
|
||||||
// #92: expand ${VAR} and ~/ in URL
|
// #92: expand ${VAR} and ~/ in URL
|
||||||
url: expand_config_value(expect_string(object, "url", context)?),
|
url: expand_config_value(expect_string(object, "url", context)?),
|
||||||
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
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 {
|
"sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
|
||||||
name: expect_string(object, "name", context)?.to_string(),
|
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 {
|
fn infer_mcp_server_type(object: &BTreeMap<String, JsonValue>) -> &'static str {
|
||||||
if object.contains_key("url") {
|
if object.contains_key("url") {
|
||||||
"http"
|
"http"
|
||||||
|
|
@ -2448,11 +2572,27 @@ fn parse_mcp_remote_server_config(
|
||||||
// #92: expand ${VAR} and ~/ in URL
|
// #92: expand ${VAR} and ~/ in URL
|
||||||
url: expand_config_value(expect_string(object, "url", context)?),
|
url: expand_config_value(expect_string(object, "url", context)?),
|
||||||
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
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)?,
|
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(
|
fn parse_optional_mcp_oauth_config(
|
||||||
object: &BTreeMap<String, JsonValue>,
|
object: &BTreeMap<String, JsonValue>,
|
||||||
context: &str,
|
context: &str,
|
||||||
|
|
@ -2724,8 +2864,9 @@ fn deep_merge_objects(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
deep_merge_objects, parse_permission_mode_label, ConfigFileStatus, ConfigLoader,
|
deep_merge_objects, parse_permission_mode_label, ConfigFileStatus, ConfigLoader,
|
||||||
ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeFeatureConfig,
|
ConfigSource, DynamicWorkflowSize, McpServerConfig, McpTransport, ResolvedPermissionMode,
|
||||||
RuntimeHookCommand, RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimePluginConfig,
|
||||||
|
CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
use crate::json::JsonValue;
|
use crate::json::JsonValue;
|
||||||
use crate::sandbox::FilesystemIsolationMode;
|
use crate::sandbox::FilesystemIsolationMode;
|
||||||
|
|
@ -3276,6 +3417,7 @@ mod tests {
|
||||||
"command": "uvx",
|
"command": "uvx",
|
||||||
"args": ["mcp-server"],
|
"args": ["mcp-server"],
|
||||||
"env": {"TOKEN": "secret"},
|
"env": {"TOKEN": "secret"},
|
||||||
|
"request_timeout_ms": 12345,
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
"remote-server": {
|
"remote-server": {
|
||||||
|
|
@ -3327,6 +3469,12 @@ mod tests {
|
||||||
assert_eq!(stdio_server.scope, ConfigSource::User);
|
assert_eq!(stdio_server.scope, ConfigSource::User);
|
||||||
assert!(stdio_server.required);
|
assert!(stdio_server.required);
|
||||||
assert_eq!(stdio_server.transport(), McpTransport::Stdio);
|
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
|
let remote_server = loaded
|
||||||
.mcp()
|
.mcp()
|
||||||
|
|
@ -3574,6 +3722,50 @@ mod tests {
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
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]
|
#[test]
|
||||||
fn parses_user_defined_model_aliases_from_settings() {
|
fn parses_user_defined_model_aliases_from_settings() {
|
||||||
// given
|
// given
|
||||||
|
|
@ -3717,6 +3909,32 @@ mod tests {
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
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]
|
#[test]
|
||||||
fn permission_mode_aliases_resolve_to_expected_modes() {
|
fn permission_mode_aliases_resolve_to_expected_modes() {
|
||||||
// given / when / then
|
// given / when / then
|
||||||
|
|
@ -4059,7 +4277,7 @@ mod tests {
|
||||||
fs::create_dir_all(&cwd).expect("project dir");
|
fs::create_dir_all(&cwd).expect("project dir");
|
||||||
fs::write(
|
fs::write(
|
||||||
home.join("settings.json"),
|
home.join("settings.json"),
|
||||||
r#"{"enableWorkflows": true, "workflowKeywordTriggerEnabled": false}"#,
|
r#"{"enableWorkflows": true, "workflowKeywordTriggerEnabled": false, "dynamicWorkflowSize": "large"}"#,
|
||||||
)
|
)
|
||||||
.expect("write settings");
|
.expect("write settings");
|
||||||
|
|
||||||
|
|
@ -4070,6 +4288,17 @@ mod tests {
|
||||||
assert!(loaded.workflows_enabled());
|
assert!(loaded.workflows_enabled());
|
||||||
assert!(!loaded.workflow_keyword_trigger_enabled());
|
assert!(!loaded.workflow_keyword_trigger_enabled());
|
||||||
assert_eq!(loaded.workflows().enable_workflows(), Some(true));
|
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");
|
std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", "1");
|
||||||
assert!(!loaded.workflows_enabled());
|
assert!(!loaded.workflows_enabled());
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,22 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
||||||
name: "subagentModel",
|
name: "subagentModel",
|
||||||
expected: FieldType::String,
|
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] = &[
|
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,13 @@ pub use config::{
|
||||||
clear_user_provider_settings, default_config_home, save_user_provider_settings,
|
clear_user_provider_settings, default_config_home, save_user_provider_settings,
|
||||||
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
||||||
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
||||||
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
|
DynamicWorkflowSize, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig,
|
||||||
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
|
||||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
|
||||||
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
|
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimeInvalidHookConfig,
|
||||||
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig,
|
||||||
|
CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
pub use config_validate::{
|
pub use config_validate::{
|
||||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
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() {
|
let topic = match rest[0].as_str() {
|
||||||
"status" => Some(LocalHelpTopic::Status),
|
"status" => Some(LocalHelpTopic::Status),
|
||||||
"sandbox" => Some(LocalHelpTopic::Sandbox),
|
"sandbox" => Some(LocalHelpTopic::Sandbox),
|
||||||
"doctor" => Some(LocalHelpTopic::Doctor),
|
"doctor" | "checkup" => Some(LocalHelpTopic::Doctor),
|
||||||
"acp" => Some(LocalHelpTopic::Acp),
|
"acp" => Some(LocalHelpTopic::Acp),
|
||||||
"init" => Some(LocalHelpTopic::Init),
|
"init" => Some(LocalHelpTopic::Init),
|
||||||
"setup" => Some(LocalHelpTopic::Setup),
|
"setup" => Some(LocalHelpTopic::Setup),
|
||||||
|
|
@ -2549,7 +2549,7 @@ fn parse_local_help_action(
|
||||||
let topic = match rest[0].as_str() {
|
let topic = match rest[0].as_str() {
|
||||||
"status" => LocalHelpTopic::Status,
|
"status" => LocalHelpTopic::Status,
|
||||||
"sandbox" => LocalHelpTopic::Sandbox,
|
"sandbox" => LocalHelpTopic::Sandbox,
|
||||||
"doctor" => LocalHelpTopic::Doctor,
|
"doctor" | "checkup" => LocalHelpTopic::Doctor,
|
||||||
"acp" => LocalHelpTopic::Acp,
|
"acp" => LocalHelpTopic::Acp,
|
||||||
"init" => LocalHelpTopic::Init,
|
"init" => LocalHelpTopic::Init,
|
||||||
"setup" => LocalHelpTopic::Setup,
|
"setup" => LocalHelpTopic::Setup,
|
||||||
|
|
@ -2598,7 +2598,7 @@ fn parse_single_word_command_alias(
|
||||||
let verb = &rest[0];
|
let verb = &rest[0];
|
||||||
let is_diagnostic = matches!(
|
let is_diagnostic = matches!(
|
||||||
verb.as_str(),
|
verb.as_str(),
|
||||||
"help" | "version" | "status" | "sandbox" | "doctor" | "setup" | "state"
|
"help" | "version" | "status" | "sandbox" | "doctor" | "checkup" | "setup" | "state"
|
||||||
);
|
);
|
||||||
|
|
||||||
if is_diagnostic && rest.len() > 1 {
|
if is_diagnostic && rest.len() > 1 {
|
||||||
|
|
@ -2615,7 +2615,7 @@ fn parse_single_word_command_alias(
|
||||||
let topic = match topic_name {
|
let topic = match topic_name {
|
||||||
"status" => Some(LocalHelpTopic::Status),
|
"status" => Some(LocalHelpTopic::Status),
|
||||||
"sandbox" => Some(LocalHelpTopic::Sandbox),
|
"sandbox" => Some(LocalHelpTopic::Sandbox),
|
||||||
"doctor" => Some(LocalHelpTopic::Doctor),
|
"doctor" | "checkup" => Some(LocalHelpTopic::Doctor),
|
||||||
"acp" => Some(LocalHelpTopic::Acp),
|
"acp" => Some(LocalHelpTopic::Acp),
|
||||||
"init" => Some(LocalHelpTopic::Init),
|
"init" => Some(LocalHelpTopic::Init),
|
||||||
"setup" => Some(LocalHelpTopic::Setup),
|
"setup" => Some(LocalHelpTopic::Setup),
|
||||||
|
|
@ -2670,7 +2670,7 @@ fn parse_single_word_command_alias(
|
||||||
let topic = match topic_name {
|
let topic = match topic_name {
|
||||||
"status" => Some(LocalHelpTopic::Status),
|
"status" => Some(LocalHelpTopic::Status),
|
||||||
"sandbox" => Some(LocalHelpTopic::Sandbox),
|
"sandbox" => Some(LocalHelpTopic::Sandbox),
|
||||||
"doctor" => Some(LocalHelpTopic::Doctor),
|
"doctor" | "checkup" => Some(LocalHelpTopic::Doctor),
|
||||||
"acp" => Some(LocalHelpTopic::Acp),
|
"acp" => Some(LocalHelpTopic::Acp),
|
||||||
"init" => Some(LocalHelpTopic::Init),
|
"init" => Some(LocalHelpTopic::Init),
|
||||||
"setup" => Some(LocalHelpTopic::Setup),
|
"setup" => Some(LocalHelpTopic::Setup),
|
||||||
|
|
@ -2711,7 +2711,7 @@ fn parse_single_word_command_alias(
|
||||||
}
|
}
|
||||||
// Known CLI subcommands that don't accept additional arguments
|
// Known CLI subcommands that don't accept additional arguments
|
||||||
const CLI_SUBCOMMANDS: &[&str] = &[
|
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()) {
|
if rest.len() > 1 && !CLI_SUBCOMMANDS.contains(&rest[0].as_str()) {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -2730,7 +2730,7 @@ fn parse_single_word_command_alias(
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
})),
|
})),
|
||||||
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
||||||
"doctor" => Some(Ok(CliAction::Doctor {
|
"doctor" | "checkup" => Some(Ok(CliAction::Doctor {
|
||||||
output_format,
|
output_format,
|
||||||
permission_mode: permission_mode_override
|
permission_mode: permission_mode_override
|
||||||
.map(PermissionModeProvenance::from_flag)
|
.map(PermissionModeProvenance::from_flag)
|
||||||
|
|
@ -3231,6 +3231,7 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
||||||
"status",
|
"status",
|
||||||
"sandbox",
|
"sandbox",
|
||||||
"doctor",
|
"doctor",
|
||||||
|
"checkup",
|
||||||
"setup",
|
"setup",
|
||||||
"state",
|
"state",
|
||||||
"dump-manifests",
|
"dump-manifests",
|
||||||
|
|
@ -3278,6 +3279,7 @@ fn is_known_top_level_subcommand(value: &str) -> bool {
|
||||||
| "status"
|
| "status"
|
||||||
| "sandbox"
|
| "sandbox"
|
||||||
| "doctor"
|
| "doctor"
|
||||||
|
| "checkup"
|
||||||
| "state"
|
| "state"
|
||||||
| "dump-manifests"
|
| "dump-manifests"
|
||||||
| "bootstrap-plan"
|
| "bootstrap-plan"
|
||||||
|
|
@ -17808,6 +17810,13 @@ mod tests {
|
||||||
permission_mode: PermissionModeProvenance::default_fallback(),
|
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!(
|
assert_eq!(
|
||||||
parse_args(&["state".to_string()]).expect("state should parse"),
|
parse_args(&["state".to_string()]).expect("state should parse"),
|
||||||
CliAction::State {
|
CliAction::State {
|
||||||
|
|
@ -18463,6 +18472,14 @@ mod tests {
|
||||||
output_format: CliOutputFormat::Text,
|
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!(
|
assert_eq!(
|
||||||
parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"),
|
parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"),
|
||||||
CliAction::HelpTopic {
|
CliAction::HelpTopic {
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ mod tests {
|
||||||
lane_events: vec![],
|
lane_events: vec![],
|
||||||
derived_state: "working".to_string(),
|
derived_state: "working".to_string(),
|
||||||
current_blocker: None,
|
current_blocker: None,
|
||||||
|
telemetry_attributes: std::collections::BTreeMap::new(),
|
||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
@ -27,10 +28,10 @@ use runtime::{
|
||||||
worker_boot::{WorkerReadySnapshot, WorkerRegistry, WorkerTaskReceipt},
|
worker_boot::{WorkerReadySnapshot, WorkerRegistry, WorkerTaskReceipt},
|
||||||
write_file_in_workspace, ApiClient, ApiRequest, AssistantEvent, BashCommandInput,
|
write_file_in_workspace, ApiClient, ApiRequest, AssistantEvent, BashCommandInput,
|
||||||
BashCommandOutput, BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage,
|
BashCommandOutput, BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage,
|
||||||
ConversationRuntime, GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
|
ConversationRuntime, DynamicWorkflowSize, GrepSearchInput, LaneCommitProvenance, LaneEvent,
|
||||||
LaneEventName, LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole,
|
LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, McpDegradedReport,
|
||||||
PermissionMode, PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError,
|
MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig,
|
||||||
Session, TaskPacket, ToolError, ToolExecutor,
|
RuntimeError, Session, TaskPacket, ToolError, ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
@ -1277,7 +1278,8 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
||||||
"command": { "type": "string" },
|
"command": { "type": "string" },
|
||||||
"path": { "type": "string" },
|
"path": { "type": "string" },
|
||||||
"pattern": { "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
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
|
|
@ -2370,7 +2372,7 @@ fn resolve_workflow_script(input: WorkflowInput) -> Result<ResolvedWorkflowScrip
|
||||||
};
|
};
|
||||||
|
|
||||||
if name == "deep-research" {
|
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 {
|
return Ok(ResolvedWorkflowScript {
|
||||||
name,
|
name,
|
||||||
description: Some(String::from(
|
description: Some(String::from(
|
||||||
|
|
@ -2409,6 +2411,8 @@ where
|
||||||
let script_path = persist_workflow_script_for_run(&store_dir, &run_id, &workflow)?;
|
let script_path = persist_workflow_script_for_run(&store_dir, &run_id, &workflow)?;
|
||||||
let manifest_file = store_dir.join(format!("{run_id}.json"));
|
let manifest_file = store_dir.join(format!("{run_id}.json"));
|
||||||
let calls = parse_workflow_agent_calls(&workflow.script);
|
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();
|
let mut agents = Vec::new();
|
||||||
for (index, call) in calls.into_iter().enumerate() {
|
for (index, call) in calls.into_iter().enumerate() {
|
||||||
|
|
@ -2419,7 +2423,7 @@ where
|
||||||
let manifest = execute_agent_with_spawn(
|
let manifest = execute_agent_with_spawn(
|
||||||
AgentInput {
|
AgentInput {
|
||||||
description: description.clone(),
|
description: description.clone(),
|
||||||
prompt: workflow_agent_prompt(&workflow, &call),
|
prompt: workflow_agent_prompt(&workflow, &call, workflow_size),
|
||||||
subagent_type: call
|
subagent_type: call
|
||||||
.agent_type
|
.agent_type
|
||||||
.or_else(|| Some(String::from("general-purpose"))),
|
.or_else(|| Some(String::from("general-purpose"))),
|
||||||
|
|
@ -2428,6 +2432,8 @@ where
|
||||||
effort: call.effort,
|
effort: call.effort,
|
||||||
run_in_background: Some(true),
|
run_in_background: Some(true),
|
||||||
isolation: call.isolation,
|
isolation: call.isolation,
|
||||||
|
workflow_run_id: Some(run_id.clone()),
|
||||||
|
workflow_name: Some(workflow.name.clone()),
|
||||||
},
|
},
|
||||||
spawn_fn,
|
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 {
|
let run = WorkflowRunOutput {
|
||||||
run_id,
|
run_id,
|
||||||
kind: String::from("workflow"),
|
kind: String::from("workflow"),
|
||||||
|
|
@ -2470,24 +2483,45 @@ where
|
||||||
created_at,
|
created_at,
|
||||||
agents,
|
agents,
|
||||||
args: workflow.args,
|
args: workflow.args,
|
||||||
|
workflow_size: workflow_size.as_str().to_string(),
|
||||||
|
recommended_agent_range: [recommended_min, recommended_max],
|
||||||
|
telemetry_attributes,
|
||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
write_workflow_run_manifest(&run)?;
|
write_workflow_run_manifest(&run)?;
|
||||||
Ok(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
|
let args = workflow
|
||||||
.args
|
.args
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|value| format!("\n\nWorkflow args:\n{value}"))
|
.map(|value| format!("\n\nWorkflow args:\n{value}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
"You are a subagent spawned by workflow `{}`. Complete this workflow step and return a concise result.\n\n{}{}",
|
"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, call.prompt, args
|
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> {
|
fn resolve_workflow_script_path(raw: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||||||
if raw.starts_with("\\\\") {
|
if raw.starts_with("\\\\") {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
|
|
@ -2661,13 +2695,64 @@ fn parse_workflow_meta_string(script: &str, key: &str) -> Option<String> {
|
||||||
parse_workflow_option_string(script, key)
|
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 =
|
let question =
|
||||||
workflow_args_to_prompt(args).unwrap_or_else(|| String::from("the requested topic"));
|
workflow_args_to_prompt(args).unwrap_or_else(|| String::from("the requested topic"));
|
||||||
let question = escape_workflow_template_text(&question);
|
let question = escape_workflow_template_text(&question);
|
||||||
format!(
|
let phase_count = match workflow_size {
|
||||||
"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"
|
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> {
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_monitor(input: MonitorInput) -> Result<String, String> {
|
fn run_monitor(input: MonitorInput) -> Result<String, String> {
|
||||||
to_pretty_json(json!({
|
if input.command.as_deref().is_none_or(str::is_empty)
|
||||||
"status": "unsupported",
|
&& input.path.as_deref().is_none_or(str::is_empty)
|
||||||
"kind": "monitor",
|
{
|
||||||
"message": "Monitor contract is registered for Claude Code v2.1.201 compatibility; live monitor execution is not available in this runtime.",
|
return Err("Monitor requires command or path".to_string());
|
||||||
"command": input.command,
|
}
|
||||||
"path": input.path,
|
|
||||||
"pattern": input.pattern,
|
let timeout_ms = input.timeout_ms.unwrap_or(30_000).clamp(1, 600_000);
|
||||||
"timeout_ms": input.timeout_ms,
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn run_schedule_wakeup(input: ScheduleWakeupInput) -> Result<String, String> {
|
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);
|
let clamped = input.delay_seconds.clamp(60, 3600);
|
||||||
to_pretty_json(json!({
|
let wakeup_id = make_wakeup_id();
|
||||||
"status": "scheduled_contract_only",
|
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",
|
"kind": "schedule_wakeup",
|
||||||
"delaySeconds": clamped,
|
"delaySeconds": clamped,
|
||||||
"reason": input.reason,
|
"reason": input.reason,
|
||||||
"prompt": input.prompt,
|
"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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
|
|
@ -3257,6 +3554,20 @@ fn classify_bash_permission(command: &str) -> PermissionMode {
|
||||||
"pwd", "echo", "printf",
|
"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)
|
// Get the base command (first word before any args or pipes)
|
||||||
let base_cmd = command.split_whitespace().next().unwrap_or("");
|
let base_cmd = command.split_whitespace().next().unwrap_or("");
|
||||||
let base_cmd = base_cmd.split('|').next().unwrap_or("").trim();
|
let base_cmd = base_cmd.split('|').next().unwrap_or("").trim();
|
||||||
|
|
@ -3301,6 +3612,10 @@ fn has_dangerous_paths(command: &str) -> bool {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let Some(token) = token_without_dev_null_redirection(token) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
if token.contains('$') {
|
if token.contains('$') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -3346,6 +3661,23 @@ fn has_dangerous_paths(command: &str) -> bool {
|
||||||
false
|
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 {
|
fn looks_like_windows_absolute_path(token: &str) -> bool {
|
||||||
let bytes = token.as_bytes();
|
let bytes = token.as_bytes();
|
||||||
(bytes.len() >= 3
|
(bytes.len() >= 3
|
||||||
|
|
@ -3900,6 +4232,10 @@ struct AgentInput {
|
||||||
effort: Option<String>,
|
effort: Option<String>,
|
||||||
run_in_background: Option<bool>,
|
run_in_background: Option<bool>,
|
||||||
isolation: Option<String>,
|
isolation: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
workflow_run_id: Option<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
workflow_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -4196,9 +4532,23 @@ struct WorkflowRunOutput {
|
||||||
agents: Vec<WorkflowAgentRecord>,
|
agents: Vec<WorkflowAgentRecord>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
args: Option<Value>,
|
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,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_workflow_size_label() -> String {
|
||||||
|
DynamicWorkflowSize::Medium.as_str().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct WorkflowAgentRecord {
|
struct WorkflowAgentRecord {
|
||||||
#[serde(rename = "agentId")]
|
#[serde(rename = "agentId")]
|
||||||
|
|
@ -4219,6 +4569,7 @@ struct MonitorInput {
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
pattern: Option<String>,
|
pattern: Option<String>,
|
||||||
timeout_ms: Option<u64>,
|
timeout_ms: Option<u64>,
|
||||||
|
interval_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -4442,6 +4793,12 @@ struct AgentOutput {
|
||||||
current_blocker: Option<LaneEventBlocker>,
|
current_blocker: Option<LaneEventBlocker>,
|
||||||
#[serde(rename = "derivedState")]
|
#[serde(rename = "derivedState")]
|
||||||
derived_state: String,
|
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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -5366,6 +5723,16 @@ where
|
||||||
let created_at = iso8601_now();
|
let created_at = iso8601_now();
|
||||||
let system_prompt = build_agent_system_prompt(&normalized_subagent_type, &model)?;
|
let system_prompt = build_agent_system_prompt(&normalized_subagent_type, &model)?;
|
||||||
let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
|
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!(
|
let output_contents = format!(
|
||||||
"# Agent Task
|
"# Agent Task
|
||||||
|
|
@ -5402,6 +5769,7 @@ where
|
||||||
lane_events: vec![LaneEvent::started(iso8601_now())],
|
lane_events: vec![LaneEvent::started(iso8601_now())],
|
||||||
current_blocker: None,
|
current_blocker: None,
|
||||||
derived_state: String::from("working"),
|
derived_state: String::from("working"),
|
||||||
|
telemetry_attributes,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
write_agent_manifest(&manifest)?;
|
write_agent_manifest(&manifest)?;
|
||||||
|
|
@ -8104,9 +8472,10 @@ mod tests {
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
agent_permission_policy, allowed_tools_for_subagent, build_agent_system_prompt,
|
agent_permission_policy, allowed_tools_for_subagent, build_agent_system_prompt,
|
||||||
classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool,
|
builtin_deep_research_script, classify_bash_permission, classify_lane_failure,
|
||||||
extract_recovery_outcome, final_assistant_text, global_cron_registry, global_mcp_registry,
|
derive_agent_state, execute_agent_with_spawn, execute_tool, extract_recovery_outcome,
|
||||||
maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin,
|
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,
|
persist_agent_terminal_state, push_output_block, run_task_packet, run_workflow_with_spawn,
|
||||||
AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
|
AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
|
||||||
ProviderRuntimeClient, SubagentToolExecutor, WorkflowInput,
|
ProviderRuntimeClient, SubagentToolExecutor, WorkflowInput,
|
||||||
|
|
@ -8116,7 +8485,8 @@ mod tests {
|
||||||
use runtime::ProviderFallbackConfig;
|
use runtime::ProviderFallbackConfig;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
permission_enforcer::PermissionEnforcer, ApiRequest, AssistantEvent, ConversationRuntime,
|
permission_enforcer::PermissionEnforcer, ApiRequest, AssistantEvent, ConversationRuntime,
|
||||||
PermissionMode, PermissionPolicy, RuntimeError, Session, TaskPacket, ToolExecutor,
|
DynamicWorkflowSize, PermissionMode, PermissionPolicy, RuntimeError, Session, TaskPacket,
|
||||||
|
ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
|
@ -8277,9 +8647,12 @@ mod tests {
|
||||||
fn v201_host_contract_tools_return_stable_payloads() {
|
fn v201_host_contract_tools_return_stable_payloads() {
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
let workflow_store = temp_path("workflow-contract-store");
|
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_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();
|
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_WORKFLOW_STORE", &workflow_store);
|
||||||
|
std::env::set_var("CLAWD_WAKEUP_STORE", &wakeup_store);
|
||||||
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
|
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
|
||||||
|
|
||||||
let workflow = execute_tool(
|
let workflow = execute_tool(
|
||||||
|
|
@ -8300,15 +8673,18 @@ mod tests {
|
||||||
assert!(script_path.exists());
|
assert!(script_path.exists());
|
||||||
assert!(script_path.starts_with(&workflow_store));
|
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(
|
let monitor = execute_tool(
|
||||||
"Monitor",
|
"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");
|
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["kind"], "monitor");
|
||||||
assert_eq!(monitor["timeout_ms"], 250);
|
assert_eq!(monitor["source"], "path");
|
||||||
|
let _ = fs::remove_file(monitor_path);
|
||||||
|
|
||||||
let wakeup = execute_tool(
|
let wakeup = execute_tool(
|
||||||
"ScheduleWakeup",
|
"ScheduleWakeup",
|
||||||
|
|
@ -8316,8 +8692,11 @@ mod tests {
|
||||||
)
|
)
|
||||||
.expect("ScheduleWakeup contract should be captured");
|
.expect("ScheduleWakeup contract should be captured");
|
||||||
let wakeup: serde_json::Value = serde_json::from_str(&wakeup).expect("wakeup json");
|
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);
|
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(
|
let notification = execute_tool(
|
||||||
"PushNotification",
|
"PushNotification",
|
||||||
|
|
@ -8384,11 +8763,16 @@ mod tests {
|
||||||
Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value),
|
Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value),
|
||||||
None => std::env::remove_var("CLAWD_WORKFLOW_STORE"),
|
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 {
|
match original_disable_workflows {
|
||||||
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
|
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
|
||||||
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
|
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
|
||||||
}
|
}
|
||||||
let _ = fs::remove_dir_all(workflow_store);
|
let _ = fs::remove_dir_all(workflow_store);
|
||||||
|
let _ = fs::remove_dir_all(wakeup_store);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -8399,11 +8783,15 @@ mod tests {
|
||||||
|
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
let root = temp_path("workflow-static-agent");
|
let root = temp_path("workflow-static-agent");
|
||||||
|
let project = root.join("project");
|
||||||
let workflow_store = root.join("workflows");
|
let workflow_store = root.join("workflows");
|
||||||
let agent_store = root.join("agents");
|
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_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok();
|
||||||
let original_agent_store = std::env::var("CLAWD_AGENT_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();
|
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_WORKFLOW_STORE", &workflow_store);
|
||||||
std::env::set_var("CLAWD_AGENT_STORE", &agent_store);
|
std::env::set_var("CLAWD_AGENT_STORE", &agent_store);
|
||||||
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
|
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
|
||||||
|
|
@ -8426,9 +8814,32 @@ mod tests {
|
||||||
assert_eq!(output["name"], "release-check");
|
assert_eq!(output["name"], "release-check");
|
||||||
assert_eq!(output["description"], "Check release readiness");
|
assert_eq!(output["description"], "Check release readiness");
|
||||||
assert_eq!(output["source"], "inline");
|
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"].as_array().expect("agents").len(), 1);
|
||||||
assert_eq!(output["agents"][0]["description"], "research");
|
assert_eq!(output["agents"][0]["description"], "research");
|
||||||
assert_eq!(output["agents"][0]["status"], "running");
|
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"]
|
assert!(output["message"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("message")
|
.expect("message")
|
||||||
|
|
@ -8452,9 +8863,80 @@ mod tests {
|
||||||
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
|
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
|
||||||
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
|
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);
|
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]
|
#[test]
|
||||||
fn workflow_tool_respects_disable_env() {
|
fn workflow_tool_respects_disable_env() {
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue