Compare commits

..

4 Commits

Author SHA1 Message Date
YeonGyu-Kim
f079b7b616 ROADMAP #132: --output-format json global error renderer flattens every typed error variant into {type:"error",error:<prose>}, erasing §4.44 envelope structure at the final serialization boundary — runtime has 5 typed error enums (SessionError/ConfigError/McpServerManagerError/PromptBuildError/SessionControlError) but ~11 CLI emission sites call error.to_string() and wrap in flat {type,error} shape; kind/operation/target/errno/hint/retryable all discarded. #130 fix lands typed envelope in text mode but JSON mode still emits flat string. Commit d305178 body self-declares this debt. Closes the renderer-side half of §4.44. Joins JSON-envelope asymmetry family #90/#91/#92/#110/#115/#116 as 7th; joins silent-state inventory #102/#127/#129/#130/#131 as 6th. Bundle: #130+#132 export-surface typed errors text+json parity. 2026-04-20 14:14:41 +09:00
YeonGyu-Kim
93da4f14ab ROADMAP #131: claw export positional arg silently treated as output PATH not session reference — operator types 'claw export <session-id> --output /tmp/x.md' expecting to export <session-id>, but parser puts <session-id> in output_path slot and session_reference defaults to LATEST. Result: wrong session exported with no warning. Discovered while auditing #130 export error path during scope-truth pass on 2026-04-20 dogfood cycle. Joins silent-state inventory family (#102 + #127 + #129 + #130) and parser-level trust gap quintet (#108 + #117 + #119 + #122 + #127). 2026-04-20 14:07:27 +09:00
YeonGyu-Kim
d305178591 fix(cli): #130 export error envelope — wrap fs::write() in run_export() with structured ExportError per Phase 2 §4.44 typed-error envelope contract; eliminates zero-context errno output. ExportError struct includes kind/operation/target/errno/hint/retryable fields with serde::Serialize and Display impls. wrap_export_io_error() classifies io::ErrorKind into filesystem/permission/invalid_path categories and synthesizes actionable hints (e.g. 'intermediate directory does not exist; try mkdir -p X first'). Verified end-to-end: ENOENT, EPERM, IsADirectory, empty path, trailing slash all emit structured envelope; success case unchanged (backward-compat anchor preserved). JSON mode still uses string error rendering — separate concern requiring global error renderer refactor (tracked for follow-up cycle). 2026-04-20 14:02:15 +09:00
YeonGyu-Kim
0cbff5dc76 prep(cli): #130 structured ExportError type design ready — conforms to §4.44 typed-error envelope contract; includes kind/operation/target/errno/hint/retryable fields, Display + serde::Serialize impl, wrap_export_io_error helper; ready for integration into run_export handler 2026-04-20 13:42:57 +09:00
26 changed files with 397 additions and 6685 deletions

View File

@@ -1,5 +0,0 @@
{
"aliases": {
"quick": "haiku"
}
}

View File

@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Frameworks: none detected from the supported starter markers. - Frameworks: none detected from the supported starter markers.
## Verification ## Verification
- Run Rust verification from repo root: `scripts/fmt.sh --check`; for formatting use `scripts/fmt.sh`. Run Rust clippy/tests from `rust/`: `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` - Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
- `src/` and `tests/` are both present; update both surfaces together when behavior changes. - `src/` and `tests/` are both present; update both surfaces together when behavior changes.
## Repository shape ## Repository shape

View File

@@ -98,87 +98,10 @@ export ANTHROPIC_API_KEY="sk-ant-..."
**Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install. **Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install.
## Post-build: locate the binary and verify
After running `cargo build --workspace`, the `claw` binary is built but **not** automatically installed to your system. Here's where to find it and how to verify the build succeeded.
### Binary location
After `cargo build --workspace` in `claw-code/rust/`:
**Debug build (default, faster compile):**
- **macOS/Linux:** `rust/target/debug/claw`
- **Windows:** `rust/target/debug/claw.exe`
**Release build (optimized, slower compile):**
- **macOS/Linux:** `rust/target/release/claw`
- **Windows:** `rust/target/release/claw.exe`
If you ran `cargo build` without `--release`, the binary is in the `debug/` folder.
### Verify the build succeeded
Test the binary directly using its path:
```bash
# macOS/Linux (debug build)
./rust/target/debug/claw --help
./rust/target/debug/claw doctor
# Windows PowerShell (debug build)
.\rust\target\debug\claw.exe --help
.\rust\target\debug\claw.exe doctor
```
If these commands succeed, the build is working. `claw doctor` is your first health check — it validates your API key, model access, and tool configuration.
### Optional: Add to PATH
If you want to run `claw` from any directory without the full path, choose one of these approaches:
**Option 1: Symlink (macOS/Linux)**
```bash
ln -s $(pwd)/rust/target/debug/claw /usr/local/bin/claw
```
Then reload your shell and test:
```bash
claw --help
```
**Option 2: Use `cargo install` (all platforms)**
Build and install to Cargo's default location (`~/.cargo/bin/`, which is usually on PATH):
```bash
# From the claw-code/rust/ directory
cargo install --path . --force
# Then from anywhere
claw --help
```
**Option 3: Update shell profile (bash/zsh)**
Add this line to `~/.bashrc` or `~/.zshrc`:
```bash
export PATH="$(pwd)/rust/target/debug:$PATH"
```
Reload your shell:
```bash
source ~/.bashrc # or source ~/.zshrc
claw --help
```
### Troubleshooting
- **"command not found: claw"** — The binary is in `rust/target/debug/claw`, but it's not on your PATH. Use the full path `./rust/target/debug/claw` or symlink/install as above.
- **"permission denied"** — On macOS/Linux, you may need `chmod +x rust/target/debug/claw` if the executable bit isn't set (rare).
- **Debug vs. release** — If the build is slow, you're in debug mode (default). Add `--release` to `cargo build` for faster runtime, but the build itself will take 510 minutes.
> [!NOTE] > [!NOTE]
> **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path. > **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path.
Run the workspace test suite after verifying the binary works: Run the workspace test suite:
```bash ```bash
cd rust cd rust

1425
ROADMAP.md

File diff suppressed because one or more lines are too long

108
USAGE.md
View File

@@ -43,35 +43,6 @@ cd rust
/doctor /doctor
``` ```
Or run doctor directly with JSON output for scripting:
```bash
cd rust
./target/debug/claw doctor --output-format json
```
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
### Initialize a repository
Set up a new repository with `.claw` config, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
```bash
cd /path/to/your/repo
./target/debug/claw init
```
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped".
JSON mode for scripting:
```bash
./target/debug/claw init --output-format json
```
Returns structured output with `project_path`, `created[]`, `updated[]`, `skipped[]` arrays (one per artifact), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
**Why structured fields matter:** Claws can detect per-artifact state (`created` vs `updated` vs `skipped`) without substring-matching human prose. Use the `created[]`, `updated[]`, and `skipped[]` arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
### Interactive REPL ### Interactive REPL
```bash ```bash
@@ -100,85 +71,6 @@ cd rust
./target/debug/claw --output-format json prompt "status" ./target/debug/claw --output-format json prompt "status"
``` ```
### Inspect worker state
The `claw state` command reads `.claw/worker-state.json`, which is written by the interactive REPL or a one-shot prompt when a worker executes a task. This file contains the worker ID, session reference, model, and permission mode.
Prerequisite: You must run `claw` (interactive REPL) or `claw prompt <text>` at least once in the repository to produce the worker state file.
```bash
cd rust
./target/debug/claw state
```
JSON mode:
```bash
./target/debug/claw state --output-format json
```
If you run `claw state` before any worker has executed, you will see a helpful error:
```
error: no worker state file found at .claw/worker-state.json
Hint: worker state is written by the interactive REPL or a non-interactive prompt.
Run: claw # start the REPL (writes state on first turn)
Or: claw prompt <text> # run one non-interactive turn
Then rerun: claw state [--output-format json]
```
## Advanced slash commands (Interactive REPL only)
These commands are available inside the interactive REPL (`claw` with no args). They extend the assistant with workspace analysis, planning, and navigation features.
### `/ultraplan` — Deep planning with multi-step reasoning
**Purpose:** Break down a complex task into steps using extended reasoning.
```bash
# Start the REPL
claw
# Inside the REPL
/ultraplan refactor the auth module to use async/await
/ultraplan design a caching layer for database queries
/ultraplan analyze this module for performance bottlenecks
```
Output: A structured plan with numbered steps, reasoning for each step, and expected outcomes. Use this when you want the assistant to think through a problem in detail before coding.
### `/teleport` — Jump to a file or symbol
**Purpose:** Quickly navigate to a file, function, class, or struct by name.
```bash
# Jump to a symbol
/teleport UserService
/teleport authenticate_user
/teleport RequestHandler
# Jump to a file
/teleport src/auth.rs
/teleport crates/runtime/lib.rs
/teleport ./ARCHITECTURE.md
```
Output: The file content, with the requested symbol highlighted or the file fully loaded. Useful for exploring the codebase without manually navigating directories. If multiple matches exist, the assistant shows the top candidates.
### `/bughunter` — Scan for likely bugs and issues
**Purpose:** Analyze code for common pitfalls, anti-patterns, and potential bugs.
```bash
# Scan the entire workspace
/bughunter
# Scan a specific directory or file
/bughunter src/handlers
/bughunter rust/crates/runtime
/bughunter src/auth.rs
```
Output: A list of suspicious patterns with explanations (e.g., "unchecked unwrap()", "potential race condition", "missing error handling"). Each finding includes the file, line number, and suggested fix. Use this as a first pass before a full code review.
## Model and permission controls ## Model and permission controls
```bash ```bash

View File

@@ -74,18 +74,6 @@ US-007 COMPLETE (Phase 5 - Plugin/MCP lifecycle maturity)
- DegradedMode behavior - DegradedMode behavior
- Tests: 11 unit tests passing - Tests: 11 unit tests passing
Iteration 2026-04-27 - ROADMAP #200 COMPLETED
------------------------------------------------
- Selected next actionable backlog item because no active task was in progress.
- ROADMAP #200: Interactive MCP/tool permission prompts are invisible blockers.
- Files: rust/crates/runtime/src/worker_boot.rs, rust/crates/runtime/src/recovery_recipes.rs, ROADMAP.md, progress.txt.
- Added tool_permission_required worker status and event classification for interactive MCP/tool permission gates.
- Added structured ToolPermissionPrompt payload with server/tool identity and prompt preview.
- Startup evidence now records tool_permission_prompt_detected and classifies timeout evidence as tool_permission_required.
- Readiness snapshots now mark tool-permission-gated workers as blocked, not ready/idle.
- Tests: targeted tool_permission regressions, full runtime test/clippy/fmt pending in Ralph verification loop.
VERIFICATION STATUS: VERIFICATION STATUS:
------------------ ------------------
- cargo build --workspace: PASSED - cargo build --workspace: PASSED
@@ -120,29 +108,6 @@ US-010 COMPLETED (Add model compatibility documentation)
- Cross-referenced with existing code comments in openai_compat.rs - Cross-referenced with existing code comments in openai_compat.rs
- cargo clippy passes - cargo clippy passes
Iteration 3: 2026-04-16
------------------------
US-012 COMPLETED (Trust prompt resolver with allowlist auto-trust)
- Files: rust/crates/runtime/src/trust_resolver.rs
- Enhanced TrustConfig with pattern matching and serde support:
- TrustAllowlistEntry struct with pattern, worktree_pattern, description
- TrustResolution enum (AutoAllowlisted, ManualApproval)
- Enhanced TrustEvent variants with serde tags and metadata
- Glob pattern matching with * and ? wildcards
- Support for path prefix matching and worktree patterns
- Updated TrustResolver with new resolve() signature:
- Added worktree parameter for worktree pattern matching
- Proper event emission with TrustResolution
- Manual approval detection from screen text
- Added helper functions:
- extract_repo_name() - extracts repo name from path
- detect_manual_approval() - detects manual trust from screen text
- glob_matches() - recursive backtracking glob matcher
- Tests: 25 new tests for pattern matching, serialization, and resolver behavior
- All 483 runtime tests pass
- cargo clippy passes with no warnings
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead) US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
- Files: - Files:
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config) - rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
@@ -166,213 +131,3 @@ US-011 COMPLETED (Performance optimization: reduce API request serialization ove
- is_reasoning_model detection: ~26-42ns depending on model - is_reasoning_model detection: ~26-42ns depending on model
- All tests pass (119 unit tests + 29 integration tests) - All tests pass (119 unit tests + 29 integration tests)
- cargo clippy passes - cargo clippy passes
VERIFICATION STATUS (Iteration 3):
----------------------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (891+ tests)
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
- cargo fmt -- --check: PASSED
All 12 stories from prd.json now have passes: true
- US-001 through US-007: Pre-existing implementations
- US-008: kimi-k2.5 model API compatibility fix
- US-009: Unit tests for kimi model compatibility
- US-010: Model compatibility documentation
- US-011: Performance optimization with criterion benchmarks
- US-012: Trust prompt resolver with allowlist auto-trust
Iteration 4: 2026-04-16
------------------------
US-013 COMPLETED (Phase 2 - Session event ordering + terminal-state reconciliation)
- Files: rust/crates/runtime/src/lane_events.rs
- Added EventTerminality enum (Terminal, Advisory, Uncertainty)
- Added classify_event_terminality() function for event classification
- Added reconcile_terminal_events() function for deterministic event ordering:
- Sorts events by monotonic sequence number
- Deduplicates terminal events by fingerprint
- Detects transport death uncertainty (terminal + transport death)
- Handles out-of-order event bursts
- Added events_materially_differ() for detecting meaningful differences
- Added 8 comprehensive tests for reconciliation logic:
- reconcile_terminal_events_sorts_by_monotonic_sequence
- reconcile_terminal_events_deduplicates_same_fingerprint
- reconcile_terminal_events_detects_transport_death_uncertainty
- reconcile_terminal_events_handles_completed_idle_error_completed_noise
- reconcile_terminal_events_returns_none_for_empty_input
- reconcile_terminal_events_preserves_advisory_events
- events_materially_differ_detects_real_differences
- classify_event_terminality_correctly_classifies
- Fixed test compilation issues with LaneEventBuilder API
VERIFICATION STATUS (Iteration 4):
----------------------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (891+ tests)
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
- cargo fmt -- --check: PASSED
US-013 marked passes: true in prd.json
US-014 COMPLETED (Phase 2 - Event provenance / environment labeling)
- Files: rust/crates/runtime/src/lane_events.rs
- Added ConfidenceLevel enum (High, Medium, Low, Unknown)
- Added fields to LaneEventMetadata:
- environment_label: Option<String> - environment/channel (production, staging, dev)
- emitter_identity: Option<String> - emitter (clawd, plugin-name, operator-id)
- confidence_level: Option<ConfidenceLevel> - trust level for automation
- Added builder methods: with_environment(), with_emitter(), with_confidence()
- Added filtering functions:
- filter_by_provenance() - select events by source
- filter_by_environment() - select events by environment label
- filter_by_confidence() - select events above confidence threshold
- is_test_event() - check if synthetic source (test, healthcheck, replay)
- is_live_lane_event() - check if production event
- Added 7 comprehensive tests for US-014:
- confidence_level_round_trips_through_serialization
- filter_by_provenance_selects_only_matching_events
- filter_by_environment_selects_only_matching_environment
- filter_by_confidence_selects_events_above_threshold
- is_test_event_detects_synthetic_sources
- is_live_lane_event_detects_production_events
- lane_event_metadata_includes_us014_fields
US-016 COMPLETED (Phase 2 - Duplicate terminal-event suppression)
- Files: rust/crates/runtime/src/lane_events.rs
- Event fingerprinting already implemented via compute_event_fingerprint()
- Fingerprint attached via LaneEventMetadata.event_fingerprint
- Deduplication via dedupe_terminal_events() - returns first occurrence of each fingerprint
- Raw event history preserved separately from deduplicated actionable events
- Material difference detection via events_materially_differ():
- Different event type (Finished vs Failed) is material
- Different status is material
- Different failure class is material
- Different data payload is material
- Reconcile function surfaces latest terminal event when materially different
- Added 5 comprehensive tests for US-016:
- canonical_terminal_event_fingerprint_attached_to_metadata
- dedupe_terminal_events_suppresses_repeated_fingerprints
- dedupe_preserves_raw_event_history_separately
- events_materially_differ_detects_payload_differences
- reconcile_terminal_events_surfaces_latest_when_different
US-017 COMPLETED (Phase 2 - Lane ownership / scope binding)
- Files: rust/crates/runtime/src/lane_events.rs
- LaneOwnership struct already existed with:
- owner: String - owner/assignee identity
- workflow_scope: String - workflow scope (claw-code-dogfood, etc.)
- watcher_action: WatcherAction - Act, Observe, Ignore
- Ownership preserved through lifecycle via with_ownership() builder method
- All lifecycle events (Started -> Ready -> Finished) preserve ownership
- Added 3 comprehensive tests for US-017:
- lane_ownership_attached_to_metadata
- lane_ownership_preserved_through_lifecycle_events
- lane_ownership_watcher_action_variants
US-015 COMPLETED (Phase 2 - Session identity completeness at creation time)
- Files: rust/crates/runtime/src/lane_events.rs
- SessionIdentity struct already existed with:
- title: String - stable title for the session
- workspace: String - workspace/worktree path
- purpose: String - lane/session purpose
- placeholder_reason: Option<String> - reason for placeholder values
- Added reconcile_enriched() method for updating session identity:
- Updates title/workspace/purpose with newly available data
- Clears placeholder_reason when real values are provided
- Preserves existing values for fields not being updated
- Allows incremental enrichment without ambiguity
- Added 2 comprehensive tests:
- session_identity_reconcile_enriched_updates_fields
- session_identity_reconcile_preserves_placeholder_if_no_new_data
US-018 COMPLETED (Phase 2 - Nudge acknowledgment / dedupe contract)
- Files: rust/crates/runtime/src/lane_events.rs
- Added NudgeTracking struct:
- nudge_id: String - unique nudge identifier
- delivered_at: String - timestamp of delivery
- acknowledged: bool - whether acknowledged
- acknowledged_at: Option<String> - when acknowledged
- is_retry: bool - whether this is a retry
- original_nudge_id: Option<String> - original ID if retry
- Added NudgeClassification enum (New, Retry, StaleDuplicate)
- Added classify_nudge() function for deduplication logic
- Added 6 comprehensive tests for US-018
US-019 COMPLETED (Phase 2 - Stable roadmap-id assignment)
- Files: rust/crates/runtime/src/lane_events.rs
- Added RoadmapId struct:
- id: String - canonical unique identifier
- filed_at: String - timestamp when filed
- is_new_filing: bool - new vs update
- supersedes: Option<String> - lineage for supersedes
- Added builder methods: new_filing(), update(), supersedes()
- Added 3 comprehensive tests for US-019
US-020 COMPLETED (Phase 2 - Roadmap item lifecycle state contract)
- Files: rust/crates/runtime/src/lane_events.rs
- Added RoadmapLifecycleState enum (Filed, Acknowledged, InProgress, Blocked, Done, Superseded)
- Added RoadmapLifecycle struct:
- state: RoadmapLifecycleState - current state
- state_changed_at: String - last transition timestamp
- filed_at: String - original filing timestamp
- lineage: Vec<String> - supersession chain
- Added methods: new_filed(), transition(), superseded_by(), is_terminal(), is_active()
- Added 5 comprehensive tests for US-020
VERIFICATION STATUS (Iteration 7):
----------------------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (891+ tests)
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
- cargo fmt -- --check: PASSED
US-013 through US-015 and US-018 through US-020 now marked passes: true
FINAL VERIFICATION (All 20 Stories Complete):
------------------------------------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (119+ API tests, 39 runtime tests, 12 integration tests)
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
- cargo fmt -- --check: PASSED
ALL 20 STORIES FROM PRD COMPLETE:
- US-001 through US-012: Pre-existing implementations (verified working)
- US-013: Session event ordering + terminal-state reconciliation
- US-014: Event provenance / environment labeling
- US-015: Session identity completeness at creation time
- US-016: Duplicate terminal-event suppression
- US-017: Lane ownership / scope binding
- US-018: Nudge acknowledgment / dedupe contract
- US-019: Stable roadmap-id assignment
- US-020: Roadmap item lifecycle state contract
Iteration 8: 2026-04-16
------------------------
US-021 COMPLETED (Request body size pre-flight check - from dogfood findings)
- Files:
- rust/crates/api/src/error.rs (new error variant)
- rust/crates/api/src/providers/openai_compat.rs
- Added RequestBodySizeExceeded error variant with actionable message
- Added max_request_body_bytes to OpenAiCompatConfig:
- DashScope: 6MB (6_291_456 bytes) - from dogfood with kimi-k2.5
- OpenAI: 100MB (104_857_600 bytes)
- xAI: 50MB (52_428_800 bytes)
- Added estimate_request_body_size() for pre-flight checks
- Added check_request_body_size() for validation
- Pre-flight check integrated in send_raw_request()
- Tests: 5 new tests for size estimation and limit checking
PROJECT STATUS: COMPLETE (21/21 stories)
Iteration 2026-04-29 - ROADMAP #96 COMPLETED
------------------------------------------------
- Pulled origin/main: already up to date.
- Selected ROADMAP #96 as a small repo-local Immediate Backlog item: the `claw --help` Resume-safe command summary leaked slash-command stubs despite the main Interactive command listing filtering them.
- Files: rust/crates/rusty-claude-cli/src/main.rs, ROADMAP.md, progress.txt.
- Changed help rendering to filter `resume_supported_slash_commands()` through `STUB_COMMANDS` before building the Resume-safe one-liner.
- Added `stub_commands_absent_from_resume_safe_help` regression coverage so future stub additions cannot leak into the Resume-safe summary.
- Targeted verification: `cargo test -p rusty-claude-cli stub_commands_absent_from_resume_safe_help -- --nocapture` passed; `cargo test -p rusty-claude-cli parses_direct_cli_actions -- --nocapture` passed.
- Format/check verification: `cargo fmt --all --check`, `git diff --check`, and `cargo check -p rusty-claude-cli` passed.
- Broader clippy note: `cargo clippy -p rusty-claude-cli --all-targets -- -D warnings` is blocked by pre-existing `clippy::unnecessary_wraps` failures in `rust/crates/commands/src/lib.rs` (`render_mcp_report_for`, `render_mcp_report_json_for`), outside this diff.

View File

@@ -1,5 +0,0 @@
{
"permissions": {
"defaultMode": "dontAsk"
}
}

4
rust/.gitignore vendored
View File

@@ -1,7 +1,3 @@
target/ target/
.omx/ .omx/
.clawd-agents/ .clawd-agents/
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
.clawhip/

View File

@@ -1,16 +0,0 @@
# CLAUDE.md
This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.
## Detected stack
- Languages: Rust.
- Frameworks: none detected from the supported starter markers.
## Verification
- From the repository root, run Rust formatting with `scripts/fmt.sh` (or `scripts/fmt.sh --check` for CI-style checks). From this `rust/` directory, the equivalent command is `../scripts/fmt.sh`. Root-level `cargo fmt --manifest-path rust/Cargo.toml` is not the supported formatting command.
- From this `rust/` directory, run Rust verification with `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace`.
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.

View File

@@ -753,14 +753,14 @@ mod tests {
#[test] #[test]
fn returns_context_window_metadata_for_kimi_models() { fn returns_context_window_metadata_for_kimi_models() {
// kimi-k2.5 // kimi-k2.5
let k25_limit = let k25_limit = model_token_limit("kimi-k2.5")
model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have token limit metadata"); .expect("kimi-k2.5 should have token limit metadata");
assert_eq!(k25_limit.max_output_tokens, 16_384); assert_eq!(k25_limit.max_output_tokens, 16_384);
assert_eq!(k25_limit.context_window_tokens, 256_000); assert_eq!(k25_limit.context_window_tokens, 256_000);
// kimi-k1.5 // kimi-k1.5
let k15_limit = let k15_limit = model_token_limit("kimi-k1.5")
model_token_limit("kimi-k1.5").expect("kimi-k1.5 should have token limit metadata"); .expect("kimi-k1.5 should have token limit metadata");
assert_eq!(k15_limit.max_output_tokens, 16_384); assert_eq!(k15_limit.max_output_tokens, 16_384);
assert_eq!(k15_limit.context_window_tokens, 256_000); assert_eq!(k15_limit.context_window_tokens, 256_000);
} }
@@ -768,13 +768,11 @@ mod tests {
#[test] #[test]
fn kimi_alias_resolves_to_kimi_k25_token_limits() { fn kimi_alias_resolves_to_kimi_k25_token_limits() {
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias() // The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
let alias_limit = let alias_limit = model_token_limit("kimi")
model_token_limit("kimi").expect("kimi alias should resolve to kimi-k2.5 limits"); .expect("kimi alias should resolve to kimi-k2.5 limits");
let direct_limit = model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have limits"); let direct_limit = model_token_limit("kimi-k2.5")
assert_eq!( .expect("kimi-k2.5 should have limits");
alias_limit.max_output_tokens, assert_eq!(alias_limit.max_output_tokens, direct_limit.max_output_tokens);
direct_limit.max_output_tokens
);
assert_eq!( assert_eq!(
alias_limit.context_window_tokens, alias_limit.context_window_tokens,
direct_limit.context_window_tokens direct_limit.context_window_tokens

View File

@@ -2195,16 +2195,9 @@ mod tests {
#[test] #[test]
fn provider_specific_size_limits_are_correct() { fn provider_specific_size_limits_are_correct() {
assert_eq!( assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB
OpenAiCompatConfig::dashscope().max_request_body_bytes, assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB
6_291_456 assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB
); // 6MB
assert_eq!(
OpenAiCompatConfig::openai().max_request_body_bytes,
104_857_600
); // 100MB
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800);
// 50MB
} }
#[test] #[test]

View File

@@ -2554,22 +2554,11 @@ fn render_mcp_report_for(
match normalize_optional_args(args) { match normalize_optional_args(args) {
None | Some("list") => { None | Some("list") => {
// #144: degrade gracefully on config parse failure (same contract let runtime_config = loader.load()?;
// as #143 for `status`). Text mode prepends a "Config load error" Ok(render_mcp_summary_report(
// block before the MCP list; the list falls back to empty. cwd,
match loader.load() { runtime_config.mcp().servers(),
Ok(runtime_config) => Ok(render_mcp_summary_report( ))
cwd,
runtime_config.mcp().servers(),
)),
Err(err) => {
let empty = std::collections::BTreeMap::new();
Ok(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial MCP view\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}",
render_mcp_summary_report(cwd, &empty)
))
}
}
} }
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)), Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
Some("show") => Ok(render_mcp_usage(Some("show"))), Some("show") => Ok(render_mcp_usage(Some("show"))),
@@ -2582,19 +2571,12 @@ fn render_mcp_report_for(
if parts.next().is_some() { if parts.next().is_some() {
return Ok(render_mcp_usage(Some(args))); return Ok(render_mcp_usage(Some(args)));
} }
// #144: same degradation for `mcp show`; if config won't parse, let runtime_config = loader.load()?;
// the specific server lookup can't succeed, so report the parse Ok(render_mcp_server_report(
// error with context. cwd,
match loader.load() { server_name,
Ok(runtime_config) => Ok(render_mcp_server_report( runtime_config.mcp().get(server_name),
cwd, ))
server_name,
runtime_config.mcp().get(server_name),
)),
Err(err) => Ok(format!(
"Config load error\n Status fail\n Summary runtime config failed to load; cannot resolve `{server_name}`\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
)),
}
} }
Some(args) => Ok(render_mcp_usage(Some(args))), Some(args) => Ok(render_mcp_usage(Some(args))),
} }
@@ -2617,33 +2599,11 @@ fn render_mcp_report_json_for(
match normalize_optional_args(args) { match normalize_optional_args(args) {
None | Some("list") => { None | Some("list") => {
// #144: match #143's degraded envelope contract. On config parse let runtime_config = loader.load()?;
// failure, emit top-level `status: "degraded"` with Ok(render_mcp_summary_report_json(
// `config_load_error`, empty servers[], and exit 0. On clean cwd,
// runs, the existing serializer adds `status: "ok"` below. runtime_config.mcp().servers(),
match loader.load() { ))
Ok(runtime_config) => {
let mut value =
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert("config_load_error".to_string(), Value::Null);
}
Ok(value)
}
Err(err) => {
let empty = std::collections::BTreeMap::new();
let mut value = render_mcp_summary_report_json(cwd, &empty);
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("degraded".to_string()));
map.insert(
"config_load_error".to_string(),
Value::String(err.to_string()),
);
}
Ok(value)
}
}
} }
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)), Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
Some("show") => Ok(render_mcp_usage_json(Some("show"))), Some("show") => Ok(render_mcp_usage_json(Some("show"))),
@@ -2656,29 +2616,12 @@ fn render_mcp_report_json_for(
if parts.next().is_some() { if parts.next().is_some() {
return Ok(render_mcp_usage_json(Some(args))); return Ok(render_mcp_usage_json(Some(args)));
} }
// #144: same degradation pattern for show action. let runtime_config = loader.load()?;
match loader.load() { Ok(render_mcp_server_report_json(
Ok(runtime_config) => { cwd,
let mut value = render_mcp_server_report_json( server_name,
cwd, runtime_config.mcp().get(server_name),
server_name, ))
runtime_config.mcp().get(server_name),
);
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert("config_load_error".to_string(), Value::Null);
}
Ok(value)
}
Err(err) => Ok(serde_json::json!({
"kind": "mcp",
"action": "show",
"server": server_name,
"status": "degraded",
"config_load_error": err.to_string(),
"working_directory": cwd.display().to_string(),
})),
}
} }
Some(args) => Ok(render_mcp_usage_json(Some(args))), Some(args) => Ok(render_mcp_usage_json(Some(args))),
} }
@@ -5536,82 +5479,6 @@ mod tests {
let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(config_home);
} }
#[test]
fn mcp_degrades_gracefully_on_malformed_mcp_config_144() {
// #144: mirror of #143's partial-success contract for `claw mcp`.
// Previously `mcp` hard-failed on any config parse error, hiding
// well-formed servers and forcing claws to fall back to `doctor`.
// Now `mcp` emits a degraded envelope instead: exit 0, status:
// "degraded", config_load_error populated, servers[] empty.
let _guard = env_guard();
let workspace = temp_dir("mcp-degrades-144");
let config_home = temp_dir("mcp-degrades-144-cfg");
fs::create_dir_all(workspace.join(".claw")).expect("create workspace .claw dir");
fs::create_dir_all(&config_home).expect("create config home");
// One valid server + one malformed entry missing `command`.
fs::write(
workspace.join(".claw.json"),
r#"{
"mcpServers": {
"everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]},
"missing-command": {"args": ["arg-only-no-command"]}
}
}
"#,
)
.expect("write malformed .claw.json");
let loader = ConfigLoader::new(&workspace, &config_home);
// list action: must return Ok (not Err) with degraded envelope.
let list = render_mcp_report_json_for(&loader, &workspace, None)
.expect("mcp list should not hard-fail on config parse errors (#144)");
assert_eq!(list["kind"], "mcp");
assert_eq!(list["action"], "list");
assert_eq!(
list["status"].as_str(),
Some("degraded"),
"top-level status should be 'degraded': {list}"
);
let err = list["config_load_error"]
.as_str()
.expect("config_load_error must be a string on degraded runs");
assert!(
err.contains("mcpServers.missing-command"),
"config_load_error should name the malformed field path: {err}"
);
assert_eq!(list["configured_servers"], 0);
assert!(list["servers"].as_array().unwrap().is_empty());
// show action: should also degrade (not hard-fail).
let show = render_mcp_report_json_for(&loader, &workspace, Some("show everything"))
.expect("mcp show should not hard-fail on config parse errors (#144)");
assert_eq!(show["kind"], "mcp");
assert_eq!(show["action"], "show");
assert_eq!(
show["status"].as_str(),
Some("degraded"),
"show action should also report status: 'degraded': {show}"
);
assert!(show["config_load_error"].is_string());
// Clean path: status: "ok", config_load_error: null.
let clean_ws = temp_dir("mcp-degrades-144-clean");
fs::create_dir_all(&clean_ws).expect("clean ws");
let clean_loader = ConfigLoader::new(&clean_ws, &config_home);
let clean_list = render_mcp_report_json_for(&clean_loader, &clean_ws, None)
.expect("clean mcp list should succeed");
assert_eq!(
clean_list["status"].as_str(),
Some("ok"),
"clean run should report status: 'ok'"
);
assert!(clean_list["config_load_error"].is_null());
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(clean_ws);
}
#[test] #[test]
fn parses_quoted_skill_frontmatter_values() { fn parses_quoted_skill_frontmatter_values() {
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n"; let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";

View File

@@ -8,7 +8,6 @@ use tokio::process::Command as TokioCommand;
use tokio::runtime::Builder; use tokio::runtime::Builder;
use tokio::time::timeout; use tokio::time::timeout;
use crate::lane_events::{LaneEvent, ShipMergeMethod, ShipProvenance};
use crate::sandbox::{ use crate::sandbox::{
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode, build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
SandboxConfig, SandboxStatus, SandboxConfig, SandboxStatus,
@@ -103,76 +102,11 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
runtime.block_on(execute_bash_async(input, sandbox_status, cwd)) runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
} }
/// Detect git push to main and emit ship provenance event
fn detect_and_emit_ship_prepared(command: &str) {
let trimmed = command.trim();
// Simple detection: git push with main/master
if trimmed.contains("git push") && (trimmed.contains("main") || trimmed.contains("master")) {
// Emit ship.prepared event
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let provenance = ShipProvenance {
source_branch: get_current_branch().unwrap_or_else(|| "unknown".to_string()),
base_commit: get_head_commit().unwrap_or_default(),
commit_count: 0, // Would need to calculate from range
commit_range: "unknown..HEAD".to_string(),
merge_method: ShipMergeMethod::DirectPush,
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
pr_number: None,
};
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
// Log to stderr as interim routing before event stream integration
eprintln!(
"[ship.prepared] branch={} -> main, commits={}, actor={}",
provenance.source_branch, provenance.commit_count, provenance.actor
);
}
}
fn get_current_branch() -> Option<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_head_commit() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_git_actor() -> Option<String> {
let name = Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
Some(name)
}
async fn execute_bash_async( async fn execute_bash_async(
input: BashCommandInput, input: BashCommandInput,
sandbox_status: SandboxStatus, sandbox_status: SandboxStatus,
cwd: std::path::PathBuf, cwd: std::path::PathBuf,
) -> io::Result<BashCommandOutput> { ) -> io::Result<BashCommandOutput> {
// Detect and emit ship provenance for git push operations
detect_and_emit_ship_prepared(&input.command);
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true); let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
let output_result = if let Some(timeout_ms) = input.timeout { let output_result = if let Some(timeout_ms) = input.timeout {

View File

@@ -1254,21 +1254,11 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf { fn temp_dir() -> std::path::PathBuf {
// #149: previously used `runtime-config-{nanos}` which collided
// under parallel `cargo test --workspace` when multiple tests
// started within the same nanosecond bucket on fast machines.
// Add process id + a monotonically-incrementing atomic counter
// so every callsite gets a provably-unique directory regardless
// of clock resolution or scheduling.
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now() let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.expect("time should be after epoch") .expect("time should be after epoch")
.as_nanos(); .as_nanos();
let pid = std::process::id(); std::env::temp_dir().join(format!("runtime-config-{nanos}"))
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
} }
#[test] #[test]

File diff suppressed because it is too large Load Diff

View File

@@ -84,10 +84,9 @@ pub use hooks::{
}; };
pub use lane_events::{ pub use lane_events::{
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events, compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent, is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance, LaneOwnership, SessionIdentity, WatcherAction,
WatcherAction,
}; };
pub use mcp::{ pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp, mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,

View File

@@ -45,9 +45,7 @@ impl FailureScenario {
#[must_use] #[must_use]
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self { pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
match kind { match kind {
WorkerFailureKind::TrustGate | WorkerFailureKind::ToolPermissionGate => { WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
Self::TrustPromptUnresolved
}
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery, WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
WorkerFailureKind::Protocol => Self::McpHandshakeFailure, WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => { WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {

View File

@@ -31,19 +31,14 @@ impl SessionStore {
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`. /// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> { pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
let cwd = cwd.as_ref(); let cwd = cwd.as_ref();
// #151: canonicalize so equivalent paths (symlinks, relative vs let sessions_root = cwd
// absolute, /tmp vs /private/tmp on macOS) produce the same
// workspace_fingerprint. Falls back to the raw path if canonicalize
// fails (e.g. the directory doesn't exist yet).
let canonical_cwd = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
let sessions_root = canonical_cwd
.join(".claw") .join(".claw")
.join("sessions") .join("sessions")
.join(workspace_fingerprint(&canonical_cwd)); .join(workspace_fingerprint(cwd));
fs::create_dir_all(&sessions_root)?; fs::create_dir_all(&sessions_root)?;
Ok(Self { Ok(Self {
sessions_root, sessions_root,
workspace_root: canonical_cwd, workspace_root: cwd.to_path_buf(),
}) })
} }
@@ -56,18 +51,14 @@ impl SessionStore {
workspace_root: impl AsRef<Path>, workspace_root: impl AsRef<Path>,
) -> Result<Self, SessionControlError> { ) -> Result<Self, SessionControlError> {
let workspace_root = workspace_root.as_ref(); let workspace_root = workspace_root.as_ref();
// #151: canonicalize workspace_root for consistent fingerprinting
// across equivalent path representations.
let canonical_workspace =
fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
let sessions_root = data_dir let sessions_root = data_dir
.as_ref() .as_ref()
.join("sessions") .join("sessions")
.join(workspace_fingerprint(&canonical_workspace)); .join(workspace_fingerprint(workspace_root));
fs::create_dir_all(&sessions_root)?; fs::create_dir_all(&sessions_root)?;
Ok(Self { Ok(Self {
sessions_root, sessions_root,
workspace_root: canonical_workspace, workspace_root: workspace_root.to_path_buf(),
}) })
} }
@@ -112,7 +103,7 @@ impl SessionStore {
candidate candidate
} else if looks_like_path { } else if looks_like_path {
return Err(SessionControlError::Format( return Err(SessionControlError::Format(
format_missing_session_reference(reference, &self.sessions_root), format_missing_session_reference(reference),
)); ));
} else { } else {
self.resolve_managed_path(reference)? self.resolve_managed_path(reference)?
@@ -143,7 +134,7 @@ impl SessionStore {
} }
} }
Err(SessionControlError::Format( Err(SessionControlError::Format(
format_missing_session_reference(session_id, &self.sessions_root), format_missing_session_reference(session_id),
)) ))
} }
@@ -158,9 +149,10 @@ impl SessionStore {
} }
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> { pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
self.list_sessions()?.into_iter().next().ok_or_else(|| { self.list_sessions()?
SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)) .into_iter()
}) .next()
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
} }
pub fn load_session( pub fn load_session(
@@ -521,25 +513,15 @@ fn session_id_from_path(path: &Path) -> Option<String> {
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
} }
fn format_missing_session_reference(reference: &str, sessions_root: &Path) -> String { fn format_missing_session_reference(reference: &str) -> String {
// #80: show the actual workspace-fingerprint directory instead of lying about .claw/sessions/
let fingerprint_dir = sessions_root
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
format!( format!(
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/{fingerprint_dir}/ (workspace-specific partition).\nTry `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL." "session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
) )
} }
fn format_no_managed_sessions(sessions_root: &Path) -> String { fn format_no_managed_sessions() -> String {
// #80: show the actual workspace-fingerprint directory instead of lying about .claw/sessions/
let fingerprint_dir = sessions_root
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
format!( format!(
"no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible." "no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
) )
} }
@@ -762,40 +744,6 @@ mod tests {
assert_eq!(fp_a1.len(), 16, "fingerprint must be a 16-char hex string"); assert_eq!(fp_a1.len(), 16, "fingerprint must be a 16-char hex string");
} }
/// #151 regression: equivalent paths (e.g. `/tmp/foo` vs `/private/tmp/foo`
/// on macOS where `/tmp` is a symlink to `/private/tmp`) must resolve to
/// the same session store. Previously they diverged because
/// `workspace_fingerprint()` hashed the raw path string. Now
/// `SessionStore::from_cwd()` canonicalizes first.
#[test]
fn session_store_from_cwd_canonicalizes_equivalent_paths() {
let base = temp_dir();
let real_dir = base.join("real-workspace");
fs::create_dir_all(&real_dir).expect("real workspace should exist");
// Build two stores via different but equivalent path representations:
// the raw path and the canonicalized path.
let raw_path = real_dir.clone();
let canonical_path = fs::canonicalize(&real_dir).expect("canonicalize ok");
let store_from_raw =
SessionStore::from_cwd(&raw_path).expect("store from raw should build");
let store_from_canonical =
SessionStore::from_cwd(&canonical_path).expect("store from canonical should build");
assert_eq!(
store_from_raw.sessions_dir(),
store_from_canonical.sessions_dir(),
"equivalent paths must produce the same sessions dir (raw={} canonical={})",
raw_path.display(),
canonical_path.display()
);
if base.exists() {
fs::remove_dir_all(base).expect("cleanup ok");
}
}
#[test] #[test]
fn session_store_from_cwd_isolates_sessions_by_workspace() { fn session_store_from_cwd_isolates_sessions_by_workspace() {
// given // given
@@ -884,11 +832,6 @@ mod tests {
let workspace_b = base.join("repo-beta"); let workspace_b = base.join("repo-beta");
fs::create_dir_all(&workspace_a).expect("workspace a should exist"); fs::create_dir_all(&workspace_a).expect("workspace a should exist");
fs::create_dir_all(&workspace_b).expect("workspace b should exist"); fs::create_dir_all(&workspace_b).expect("workspace b should exist");
// #151: canonicalize so test expectations match the store's canonical
// workspace_root. Without this, the test builds sessions with a raw
// path but the store resolves to the canonical form.
let workspace_a = fs::canonicalize(&workspace_a).unwrap_or(workspace_a);
let workspace_b = fs::canonicalize(&workspace_b).unwrap_or(workspace_b);
let store_b = SessionStore::from_cwd(&workspace_b).expect("store b should build"); let store_b = SessionStore::from_cwd(&workspace_b).expect("store b should build");
let legacy_root = workspace_b.join(".claw").join("sessions"); let legacy_root = workspace_b.join(".claw").join("sessions");
@@ -922,8 +865,6 @@ mod tests {
// given // given
let base = temp_dir(); let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist"); fs::create_dir_all(&base).expect("base dir should exist");
// #151: canonicalize for path-representation consistency with store.
let base = fs::canonicalize(&base).unwrap_or(base);
let store = SessionStore::from_cwd(&base).expect("store should build"); let store = SessionStore::from_cwd(&base).expect("store should build");
let legacy_root = base.join(".claw").join("sessions"); let legacy_root = base.join(".claw").join("sessions");
let legacy_path = legacy_root.join("legacy-safe.jsonl"); let legacy_path = legacy_root.join("legacy-safe.jsonl");
@@ -952,8 +893,6 @@ mod tests {
// given // given
let base = temp_dir(); let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist"); fs::create_dir_all(&base).expect("base dir should exist");
// #151: canonicalize for path-representation consistency with store.
let base = fs::canonicalize(&base).unwrap_or(base);
let store = SessionStore::from_cwd(&base).expect("store should build"); let store = SessionStore::from_cwd(&base).expect("store should build");
let legacy_root = base.join(".claw").join("sessions"); let legacy_root = base.join(".claw").join("sessions");
let legacy_path = legacy_root.join("legacy-unbound.json"); let legacy_path = legacy_root.join("legacy-unbound.json");

View File

@@ -1,7 +1,5 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const TRUST_PROMPT_CUES: &[&str] = &[ const TRUST_PROMPT_CUES: &[&str] = &[
"do you trust the files in this folder", "do you trust the files in this folder",
"trust the files in this folder", "trust the files in this folder",
@@ -10,121 +8,24 @@ const TRUST_PROMPT_CUES: &[&str] = &[
"yes, proceed", "yes, proceed",
]; ];
/// Resolution method for trust decisions. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustPolicy { pub enum TrustPolicy {
/// Automatically trust this path (allowlisted)
AutoTrust, AutoTrust,
/// Require manual approval
RequireApproval, RequireApproval,
/// Deny trust for this path
Deny, Deny,
} }
/// Events emitted during trust resolution lifecycle. #[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TrustEvent { pub enum TrustEvent {
/// Trust prompt was detected and is required TrustRequired { cwd: String },
TrustRequired { TrustResolved { cwd: String, policy: TrustPolicy },
/// Current working directory where trust is needed TrustDenied { cwd: String, reason: String },
cwd: String,
/// Optional repo identifier
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<String>,
/// Optional worktree path
#[serde(skip_serializing_if = "Option::is_none")]
worktree: Option<String>,
},
/// Trust was resolved (granted)
TrustResolved {
/// Current working directory
cwd: String,
/// The policy that was applied
policy: TrustPolicy,
/// How the trust was resolved
resolution: TrustResolution,
},
/// Trust was denied
TrustDenied {
/// Current working directory
cwd: String,
/// Reason for denial
reason: String,
},
} }
/// How trust was resolved. #[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustResolution {
/// Automatically granted due to allowlist
AutoAllowlisted,
/// Manually approved by user
ManualApproval,
}
/// Entry in the trust allowlist with pattern matching support.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustAllowlistEntry {
/// Repository path or glob pattern to match
pub pattern: String,
/// Optional worktree subpath pattern
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_pattern: Option<String>,
/// Human-readable description of why this is allowlisted
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl TrustAllowlistEntry {
#[must_use]
pub fn new(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
worktree_pattern: None,
description: None,
}
}
#[must_use]
pub fn with_worktree_pattern(mut self, pattern: impl Into<String>) -> Self {
self.worktree_pattern = Some(pattern.into());
self
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
}
/// Configuration for trust resolution with allowlist/denylist support.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustConfig { pub struct TrustConfig {
/// Allowlisted paths with pattern matching allowlisted: Vec<PathBuf>,
pub allowlisted: Vec<TrustAllowlistEntry>, denied: Vec<PathBuf>,
/// Denied paths (exact or prefix matches)
pub denied: Vec<PathBuf>,
/// Whether to emit events for trust decisions
#[serde(default = "default_emit_events")]
pub emit_events: bool,
}
fn default_emit_events() -> bool {
true
}
impl Default for TrustConfig {
fn default() -> Self {
Self {
allowlisted: Vec::new(),
denied: Vec::new(),
emit_events: true,
}
}
} }
impl TrustConfig { impl TrustConfig {
@@ -134,14 +35,8 @@ impl TrustConfig {
} }
#[must_use] #[must_use]
pub fn with_allowlisted(mut self, path: impl Into<String>) -> Self { pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
self.allowlisted.push(TrustAllowlistEntry::new(path)); self.allowlisted.push(path.into());
self
}
#[must_use]
pub fn with_allowlisted_entry(mut self, entry: TrustAllowlistEntry) -> Self {
self.allowlisted.push(entry);
self self
} }
@@ -150,147 +45,6 @@ impl TrustConfig {
self.denied.push(path.into()); self.denied.push(path.into());
self self
} }
/// Check if a path matches an allowlisted entry using glob patterns.
#[must_use]
pub fn is_allowlisted(
&self,
cwd: &str,
worktree: Option<&str>,
) -> Option<&TrustAllowlistEntry> {
self.allowlisted.iter().find(|entry| {
let path_matches = Self::pattern_matches(&entry.pattern, cwd);
if !path_matches {
return false;
}
match (&entry.worktree_pattern, worktree) {
(Some(wt_pattern), Some(wt)) => Self::pattern_matches(wt_pattern, wt),
(Some(_), None) => false,
(None, _) => true,
}
})
}
/// Match a pattern against a path string.
/// Supports exact matching and glob patterns (* and ?).
fn pattern_matches(pattern: &str, path: &str) -> bool {
let pattern = pattern.trim();
let path = path.trim();
// Exact match
if pattern == path {
return true;
}
// Normalize paths for comparison
let pattern_normalized = pattern.replace("//", "/");
let path_normalized = path.replace("//", "/");
// Check if pattern is a path prefix (e.g., "/tmp/worktrees" matches "/tmp/worktrees/repo-a")
// This handles the common case of directory containment
if !pattern_normalized.contains('*') && !pattern_normalized.contains('?') {
// Prefix match: pattern is a directory that contains path
if path_normalized.starts_with(&pattern_normalized) {
let rest = &path_normalized[pattern_normalized.len()..];
// Must be exact match or continue with /
return rest.is_empty() || rest.starts_with('/');
}
}
// Check if pattern ends with wildcard (prefix match)
if pattern_normalized.ends_with("/*") {
let prefix = pattern_normalized.trim_end_matches("/*");
if let Some(rest) = path_normalized.strip_prefix(prefix) {
// Must either be exact match or continue with /
return rest.is_empty() || rest.starts_with('/');
}
} else if pattern_normalized.ends_with('*') && !pattern_normalized.contains("/*/") {
// Simple trailing * (not a path component wildcard)
let prefix = pattern_normalized.trim_end_matches('*');
if let Some(rest) = path_normalized.strip_prefix(prefix) {
return rest.is_empty() || !rest.starts_with('/');
}
}
// Check if pattern is a path component match (bounded by /)
if path_normalized
.split('/')
.any(|component| component == pattern_normalized)
{
return true;
}
// Check if pattern appears as a substring within a path component
// (e.g., "repo" matches "/tmp/worktrees/repo-a")
if path_normalized
.split('/')
.any(|component| component.contains(&pattern_normalized))
{
return true;
}
// Glob matching for patterns with ? or * in the middle
if pattern.contains('?') || pattern.contains("/*/") || pattern.starts_with("*/") {
return Self::glob_matches(&pattern_normalized, &path_normalized);
}
false
}
/// Simple glob pattern matching (? matches single char, * matches any sequence).
/// Handles patterns like /tmp/*/repo-* where * matches path components.
fn glob_matches(pattern: &str, path: &str) -> bool {
// Use recursive backtracking for proper glob matching
Self::glob_match_recursive(pattern, path, 0, 0)
}
fn glob_match_recursive(pattern: &str, path: &str, p_idx: usize, s_idx: usize) -> bool {
let p_chars: Vec<char> = pattern.chars().collect();
let s_chars: Vec<char> = path.chars().collect();
let mut p = p_idx;
let mut s = s_idx;
while p < p_chars.len() {
match p_chars[p] {
'*' => {
// Try all possible matches for *
p += 1;
if p >= p_chars.len() {
// * at end matches everything remaining
return true;
}
// Try matching 0 or more characters
for skip in 0..=(s_chars.len() - s) {
if Self::glob_match_recursive(pattern, path, p, s + skip) {
return true;
}
}
return false;
}
'?' => {
// ? matches exactly one character
if s >= s_chars.len() {
return false;
}
p += 1;
s += 1;
}
c => {
// Exact character match
if s >= s_chars.len() || s_chars[s] != c {
return false;
}
p += 1;
s += 1;
}
}
}
// Pattern exhausted - path must also be exhausted
s >= s_chars.len()
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -332,19 +86,15 @@ impl TrustResolver {
} }
#[must_use] #[must_use]
pub fn resolve(&self, cwd: &str, worktree: Option<&str>, screen_text: &str) -> TrustDecision { pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
if !detect_trust_prompt(screen_text) { if !detect_trust_prompt(screen_text) {
return TrustDecision::NotRequired; return TrustDecision::NotRequired;
} }
let repo = extract_repo_name(cwd);
let mut events = vec![TrustEvent::TrustRequired { let mut events = vec![TrustEvent::TrustRequired {
cwd: cwd.to_owned(), cwd: cwd.to_owned(),
repo: repo.clone(),
worktree: worktree.map(String::from),
}]; }];
// Check denylist first
if let Some(matched_root) = self if let Some(matched_root) = self
.config .config
.denied .denied
@@ -362,12 +112,15 @@ impl TrustResolver {
}; };
} }
// Check allowlist with pattern matching if self
if self.config.is_allowlisted(cwd, worktree).is_some() { .config
.allowlisted
.iter()
.any(|root| path_matches(cwd, root))
{
events.push(TrustEvent::TrustResolved { events.push(TrustEvent::TrustResolved {
cwd: cwd.to_owned(), cwd: cwd.to_owned(),
policy: TrustPolicy::AutoTrust, policy: TrustPolicy::AutoTrust,
resolution: TrustResolution::AutoAllowlisted,
}); });
return TrustDecision::Required { return TrustDecision::Required {
policy: TrustPolicy::AutoTrust, policy: TrustPolicy::AutoTrust,
@@ -375,19 +128,6 @@ impl TrustResolver {
}; };
} }
// Check for manual trust resolution via screen text analysis
if detect_manual_approval(screen_text) {
events.push(TrustEvent::TrustResolved {
cwd: cwd.to_owned(),
policy: TrustPolicy::RequireApproval,
resolution: TrustResolution::ManualApproval,
});
return TrustDecision::Required {
policy: TrustPolicy::RequireApproval,
events,
};
}
TrustDecision::Required { TrustDecision::Required {
policy: TrustPolicy::RequireApproval, policy: TrustPolicy::RequireApproval,
events, events,
@@ -395,20 +135,17 @@ impl TrustResolver {
} }
#[must_use] #[must_use]
pub fn trusts(&self, cwd: &str, worktree: Option<&str>) -> bool { pub fn trusts(&self, cwd: &str) -> bool {
// Check denylist first !self
let denied = self
.config .config
.denied .denied
.iter() .iter()
.any(|root| path_matches(cwd, root)); .any(|root| path_matches(cwd, root))
&& self
if denied { .config
return false; .allowlisted
} .iter()
.any(|root| path_matches(cwd, root))
// Check allowlist using pattern matching
self.config.is_allowlisted(cwd, worktree).is_some()
} }
} }
@@ -435,240 +172,11 @@ fn normalize_path(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
} }
/// Extract repository name from a path for event context.
fn extract_repo_name(cwd: &str) -> Option<String> {
let path = Path::new(cwd);
// Try to find a .git directory to identify repo root
let mut current = Some(path);
while let Some(p) = current {
if p.join(".git").is_dir() {
return p.file_name().map(|n| n.to_string_lossy().to_string());
}
current = p.parent();
}
// Fallback: use the last component of the path
path.file_name().map(|n| n.to_string_lossy().to_string())
}
/// Detect if the screen text indicates manual approval was granted.
fn detect_manual_approval(screen_text: &str) -> bool {
let lowered = screen_text.to_ascii_lowercase();
// Look for indicators that user manually approved
MANUAL_APPROVAL_CUES.iter().any(|cue| lowered.contains(cue))
}
const MANUAL_APPROVAL_CUES: &[&str] = &[
"yes, i trust",
"i trust this",
"trusted manually",
"approval granted",
];
#[cfg(test)]
mod path_matching_tests {
use super::*;
#[test]
fn glob_pattern_star_matches_any_sequence() {
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/foo"));
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/bar/baz"));
assert!(!TrustConfig::pattern_matches("/tmp/*", "/other/tmp/foo"));
}
#[test]
fn glob_pattern_question_matches_single_char() {
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/test1"));
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/testA"));
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test12"));
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test"));
}
#[test]
fn pattern_matches_exact() {
assert!(TrustConfig::pattern_matches(
"/tmp/worktrees",
"/tmp/worktrees"
));
assert!(!TrustConfig::pattern_matches(
"/tmp/worktrees",
"/tmp/worktrees-other"
));
}
#[test]
fn pattern_matches_prefix_with_wildcard() {
assert!(TrustConfig::pattern_matches(
"/tmp/worktrees/*",
"/tmp/worktrees/repo-a"
));
assert!(TrustConfig::pattern_matches(
"/tmp/worktrees/*",
"/tmp/worktrees/repo-a/subdir"
));
assert!(!TrustConfig::pattern_matches(
"/tmp/worktrees/*",
"/tmp/other/repo"
));
}
#[test]
fn pattern_matches_contains() {
// Pattern contained within path
assert!(TrustConfig::pattern_matches(
"worktrees",
"/tmp/worktrees/repo-a"
));
assert!(TrustConfig::pattern_matches(
"repo",
"/tmp/worktrees/repo-a"
));
}
#[test]
fn allowlist_entry_with_worktree_pattern() {
let config = TrustConfig::new().with_allowlisted_entry(
TrustAllowlistEntry::new("/tmp/worktrees/*")
.with_worktree_pattern("*/.git")
.with_description("Git worktrees"),
);
// Should match when both patterns match
assert!(config
.is_allowlisted("/tmp/worktrees/repo-a", Some("/tmp/worktrees/repo-a/.git"))
.is_some());
// Should not match when worktree pattern doesn't match
assert!(config
.is_allowlisted("/tmp/worktrees/repo-a", Some("/other/path"))
.is_none());
// Should not match when a worktree pattern is required but no worktree is supplied
assert!(config
.is_allowlisted("/tmp/worktrees/repo-a", None)
.is_none());
// Should match when no worktree pattern required and path matches
let config_no_worktree = TrustConfig::new().with_allowlisted("/tmp/worktrees/*");
assert!(config_no_worktree
.is_allowlisted("/tmp/worktrees/repo-a", None)
.is_some());
}
#[test]
fn allowlist_entry_returns_matched_entry() {
let entry = TrustAllowlistEntry::new("/tmp/worktrees/*").with_description("Test worktrees");
let config = TrustConfig::new().with_allowlisted_entry(entry.clone());
let matched = config.is_allowlisted("/tmp/worktrees/repo-a", None);
assert!(matched.is_some());
assert_eq!(
matched.unwrap().description,
Some("Test worktrees".to_string())
);
}
#[test]
fn complex_glob_patterns() {
// Multiple wildcards
assert!(TrustConfig::pattern_matches(
"/tmp/*/repo-*",
"/tmp/worktrees/repo-123"
));
assert!(TrustConfig::pattern_matches(
"/tmp/*/repo-*",
"/tmp/other/repo-abc"
));
assert!(!TrustConfig::pattern_matches(
"/tmp/*/repo-*",
"/tmp/worktrees/other"
));
// Mixed ? and *
assert!(TrustConfig::pattern_matches(
"/tmp/test?/*.txt",
"/tmp/test1/file.txt"
));
assert!(TrustConfig::pattern_matches(
"/tmp/test?/*.txt",
"/tmp/testA/subdir/file.txt"
));
}
#[test]
fn serde_serialization_roundtrip() {
let config = TrustConfig::new()
.with_allowlisted_entry(
TrustAllowlistEntry::new("/tmp/worktrees/*")
.with_worktree_pattern("*/.git")
.with_description("Git worktrees"),
)
.with_denied("/tmp/malicious");
let json = serde_json::to_string(&config).expect("serialization failed");
let deserialized: TrustConfig =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(config.allowlisted.len(), deserialized.allowlisted.len());
assert_eq!(config.denied.len(), deserialized.denied.len());
assert_eq!(config.emit_events, deserialized.emit_events);
}
#[test]
fn trust_event_serialization() {
let event = TrustEvent::TrustRequired {
cwd: "/tmp/test".to_string(),
repo: Some("test-repo".to_string()),
worktree: Some("/tmp/test/.git".to_string()),
};
let json = serde_json::to_string(&event).expect("serialization failed");
assert!(json.contains("trust_required"));
assert!(json.contains("/tmp/test"));
assert!(json.contains("test-repo"));
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
match deserialized {
TrustEvent::TrustRequired {
cwd,
repo,
worktree,
} => {
assert_eq!(cwd, "/tmp/test");
assert_eq!(repo, Some("test-repo".to_string()));
assert_eq!(worktree, Some("/tmp/test/.git".to_string()));
}
_ => panic!("wrong event type"),
}
}
#[test]
fn trust_event_resolved_serialization() {
let event = TrustEvent::TrustResolved {
cwd: "/tmp/test".to_string(),
policy: TrustPolicy::AutoTrust,
resolution: TrustResolution::AutoAllowlisted,
};
let json = serde_json::to_string(&event).expect("serialization failed");
assert!(json.contains("trust_resolved"));
assert!(json.contains("auto_allowlisted"));
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
match deserialized {
TrustEvent::TrustResolved { resolution, .. } => {
assert_eq!(resolution, TrustResolution::AutoAllowlisted);
}
_ => panic!("wrong event type"),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
detect_manual_approval, detect_trust_prompt, path_matches_trusted_root, detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
TrustAllowlistEntry, TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolution, TrustPolicy, TrustResolver,
TrustResolver,
}; };
#[test] #[test]
@@ -689,7 +197,7 @@ mod tests {
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees")); let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
// when // when
let decision = resolver.resolve("/tmp/worktrees/repo-a", None, "Ready for your input\n>"); let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
// then // then
assert_eq!(decision, TrustDecision::NotRequired); assert_eq!(decision, TrustDecision::NotRequired);
@@ -705,23 +213,23 @@ mod tests {
// when // when
let decision = resolver.resolve( let decision = resolver.resolve(
"/tmp/worktrees/repo-a", "/tmp/worktrees/repo-a",
None,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No", "Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
); );
// then // then
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust)); assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
let events = decision.events(); assert_eq!(
assert_eq!(events.len(), 2); decision.events(),
assert!(matches!(events[0], TrustEvent::TrustRequired { .. })); &[
assert!(matches!( TrustEvent::TrustRequired {
events[1], cwd: "/tmp/worktrees/repo-a".to_string(),
TrustEvent::TrustResolved { },
policy: TrustPolicy::AutoTrust, TrustEvent::TrustResolved {
resolution: TrustResolution::AutoAllowlisted, cwd: "/tmp/worktrees/repo-a".to_string(),
.. policy: TrustPolicy::AutoTrust,
} },
)); ]
);
} }
#[test] #[test]
@@ -732,7 +240,6 @@ mod tests {
// when // when
let decision = resolver.resolve( let decision = resolver.resolve(
"/tmp/other/repo-b", "/tmp/other/repo-b",
None,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No", "Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
); );
@@ -742,8 +249,6 @@ mod tests {
decision.events(), decision.events(),
&[TrustEvent::TrustRequired { &[TrustEvent::TrustRequired {
cwd: "/tmp/other/repo-b".to_string(), cwd: "/tmp/other/repo-b".to_string(),
repo: Some("repo-b".to_string()),
worktree: None,
}] }]
); );
} }
@@ -760,7 +265,6 @@ mod tests {
// when // when
let decision = resolver.resolve( let decision = resolver.resolve(
"/tmp/worktrees/repo-c", "/tmp/worktrees/repo-c",
None,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No", "Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
); );
@@ -771,8 +275,6 @@ mod tests {
&[ &[
TrustEvent::TrustRequired { TrustEvent::TrustRequired {
cwd: "/tmp/worktrees/repo-c".to_string(), cwd: "/tmp/worktrees/repo-c".to_string(),
repo: Some("repo-c".to_string()),
worktree: None,
}, },
TrustEvent::TrustDenied { TrustEvent::TrustDenied {
cwd: "/tmp/worktrees/repo-c".to_string(), cwd: "/tmp/worktrees/repo-c".to_string(),
@@ -782,66 +284,6 @@ mod tests {
); );
} }
#[test]
fn auto_trusts_with_glob_pattern_allowlist() {
// given
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees/*"));
// when - any repo under /tmp/worktrees should auto-trust
let decision = resolver.resolve(
"/tmp/worktrees/repo-a",
None,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
}
#[test]
fn resolve_with_worktree_pattern_matching() {
// given
let config = TrustConfig::new().with_allowlisted_entry(
TrustAllowlistEntry::new("/tmp/worktrees/*").with_worktree_pattern("*/.git"),
);
let resolver = TrustResolver::new(config);
// when - with worktree that matches the pattern
let decision = resolver.resolve(
"/tmp/worktrees/repo-a",
Some("/tmp/worktrees/repo-a/.git"),
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then - should auto-trust because both patterns match
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
}
#[test]
fn manual_approval_detected_from_screen_text() {
// given
let resolver = TrustResolver::new(TrustConfig::new());
// when - screen text indicates manual approval
let decision = resolver.resolve(
"/tmp/some/repo",
None,
"Do you trust the files in this folder?\nUser selected: Yes, I trust this folder",
);
// then - should detect manual approval
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
let events = decision.events();
assert!(events.len() >= 2);
assert!(matches!(
events[events.len() - 1],
TrustEvent::TrustResolved {
resolution: TrustResolution::ManualApproval,
..
}
));
}
#[test] #[test]
fn sibling_prefix_does_not_match_trusted_root() { fn sibling_prefix_does_not_match_trusted_root() {
// given // given
@@ -854,70 +296,4 @@ mod tests {
// then // then
assert!(!matched); assert!(!matched);
} }
#[test]
fn detects_manual_approval_cues() {
assert!(detect_manual_approval(
"User selected: Yes, I trust this folder"
));
assert!(detect_manual_approval(
"I trust this repository and its contents"
));
assert!(detect_manual_approval("Approval granted by user"));
assert!(!detect_manual_approval(
"Do you trust the files in this folder?"
));
assert!(!detect_manual_approval("Some unrelated text"));
}
#[test]
fn trust_config_default_emit_events() {
let config = TrustConfig::default();
assert!(config.emit_events);
}
#[test]
fn trust_resolver_trusts_method() {
let resolver = TrustResolver::new(
TrustConfig::new()
.with_allowlisted("/tmp/worktrees/*")
.with_denied("/tmp/worktrees/bad-repo"),
);
// Should trust allowlisted paths
assert!(resolver.trusts("/tmp/worktrees/good-repo", None));
// Should not trust denied paths
assert!(!resolver.trusts("/tmp/worktrees/bad-repo", None));
// Should not trust unknown paths
assert!(!resolver.trusts("/tmp/other/repo", None));
}
#[test]
fn trust_policy_serde_roundtrip() {
for policy in [
TrustPolicy::AutoTrust,
TrustPolicy::RequireApproval,
TrustPolicy::Deny,
] {
let json = serde_json::to_string(&policy).expect("serialization failed");
let deserialized: TrustPolicy =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(policy, deserialized);
}
}
#[test]
fn trust_resolution_serde_roundtrip() {
for resolution in [
TrustResolution::AutoAllowlisted,
TrustResolution::ManualApproval,
] {
let json = serde_json::to_string(&resolution).expect("serialization failed");
let deserialized: TrustResolution =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(resolution, deserialized);
}
}
} }

View File

@@ -30,7 +30,6 @@ fn now_secs() -> u64 {
pub enum WorkerStatus { pub enum WorkerStatus {
Spawning, Spawning,
TrustRequired, TrustRequired,
ToolPermissionRequired,
ReadyForPrompt, ReadyForPrompt,
Running, Running,
Finished, Finished,
@@ -42,7 +41,6 @@ impl std::fmt::Display for WorkerStatus {
match self { match self {
Self::Spawning => write!(f, "spawning"), Self::Spawning => write!(f, "spawning"),
Self::TrustRequired => write!(f, "trust_required"), Self::TrustRequired => write!(f, "trust_required"),
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
Self::ReadyForPrompt => write!(f, "ready_for_prompt"), Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
Self::Running => write!(f, "running"), Self::Running => write!(f, "running"),
Self::Finished => write!(f, "finished"), Self::Finished => write!(f, "finished"),
@@ -55,7 +53,6 @@ impl std::fmt::Display for WorkerStatus {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerFailureKind { pub enum WorkerFailureKind {
TrustGate, TrustGate,
ToolPermissionGate,
PromptDelivery, PromptDelivery,
Protocol, Protocol,
Provider, Provider,
@@ -74,7 +71,6 @@ pub struct WorkerFailure {
pub enum WorkerEventKind { pub enum WorkerEventKind {
Spawning, Spawning,
TrustRequired, TrustRequired,
ToolPermissionRequired,
TrustResolved, TrustResolved,
ReadyForPrompt, ReadyForPrompt,
PromptMisdelivery, PromptMisdelivery,
@@ -108,8 +104,6 @@ pub enum WorkerPromptTarget {
pub enum StartupFailureClassification { pub enum StartupFailureClassification {
/// Trust prompt is required but not detected/resolved /// Trust prompt is required but not detected/resolved
TrustRequired, TrustRequired,
/// Tool permission prompt is required before startup can continue
ToolPermissionRequired,
/// Prompt was delivered to wrong target (shell misdelivery) /// Prompt was delivered to wrong target (shell misdelivery)
PromptMisdelivery, PromptMisdelivery,
/// Prompt was sent but acceptance timed out /// Prompt was sent but acceptance timed out
@@ -136,14 +130,6 @@ pub struct StartupEvidenceBundle {
pub prompt_acceptance_state: bool, pub prompt_acceptance_state: bool,
/// Result of trust prompt detection at timeout /// Result of trust prompt detection at timeout
pub trust_prompt_detected: bool, pub trust_prompt_detected: bool,
/// Result of tool permission prompt detection at timeout
pub tool_permission_prompt_detected: bool,
/// Age in seconds of the latest tool permission prompt, when observed
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_permission_prompt_age_seconds: Option<u64>,
/// Whether the prompt surface exposed only a session allow path or also an always-allow path
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_permission_allow_scope: Option<ToolPermissionAllowScope>,
/// Transport health summary (true = healthy/responsive) /// Transport health summary (true = healthy/responsive)
pub transport_healthy: bool, pub transport_healthy: bool,
/// MCP health summary (true = all servers healthy) /// MCP health summary (true = all servers healthy)
@@ -160,15 +146,6 @@ pub enum WorkerEventPayload {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
resolution: Option<WorkerTrustResolution>, resolution: Option<WorkerTrustResolution>,
}, },
ToolPermissionPrompt {
#[serde(skip_serializing_if = "Option::is_none")]
server_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
prompt_age_seconds: u64,
allow_scope: ToolPermissionAllowScope,
prompt_preview: String,
},
PromptDelivery { PromptDelivery {
prompt_preview: String, prompt_preview: String,
observed_target: WorkerPromptTarget, observed_target: WorkerPromptTarget,
@@ -186,14 +163,6 @@ pub enum WorkerEventPayload {
}, },
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolPermissionAllowScope {
SessionOnly,
SessionOrAlways,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerTaskReceipt { pub struct WorkerTaskReceipt {
pub repo: String, pub repo: String,
@@ -307,29 +276,6 @@ impl WorkerRegistry {
.ok_or_else(|| format!("worker not found: {worker_id}"))?; .ok_or_else(|| format!("worker not found: {worker_id}"))?;
let lowered = screen_text.to_ascii_lowercase(); let lowered = screen_text.to_ascii_lowercase();
if let Some(tool_prompt) = detect_tool_permission_prompt(screen_text, &lowered) {
worker.status = WorkerStatus::ToolPermissionRequired;
worker.last_error = Some(WorkerFailure {
kind: WorkerFailureKind::ToolPermissionGate,
message: tool_prompt.message(),
created_at: now_secs(),
});
push_event(
worker,
WorkerEventKind::ToolPermissionRequired,
WorkerStatus::ToolPermissionRequired,
Some("tool permission prompt detected".to_string()),
Some(WorkerEventPayload::ToolPermissionPrompt {
server_name: tool_prompt.server_name,
tool_name: tool_prompt.tool_name,
prompt_age_seconds: 0,
allow_scope: tool_prompt.allow_scope,
prompt_preview: tool_prompt.prompt_preview,
}),
);
return Ok(worker.clone());
}
if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) { if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) {
worker.status = WorkerStatus::TrustRequired; worker.status = WorkerStatus::TrustRequired;
worker.last_error = Some(WorkerFailure { worker.last_error = Some(WorkerFailure {
@@ -557,9 +503,7 @@ impl WorkerRegistry {
ready: worker.status == WorkerStatus::ReadyForPrompt, ready: worker.status == WorkerStatus::ReadyForPrompt,
blocked: matches!( blocked: matches!(
worker.status, worker.status,
WorkerStatus::TrustRequired WorkerStatus::TrustRequired | WorkerStatus::Failed
| WorkerStatus::ToolPermissionRequired
| WorkerStatus::Failed
), ),
replay_prompt_ready: worker.replay_prompt.is_some(), replay_prompt_ready: worker.replay_prompt.is_some(),
last_error: worker.last_error.clone(), last_error: worker.last_error.clone(),
@@ -680,18 +624,6 @@ impl WorkerRegistry {
let now = now_secs(); let now = now_secs();
let elapsed = now.saturating_sub(worker.created_at); let elapsed = now.saturating_sub(worker.created_at);
let latest_tool_permission_event = worker
.events
.iter()
.rev()
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired);
let tool_permission_allow_scope =
latest_tool_permission_event.and_then(|event| match &event.payload {
Some(WorkerEventPayload::ToolPermissionPrompt { allow_scope, .. }) => {
Some(*allow_scope)
}
_ => None,
});
// Build evidence bundle // Build evidence bundle
let evidence = StartupEvidenceBundle { let evidence = StartupEvidenceBundle {
@@ -708,13 +640,6 @@ impl WorkerRegistry {
.events .events
.iter() .iter()
.any(|e| e.kind == WorkerEventKind::TrustRequired), .any(|e| e.kind == WorkerEventKind::TrustRequired),
tool_permission_prompt_detected: worker
.events
.iter()
.any(|e| e.kind == WorkerEventKind::ToolPermissionRequired),
tool_permission_prompt_age_seconds: latest_tool_permission_event
.map(|event| now.saturating_sub(event.timestamp)),
tool_permission_allow_scope,
transport_healthy, transport_healthy,
mcp_healthy, mcp_healthy,
elapsed_seconds: elapsed, elapsed_seconds: elapsed,
@@ -769,13 +694,6 @@ fn classify_startup_failure(evidence: &StartupEvidenceBundle) -> StartupFailureC
return StartupFailureClassification::TrustRequired; return StartupFailureClassification::TrustRequired;
} }
// Check for tool permission prompts that were not resolved
if evidence.tool_permission_prompt_detected
&& evidence.last_lifecycle_state == WorkerStatus::ToolPermissionRequired
{
return StartupFailureClassification::ToolPermissionRequired;
}
// Check for prompt acceptance timeout // Check for prompt acceptance timeout
if evidence.prompt_sent_at.is_some() if evidence.prompt_sent_at.is_some()
&& !evidence.prompt_acceptance_state && !evidence.prompt_acceptance_state
@@ -897,140 +815,6 @@ fn normalize_path(path: &str) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf()) std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf())
} }
#[derive(Debug, Clone, PartialEq, Eq)]
struct ToolPermissionPromptObservation {
server_name: Option<String>,
tool_name: Option<String>,
allow_scope: ToolPermissionAllowScope,
prompt_preview: String,
}
impl ToolPermissionPromptObservation {
fn message(&self) -> String {
match (&self.server_name, &self.tool_name) {
(Some(server), Some(tool)) => {
format!("worker boot blocked on tool permission prompt for {server}.{tool}")
}
(Some(server), None) => {
format!("worker boot blocked on tool permission prompt for {server}")
}
(None, Some(tool)) => {
format!("worker boot blocked on tool permission prompt for {tool}")
}
(None, None) => "worker boot blocked on tool permission prompt".to_string(),
}
}
}
fn detect_tool_permission_prompt(
screen_text: &str,
lowered: &str,
) -> Option<ToolPermissionPromptObservation> {
let looks_like_prompt = lowered.contains("allow the")
&& lowered.contains("server")
&& lowered.contains("tool")
&& lowered.contains("run");
let looks_like_tool_gate = lowered.contains("allow tool") && lowered.contains("run");
if !looks_like_prompt && !looks_like_tool_gate {
return None;
}
let prompt_line = screen_text
.lines()
.rev()
.find(|line| {
let lowered_line = line.to_ascii_lowercase();
lowered_line.contains("allow")
&& lowered_line.contains("tool")
&& (lowered_line.contains("run") || lowered_line.contains("server"))
})
.unwrap_or(screen_text)
.trim();
let tool_name = extract_quoted_value(prompt_line)
.or_else(|| extract_after(prompt_line, "tool ").map(|token| normalize_tool_token(&token)));
let server_name = extract_between(prompt_line, "the ", " server")
.map(|server| server.trim_end_matches(" MCP").to_string())
.or_else(|| {
tool_name
.as_deref()
.and_then(extract_server_from_qualified_tool)
});
Some(ToolPermissionPromptObservation {
server_name,
tool_name,
allow_scope: detect_tool_permission_allow_scope(lowered),
prompt_preview: prompt_preview(prompt_line),
})
}
fn detect_tool_permission_allow_scope(lowered: &str) -> ToolPermissionAllowScope {
let always_allow_capable = [
"always allow",
"allow always",
"allow this tool always",
"allow for all sessions",
]
.iter()
.any(|needle| lowered.contains(needle));
if always_allow_capable {
return ToolPermissionAllowScope::SessionOrAlways;
}
let session_allow_capable = [
"allow once",
"allow for this session",
"allow this session",
"yes, allow",
]
.iter()
.any(|needle| lowered.contains(needle));
if session_allow_capable {
ToolPermissionAllowScope::SessionOnly
} else {
ToolPermissionAllowScope::Unknown
}
}
fn extract_quoted_value(text: &str) -> Option<String> {
let start = text.find('"')? + 1;
let rest = &text[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn extract_between(text: &str, prefix: &str, suffix: &str) -> Option<String> {
let start = text.find(prefix)? + prefix.len();
let rest = &text[start..];
let end = rest.find(suffix)?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_string())
}
fn extract_after(text: &str, prefix: &str) -> Option<String> {
let start = text.to_ascii_lowercase().find(prefix)? + prefix.len();
let value = text[start..]
.split_whitespace()
.next()?
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'');
(!value.is_empty()).then(|| value.to_string())
}
fn normalize_tool_token(token: &str) -> String {
token
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'')
.to_string()
}
fn extract_server_from_qualified_tool(tool: &str) -> Option<String> {
let rest = tool.strip_prefix("mcp__")?;
let (server, _) = rest.split_once("__")?;
(!server.is_empty()).then(|| server.to_string())
}
fn detect_trust_prompt(lowered: &str) -> bool { fn detect_trust_prompt(lowered: &str) -> bool {
[ [
"do you trust the files in this folder", "do you trust the files in this folder",
@@ -1350,96 +1134,6 @@ mod tests {
assert!(detect_ready_for_prompt("│ >", "│ >")); assert!(detect_ready_for_prompt("│ >", "│ >"));
} }
#[test]
fn tool_permission_prompt_blocks_worker_with_structured_event() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-mcp", &[], true);
let blocked = registry
.observe(
&worker.worker_id,
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?\n\
1. Yes, allow once\n\
2. Always allow this tool",
)
.expect("tool permission observe should succeed");
assert_eq!(blocked.status, WorkerStatus::ToolPermissionRequired);
assert_eq!(
blocked
.last_error
.as_ref()
.expect("tool permission error should exist")
.kind,
WorkerFailureKind::ToolPermissionGate
);
let event = blocked
.events
.iter()
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired)
.expect("tool permission event should exist");
assert_eq!(
event.payload,
Some(WorkerEventPayload::ToolPermissionPrompt {
server_name: Some("omx_memory".to_string()),
tool_name: Some("project_memory_read".to_string()),
prompt_age_seconds: 0,
allow_scope: ToolPermissionAllowScope::SessionOrAlways,
prompt_preview: prompt_preview(
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?",
),
})
);
let readiness = registry
.await_ready(&worker.worker_id)
.expect("ready snapshot should load");
assert!(readiness.blocked);
assert!(!readiness.ready);
}
#[test]
fn startup_timeout_classifies_tool_permission_prompt() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-mcp-timeout", &[], true);
registry
.observe(
&worker.worker_id,
"Allow the omx_memory MCP server to run tool \"notepad_read\"?\n\
1. Yes, allow once",
)
.expect("tool permission observe should succeed");
let timed_out = registry
.observe_startup_timeout(&worker.worker_id, "claw prompt", true, true)
.expect("startup timeout observe should succeed");
let event = timed_out
.events
.iter()
.find(|event| event.kind == WorkerEventKind::StartupNoEvidence)
.expect("startup no evidence event should exist");
match event.payload.as_ref() {
Some(WorkerEventPayload::StartupNoEvidence {
classification,
evidence,
}) => {
assert_eq!(
*classification,
StartupFailureClassification::ToolPermissionRequired
);
assert!(evidence.tool_permission_prompt_detected);
assert_eq!(
evidence.tool_permission_allow_scope,
Some(ToolPermissionAllowScope::SessionOnly)
);
assert!(evidence.tool_permission_prompt_age_seconds.is_some());
}
_ => panic!("expected StartupNoEvidence payload"),
}
}
#[test] #[test]
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() { fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
let registry = WorkerRegistry::new(); let registry = WorkerRegistry::new();
@@ -1940,9 +1634,6 @@ mod tests {
prompt_sent_at: Some(1_234_567_890), prompt_sent_at: Some(1_234_567_890),
prompt_acceptance_state: false, prompt_acceptance_state: false,
trust_prompt_detected: true, trust_prompt_detected: true,
tool_permission_prompt_detected: false,
tool_permission_prompt_age_seconds: None,
tool_permission_allow_scope: None,
transport_healthy: true, transport_healthy: true,
mcp_healthy: false, mcp_healthy: false,
elapsed_seconds: 60, elapsed_seconds: 60,
@@ -1970,9 +1661,6 @@ mod tests {
prompt_sent_at: None, prompt_sent_at: None,
prompt_acceptance_state: false, prompt_acceptance_state: false,
trust_prompt_detected: false, trust_prompt_detected: false,
tool_permission_prompt_detected: false,
tool_permission_prompt_age_seconds: None,
tool_permission_allow_scope: None,
transport_healthy: false, transport_healthy: false,
mcp_healthy: true, mcp_healthy: true,
elapsed_seconds: 30, elapsed_seconds: 30,
@@ -1990,9 +1678,6 @@ mod tests {
prompt_sent_at: None, prompt_sent_at: None,
prompt_acceptance_state: false, prompt_acceptance_state: false,
trust_prompt_detected: false, trust_prompt_detected: false,
tool_permission_prompt_detected: false,
tool_permission_prompt_age_seconds: None,
tool_permission_allow_scope: None,
transport_healthy: true, transport_healthy: true,
mcp_healthy: true, mcp_healthy: true,
elapsed_seconds: 10, elapsed_seconds: 10,
@@ -2012,9 +1697,6 @@ mod tests {
prompt_sent_at: None, // No prompt sent yet prompt_sent_at: None, // No prompt sent yet
prompt_acceptance_state: false, prompt_acceptance_state: false,
trust_prompt_detected: false, trust_prompt_detected: false,
tool_permission_prompt_detected: false,
tool_permission_prompt_age_seconds: None,
tool_permission_allow_scope: None,
transport_healthy: true, transport_healthy: true,
mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash
elapsed_seconds: 45, elapsed_seconds: 45,

View File

@@ -27,18 +27,6 @@ impl InitStatus {
Self::Skipped => "skipped (already exists)", Self::Skipped => "skipped (already exists)",
} }
} }
/// Machine-stable identifier for structured output (#142).
/// Unlike `label()`, this never changes wording: claws can switch on
/// these values without brittle substring matching.
#[must_use]
pub(crate) fn json_tag(self) -> &'static str {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Skipped => "skipped",
}
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -70,36 +58,6 @@ impl InitReport {
lines.push(" Next step Review and tailor the generated guidance".to_string()); lines.push(" Next step Review and tailor the generated guidance".to_string());
lines.join("\n") lines.join("\n")
} }
/// Summary constant that claws can embed in JSON output without having
/// to read it out of the human-formatted `message` string (#142).
pub(crate) const NEXT_STEP: &'static str = "Review and tailor the generated guidance";
/// Artifact names that ended in the given status. Used to build the
/// structured `created[]`/`updated[]`/`skipped[]` arrays for #142.
#[must_use]
pub(crate) fn artifacts_with_status(&self, status: InitStatus) -> Vec<String> {
self.artifacts
.iter()
.filter(|artifact| artifact.status == status)
.map(|artifact| artifact.name.to_string())
.collect()
}
/// Structured artifact list for JSON output (#142). Each entry carries
/// `name` and machine-stable `status` tag.
#[must_use]
pub(crate) fn artifact_json_entries(&self) -> Vec<serde_json::Value> {
self.artifacts
.iter()
.map(|artifact| {
serde_json::json!({
"name": artifact.name,
"status": artifact.status.json_tag(),
})
})
.collect()
}
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -375,7 +333,7 @@ fn framework_notes(detection: &RepoDetection) -> Vec<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{initialize_repo, render_init_claude_md, InitStatus}; use super::{initialize_repo, render_init_claude_md};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -455,63 +413,6 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }
#[test]
fn artifacts_with_status_partitions_fresh_and_idempotent_runs() {
// #142: the structured JSON output needs to be able to partition
// artifacts into created/updated/skipped without substring matching
// the human-formatted `message` string.
let root = temp_dir();
fs::create_dir_all(&root).expect("create root");
let fresh = initialize_repo(&root).expect("fresh init should succeed");
let created_names = fresh.artifacts_with_status(InitStatus::Created);
assert_eq!(
created_names,
vec![
".claw/".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"fresh init should place all four artifacts in created[]"
);
assert!(
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
"fresh init should have no skipped artifacts"
);
let second = initialize_repo(&root).expect("second init should succeed");
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
assert_eq!(
skipped_names,
vec![
".claw/".to_string(),
".claw.json".to_string(),
".gitignore".to_string(),
"CLAUDE.md".to_string(),
],
"idempotent init should place all four artifacts in skipped[]"
);
assert!(
second.artifacts_with_status(InitStatus::Created).is_empty(),
"idempotent init should have no created artifacts"
);
// artifact_json_entries() uses the machine-stable `json_tag()` which
// never changes wording (unlike `label()` which says "skipped (already exists)").
let entries = second.artifact_json_entries();
assert_eq!(entries.len(), 4);
for entry in &entries {
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
assert_eq!(
status, "skipped",
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
);
}
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test] #[test]
fn render_init_template_mentions_detected_python_and_nextjs_markers() { fn render_init_template_mentions_detected_python_and_nextjs_markers() {
let root = temp_dir(); let root = temp_dir();

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX}; use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -126,63 +125,6 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed"); fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
} }
#[test]
fn compact_flag_with_json_output_emits_structured_json() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("compact-json");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
let output = run_claw(
&workspace,
&config_home,
&home,
&base_url,
&[
"--model",
"sonnet",
"--permission-mode",
"read-only",
"--output-format",
"json",
"--compact",
&prompt,
],
);
assert!(
output.status.success(),
"compact json run should succeed
stdout:
{}
stderr:
{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
assert_eq!(
parsed["message"],
"Mock streaming says hello from the parity harness."
);
assert_eq!(parsed["compact"], true);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["usage"].is_object());
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
fn run_claw( fn run_claw(
cwd: &std::path::Path, cwd: &std::path::Path,
config_home: &std::path::Path, config_home: &std::path::Path,

View File

@@ -180,8 +180,6 @@ fn resume_latest_restores_the_most_recent_managed_session() {
// given // given
let temp_dir = unique_temp_dir("resume-latest"); let temp_dir = unique_temp_dir("resume-latest");
let project_dir = temp_dir.join("project"); let project_dir = temp_dir.join("project");
fs::create_dir_all(&project_dir).expect("project dir should exist");
let project_dir = fs::canonicalize(&project_dir).unwrap_or(project_dir);
let store = runtime::SessionStore::from_cwd(&project_dir).expect("session store should build"); let store = runtime::SessionStore::from_cwd(&project_dir).expect("session store should build");
let older_path = store.create_handle("session-older").path; let older_path = store.create_handle("session-older").path;
let newer_path = store.create_handle("session-newer").path; let newer_path = store.create_handle("session-newer").path;

View File

@@ -240,13 +240,6 @@ impl GlobalToolRegistry {
} }
} }
if allowed.is_empty() {
return Err(format!(
"--allowedTools was provided with no usable tool names (got `{}`). Omit the flag to allow all tools.",
values.join(" ")
));
}
Ok(Some(allowed)) Ok(Some(allowed))
} }
@@ -4466,7 +4459,6 @@ fn classify_lane_blocker(error: &str) -> LaneEventBlocker {
LaneEventBlocker { LaneEventBlocker {
failure_class: classify_lane_failure(error), failure_class: classify_lane_failure(error),
detail, detail,
subphase: None,
} }
} }
@@ -6890,21 +6882,6 @@ mod tests {
assert!(empty_permission.contains("unsupported plugin permission: ")); assert!(empty_permission.contains("unsupported plugin permission: "));
} }
#[test]
fn allowed_tools_rejects_empty_token_lists() {
let registry = GlobalToolRegistry::builtin();
for raw in ["", ",,", " "] {
let err = registry
.normalize_allowed_tools(&[raw.to_string()])
.expect_err("empty allow-list input should be rejected");
assert!(
err.contains("--allowedTools was provided with no usable tool names"),
"unexpected error for {raw:?}: {err}"
);
}
}
#[test] #[test]
fn runtime_tools_extend_registry_definitions_permissions_and_search() { fn runtime_tools_extend_registry_definitions_permissions_and_search() {
let registry = GlobalToolRegistry::builtin() let registry = GlobalToolRegistry::builtin()

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT/rust"
exec cargo fmt "$@"