mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-13 17:36:44 +00:00
Compare commits
2 Commits
46ddb248f2
...
claw-code-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc8da08ad3 | ||
|
|
e43c6b81db |
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Repository shape
|
||||
|
||||
25
README.md
25
README.md
@@ -1,5 +1,13 @@
|
||||
# Claw Code
|
||||
|
||||
<p align="center">
|
||||
<strong>188K GitHub stars and climbing.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Rust-native agent execution for people who want speed, control, and a real terminal.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
|
||||
·
|
||||
@@ -28,8 +36,21 @@
|
||||
<img src="assets/claw-hero.jpeg" alt="Claw Code" width="300" />
|
||||
</p>
|
||||
|
||||
Claw Code is the public Rust implementation of the `claw` CLI agent harness.
|
||||
The canonical implementation lives in [`rust/`](./rust), and the current source of truth for this repository is **ultraworkers/claw-code**.
|
||||
<p align="center">
|
||||
Claw Code just crossed <strong>188,000 GitHub stars</strong>. This repo is the public Rust implementation of the <code>claw</code> CLI agent harness, built in the open with the UltraWorkers community.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
The canonical implementation lives in <a href="./rust/">rust/</a>, and the current source of truth for this repository is <strong>ultraworkers/claw-code</strong>.
|
||||
</p>
|
||||
|
||||
## 188K and climbing
|
||||
|
||||
Thanks to everyone who starred, tested, reviewed, and pushed the project forward. Claw Code is focused on a straightforward promise: a fast local-first CLI agent runtime with native tools, inspectable behavior, and a Rust workspace that stays close to the metal.
|
||||
|
||||
- Native Rust workspace and CLI binary under [`rust/`](./rust)
|
||||
- Local-first workflows for prompts, sessions, tooling, and parity validation
|
||||
- Open development across the broader UltraWorkers ecosystem
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.
|
||||
|
||||
161
ROADMAP.md
161
ROADMAP.md
File diff suppressed because one or more lines are too long
245
progress.txt
245
progress.txt
@@ -74,18 +74,6 @@ US-007 COMPLETE (Phase 5 - Plugin/MCP lifecycle maturity)
|
||||
- DegradedMode behavior
|
||||
- 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:
|
||||
------------------
|
||||
- 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
|
||||
- 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)
|
||||
- Files:
|
||||
- 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
|
||||
- All tests pass (119 unit tests + 29 integration tests)
|
||||
- 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.
|
||||
|
||||
@@ -7,8 +7,7 @@ This file provides guidance to Claw Code (clawcode.dev) when working with code i
|
||||
- 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`.
|
||||
- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
||||
|
||||
## Working agreement
|
||||
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
||||
|
||||
@@ -122,7 +122,7 @@ fn detect_and_emit_ship_prepared(command: &str) {
|
||||
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
||||
pr_number: None,
|
||||
};
|
||||
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
|
||||
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={}",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,9 +45,7 @@ impl FailureScenario {
|
||||
#[must_use]
|
||||
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
|
||||
match kind {
|
||||
WorkerFailureKind::TrustGate | WorkerFailureKind::ToolPermissionGate => {
|
||||
Self::TrustPromptUnresolved
|
||||
}
|
||||
WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
|
||||
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
|
||||
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
|
||||
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const TRUST_PROMPT_CUES: &[&str] = &[
|
||||
"do you trust the files in this folder",
|
||||
"trust the files in this folder",
|
||||
@@ -10,121 +8,24 @@ const TRUST_PROMPT_CUES: &[&str] = &[
|
||||
"yes, proceed",
|
||||
];
|
||||
|
||||
/// Resolution method for trust decisions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrustPolicy {
|
||||
/// Automatically trust this path (allowlisted)
|
||||
AutoTrust,
|
||||
/// Require manual approval
|
||||
RequireApproval,
|
||||
/// Deny trust for this path
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// Events emitted during trust resolution lifecycle.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TrustEvent {
|
||||
/// Trust prompt was detected and is required
|
||||
TrustRequired {
|
||||
/// Current working directory where trust is needed
|
||||
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,
|
||||
},
|
||||
TrustRequired { cwd: String },
|
||||
TrustResolved { cwd: String, policy: TrustPolicy },
|
||||
TrustDenied { cwd: String, reason: String },
|
||||
}
|
||||
|
||||
/// How trust was resolved.
|
||||
#[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)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TrustConfig {
|
||||
/// Allowlisted paths with pattern matching
|
||||
pub allowlisted: Vec<TrustAllowlistEntry>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
allowlisted: Vec<PathBuf>,
|
||||
denied: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl TrustConfig {
|
||||
@@ -134,14 +35,8 @@ impl TrustConfig {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_allowlisted(mut self, path: impl Into<String>) -> Self {
|
||||
self.allowlisted.push(TrustAllowlistEntry::new(path));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_allowlisted_entry(mut self, entry: TrustAllowlistEntry) -> Self {
|
||||
self.allowlisted.push(entry);
|
||||
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.allowlisted.push(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -150,147 +45,6 @@ impl TrustConfig {
|
||||
self.denied.push(path.into());
|
||||
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)]
|
||||
@@ -332,19 +86,15 @@ impl TrustResolver {
|
||||
}
|
||||
|
||||
#[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) {
|
||||
return TrustDecision::NotRequired;
|
||||
}
|
||||
|
||||
let repo = extract_repo_name(cwd);
|
||||
let mut events = vec![TrustEvent::TrustRequired {
|
||||
cwd: cwd.to_owned(),
|
||||
repo: repo.clone(),
|
||||
worktree: worktree.map(String::from),
|
||||
}];
|
||||
|
||||
// Check denylist first
|
||||
if let Some(matched_root) = self
|
||||
.config
|
||||
.denied
|
||||
@@ -362,12 +112,15 @@ impl TrustResolver {
|
||||
};
|
||||
}
|
||||
|
||||
// Check allowlist with pattern matching
|
||||
if self.config.is_allowlisted(cwd, worktree).is_some() {
|
||||
if self
|
||||
.config
|
||||
.allowlisted
|
||||
.iter()
|
||||
.any(|root| path_matches(cwd, root))
|
||||
{
|
||||
events.push(TrustEvent::TrustResolved {
|
||||
cwd: cwd.to_owned(),
|
||||
policy: TrustPolicy::AutoTrust,
|
||||
resolution: TrustResolution::AutoAllowlisted,
|
||||
});
|
||||
return TrustDecision::Required {
|
||||
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 {
|
||||
policy: TrustPolicy::RequireApproval,
|
||||
events,
|
||||
@@ -395,20 +135,17 @@ impl TrustResolver {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn trusts(&self, cwd: &str, worktree: Option<&str>) -> bool {
|
||||
// Check denylist first
|
||||
let denied = self
|
||||
pub fn trusts(&self, cwd: &str) -> bool {
|
||||
!self
|
||||
.config
|
||||
.denied
|
||||
.iter()
|
||||
.any(|root| path_matches(cwd, root));
|
||||
|
||||
if denied {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check allowlist using pattern matching
|
||||
self.config.is_allowlisted(cwd, worktree).is_some()
|
||||
.any(|root| path_matches(cwd, root))
|
||||
&& self
|
||||
.config
|
||||
.allowlisted
|
||||
.iter()
|
||||
.any(|root| path_matches(cwd, root))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,240 +172,11 @@ fn normalize_path(path: &Path) -> PathBuf {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::{
|
||||
detect_manual_approval, detect_trust_prompt, path_matches_trusted_root,
|
||||
TrustAllowlistEntry, TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolution,
|
||||
TrustResolver,
|
||||
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
|
||||
TrustPolicy, TrustResolver,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -689,7 +197,7 @@ mod tests {
|
||||
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
||||
|
||||
// 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
|
||||
assert_eq!(decision, TrustDecision::NotRequired);
|
||||
@@ -705,23 +213,23 @@ mod tests {
|
||||
// when
|
||||
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));
|
||||
let events = decision.events();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(matches!(events[0], TrustEvent::TrustRequired { .. }));
|
||||
assert!(matches!(
|
||||
events[1],
|
||||
TrustEvent::TrustResolved {
|
||||
policy: TrustPolicy::AutoTrust,
|
||||
resolution: TrustResolution::AutoAllowlisted,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
decision.events(),
|
||||
&[
|
||||
TrustEvent::TrustRequired {
|
||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
||||
},
|
||||
TrustEvent::TrustResolved {
|
||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
||||
policy: TrustPolicy::AutoTrust,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -732,7 +240,6 @@ mod tests {
|
||||
// when
|
||||
let decision = resolver.resolve(
|
||||
"/tmp/other/repo-b",
|
||||
None,
|
||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||
);
|
||||
|
||||
@@ -742,8 +249,6 @@ mod tests {
|
||||
decision.events(),
|
||||
&[TrustEvent::TrustRequired {
|
||||
cwd: "/tmp/other/repo-b".to_string(),
|
||||
repo: Some("repo-b".to_string()),
|
||||
worktree: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -760,7 +265,6 @@ mod tests {
|
||||
// when
|
||||
let decision = resolver.resolve(
|
||||
"/tmp/worktrees/repo-c",
|
||||
None,
|
||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||
);
|
||||
|
||||
@@ -771,8 +275,6 @@ mod tests {
|
||||
&[
|
||||
TrustEvent::TrustRequired {
|
||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
||||
repo: Some("repo-c".to_string()),
|
||||
worktree: None,
|
||||
},
|
||||
TrustEvent::TrustDenied {
|
||||
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]
|
||||
fn sibling_prefix_does_not_match_trusted_root() {
|
||||
// given
|
||||
@@ -854,70 +296,4 @@ mod tests {
|
||||
// then
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ fn now_secs() -> u64 {
|
||||
pub enum WorkerStatus {
|
||||
Spawning,
|
||||
TrustRequired,
|
||||
ToolPermissionRequired,
|
||||
ReadyForPrompt,
|
||||
Running,
|
||||
Finished,
|
||||
@@ -42,7 +41,6 @@ impl std::fmt::Display for WorkerStatus {
|
||||
match self {
|
||||
Self::Spawning => write!(f, "spawning"),
|
||||
Self::TrustRequired => write!(f, "trust_required"),
|
||||
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
|
||||
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Finished => write!(f, "finished"),
|
||||
@@ -55,7 +53,6 @@ impl std::fmt::Display for WorkerStatus {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerFailureKind {
|
||||
TrustGate,
|
||||
ToolPermissionGate,
|
||||
PromptDelivery,
|
||||
Protocol,
|
||||
Provider,
|
||||
@@ -74,7 +71,6 @@ pub struct WorkerFailure {
|
||||
pub enum WorkerEventKind {
|
||||
Spawning,
|
||||
TrustRequired,
|
||||
ToolPermissionRequired,
|
||||
TrustResolved,
|
||||
ReadyForPrompt,
|
||||
PromptMisdelivery,
|
||||
@@ -108,8 +104,6 @@ pub enum WorkerPromptTarget {
|
||||
pub enum StartupFailureClassification {
|
||||
/// Trust prompt is required but not detected/resolved
|
||||
TrustRequired,
|
||||
/// Tool permission prompt is required before startup can continue
|
||||
ToolPermissionRequired,
|
||||
/// Prompt was delivered to wrong target (shell misdelivery)
|
||||
PromptMisdelivery,
|
||||
/// Prompt was sent but acceptance timed out
|
||||
@@ -136,14 +130,6 @@ pub struct StartupEvidenceBundle {
|
||||
pub prompt_acceptance_state: bool,
|
||||
/// Result of trust prompt detection at timeout
|
||||
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)
|
||||
pub transport_healthy: bool,
|
||||
/// MCP health summary (true = all servers healthy)
|
||||
@@ -160,15 +146,6 @@ pub enum WorkerEventPayload {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
prompt_preview: String,
|
||||
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)]
|
||||
pub struct WorkerTaskReceipt {
|
||||
pub repo: String,
|
||||
@@ -307,29 +276,6 @@ impl WorkerRegistry {
|
||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
||||
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) {
|
||||
worker.status = WorkerStatus::TrustRequired;
|
||||
worker.last_error = Some(WorkerFailure {
|
||||
@@ -557,9 +503,7 @@ impl WorkerRegistry {
|
||||
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||
blocked: matches!(
|
||||
worker.status,
|
||||
WorkerStatus::TrustRequired
|
||||
| WorkerStatus::ToolPermissionRequired
|
||||
| WorkerStatus::Failed
|
||||
WorkerStatus::TrustRequired | WorkerStatus::Failed
|
||||
),
|
||||
replay_prompt_ready: worker.replay_prompt.is_some(),
|
||||
last_error: worker.last_error.clone(),
|
||||
@@ -680,18 +624,6 @@ impl WorkerRegistry {
|
||||
|
||||
let now = now_secs();
|
||||
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
|
||||
let evidence = StartupEvidenceBundle {
|
||||
@@ -708,13 +640,6 @@ impl WorkerRegistry {
|
||||
.events
|
||||
.iter()
|
||||
.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,
|
||||
mcp_healthy,
|
||||
elapsed_seconds: elapsed,
|
||||
@@ -769,13 +694,6 @@ fn classify_startup_failure(evidence: &StartupEvidenceBundle) -> StartupFailureC
|
||||
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
|
||||
if evidence.prompt_sent_at.is_some()
|
||||
&& !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())
|
||||
}
|
||||
|
||||
#[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 {
|
||||
[
|
||||
"do you trust the files in this folder",
|
||||
@@ -1350,96 +1134,6 @@ mod tests {
|
||||
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]
|
||||
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
|
||||
let registry = WorkerRegistry::new();
|
||||
@@ -1940,9 +1634,6 @@ mod tests {
|
||||
prompt_sent_at: Some(1_234_567_890),
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: true,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
mcp_healthy: false,
|
||||
elapsed_seconds: 60,
|
||||
@@ -1970,9 +1661,6 @@ mod tests {
|
||||
prompt_sent_at: None,
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: false,
|
||||
mcp_healthy: true,
|
||||
elapsed_seconds: 30,
|
||||
@@ -1990,9 +1678,6 @@ mod tests {
|
||||
prompt_sent_at: None,
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
mcp_healthy: true,
|
||||
elapsed_seconds: 10,
|
||||
@@ -2012,9 +1697,6 @@ mod tests {
|
||||
prompt_sent_at: None, // No prompt sent yet
|
||||
prompt_acceptance_state: false,
|
||||
trust_prompt_detected: false,
|
||||
tool_permission_prompt_detected: false,
|
||||
tool_permission_prompt_age_seconds: None,
|
||||
tool_permission_allow_scope: None,
|
||||
transport_healthy: true,
|
||||
mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash
|
||||
elapsed_seconds: 45,
|
||||
|
||||
@@ -374,13 +374,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
model_flag_raw,
|
||||
permission_mode,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
} => print_status_snapshot(
|
||||
&model,
|
||||
model_flag_raw.as_deref(),
|
||||
permission_mode,
|
||||
output_format,
|
||||
allowed_tools.as_ref(),
|
||||
)?,
|
||||
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
|
||||
CliAction::Prompt {
|
||||
@@ -517,7 +515,6 @@ enum CliAction {
|
||||
model_flag_raw: Option<String>,
|
||||
permission_mode: PermissionMode,
|
||||
output_format: CliOutputFormat,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
},
|
||||
Sandbox {
|
||||
output_format: CliOutputFormat,
|
||||
@@ -852,7 +849,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
model_flag_raw.as_deref(),
|
||||
permission_mode_override,
|
||||
output_format,
|
||||
allowed_tools.clone(),
|
||||
) {
|
||||
return action;
|
||||
}
|
||||
@@ -1058,7 +1054,6 @@ fn parse_single_word_command_alias(
|
||||
model_flag_raw: Option<&str>,
|
||||
permission_mode_override: Option<PermissionMode>,
|
||||
output_format: CliOutputFormat,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
) -> Option<Result<CliAction, String>> {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
@@ -1103,7 +1098,6 @@ fn parse_single_word_command_alias(
|
||||
model_flag_raw: model_flag_raw.map(str::to_string), // #148
|
||||
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
|
||||
output_format,
|
||||
allowed_tools,
|
||||
})),
|
||||
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
||||
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
|
||||
@@ -1961,7 +1955,6 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
session_lifecycle: classify_session_lifecycle_for(&cwd),
|
||||
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
|
||||
// Doctor path has its own config check; StatusContext here is only
|
||||
// fed into health renderers that don't read config_load_error.
|
||||
@@ -2806,7 +2799,6 @@ struct StatusContext {
|
||||
project_root: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
git_summary: GitWorkspaceSummary,
|
||||
session_lifecycle: SessionLifecycleSummary,
|
||||
sandbox_status: runtime::SandboxStatus,
|
||||
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
|
||||
/// we capture the parse error here and still populate every field that
|
||||
@@ -2836,75 +2828,6 @@ struct GitWorkspaceSummary {
|
||||
conflicted_files: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SessionLifecycleKind {
|
||||
RunningProcess,
|
||||
IdleShell,
|
||||
SavedOnly,
|
||||
}
|
||||
|
||||
impl SessionLifecycleKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::RunningProcess => "running_process",
|
||||
Self::IdleShell => "idle_shell",
|
||||
Self::SavedOnly => "saved_only",
|
||||
}
|
||||
}
|
||||
|
||||
fn human_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::RunningProcess => "running process",
|
||||
Self::IdleShell => "idle shell",
|
||||
Self::SavedOnly => "saved only",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind,
|
||||
pane_id: Option<String>,
|
||||
pane_command: Option<String>,
|
||||
pane_path: Option<PathBuf>,
|
||||
workspace_dirty: bool,
|
||||
abandoned: bool,
|
||||
}
|
||||
|
||||
impl SessionLifecycleSummary {
|
||||
fn signal(&self) -> String {
|
||||
let mut parts = vec![self.kind.human_label().to_string()];
|
||||
if self.workspace_dirty {
|
||||
parts.push("dirty worktree".to_string());
|
||||
}
|
||||
if self.abandoned {
|
||||
parts.push("abandoned?".to_string());
|
||||
}
|
||||
if let Some(command) = self.pane_command.as_deref() {
|
||||
parts.push(format!("cmd={command}"));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"kind": self.kind.as_str(),
|
||||
"pane_id": self.pane_id,
|
||||
"pane_command": self.pane_command,
|
||||
"pane_path": self.pane_path.as_ref().map(|path| path.display().to_string()),
|
||||
"workspace_dirty": self.workspace_dirty,
|
||||
"abandoned": self.abandoned,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TmuxPaneSnapshot {
|
||||
pane_id: String,
|
||||
current_command: String,
|
||||
current_path: PathBuf,
|
||||
}
|
||||
|
||||
impl GitWorkspaceSummary {
|
||||
fn is_clean(self) -> bool {
|
||||
self.changed_files == 0
|
||||
@@ -2936,120 +2859,6 @@ impl GitWorkspaceSummary {
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
|
||||
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
|
||||
}
|
||||
|
||||
fn classify_session_lifecycle_from_panes(
|
||||
workspace: &Path,
|
||||
panes: Vec<TmuxPaneSnapshot>,
|
||||
) -> SessionLifecycleSummary {
|
||||
let workspace_dirty = git_worktree_is_dirty(workspace);
|
||||
let mut idle_shell = None;
|
||||
for pane in panes {
|
||||
if !pane_path_matches_workspace(&pane.current_path, workspace) {
|
||||
continue;
|
||||
}
|
||||
if is_idle_shell_command(&pane.current_command) {
|
||||
idle_shell.get_or_insert(pane);
|
||||
} else {
|
||||
return SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pane) = idle_shell {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::IdleShell,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
} else {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::SavedOnly,
|
||||
pane_id: None,
|
||||
pane_command: None,
|
||||
pane_path: None,
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
|
||||
let output = Command::new("tmux")
|
||||
.args([
|
||||
"list-panes",
|
||||
"-a",
|
||||
"-F",
|
||||
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
|
||||
])
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_tmux_pane_snapshots(&stdout)
|
||||
}
|
||||
|
||||
fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut fields = line.splitn(3, '\t');
|
||||
let pane_id = fields.next()?.trim();
|
||||
let current_command = fields.next()?.trim();
|
||||
let current_path = fields.next()?.trim();
|
||||
if pane_id.is_empty() || current_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(TmuxPaneSnapshot {
|
||||
pane_id: pane_id.to_string(),
|
||||
current_command: current_command.to_string(),
|
||||
current_path: PathBuf::from(current_path),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
|
||||
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
|
||||
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
||||
pane_path == workspace || pane_path.starts_with(&workspace)
|
||||
}
|
||||
|
||||
fn is_idle_shell_command(command: &str) -> bool {
|
||||
let command = command.rsplit('/').next().unwrap_or(command);
|
||||
matches!(
|
||||
command,
|
||||
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
|
||||
)
|
||||
}
|
||||
|
||||
fn git_worktree_is_dirty(workspace: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args(["status", "--porcelain"])
|
||||
.output();
|
||||
output
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.is_some_and(|output| !output.stdout.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn format_unknown_slash_command_message(name: &str) -> String {
|
||||
let suggestions = suggest_slash_commands(name);
|
||||
@@ -3415,7 +3224,6 @@ fn run_resume_command(
|
||||
default_permission_mode().as_str(),
|
||||
&context,
|
||||
None, // #148: resumed sessions don't have flag provenance
|
||||
None,
|
||||
)),
|
||||
})
|
||||
}
|
||||
@@ -3592,18 +3400,6 @@ fn run_resume_command(
|
||||
} if act == "list" => {
|
||||
let sessions = list_managed_sessions().unwrap_or_default();
|
||||
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
|
||||
let session_details: Vec<serde_json::Value> = sessions
|
||||
.iter()
|
||||
.map(|session| {
|
||||
serde_json::json!({
|
||||
"id": session.id,
|
||||
"path": session.path.display().to_string(),
|
||||
"message_count": session.message_count,
|
||||
"updated_at_ms": session.updated_at_ms,
|
||||
"lifecycle": session.lifecycle.json_value(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let active_id = session.session_id.clone();
|
||||
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
|
||||
Ok(ResumeCommandOutcome {
|
||||
@@ -3612,7 +3408,6 @@ fn run_resume_command(
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "session_list",
|
||||
"sessions": session_ids,
|
||||
"session_details": session_details,
|
||||
"active": active_id,
|
||||
})),
|
||||
})
|
||||
@@ -3844,7 +3639,6 @@ struct ManagedSessionSummary {
|
||||
message_count: usize,
|
||||
parent_session_id: Option<String>,
|
||||
branch_name: Option<String>,
|
||||
lifecycle: SessionLifecycleSummary,
|
||||
}
|
||||
|
||||
struct LiveCli {
|
||||
@@ -5450,9 +5244,7 @@ fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std
|
||||
}
|
||||
|
||||
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
||||
let store = current_session_store()?;
|
||||
let lifecycle = classify_session_lifecycle_for(store.workspace_root());
|
||||
Ok(store
|
||||
Ok(current_session_store()?
|
||||
.list_sessions()
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
|
||||
.into_iter()
|
||||
@@ -5464,15 +5256,12 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle: lifecycle.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
||||
let store = current_session_store()?;
|
||||
let lifecycle = classify_session_lifecycle_for(store.workspace_root());
|
||||
let session = store
|
||||
let session = current_session_store()?
|
||||
.latest_session()
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
Ok(ManagedSessionSummary {
|
||||
@@ -5483,7 +5272,6 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5548,9 +5336,8 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
||||
(None, None) => String::new(),
|
||||
};
|
||||
lines.push(format!(
|
||||
" {id:<20} {marker:<10} lifecycle={lifecycle} msgs={msgs:<4} modified={modified}{lineage} path={path}",
|
||||
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
|
||||
id = session.id,
|
||||
lifecycle = session.lifecycle.signal(),
|
||||
msgs = session.message_count,
|
||||
modified = format_session_modified_age(session.modified_epoch_millis),
|
||||
lineage = lineage,
|
||||
@@ -5627,7 +5414,6 @@ fn print_status_snapshot(
|
||||
model_flag_raw: Option<&str>,
|
||||
permission_mode: PermissionMode,
|
||||
output_format: CliOutputFormat,
|
||||
allowed_tools: Option<&AllowedToolSet>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let usage = StatusUsage {
|
||||
message_count: 0,
|
||||
@@ -5667,7 +5453,6 @@ fn print_status_snapshot(
|
||||
permission_mode.as_str(),
|
||||
&context,
|
||||
Some(&provenance),
|
||||
allowed_tools,
|
||||
))?
|
||||
),
|
||||
}
|
||||
@@ -5685,7 +5470,6 @@ fn status_json_value(
|
||||
// that don't have provenance (legacy resume paths) pass None, in which
|
||||
// case both new fields are omitted.
|
||||
provenance: Option<&ModelProvenance>,
|
||||
allowed_tools: Option<&AllowedToolSet>,
|
||||
) -> serde_json::Value {
|
||||
// #143: top-level `status` marker so claws can distinguish
|
||||
// a clean run from a degraded run (config parse failed but other fields
|
||||
@@ -5695,7 +5479,6 @@ fn status_json_value(
|
||||
let degraded = context.config_load_error.is_some();
|
||||
let model_source = provenance.map(|p| p.source.as_str());
|
||||
let model_raw = provenance.and_then(|p| p.raw.clone());
|
||||
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
||||
json!({
|
||||
"kind": "status",
|
||||
"status": if degraded { "degraded" } else { "ok" },
|
||||
@@ -5704,11 +5487,6 @@ fn status_json_value(
|
||||
"model_source": model_source,
|
||||
"model_raw": model_raw,
|
||||
"permission_mode": permission_mode,
|
||||
"allowed_tools": {
|
||||
"source": if allowed_tools.is_some() { "flag" } else { "default" },
|
||||
"restricted": allowed_tools.is_some(),
|
||||
"entries": allowed_tool_entries,
|
||||
},
|
||||
"usage": {
|
||||
"messages": usage.message_count,
|
||||
"turns": usage.turns,
|
||||
@@ -5733,7 +5511,6 @@ fn status_json_value(
|
||||
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
|
||||
path.file_stem().map(|n| n.to_string_lossy().into_owned())
|
||||
}),
|
||||
"session_lifecycle": context.session_lifecycle.json_value(),
|
||||
"loaded_config_files": context.loaded_config_files,
|
||||
"discovered_config_files": context.discovered_config_files,
|
||||
"memory_file_count": context.memory_file_count,
|
||||
@@ -5789,7 +5566,7 @@ fn status_context(
|
||||
parse_git_status_metadata(project_context.git_status.as_deref());
|
||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||
Ok(StatusContext {
|
||||
cwd: cwd.clone(),
|
||||
cwd,
|
||||
session_path: session_path.map(Path::to_path_buf),
|
||||
loaded_config_files,
|
||||
discovered_config_files,
|
||||
@@ -5797,7 +5574,6 @@ fn status_context(
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
session_lifecycle: classify_session_lifecycle_for(&cwd),
|
||||
sandbox_status,
|
||||
config_load_error,
|
||||
})
|
||||
@@ -5871,7 +5647,6 @@ fn format_status_report(
|
||||
Unstaged {}
|
||||
Untracked {}
|
||||
Session {}
|
||||
Lifecycle {}
|
||||
Config files loaded {}/{}
|
||||
Memory files {}
|
||||
Suggested flow /status → /diff → /commit",
|
||||
@@ -5890,7 +5665,6 @@ fn format_status_report(
|
||||
|| "live-repl".to_string(),
|
||||
|path| path.display().to_string()
|
||||
),
|
||||
context.session_lifecycle.signal(),
|
||||
context.loaded_config_files,
|
||||
context.discovered_config_files,
|
||||
context.memory_file_count,
|
||||
@@ -6207,7 +5981,7 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
|
||||
}
|
||||
|
||||
fn render_config_json(
|
||||
section: Option<&str>,
|
||||
_section: Option<&str>,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
@@ -6240,52 +6014,13 @@ fn render_config_json(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let base = serde_json::json!({
|
||||
Ok(serde_json::json!({
|
||||
"kind": "config",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"loaded_files": loaded_paths.len(),
|
||||
"merged_keys": runtime_config.merged().len(),
|
||||
"files": files,
|
||||
});
|
||||
|
||||
if let Some(section) = section {
|
||||
let section_rendered: Option<String> = match section {
|
||||
"env" => runtime_config.get("env").map(|v| v.render()),
|
||||
"hooks" => runtime_config.get("hooks").map(|v| v.render()),
|
||||
"model" => runtime_config.get("model").map(|v| v.render()),
|
||||
"plugins" => runtime_config
|
||||
.get("plugins")
|
||||
.or_else(|| runtime_config.get("enabledPlugins"))
|
||||
.map(|v| v.render()),
|
||||
other => {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "config",
|
||||
"section": other,
|
||||
"ok": false,
|
||||
"error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."),
|
||||
"cwd": cwd.display().to_string(),
|
||||
"loaded_files": loaded_paths.len(),
|
||||
"files": files,
|
||||
}));
|
||||
}
|
||||
};
|
||||
// Parse the rendered JSON string back into serde_json::Value so that
|
||||
// section_value is a real JSON object/array in the envelope, not a quoted string.
|
||||
let section_value: serde_json::Value = section_rendered
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let mut obj = base;
|
||||
let map = obj.as_object_mut().expect("base is object");
|
||||
map.insert(
|
||||
"section".to_string(),
|
||||
serde_json::Value::String(section.to_string()),
|
||||
);
|
||||
map.insert("section_value".to_string(), section_value);
|
||||
return Ok(obj);
|
||||
}
|
||||
|
||||
Ok(base)
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||
@@ -9201,7 +8936,6 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
writeln!(out)?;
|
||||
let resume_commands = resume_supported_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|spec| !STUB_COMMANDS.contains(&spec.name))
|
||||
.map(|spec| match spec.argument_hint {
|
||||
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||||
None => format!("/{}", spec.name),
|
||||
@@ -9275,10 +9009,10 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
||||
mod tests {
|
||||
use super::{
|
||||
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
||||
classify_error_kind, classify_session_lifecycle_from_panes, collect_session_prompt_history,
|
||||
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
||||
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
|
||||
format_compact_report, format_connected_line, format_cost_report, format_history_timestamp,
|
||||
classify_error_kind, collect_session_prompt_history, create_managed_session_handle,
|
||||
describe_tool_progress, filter_tool_specs, format_bughunter_report,
|
||||
format_commit_preflight_report, format_commit_skipped_report, format_compact_report,
|
||||
format_connected_line, format_cost_report, format_history_timestamp,
|
||||
format_internal_prompt_progress_line, format_issue_report, format_model_report,
|
||||
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
||||
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
|
||||
@@ -9289,15 +9023,14 @@ mod tests {
|
||||
parse_history_count, permission_policy, print_help_to, push_output_block,
|
||||
render_config_report, render_diff_report, render_diff_report_for, render_help_topic,
|
||||
render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage,
|
||||
render_session_list, render_session_markdown, resolve_model_alias,
|
||||
resolve_model_alias_with_config, resolve_repl_model, resolve_session_reference,
|
||||
response_to_events, resume_supported_slash_commands, run_resume_command, short_tool_id,
|
||||
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
|
||||
resolve_repl_model, resolve_session_reference, response_to_events,
|
||||
resume_supported_slash_commands, run_resume_command, short_tool_id,
|
||||
slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
|
||||
status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt,
|
||||
validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor,
|
||||
GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli,
|
||||
LocalHelpTopic, PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary,
|
||||
SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
|
||||
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
|
||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||
PromptHistoryEntry, SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
|
||||
STUB_COMMANDS,
|
||||
};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
@@ -10075,18 +9808,6 @@ mod tests {
|
||||
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_allowed_tools_flag() {
|
||||
for raw in ["", ",,"] {
|
||||
let error = parse_args(&["--allowedTools".to_string(), raw.to_string()])
|
||||
.expect_err("empty allowedTools should be rejected");
|
||||
assert!(
|
||||
error.contains("--allowedTools was provided with no usable tool names"),
|
||||
"unexpected error for {raw:?}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_system_prompt_options() {
|
||||
let args = vec![
|
||||
@@ -10542,14 +10263,8 @@ mod tests {
|
||||
cumulative: runtime::TokenUsage::default(),
|
||||
estimated_tokens: 0,
|
||||
};
|
||||
let json = super::status_json_value(
|
||||
Some("test-model"),
|
||||
usage,
|
||||
"workspace-write",
|
||||
&context,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let json =
|
||||
super::status_json_value(Some("test-model"), usage, "workspace-write", &context, None);
|
||||
assert_eq!(
|
||||
json.get("status").and_then(|v| v.as_str()),
|
||||
Some("degraded"),
|
||||
@@ -10574,46 +10289,6 @@ mod tests {
|
||||
json.get("sandbox").is_some(),
|
||||
"sandbox field still reported"
|
||||
);
|
||||
assert_eq!(
|
||||
json.pointer("/allowed_tools/source")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("default"),
|
||||
"default status should expose unrestricted tool source: {json}"
|
||||
);
|
||||
assert_eq!(
|
||||
json.pointer("/allowed_tools/restricted")
|
||||
.and_then(|v| v.as_bool()),
|
||||
Some(false),
|
||||
"default status should expose unrestricted tool state: {json}"
|
||||
);
|
||||
|
||||
let allowed: super::AllowedToolSet = ["read_file", "grep_search"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let restricted_json = super::status_json_value(
|
||||
Some("test-model"),
|
||||
usage,
|
||||
"workspace-write",
|
||||
&context,
|
||||
None,
|
||||
Some(&allowed),
|
||||
);
|
||||
assert_eq!(
|
||||
restricted_json
|
||||
.pointer("/allowed_tools/source")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("flag"),
|
||||
"flag status should expose allow-list source: {restricted_json}"
|
||||
);
|
||||
assert_eq!(
|
||||
restricted_json
|
||||
.pointer("/allowed_tools/entries")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(Vec::len),
|
||||
Some(2),
|
||||
"flag status should expose allow-list entries: {restricted_json}"
|
||||
);
|
||||
|
||||
// Clean path: no config error → status: "ok", config_load_error: null.
|
||||
let clean_cwd = root.join("project-with-clean-config");
|
||||
@@ -10628,7 +10303,6 @@ mod tests {
|
||||
"workspace-write",
|
||||
&clean_context,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
clean_json.get("status").and_then(|v| v.as_str()),
|
||||
@@ -10706,7 +10380,6 @@ mod tests {
|
||||
model_flag_raw: None, // #148: no --model flag passed
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -11888,14 +11561,6 @@ mod tests {
|
||||
untracked_files: 1,
|
||||
conflicted_files: 0,
|
||||
},
|
||||
session_lifecycle: SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::IdleShell,
|
||||
pane_id: Some("%7".to_string()),
|
||||
pane_command: Some("zsh".to_string()),
|
||||
pane_path: Some(PathBuf::from("/tmp/project")),
|
||||
workspace_dirty: true,
|
||||
abandoned: true,
|
||||
},
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
config_load_error: None,
|
||||
},
|
||||
@@ -11918,149 +11583,11 @@ mod tests {
|
||||
assert!(status.contains("Unstaged 1"));
|
||||
assert!(status.contains("Untracked 1"));
|
||||
assert!(status.contains("Session session.jsonl"));
|
||||
assert!(
|
||||
status.contains("Lifecycle idle shell · dirty worktree · abandoned? · cmd=zsh")
|
||||
);
|
||||
assert!(status.contains("Config files loaded 2/3"));
|
||||
assert!(status.contains("Memory files 4"));
|
||||
assert!(status.contains("Suggested flow /status → /diff → /commit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_lifecycle_prefers_running_process_over_idle_shell() {
|
||||
let workspace = PathBuf::from("/tmp/project");
|
||||
let lifecycle = classify_session_lifecycle_from_panes(
|
||||
&workspace,
|
||||
vec![
|
||||
TmuxPaneSnapshot {
|
||||
pane_id: "%1".to_string(),
|
||||
current_command: "zsh".to_string(),
|
||||
current_path: workspace.clone(),
|
||||
},
|
||||
TmuxPaneSnapshot {
|
||||
pane_id: "%2".to_string(),
|
||||
current_command: "claw".to_string(),
|
||||
current_path: workspace.join("rust"),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(lifecycle.kind, SessionLifecycleKind::RunningProcess);
|
||||
assert_eq!(lifecycle.pane_id.as_deref(), Some("%2"));
|
||||
assert_eq!(lifecycle.pane_command.as_deref(), Some("claw"));
|
||||
assert!(!lifecycle.abandoned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_lifecycle_marks_dirty_idle_shell_as_abandoned() {
|
||||
let _guard = env_lock();
|
||||
let workspace = temp_workspace("dirty-idle-shell");
|
||||
fs::create_dir_all(&workspace).expect("workspace should create");
|
||||
git(&["init", "--quiet"], &workspace);
|
||||
git(&["config", "user.email", "tests@example.com"], &workspace);
|
||||
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
|
||||
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
|
||||
git(&["add", "tracked.txt"], &workspace);
|
||||
git(&["commit", "-m", "init", "--quiet"], &workspace);
|
||||
fs::write(workspace.join("tracked.txt"), "hello\nchanged\n").expect("dirty tracked");
|
||||
|
||||
let lifecycle = classify_session_lifecycle_from_panes(
|
||||
&workspace,
|
||||
vec![TmuxPaneSnapshot {
|
||||
pane_id: "%3".to_string(),
|
||||
current_command: "bash".to_string(),
|
||||
current_path: workspace.clone(),
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(lifecycle.kind, SessionLifecycleKind::IdleShell);
|
||||
assert!(lifecycle.workspace_dirty);
|
||||
assert!(lifecycle.abandoned);
|
||||
|
||||
fs::remove_dir_all(workspace).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_list_surfaces_saved_dirty_abandoned_lifecycle() {
|
||||
let _guard = cwd_guard();
|
||||
let workspace = temp_workspace("session-list-lifecycle");
|
||||
fs::create_dir_all(&workspace).expect("workspace should create");
|
||||
git(&["init", "--quiet"], &workspace);
|
||||
git(&["config", "user.email", "tests@example.com"], &workspace);
|
||||
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
|
||||
fs::write(workspace.join(".gitignore"), ".claw/\n").expect("write gitignore");
|
||||
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
|
||||
git(&["add", ".gitignore", "tracked.txt"], &workspace);
|
||||
git(&["commit", "-m", "init", "--quiet"], &workspace);
|
||||
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&workspace).expect("switch cwd");
|
||||
let handle = create_managed_session_handle("session-alpha").expect("session handle");
|
||||
Session::new()
|
||||
.with_workspace_root(workspace.clone())
|
||||
.with_persistence_path(handle.path.clone())
|
||||
.save_to_path(&handle.path)
|
||||
.expect("session should save");
|
||||
fs::write(workspace.join("tracked.txt"), "hello\nchanged\n").expect("dirty tracked");
|
||||
|
||||
let report = render_session_list("session-alpha").expect("session list should render");
|
||||
|
||||
assert!(report.contains("session-alpha"));
|
||||
assert!(report.contains("lifecycle=saved only · dirty worktree · abandoned?"));
|
||||
|
||||
std::env::set_current_dir(previous).expect("restore cwd");
|
||||
fs::remove_dir_all(workspace).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_json_surfaces_session_lifecycle_for_clawhip() {
|
||||
let context = super::StatusContext {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
session_path: None,
|
||||
loaded_config_files: 0,
|
||||
discovered_config_files: 0,
|
||||
memory_file_count: 0,
|
||||
project_root: Some(PathBuf::from("/tmp/project")),
|
||||
git_branch: Some("feature/session-lifecycle".to_string()),
|
||||
git_summary: GitWorkspaceSummary::default(),
|
||||
session_lifecycle: SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some("%9".to_string()),
|
||||
pane_command: Some("claw".to_string()),
|
||||
pane_path: Some(PathBuf::from("/tmp/project")),
|
||||
workspace_dirty: false,
|
||||
abandoned: false,
|
||||
},
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
config_load_error: None,
|
||||
};
|
||||
|
||||
let value = status_json_value(
|
||||
Some("claude-sonnet"),
|
||||
StatusUsage {
|
||||
message_count: 0,
|
||||
turns: 0,
|
||||
latest: runtime::TokenUsage::default(),
|
||||
cumulative: runtime::TokenUsage::default(),
|
||||
estimated_tokens: 0,
|
||||
},
|
||||
"workspace-write",
|
||||
&context,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
value["workspace"]["session_lifecycle"]["kind"],
|
||||
"running_process"
|
||||
);
|
||||
assert_eq!(
|
||||
value["workspace"]["session_lifecycle"]["pane_command"],
|
||||
"claw"
|
||||
);
|
||||
assert_eq!(value["workspace"]["session_lifecycle"]["abandoned"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_reports_surface_workspace_context() {
|
||||
let summary = GitWorkspaceSummary {
|
||||
@@ -13397,32 +12924,6 @@ UU conflicted.rs",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_commands_absent_from_resume_safe_help() {
|
||||
let mut help = Vec::new();
|
||||
print_help_to(&mut help).expect("help should render");
|
||||
let help = String::from_utf8(help).expect("help should be utf8");
|
||||
let resume_line = help
|
||||
.lines()
|
||||
.find(|line| line.starts_with("Resume-safe commands:"))
|
||||
.expect("resume-safe command line should exist");
|
||||
let resume_roots = resume_line
|
||||
.trim_start_matches("Resume-safe commands:")
|
||||
.split(',')
|
||||
.filter_map(|entry| entry.trim().strip_prefix('/'))
|
||||
.filter_map(|entry| entry.split_whitespace().next())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for stub in STUB_COMMANDS {
|
||||
assert!(
|
||||
!resume_roots.contains(stub),
|
||||
"stub command /{stub} should not appear in resume-safe command list"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(resume_roots.contains(&"status"));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_mcp_server_fixture(script_path: &Path) {
|
||||
|
||||
@@ -105,18 +105,6 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
|
||||
assert_eq!(skills["kind"], "skills");
|
||||
assert_eq!(skills["action"], "list");
|
||||
|
||||
let plugins = assert_json_command(&root, &["--output-format", "json", "plugins"]);
|
||||
assert_eq!(plugins["kind"], "plugin");
|
||||
assert_eq!(plugins["action"], "list");
|
||||
assert!(
|
||||
plugins["reload_runtime"].is_boolean(),
|
||||
"plugins reload_runtime should be a boolean"
|
||||
);
|
||||
assert!(
|
||||
plugins["target"].is_null(),
|
||||
"plugins target should be null when no plugin is targeted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -396,44 +384,6 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
||||
assert!(root.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_section_json_emits_section_and_value() {
|
||||
let root = unique_temp_dir("config-section-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
// Without a section: should return base envelope (no section field).
|
||||
let base = assert_json_command(&root, &["--output-format", "json", "config"]);
|
||||
assert_eq!(base["kind"], "config");
|
||||
assert!(base["loaded_files"].is_number());
|
||||
assert!(base["merged_keys"].is_number());
|
||||
assert!(
|
||||
base.get("section").is_none(),
|
||||
"no section field without section arg"
|
||||
);
|
||||
|
||||
// With a known section: should add section + section_value fields.
|
||||
for section in &["model", "env", "hooks", "plugins"] {
|
||||
let result = assert_json_command(&root, &["--output-format", "json", "config", section]);
|
||||
assert_eq!(result["kind"], "config", "section={section}");
|
||||
assert_eq!(
|
||||
result["section"].as_str(),
|
||||
Some(*section),
|
||||
"section field must match requested section, got {result:?}"
|
||||
);
|
||||
assert!(
|
||||
result.get("section_value").is_some(),
|
||||
"section_value field must be present for section={section}"
|
||||
);
|
||||
}
|
||||
|
||||
// With an unsupported section: should return ok:false + error field.
|
||||
let bad = assert_json_command(&root, &["--output-format", "json", "config", "unknown"]);
|
||||
assert_eq!(bad["kind"], "config");
|
||||
assert_eq!(bad["ok"], false);
|
||||
assert!(bad["error"].as_str().is_some());
|
||||
assert!(bad["section"].as_str().is_some());
|
||||
}
|
||||
|
||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -6890,21 +6883,6 @@ mod tests {
|
||||
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]
|
||||
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
|
||||
let registry = GlobalToolRegistry::builtin()
|
||||
|
||||
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user