diff --git a/.gitignore b/.gitignore index f7f3f07..3a1194f 100644 --- a/.gitignore +++ b/.gitignore @@ -156,6 +156,8 @@ activemq-data/ # Environments .env .envrc +# Project-level .code config (may carry env tokens + local permission grants) +.code/ .venv env/ venv/ diff --git a/README.md b/README.md index e1ac065..4645732 100644 --- a/README.md +++ b/README.md @@ -1,371 +1,214 @@ -# Agent Harness +# Harness -Self-improving agent orchestration system with autonomous loop execution, parallel agent spawning, and multi-LLM support. Built like Claude Code with extensible tool calling, persistent knowledge graph, and checkpoint/resume capability. +**Self-improving, multi-agent orchestration system that runs autonomous coding agents in your terminal with loops, checkpoint/resume, parallel agents, a tool-calling system, MCP support, and persistent memory.** -## Features +Built like Claude Code: an interactive Rich terminal UI plus a full CLI, all in one installable Python package (Python ≥ 3.11). -- **Autonomous Loops** - Execute long-running tasks with automatic checkpointing and resume capability -- **Parallel Agents** - Spawn up to 16 concurrent agents with automatic fallback between LLMs -- **Multi-LLM Support** - Switch between Claude, OpenAI, Azure seamlessly via litellm -- **Rich Terminal UI** - Real-time progress with Rich-based dashboard and command palette -- **Persistent Knowledge** - NetworkX-backed knowledge graph for cross-session learning -- **Tool Orchestration** - Unified interface for file operations, code execution, and external APIs -- **Database Flexibility** - SQLite for development, PostgreSQL/Supabase in production +--- -## Quick Start +## What is this? -### Installation +Harness is a production-ready foundation for building and running **autonomous agents** long-running tasks that plan, use tools, spawn sub-agents, iterate, and resume from where they left off. -```bash -# Clone repository -git clone -cd code +Instead of sitting at a prompt asking for permission at every step, your agent executes a task loop: it runs iterations, calls tools, spawns parallel agents, records what it learned, and checkpoints after every step. Interrupted or out of tokens? Pick it back up with one command — it resumes without re-doing completed work. -# Install in development mode -pip install -e . +| Layer | What it does | +|-------|--------------| +| **Loop engine** | Runs tasks autonomously with automatic checkpoint/resume per iteration | +| **Agent orchestration** | Spawns up to 16 concurrent agents with automatic fallback between LLMs | +| **Multi-LLM** | Claude, OpenAI, or Azure through one interface (litellm) | +| **Tool calling** | Unified file / code / shell / HTTP execution with timeouts, retries, and output spilling | +| **MCP + plugins** | Register any Model Context Protocol server or install marketplace plugins — stored in `settings.json` | +| **Terminal UI** | Real-time Rich dashboard with command palette and live agent/tool state | +| **Memory** | NetworkX-backed knowledge graph + SQL database for cross-session learning | +| **Human approval** | Risky tool calls pause and wait for `harness approve` / `harness approvals` | -# Install with dev dependencies (testing, type checking, linting) -pip install -e ".[dev]" +## How it helps you -# Initialize project (creates .env, directories) -harness init -``` +- **Trust your agent to grind.** Long tasks run unattended; checkpoints mean a crash or context limit is a `resume`, not a restart. +- **Parallelize the boring work.** Fan out independent sub-tasks across up to 16 agents at once, with automatic LLM fallback if one provider fails. +- **Extend without forking.** Point any MCP server at it (`harness mcp add`) or pull plugins from a marketplace no code changes. +- **Decide what's safe.** Risk-gated tool calls queue as approval requests you can review and approve/reject from the CLI. +- **Learn across sessions.** Past solutions are stored in a knowledge graph and injected into future prompts. +- **One package, two surfaces.** Launch the full terminal UI or script the exact same engine from the CLI. -### Running +--- -**Interactive Terminal UI (default):** -```bash -python -m harness.main -``` +## Quick Start (install → running in ~60 seconds) -Launches a Rich-based terminal with command palette, real-time output streaming, and live agent state tracking. +### 0. Prerequisites -**CLI Mode (specific operations):** -```bash -# Run a task autonomously -harness run --task "Build a REST API with authentication" +- **Python 3.11+** (`python --version`), **git** -# Resume from checkpoint -harness resume --task-id +### 1. Install -# Check task status -harness status --task-id +**Unix / macOS / Git Bash:** -# Search knowledge graph -harness knowledge-search "auth patterns" +```bash +git clone code && cd code +python -m venv .venv && source .venv/bin/activate +pip install -e . && harness init ``` -### Configuration - -Create `.env` after running `harness init`: - -```env -# LLM API Keys (at least one required) -ANTHROPIC_API_KEY=sk-ant-... -OPENAI_API_KEY=sk-... -AZURE_API_KEY=... +**Windows (PowerShell / cmd):** -# Database -DATABASE_URL=sqlite+aiosqlite:///harness.db -# For PostgreSQL/Supabase: -# DATABASE_URL=postgresql+asyncpg://user:pass@host/db - -# Optional -REDIS_URL=redis://localhost:6379 -MAX_PARALLEL_AGENTS=16 -TOOL_TIMEOUT_SECONDS=30 -LOG_LEVEL=info +```powershell +git clone code; cd code +python -m venv .venv +.\.venv\Scripts\activate +pip install -e .; harness init ``` -## Architecture - -**5-Layer Design** (3000-4000 LOC total): - -### 1. Loop Engine (`src/harness/core/`) -Autonomous task execution with checkpoint/resume. - -| File | Purpose | -|------|---------| -| `loop.py` | `LoopController` - Async iteration with state persistence | -| `task_manager.py` | `TaskStateManager` - Checkpoints, resume, state tracking | -| `models.py` | `TaskState` - Serializable task state | -| `completion.py` | `CompletionChecker` - Success criteria evaluation | -| `error_memory.py` | Error tracking and retry logic | - -**Pattern:** Loop persists state after each iteration. Tasks resume from checkpoint without re-executing completed work. - -### 2. Agent Orchestration (`src/harness/orchestration/`) -Parallel multi-agent coordination with LLM fallback. - -| File | Purpose | -|------|---------| -| `orchestrator.py` | `HarnessOrchestrator` - Coordinates agents + tools + prompts | -| `spawner.py` | `AgentSpawner` - Spawns agents concurrently (up to 16) | -| `agent.py` | `AgentConfig`, `AgentResult` - Agent configuration and results | - -**Pattern:** Orchestrator delegates work to agents, collects results, manages timeouts and retries. - -### 3. Tool Calling (`src/harness/tools/`) -Unified interface for file operations, code execution, API calls. +Prefer a single line (Unix/macOS/Git Bash): -| File | Purpose | -|------|---------| -| `router.py` | `ToolRouter` - Routes tool calls to handlers | -| `executor.py` | `ToolExecutor` - Wraps execution with timeout + retry | -| `handlers.py` | Tool-specific handlers (file, code, shell, http) | -| `models.py` | `ToolCall`, `ToolResult` - Request/response format | -| `factory.py` | Tool definition factory | -| `output_cap.py` | Output spilling for oversized results | - -**Pattern:** Tools return `ToolResult(status, output, metadata)`. Large outputs are spilled to persistent cache. - -### 4. Prompt Optimization (`src/harness/prompts/`) -Role-based prompt generation with context injection. - -| File | Purpose | -|------|---------| -| `engine.py` | `PromptEngine` - Renders templates with context | -| `context_injector.py` | BM25-ranked context retrieval | -| `constraints.py` | Token budget and role-specific constraints | - -**Pattern:** Prompts are Jinja2 templates with injected context from knowledge graph. - -### 5. State & Memory (`src/harness/persistence/`) -Persistent knowledge graph and session state. - -| File | Purpose | -|------|---------| -| `knowledge_graph.py` | Query/store past solutions (NetworkX + DB) | -| `session.py` | `SessionManager` - Session state persistence | -| `models.py` | SQLAlchemy ORM definitions | -| `database.py` | Connection pool and migrations | -| `transient_cache.py` | In-memory cache for tool output | - -**Pattern:** All state flows through database. NetworkX graph enables pattern recognition across sessions. - -### Terminal UI (`src/harness/ui/`) -Real-time progress tracking with Rich. - -| Phase | Purpose | -|-------|---------| -| 2A | Rendering - Rich components, layout styling | -| 2B | Keyboard input - Keybinds, command palette | -| 2C | Real-time streams - Log aggregation, output streaming | -| 2D | Agent state - Agent view, tool results display | -| 2E | Command actions - Execute user commands | - -**Pattern:** Concurrent input loop + display loop with `Rich.Live` (no flickering). +```bash +git clone code && cd code && python -m venv .venv && source .venv/bin/activate && pip install -e . && harness init +``` -## Tech Stack +That's it — `harness init` creates your config file and data directories automatically. -| Layer | Technology | Performance | -|-------|-----------|-------------| -| Loop | asyncio + uvloop + msgpack | <2s spawn, <5ms writes | -| Agents | litellm + TaskGroup | 16 parallel, auto fallback | -| Tools | httpx + aiofiles + Redis | <100ms calls, 100-1000x cache | -| Prompts | Jinja2 + BM25 | <50ms generation | -| Persistence | SQLAlchemy + PostgreSQL | 10k+ qps | -| UI | Rich (Live + Console) | <50ms render | +### 2. Provide an API key -## Development - -### Common Tasks +Set one of these (it's read at launch): ```bash -# Run tests with coverage -pytest -v --cov=src/harness - -# Run specific test -pytest tests/test_loop.py::test_checkpoint -v - -# Type checking -mypy src/harness - -# Linting -ruff check src/ +export CODE_API_KEY="sk-ant-..." # Claude / Anthropic (default) +# or: export OPENAI_API_KEY="sk-..." # OpenAI +# or: export AZURE_API_KEY="..." # Azure +``` -# Format code -black src/ tests/ +Or, instead of exporting, create a `.env` in the project root: -# Debug mode (verbose logging) -LOG_LEVEL=debug python -m harness.main ``` - -### Adding a New Tool - -1. Define handler in `src/harness/tools/handlers.py` -2. Register in `ToolRouter` (auto-discovered from handlers) -3. Return `ToolResult(status, output, metadata)` - -```python -@tool_handler("my_tool") -async def handle_my_tool(params: dict) -> ToolResult: - # Implementation - return ToolResult( - status="success", - output="Result here", - metadata={"key": "value"} - ) +CODE_API_KEY=sk-ant-... ``` -### Adding a New Agent Type - -1. Extend `AgentConfig` in `src/harness/orchestration/agent.py` -2. Implement in `AgentSpawner.spawn()` -3. Add Jinja2 template in `src/harness/prompts/` - -### Testing +### 3. Run your first task -**Write tests first (TDD):** ```bash -pytest tests/test_my_feature.py -v +harness run --task "Build a REST API with authentication" --max-iterations 20 ``` -**Check coverage:** +Or launch the interactive terminal UI, then type tasks in the prompt: + ```bash -pytest --cov=src/harness --cov-report=html -# Open htmlcov/index.html +harness ``` -Target: 80%+ coverage. - -## Configuration & Paths - -Follows **Claude Code standard** with project-level and user-level overrides. +### Verify it works -### Directory Resolution - -``` -Project-level (explicit override): -./.code/ -├── agents/ # Project-specific agents -├── skills/ # Project-specific skills -├── data/ # Project task checkpoints -├── templates/ # Project prompt templates -└── config/ # Project config - -User-level (default, auto-created): -~/.code/ -├── agents/ -├── skills/ -├── data/ -├── templates/ -└── config/ +```bash +harness status # → shows your in-progress / completed tasks +harness knowledge-search "auth patterns" # → searches learned context ``` -**In code:** -```python -from harness.config import get_settings +--- -settings = get_settings() -agents_path = settings.get_agents_dir() # ./.code/agents or ~/.code/agents -data_path = settings.get_data_dir() # ./.code/data or ~/.code/data -``` +## CLI Reference + +| Command | What it does | +|---------|--------------| +| `harness` | Launch the interactive Rich terminal UI | +| `harness run --task "..." [--max-iterations N]` | Run a task autonomously through the loop | +| `harness resume --task-id ` | Resume a task from its last checkpoint | +| `harness status` | List all tasks and their progress | +| `harness knowledge-search [--limit N]` | Search past solutions in the knowledge graph | +| `harness approvals [--task-id ]` | List pending human-approval requests | +| `harness approve [--reject] [--reason "..."]` | Approve / reject a queued risky action | +| `harness init` | Create `settings.json` + data directories | +| `harness mcp add --command ` | Register an MCP server (stdin/stdout) | +| `harness mcp add --url ` | Register an MCP server (HTTP) | +| `harness mcp remove ` / `harness mcp list` | Remove / list registered MCP servers | +| `harness plugin install ` | Install a plugin (or from a marketplace) | +| `harness plugin uninstall ` / `harness plugin list` | Remove / list installed plugins | +| `harness plugin marketplace add ` | Register a plugin marketplace (git URL) | -**Priority:** Project-level paths (if exist) override user-level paths. +--- -## Key Design Patterns +## Configuration -### Async-First -All I/O (database, files, APIs) is async. Use `asyncio.run()` for CLI entry points. +All configuration lives in **`settings.json`** (Claude-Code style), created by `harness init` and resolved project-first, then user-level (`~/.code/`) — with a `.env` file as a fallback. MCP servers and plugins you add are stored right here. -### Checkpoint/Resume -`TaskStateManager` persists complete task state after each loop iteration: -```python -# Resume doesn't re-execute completed steps -await loop.resume(task_id) +```json +{ + "env": { + "CODE_BASE_URL": "https://api.anthropic.com", + "CODE_API_KEY": "env:CODE_API_KEY" + }, + "model": "claude-3-5-sonnet-20241022", + "subagent_model": "claude-3-5-haiku-20241022", + "mcpServers": {} +} ``` -### Parallel Agents with Fallback -`AgentSpawner` manages concurrent execution with automatic LLM fallback: -```python -# If Claude fails, try OpenAI automatically -agents = await spawner.spawn(count=4, fallback_models=["gpt-4", "gpt-3.5"]) -``` +Key environment variables: -### Tool Output Spilling -Large tool outputs are stored in persistent cache, not returned directly: -```python -# If output > cap, store in cache and return reference -result = ToolResult(output="...", metadata={"spilled": True, "cache_key": "abc123"}) -``` +| Variable | Purpose | Default | +|----------|---------|---------| +| `CODE_API_KEY` | Claude / Anthropic API key (base URL overrideable via `CODE_BASE_URL`) | `https://api.anthropic.com` | +| `OPENAI_API_KEY` | OpenAI API key | — | +| `AZURE_API_KEY` | Azure API key | — | +| `DATABASE_URL` | `sqlite+aiosqlite:///harness.db` (dev) or `postgresql+asyncpg://...` (prod) | SQLite | +| `MAX_PARALLEL_AGENTS` | Concurrent agent cap | `16` | +| `TOOL_TIMEOUT_SECONDS` | Per-tool execution timeout | `30` | -### Context Injection -Prompts include ranked context from knowledge graph (BM25): -```python -# Relevant past solutions automatically injected into prompt -prompt = await engine.render("task", context=injector.get_context("auth")) -``` +--- -## Debugging +## Architecture -### Enable Verbose Logging -```bash -LOG_LEVEL=debug python -m harness.main -``` +**5-layer design** — files mirror this layout under `src/harness/`. -### Check Database State -```bash -sqlite3 harness.db -SELECT * FROM tasks ORDER BY created_at DESC; -SELECT * FROM tool_calls WHERE task_id = ''; -``` +| Layer | Module | Responsibility | +|-------|--------|----------------| +| **1. Loop Engine** | `core/` | `LoopController` async loop, `TaskStateManager` checkpoint/resume, `CompletionChecker`, error memory | +| **2. Orchestration** | `orchestration/` | `HarnessOrchestrator` coordinates agents + tools + prompts; `AgentSpawner` runs up to 16 in parallel | +| **3. Tool Calling** | `tools/` | `ToolRouter` → handlers; `ToolExecutor` wraps timeout + retries; `output_cap` spills huge outputs to cache; `mcp_manager` bridges MCP servers | +| **4. Prompting** | `prompts/` | Jinja2 templates, `context_injector` BM25-ranked context, token/role constraints | +| **5. State & Memory** | `persistence/` | NetworkX + SQLAlchemy knowledge graph, session state, `database` pooling, `transient_cache` | -### Monitor Task Execution -- TUI shows real-time logs in main panel -- Check `.code/data/` for checkpoint files -- Exceptions logged to `.code/logs/` (if configured) +**Terminal UI** (`ui/`): concurrent input loop + `Rich.Live` display (rendering → keyboard → live streams → agent/tool state → command actions), so output streams with zero flicker. -### UI Not Rendering? -- Ensure `TerminalUI.run()` is awaited (async context) -- Check Rich console for rendering errors -- Verify `auto_refresh=True` in Live display +**Key patterns:** everything is async (`asyncio`); state persists after every loop iteration; agents run in a `TaskGroup` with automatic LLM fallback; large tool outputs spill to a persistent cache and return a reference. -## Dependencies +--- -**Core runtime:** -- asyncio, aiofiles, httpx, msgpack +## Development -**Multi-LLM:** -- litellm (Claude, OpenAI, Azure, others) +```bash +# Install dev tooling (pytest, ruff, black, mypy) +pip install -e ".[dev]" -**Database:** -- SQLAlchemy, asyncpg (PostgreSQL), aiosqlite (SQLite) +# Test with coverage (target ≥80%) +pytest -v --cov=src/harness -**UI:** -- Rich (terminal styling, Live display) +# Lint / type-check / format +ruff check src/ +mypy src/harness +black src/ tests/ -**Prompts:** -- Jinja2, rank-bm25 (context ranking) +# Debug logging (verbose) — run the TUI or a task with full logs +LOG_LEVEL=debug python -m harness.main +``` -**Orchestration:** -- asyncio.TaskGroup (Python 3.11+) +**Extending the harness:** -**Monitoring:** -- structlog, prometheus-client +- **New tool** → add a `@tool_handler("name")` in `src/harness/tools/handlers.py`, return `ToolResult(status, output, metadata)`; it's auto-discovered. +- **New agent type** → extend `AgentConfig` in `orchestration/agent.py`, implement in `AgentSpawner.spawn()`, add a Jinja2 prompt in `prompts/`. +- **New MCP server** → `harness mcp add --command `; it's persisted in `settings.json`. +- Suggested flow: research existing solutions first → plan → write tests (TDD) → review → commit (conventional commits). -See `pyproject.toml` for exact versions. +--- ## Contributing -1. **Research first** - Check existing implementations before new code -2. **Plan** - Use `/plan` skill for complex features -3. **TDD** - Write tests before implementation -4. **Review** - Use `/code-review` skill after writing -5. **Commit** - Conventional commits format (feat:, fix:, etc.) +1. **Research first** — check for existing implementations before writing new code +2. **Plan** — use `/plan` for complex features +3. **TDD** — write tests before implementation +4. **Review** — use `/code-review` after writing +5. **Commit** — conventional commits (`feat:`, `fix:`, …) -See CLAUDE.md for detailed workflows. +See `CLAUDE.md` for detailed architecture, path resolution, and workflows. ## License -MIT - -## Support - -- **Documentation:** See CLAUDE.md for architecture details, paths, debugging -- **Issues:** GitHub issues for bugs, feature requests -- **Development:** `pip install -e ".[dev]"` for local setup - ---- - -**Built for Claude Code.** Extensible, observable, and designed for autonomous agent workflows. +MIT \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 28ba00a..9e561c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,10 @@ dependencies = [ # Terminal UI "rich>=13.0.0", + + # Dynamic Tool Integration (MCP servers + JSON Schema validation) + "mcp>=1.0.0", + "jsonschema>=4.0.0", ] [project.optional-dependencies] diff --git a/src/harness/config.py b/src/harness/config.py index 59fd4c8..235035c 100644 --- a/src/harness/config.py +++ b/src/harness/config.py @@ -2,6 +2,8 @@ import json import os +import ssl +import urllib.request from functools import lru_cache from typing import Any from pydantic_settings import BaseSettings @@ -9,7 +11,43 @@ from pathlib import Path -_settings_file_cache: dict[str, Any] | None = None +class URLFetchError(Exception): + """Raised when a remote marketplace/plugin fetch fails (timeout, non-2xx, bad TLS).""" + + +def is_url(value: str) -> bool: + """Return True if value looks like an http(s) URL.""" + return value.startswith(("http://", "https://")) + + +def http_get_string(url: str, timeout: int = 10) -> str: + """Fetch a URL over HTTPS and return the response body as UTF-8 text. + + Uses stdlib ``urllib`` with a verified TLS context and a hard timeout. + Only https:// is allowed; http:// is rejected to prevent spoofing on + untrusted networks. On any failure (DNS, TLS, non-2xx, timeout) raises + :class:`URLFetchError` with a redacted message (no raw URL). + """ + if not is_url(url): + raise URLFetchError(f"Unsupported URL scheme: {url.split(':', 1)[0]}://") + ctx = ssl.create_default_context() + request = urllib.request.Request( + url, headers={"User-Agent": "harness-plugin-manager/1.0"} + ) + try: + with urllib.request.urlopen(request, timeout=timeout, context=ctx) as resp: + status = getattr(resp, "status", 200) + if status >= 400: + raise URLFetchError(f"Remote responded with HTTP {status}") + return resp.read().decode("utf-8", errors="replace") + except URLFetchError: + raise + except (urllib.error.URLError, ssl.SSLError, TimeoutError, OSError) as exc: + raise URLFetchError(f"Could not reach remote source ({type(exc).__name__})") from exc + + +_user_settings_cache: dict[str, Any] | None = None +_project_settings_cache: dict[str, Any] | None = None _DEFAULT_ENV_BLOCK = { "CODE_BASE_URL": "https://api.anthropic.com", @@ -23,36 +61,133 @@ } +def _user_settings_path() -> Path: + """User-level settings file: ~/.code/settings.json (authoritative global config).""" + return Path.home() / ".code" / "settings.json" + + +def _project_settings_path() -> Path: + """Project-level settings file: ./.code/settings.json (per-project overrides).""" + return Path(".code") / "settings.json" + + +def _atomic_write(path: Path, data: dict[str, Any]) -> None: + """Write JSON to disk atomically (temp file + replace).""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2), encoding="utf-8") + tmp.replace(path) + + +def load_user_settings() -> dict[str, Any]: + """Load the user-level ~/.code/settings.json, cached once per process. + + Auto-creates a default env template when missing. This is the authoritative + global config; the harness never pastes it into a project file. + """ + global _user_settings_cache + if _user_settings_cache is None: + path = _user_settings_path() + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as f: + _user_settings_cache = json.load(f) + except (json.JSONDecodeError, OSError): + _user_settings_cache = {"env": dict(_DEFAULT_ENV_BLOCK)} + else: + _user_settings_cache = {"env": dict(_DEFAULT_ENV_BLOCK)} + try: + _atomic_write(path, _user_settings_cache) + except OSError: + pass + return _user_settings_cache + + +def load_project_settings() -> dict[str, Any]: + """Load the project-level .code/settings.json, or {} when none exists. + + Per-project overrides are authored here (e.g. a single tool permission) and + overlaid on the user file at read time — never copied wholesale. + """ + global _project_settings_cache + if _project_settings_cache is None: + path = _project_settings_path() + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as f: + _project_settings_cache = json.load(f) + except (json.JSONDecodeError, OSError): + _project_settings_cache = {} + else: + _project_settings_cache = {} + return _project_settings_cache + + +def project_settings_active() -> bool: + """True once a project settings file exists (or has been created this process). + + Drives where programmatic writes land and which path save_settings_file uses. + """ + return _project_settings_cache is not None or _project_settings_path().exists() + + +def _merge_permissions(user: dict[str, Any], project: dict[str, Any]) -> dict[str, Any]: + """Union the project and user permission blocks. + + allow/deny lists combine; per-tool patterns merge; project defaultMode wins. + A tool the project explicitly allows or denies is governed by that project + rule, so it is removed from the merged ask/alwaysAsk prompts — ask is + evaluated before allow in PermissionScope.check, so leaving it in both + would keep prompting despite a project-level persist-allow. + """ + u = user.get("permissions") or {} + p = project.get("permissions") or {} + merged = dict(u) + bound = set(p.get("allow") or []) | set(p.get("deny") or []) + for key in ("allow", "deny"): + merged[key] = list(dict.fromkeys((u.get(key) or []) + (p.get(key) or []))) + for key in ("ask", "alwaysAsk"): + merged[key] = [ + tool + for tool in dict.fromkeys((u.get(key) or []) + (p.get(key) or [])) + if tool not in bound + ] + u_pat, p_pat = u.get("patterns") or {}, p.get("patterns") or {} + patterns = dict(u_pat) + for tool, plist in p_pat.items(): + patterns[tool] = list(dict.fromkeys((u_pat.get(tool) or []) + (plist or []))) + merged["patterns"] = patterns + if p.get("defaultMode"): + merged["defaultMode"] = p["defaultMode"] + return merged + + +def _merge_settings(user: dict[str, Any], project: dict[str, Any]) -> dict[str, Any]: + """Overlay project-level settings on the user-level file. + + Non-permission dict blocks (env, mcpServers, hooks, ...) merge per-key with + project winning; scalar/list top-level keys are replaced by project values. + """ + merged = dict(user) + for key, value in project.items(): + if key == "permissions": + merged[key] = _merge_permissions(user, project) + elif isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = {**merged[key], **value} + else: + merged[key] = value + return merged + + def load_settings_file() -> dict[str, Any]: - """Load entire settings.json file once at startup, cache it. + """Load settings merged from user + project level (cached per level). - Returns all fields (env, hooks, permissions, enabledPlugins, etc.) - dynamically. As user adds new fields over time, they work automatically. - Reads from disk only once per process; subsequent calls return cached copy. + Project-level .code/settings.json overlays the user-level ~/.code/settings.json + instead of replacing it, so a minimal project file (e.g. only a permissions + block) still inherits the user's env, MCP servers and hooks. Permission lists + are unioned, so a tool allowed or asked at either level applies. """ - global _settings_file_cache - if _settings_file_cache is not None: - return _settings_file_cache - - settings = get_settings() - settings_path = settings.get_settings_file_path() - - if settings_path.exists(): - try: - with open(settings_path, "r", encoding="utf-8") as f: - _settings_file_cache = json.load(f) - except (json.JSONDecodeError, OSError): - print("Line:44") - _settings_file_cache = {"env": dict(_DEFAULT_ENV_BLOCK)} - else: - _settings_file_cache = {"env": dict(_DEFAULT_ENV_BLOCK)} - print("Line:48") - try: - settings_path.write_text(json.dumps(_settings_file_cache, indent=2)) - except OSError: - pass - - return _settings_file_cache + return _merge_settings(load_user_settings(), load_project_settings()) def get_app_settings() -> dict[str, Any]: @@ -303,3 +438,68 @@ def export_env_from_settings() -> None: def get_settings() -> Settings: """Load and return settings singleton (cached for the process lifetime).""" return Settings() + + +def _writable_settings() -> dict[str, Any]: + """Return the cache dict programmatic writes should mutate. + + Project cache when a project settings file exists (or is being created), + else the user cache — so a write never dumps the merged user+project view + into a single file (the bug that pasted ~/.code/settings.json wholesale). + """ + if _project_settings_cache is not None or _project_settings_path().exists(): + return load_project_settings() + return load_user_settings() + + +def save_settings_file() -> None: + """Persist programmatically-authored settings to disk (atomic via temp file). + + Writes the project-level .code/settings.json once a project cache exists + (the harness authors per-project config), falling back to the user-level file + when no project settings has been created yet. The user file is never pasted + into the project file. + """ + if project_settings_active(): + _atomic_write(_project_settings_path(), load_project_settings()) + elif _user_settings_cache is not None: + _atomic_write(_user_settings_path(), _user_settings_cache) + + +def update_mcp_server(name: str, config: dict) -> None: + """Persist a new or updated MCP server entry to settings.json.""" + data = _writable_settings() + data.setdefault("mcpServers", {})[name] = config + save_settings_file() + + +def remove_mcp_server(name: str) -> bool: + """Remove an MCP server from settings.json. Returns False if not found.""" + data = _writable_settings() + servers = data.get("mcpServers") or {} + if name not in servers: + return False + del servers[name] + save_settings_file() + return True + + +def add_permission_allow(tool: str) -> None: + """Persist a tool as allowed in the PROJECT-level .code/settings.json. + + Writes only the tool's permission into the project file — never a copy of the + user-level settings (the old behavior pasted the whole ~/.code/settings.json + into the project, API keys included). The tool moves from ask/alwaysAsk to + allow so PermissionScope stops prompting for it in THIS project. Reads still + merge with the user file, so nothing user-authored is lost or duplicated. + """ + data = load_project_settings() + perms = data.setdefault("permissions", {}) + allow = perms.setdefault("allow", []) + if tool not in allow: + allow.append(tool) + for key in ("ask", "alwaysAsk"): + lst = perms.get(key) + if lst and tool in lst: + lst.remove(tool) + save_settings_file() diff --git a/src/harness/core/approval_manager.py b/src/harness/core/approval_manager.py index 0fe2399..6923716 100644 --- a/src/harness/core/approval_manager.py +++ b/src/harness/core/approval_manager.py @@ -180,19 +180,46 @@ async def check_and_execute_once( return (False, executed.result_json, executed.error) return (True, executed.result_json, None) - # Execute the tool - try: - result = await executor_fn(idempotency_key) + # Optimistic insert: claim ownership before calling the executor. + # A second concurrent caller that also passed the idempotency check above will + # hit the unique constraint here and be routed to the "already executed" branch, + # preventing double-execution without any explicit distributed lock. + from sqlalchemy.exc import IntegrityError - # Record execution in ledger + try: async with get_session() as db_session: - executed_action = ExecutedAction( + placeholder = ExecutedAction( idempotency_key=idempotency_key, task_id=task_id, - result_json=result if isinstance(result, dict) else {"result": str(result)}, + result_json=None, + error=None, executed_at=datetime.now(), ) - db_session.add(executed_action) + db_session.add(placeholder) + await db_session.commit() + except IntegrityError: + # Another concurrent caller inserted first — re-read their result. + async with get_session() as db_session: + executed = await db_session.get(ExecutedAction, idempotency_key) + if executed is not None: + if executed.error: + return (False, executed.result_json, executed.error) + # If result_json is still None the other caller is mid-execution; treat + # as "pending" so the loop can retry later rather than silently succeed. + if executed.result_json is None: + return (False, None, "Concurrent execution in progress; retry later") + return (True, executed.result_json, None) + return (False, None, "Concurrent execution detected; result unavailable") + + # We own the execution — run the tool and update the placeholder with the outcome. + try: + result = await executor_fn(idempotency_key) + result_json = result if isinstance(result, dict) else {"result": str(result)} + + async with get_session() as db_session: + record = await db_session.get(ExecutedAction, idempotency_key) + if record is not None: + record.result_json = result_json await db_session.commit() logger.info( @@ -205,15 +232,10 @@ async def check_and_execute_once( except Exception as e: error_msg = str(e) - # Record failure in ledger async with get_session() as db_session: - executed_action = ExecutedAction( - idempotency_key=idempotency_key, - task_id=task_id, - error=error_msg, - executed_at=datetime.now(), - ) - db_session.add(executed_action) + record = await db_session.get(ExecutedAction, idempotency_key) + if record is not None: + record.error = error_msg await db_session.commit() logger.error( diff --git a/src/harness/core/approval_policy.py b/src/harness/core/approval_policy.py index b36864c..caba0e8 100644 --- a/src/harness/core/approval_policy.py +++ b/src/harness/core/approval_policy.py @@ -1,6 +1,14 @@ -"""Approval policy: session grants and persisted rules for tool approval decisions.""" +"""Approval policy: session grants, session denials, and persisted allow rules. + +Single source of truth for the human-in-the-loop decision state. Every grant, +deny and check routes through ``_toolkey`` so a ``ToolType`` enum member and its +plain string (e.g. ``ToolType.BASH`` and ``"Bash"``) always produce the same +key — otherwise ``str(ToolType.BASH) == "ToolType.BASH"`` silently diverges and +an approval can never be matched again. +""" import json +from enum import Enum from datetime import datetime from typing import Any, Optional import structlog @@ -10,22 +18,102 @@ logger = structlog.get_logger(__name__) -# Session-level grants: set of f"{tool}:{fingerprint}" that have been approved for this session. +def _toolkey(tool) -> str: + """Normalize a tool identity to its plain string (enum -> value).""" + return tool.value if isinstance(tool, Enum) else str(tool) + + +# Session-level grants: set of f"{tool}:{fingerprint}" approved for this session. _session_grants: set[str] = set() +# Session-level denials: set of f"{tool}:{fingerprint}" denied for this session. +_session_denials: set[str] = set() +# One-call grants/denials: f"{tool}:{exact_fingerprint}" — match only the exact call. +_once_grants: set[str] = set() +_once_denials: set[str] = set() + + +# ── Fingerprints (single source of truth, used by gates AND grant handlers) ── + +def coarse_fingerprint(tool: str, resource: str = "") -> str: + """Session-level fingerprint: Bash by first token, file tools by parent dir, + everything else (Skill, Task*, MCP, ...) whole-tool ("").""" + t = _toolkey(tool) + if t == "Bash": + return fingerprint_bash(resource) + if t in ("Read", "Write", "Update", "Edit", "Grep", "Glob"): + return fingerprint_file(resource) + return "" +def exact_fingerprint(tool: str, resource: str = "") -> str: + """One-call fingerprint: the exact command/path, whole-tool otherwise.""" + t = _toolkey(tool) + if t == "Bash": + return (resource or "").strip() + if t in ("Read", "Write", "Update", "Edit", "Grep", "Glob"): + return resource or "" + return "" + + +# ── Grant / deny actions ──────────────────────────────────────────────────── + def grant_session(tool: str, fingerprint: str) -> None: """Grant approval for this session (option [A]).""" - key = f"{tool}:{fingerprint}" - _session_grants.add(key) - logger.info("Session grant issued", tool=tool, fingerprint=fingerprint) + _session_grants.add(f"{_toolkey(tool)}:{fingerprint}") + logger.info("Session grant issued", tool=_toolkey(tool), fingerprint=fingerprint) + + +def grant_once(tool: str, resource: str) -> None: + """Approve exactly this one call (option [Y]).""" + _once_grants.add(f"{_toolkey(tool)}:{exact_fingerprint(tool, resource)}") + logger.info("One-call grant issued", tool=_toolkey(tool), resource=resource) + +def deny_session(tool: str, resource: str) -> None: + """Deny for this session (option [S]) — suppress further prompts.""" + _session_denials.add(f"{_toolkey(tool)}:{coarse_fingerprint(tool, resource)}") + logger.info("Session denial issued", tool=_toolkey(tool), resource=resource) + + +def deny_once(tool: str, resource: str) -> None: + """Deny exactly this one call (option [N]).""" + _once_denials.add(f"{_toolkey(tool)}:{exact_fingerprint(tool, resource)}") + logger.info("One-call denial issued", tool=_toolkey(tool), resource=resource) + + +def persist_allow(tool: str) -> None: + """Persist a tool as allowed in the project-level .code/settings.json + (option [P]) so future sessions in this project stop prompting for it.""" + from harness.config import add_permission_allow + add_permission_allow(_toolkey(tool)) + + +# ── Checks ────────────────────────────────────────────────────────────────── def is_granted_session(tool: str, fingerprint: str) -> bool: """Check if tool+fingerprint is granted for this session.""" - key = f"{tool}:{fingerprint}" - return key in _session_grants + return f"{_toolkey(tool)}:{fingerprint}" in _session_grants + + +def is_granted(tool: str, resource: str = "") -> bool: + """True if this tool+resource was approved (session-wide or for this exact call).""" + t = _toolkey(tool) + return ( + f"{t}:{coarse_fingerprint(t, resource)}" in _session_grants + or f"{t}:{exact_fingerprint(t, resource)}" in _once_grants + ) + + +def is_denied(tool: str, resource: str = "") -> bool: + """True if this tool+resource was denied (session-wide or for this exact call).""" + t = _toolkey(tool) + return ( + f"{t}:{coarse_fingerprint(t, resource)}" in _session_denials + or f"{t}:{exact_fingerprint(t, resource)}" in _once_denials + ) + +# ── Persisted rules (DB-backed, kept for compatibility; UI uses settings.json) ── async def grant_persisted( tool: str, @@ -33,24 +121,25 @@ async def grant_persisted( decision: str = "allow", user_id: str = "local", ) -> None: - """Save approval rule to user preferences (option [P]).""" + """Save approval rule to user preferences (legacy path).""" rules = await get_approval_rules(user_id) rules.append({ - "tool": tool, + "tool": _toolkey(tool), "pattern": fingerprint, "decision": decision, "at": datetime.now().isoformat(), }) rule_json = json.dumps(rules) await set_preference(user_id, "approval_rules", rule_json, source="user") - logger.info("Persisted approval rule added", tool=tool, pattern=fingerprint) + logger.info("Persisted approval rule added", tool=_toolkey(tool), pattern=fingerprint) async def is_granted_persisted(tool: str, fingerprint: str, user_id: str = "local") -> bool: - """Check if tool+fingerprint is in persisted rules.""" + """Check if tool+fingerprint is in persisted rules (legacy path).""" rules = await get_approval_rules(user_id) + t = _toolkey(tool) for rule in rules: - if rule.get("tool") == tool and rule.get("pattern") == fingerprint: + if rule.get("tool") == t and rule.get("pattern") == fingerprint: return rule.get("decision") == "allow" return False @@ -88,7 +177,10 @@ def fingerprint_file(path: str) -> str: def clear_session_grants() -> None: - """Clear all session grants (e.g., on restart or logout).""" - global _session_grants + """Clear all session grants/denials (e.g., on restart or logout).""" + global _session_grants, _session_denials, _once_grants, _once_denials _session_grants.clear() - logger.info("Session grants cleared") + _session_denials.clear() + _once_grants.clear() + _once_denials.clear() + logger.info("Session approval state cleared") diff --git a/src/harness/core/risk.py b/src/harness/core/risk.py index 7066e12..01a73c6 100644 --- a/src/harness/core/risk.py +++ b/src/harness/core/risk.py @@ -34,6 +34,12 @@ def classify_risk( "low" or "high" risk level. """ try: + # Fail closed: a dynamically registered MCP tool is third-party code we + # haven't vetted — always route it through the approval gate unless the + # operator explicitly allows it in permissions. + if isinstance(tool_type, str) and tool_type.startswith("mcp__"): + return "high" + if tool_type == ToolType.BASH: command = args.get("command", "").strip() if not command: diff --git a/src/harness/main.py b/src/harness/main.py index 968b750..06c9dee 100644 --- a/src/harness/main.py +++ b/src/harness/main.py @@ -1,13 +1,14 @@ """CLI entry point using Typer.""" import asyncio +import sys from pathlib import Path from typing import Optional # Typer is a library that turns Python functions into CLI commands import typer from rich.console import Console -from harness.config import get_settings +from harness.config import get_settings, update_mcp_server, remove_mcp_server, load_settings_file from harness.logging import configure_logging, get_logger from harness.core.task_manager import TaskStateManager from harness.core.loop import LoopController @@ -21,13 +22,25 @@ def main() -> None: """Main entry point - always launch UI with optional auto-execution.""" - import sys + # Windows legacy consoles (cp1252) cannot encode ✓/✗ used throughout the CLI. + # Force UTF-8 so Rich renders them everywhere (CI, pipes, and ttys alike). + try: + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + except (AttributeError, OSError): + pass + settings = get_settings() configure_logging(settings.log_level) # Parse CLI args to extract command info (if any) command_info = _parse_command_args(sys.argv[1:]) + # Non-interactive commands that exit without launching the app + if command_info and command_info["command"] in {"mcp", "plugin"}: + _run_registry_command(command_info) + return + # Always launch the app (with optional command to auto-execute) app_instance = HarnessApp(auto_command=command_info) @@ -52,6 +65,67 @@ def _parse_command_args(args: list[str]) -> Optional[dict]: command = args[0] + if command == "mcp": + # subcommands: add NAME --command CMD [--args ...] [--env K=V ...], remove NAME, list + action = args[1] if len(args) > 1 else "list" + if action == "add" and len(args) >= 2: + name = args[2] if len(args) > 2 else None + sub = {} + i = 3 + while i < len(args): + if args[i] == "--command" and i + 1 < len(args): + sub["command"] = args[i + 1] + elif args[i] == "--url" and i + 1 < len(args): + sub["url"] = args[i + 1] + elif args[i] == "--args" and i + 1 < len(args): + sub["args"] = args[i + 1].split(",") + elif args[i] == "--env" and i + 1 < len(args): + env = {} + for pair in args[i + 1].split(","): + if "=" in pair: + k, v = pair.split("=", 1) + env[k.strip()] = v.strip() + sub["env"] = env + i += 1 + if name and ("command" in sub or "url" in sub): + return {"command": "mcp", "action": "add", "name": name, "config": sub} + elif action == "remove" and len(args) > 2: + return {"command": "mcp", "action": "remove", "name": args[2]} + return {"command": "mcp", "action": action} + + elif command == "plugin": + action = args[1] if len(args) > 1 else "list" + if action == "marketplace": + sub = args[2] if len(args) > 2 else "list" + if sub == "add" and len(args) > 3: + source = args[3] + alias = None + i = 4 + while i < len(args): + if args[i] == "--alias" and i + 1 < len(args): + alias = args[i + 1] + i += 2 + else: + i += 1 + return { + "command": "plugin", + "action": "marketplace", + "sub": sub, + "target": source, + "alias": alias, + } + if sub == "remove" and len(args) > 3: + return { + "command": "plugin", + "action": "marketplace", + "sub": sub, + "target": args[3], + } + return {"command": "plugin", "action": "marketplace", "sub": sub} + if action in {"install", "uninstall"} and len(args) > 2: + return {"command": "plugin", "action": action, "target": args[2]} + return {"command": "plugin", "action": action} + if command == "run": task_desc = None max_iter = 10 @@ -117,6 +191,148 @@ def _parse_command_args(args: list[str]) -> Optional[dict]: return None +def _run_registry_command(command_info: dict) -> None: + """Handle `harness mcp ...` and `harness plugin ...` without launching the TUI.""" + command = command_info["command"] + + if command == "mcp": + action = command_info.get("action", "list") + if action == "add": + name = command_info.get("name") + config = command_info.get("config") + if not name or not config: + console.print("[yellow]Usage:[/yellow] harness mcp add --command [--args a,b] [--env K=V]") + console.print("[yellow] or:[/yellow] harness mcp add --url ") + return + update_mcp_server(name, config) + console.print( + f"[bold green]✓[/bold green] MCP server " + f"[bold]{command_info['name']}[/bold] registered in settings.json" + ) + console.print(" Restart the harness (or plugin install) to load it.") + elif action == "remove": + removed = remove_mcp_server(command_info["name"]) + if removed: + console.print( + f"[bold green]✓[/bold green] MCP server " + f"[bold]{command_info['name']}[/bold] removed from settings.json" + ) + else: + console.print( + f"[red]✗[/red] MCP server [bold]{command_info['name']}[/bold] not found" + ) + else: # list + servers = load_settings_file().get("mcpServers", {}) or {} + console.print("[bold]Configured MCP Servers:[/bold]") + if not servers: + console.print(" (none)") + for name, cfg in servers.items(): + transport = cfg.get("command") or cfg.get("url") or "?" + console.print(f" - {name} ({transport})") + + elif command == "plugin": + action = command_info.get("action", "list") + + if action == "marketplace": + _plugin_marketplace_command(command_info) + return + + from harness.plugins import PluginInstaller + + installer = PluginInstaller() + + if action == "install": + target = command_info["target"] + from_marketplace = "@" in target and not Path(target).exists() + try: + if from_marketplace: + name, _, alias = target.partition("@") + record = installer.install_from_marketplace(name, alias) + else: + record = installer.install(Path(target)) + except ValueError as exc: + console.print(f"[red]✗[/red] {exc}") + return + console.print( + f"[bold green]✓[/bold green] Plugin [bold]{record.name}[/bold] " + f"v{record.version} installed" + ) + if record.agent_names: + console.print(f" Agents: {', '.join(record.agent_names)}") + if record.skill_names: + console.print(f" Skills: {', '.join(record.skill_names)}") + if record.command_names: + console.print(f" Commands: {', '.join(record.command_names)}") + if record.instruction_names: + console.print(f" Instructions: {', '.join(record.instruction_names)}") + if record.rule_names: + console.print(f" Rules: {', '.join(record.rule_names)}") + if record.mcp_server_names: + console.print(f" MCP servers: {', '.join(record.mcp_server_names)}") + console.print(" Restart the harness to load the plugin.") + elif action == "uninstall": + removed = installer.uninstall(command_info["target"]) + if removed: + console.print( + f"[bold green]✓[/bold green] Plugin " + f"[bold]{command_info['target']}[/bold] uninstalled" + ) + else: + console.print( + f"[red]✗[/red] Plugin [bold]{command_info['target']}[/bold] not installed" + ) + else: # list + records = installer.list_installed() + console.print("[bold]Installed Plugins:[/bold]") + if not records: + console.print(" (none)") + for rec in records: + counts = ( + f"({len(rec.agent_names)}a {len(rec.skill_names)}s " + f"{len(rec.command_names)}c {len(rec.instruction_names)}i " + f"{len(rec.rule_names)}r {len(rec.mcp_server_names)}m)" + ) + console.print(f" - {rec.name} v{rec.version} {counts}") + + +def _plugin_marketplace_command(command_info: dict) -> None: + """Handle `harness plugin marketplace add/remove/list`.""" + from harness.config import URLFetchError + from harness.plugins.marketplace import MarketplaceRegistry + + registry = MarketplaceRegistry() + sub = command_info.get("sub", "list") + + if sub == "add": + try: + record = registry.add(command_info["target"], command_info.get("alias")) + except (ValueError, URLFetchError) as exc: + console.print(f"[red]✗[/red] Could not register marketplace: {exc}") + return + console.print( + f"[bold green]✓[/bold green] Marketplace [bold]{record.name}[/bold] " + f"registered (cloned to {record.path})" + ) + elif sub == "remove": + removed = registry.remove(command_info["target"]) + if removed: + console.print( + f"[bold green]✓[/bold green] Marketplace " + f"[bold]{command_info['target']}[/bold] removed" + ) + else: + console.print( + f"[red]✗[/red] Marketplace [bold]{command_info['target']}[/bold] not found" + ) + else: # list + records = registry.list() + console.print("[bold]Registered Marketplaces:[/bold]") + if not records: + console.print(" (none)") + for rec in records: + console.print(f" - {rec.name} ({rec.path})") + + @app.command() def run( task_description: str = typer.Option(..., "--task", "-t", help="Task description"), diff --git a/src/harness/orchestration/orchestrator.py b/src/harness/orchestration/orchestrator.py index 42c6bd9..27694dd 100644 --- a/src/harness/orchestration/orchestrator.py +++ b/src/harness/orchestration/orchestrator.py @@ -12,10 +12,12 @@ from harness.context.project_context import load_project_context from harness.registry.definitions import AgentRegistry, SkillRegistry, ensure_seed_agents from harness.tools.permissions import PermissionScope +from harness.tools.mcp_manager import MCPManager from harness.ui import TerminalUI, StreamListener, LogEntry, LogLevel from harness.config import get_settings from harness.memory import build_briefing from harness.persistence.session import SessionManager +from harness.plugins.loader import collect_plugin_commands, load_installed_plugins from .agent import AgentConfig, AgentType from .spawner import AgentSpawner from .llm_client import LLMClient @@ -45,6 +47,10 @@ def __init__(self, ui: Optional[TerminalUI] = None): self.agent_registry.scan() self.skill_registry.scan() + # MCP manager — owns all configured + dynamically-added servers. + # Loaded from settings.json in ensure_session(); supports hot-plug via register_mcp_server(). + self.mcp_manager = MCPManager() + # Build shared LLMClient from settings llm_client = LLMClient() @@ -59,6 +65,7 @@ def __init__(self, ui: Optional[TerminalUI] = None): stream_listener=self.stream_listener, approval_callback=self.ui.request_approval if self.ui else None, ask_user_question_callback=self.ui.ask_user_question_callback if self.ui else None, + mcp_manager=self.mcp_manager, ) # Session management and briefing @@ -75,6 +82,27 @@ async def ensure_session(self) -> str: settings = get_settings() user_id = settings.user_id + # Mount installed plugins into the agent/skill registries (namespaced + # by marketplace alias) so their agents/skills reach the LLM roster as + # `-`. No MCP reconciliation needed here — servers + # are merged into settings.json at install time. + load_installed_plugins(self.agent_registry, self.skill_registry) + + # Mount installed plugin slash-commands into the UI palette so they show + # up under Ctrl+K and run on demand (bodies read lazily at invoke time). + if self.ui is not None: + self.ui.mount_plugin_commands(collect_plugin_commands()) + + # Load MCP servers from settings.json and connect them (non-blocking: degraded + # servers are skipped so a slow or offline server never delays startup). + if not self.mcp_manager.started: + await self.mcp_manager.load_from_settings() + await self.mcp_manager.start() + logger.info( + "MCP servers loaded", + healthy=[p.name for p in self.mcp_manager.healthy()], + ) + # Create session in database self.session_id = await self._session_manager.create_session( user_id=user_id, @@ -88,6 +116,46 @@ async def ensure_session(self) -> str: logger.info(f"Session created: {self.session_id}", briefing_chars=len(self.briefing_text or "")) return self.session_id + async def register_mcp_server(self, name: str, config: dict) -> None: + """Hot-plug a new MCP server at runtime (e.g. from marketplace install). + + The server starts immediately; subsequent spawns automatically include its + tools in their router (mcp_manager is shared via AgentSpawner reference). + No restart required. + + config format (same as settings.json mcpServers block): + stdio: {"command": "npx", "args": ["@org/mcp-server"], "env": {}} + http: {"url": "https://server/mcp", "headers": {"Authorization": "Bearer ..."}} + """ + await self.mcp_manager.add(name, config) + logger.info("MCP server registered (hot-plugged)", name=name, tools=len( + [t for t in self.mcp_manager.all_tools() if t.name.startswith(f"mcp__{name}__")] + )) + + async def deregister_mcp_server(self, name: str) -> None: + """Remove an MCP server at runtime (e.g. marketplace uninstall). + + The server's tools are removed from the next spawn's router immediately. + In-flight agent runs finish with the previous router snapshot (isolated per-spawn). + """ + await self.mcp_manager.remove(name) + logger.info("MCP server deregistered", name=name) + + def reload_registries(self) -> None: + """Rescan agent and skill directories after a marketplace install/uninstall. + + Drop new agent/skill markdown files into ~/.code/agents/ or ~/.code/skills/ + (or the project-level .code/ equivalents), then call this — subsequent spawns + immediately see the new roster. No restart required. + """ + self.agent_registry.scan() + self.skill_registry.scan() + logger.info( + "Registries reloaded", + agents=len(self.agent_registry.list_agents()), + skills=len(self.skill_registry.list_skills()), + ) + def compose_capsule(self, agent_result) -> dict: """Compose token-efficient capsule from agent result. @@ -156,14 +224,16 @@ def _build_main_agent_config( prior_turns=list(self.turns), ) - async def chat(self, prompt: str, on_text_delta=None): + async def chat(self, prompt: str, on_text_delta=None, on_turn_end=None): """Handle an interactive chat prompt through the main orchestrator agent. Routes chat through the same tool-calling agent path as tasks so the model can actually read/write/run commands and delegate to sub-agents. """ config = self._build_main_agent_config(prompt) - result = await self.agent_spawner.spawn(config, on_text_delta=on_text_delta) + result = await self.agent_spawner.spawn( + config, on_text_delta=on_text_delta, on_turn_end=on_turn_end + ) self._record_turn(prompt, result) return result diff --git a/src/harness/orchestration/spawner.py b/src/harness/orchestration/spawner.py index cf0e4d1..609730e 100644 --- a/src/harness/orchestration/spawner.py +++ b/src/harness/orchestration/spawner.py @@ -1,6 +1,7 @@ """Agent spawner with multi-LLM fallback support and real tool-calling loop.""" import asyncio +import time from typing import Optional, Callable, TYPE_CHECKING from datetime import datetime import platform @@ -11,12 +12,13 @@ from .agent import AgentConfig, AgentResult, AgentStatus, AgentType from .llm_client import LLMClient, TextDelta, ToolCallsReady, StreamDone from harness.core.verifier import resolve_verifier -from harness.tools.definitions import get_tools_payload, validate_args, TOOL_REGISTRY +from harness.tools.definitions import get_tools_payload, resolve_tool_definition, validate_call_args from harness.tools.factory import build_scoped_router from harness.tools.executor import ToolExecutor +from harness.tools.models import ToolStatus from harness.tools.output_cap import cap_output from harness.config import get_settings -from pydantic import ValidationError +from harness.plugins.loader import collect_plugin_catalog if TYPE_CHECKING: from harness.ui.stream_listener import StreamListener @@ -110,9 +112,45 @@ def _compose_system_message(config: "AgentConfig", settings=None) -> str: {skill_lines} """) + # Installed plugins: cheap state-file counts (no disk scan). Tells the + # orchestrator which plugins' commands/instructions/rules it can pull + # on demand via the PluginContext tool. + plugins = collect_plugin_catalog() + if plugins: + plugin_lines = "\n".join( + f"- {p['name']}: {p['commands']} commands, " + f"{p['instructions']} instructions, {p['rules']} rules" + for p in plugins + ) + parts.append(f""" +{plugin_lines} +""") + return "\n\n".join(parts) +async def _render_pending_tasks(session_id: str) -> Optional[str]: + """Render this session's unfinished tasks, or None if there are none. + + Without this the model only sees tasks it created in the current turn, so on + the next prompt it reports nothing pending even though the rows exist. + """ + from sqlalchemy import select + from harness.persistence.database import get_session + from harness.persistence.models import UserTask + + async with get_session() as db: + rows = (await db.execute( + select(UserTask) + .where(UserTask.session_id == session_id, UserTask.status != "completed") + .order_by(UserTask.created_at) + )).scalars().all() + if not rows: + return None + lines = "\n".join(f"- [{r.status}] {r.subject} (id: {r.id})" for r in rows) + return f"\n{lines}\n" + + def _compose_post_task_reassertion(config: "AgentConfig") -> Optional[str]: """Render the POST_TASK re-assertion for this agent, or None if none applies. @@ -175,6 +213,7 @@ def __init__( stream_listener: Optional["StreamListener"] = None, approval_callback: Optional[Callable] = None, ask_user_question_callback: Optional[Callable] = None, + mcp_manager=None, ): self.llm_client = llm_client or LLMClient() self.max_parallel_agents = max_parallel_agents or get_settings().max_parallel_agents @@ -182,16 +221,23 @@ def __init__( self.stream_listener = stream_listener self.approval_callback = approval_callback self.ask_user_question_callback = ask_user_question_callback + self.mcp_manager = mcp_manager self.results = {} async def spawn( - self, config: AgentConfig, on_text_delta: Optional[Callable[[str], None]] = None + self, + config: AgentConfig, + on_text_delta: Optional[Callable[[str], None]] = None, + on_turn_end: Optional[Callable[[], None]] = None, ) -> AgentResult: """Spawn agent and execute task with real LLM tool-calling loop. Args: config: Agent configuration with task description and permissions. on_text_delta: Optional callback to stream text deltas to UI. + on_turn_end: Optional callback fired when a turn ends without a tool + call, so the UI can close the open text block. Without it a + verifier-rejected turn streams into the previous turn's block. Returns: AgentResult with status, output, and token usage. @@ -245,6 +291,8 @@ async def spawn( spawn_fn=self.spawn, parent_config=config, ask_user_question_callback=self.ask_user_question_callback, + mcp_manager=self.mcp_manager, + skill_registry=config.skill_registry, ) executor = ToolExecutor( router, @@ -278,6 +326,17 @@ async def spawn( except Exception as mem_err: logger.debug("Memory injection skipped", error=str(mem_err)) + # Pending tasks from earlier prompts in this session — the model has no + # other way to know they exist. Best-effort: a DB hiccup must not block spawn. + session_id = (config.context or {}).get("session_id") + if system_content and session_id: + try: + tasks_block = await _render_pending_tasks(session_id) + if tasks_block: + system_content = f"{system_content}\n\n{tasks_block}" + except Exception as task_err: + logger.debug("Pending-task injection skipped", error=str(task_err)) + if system_content: messages.append({"role": "system", "content": system_content}) @@ -296,11 +355,38 @@ async def spawn( reassertion = _compose_post_task_reassertion(config) reassertion_injected = False + # Per-run lifetime budget — a wall-clock cap on the WHOLE agent run. + # This is the knob that now governs sub-agents (they bypass the per- + # tool I/O timeout): a 10-20 min task runs to completion, but an + # unbounded run is a leak. 0 = no cap. + deadline = None + if config.timeout_seconds > 0: + deadline = time.monotonic() + config.timeout_seconds + + # Stall guard: N consecutive turns repeating an identical tool plan + # (same tools, same args) means the agent is spinning, not advancing. + # The wall clock bounds total time; this exits a spin far earlier so + # the budget isn't burned on one repeated loop. + stall_limit = settings.no_progress_limit or 4 + prev_plan = None + stall_streak = 0 + # Tool-calling loop for iteration in range(config.max_tool_iterations): result.status = AgentStatus.THINKING logger.debug(f"Iteration {iteration + 1}/{config.max_tool_iterations}") + # Wall-clock guard: stop cleanly (FAILED, not a crash) once the + # run exceeds its budget, before triggering another LLM call. + if deadline is not None and time.monotonic() >= deadline: + result.status = AgentStatus.FAILED + result.error = ( + f"Agent wall-clock budget exceeded ({config.timeout_seconds}s, " + f"{result.iterations} iterations) — stopped to avoid an unbounded run." + ) + logger.warning(result.error) + break + # Before the last permitted turn, re-assert the objective once so # the model closes on its pinned goal rather than drifting. if ( @@ -418,6 +504,31 @@ async def _bounded(tc): result.iterations += 1 + # Stall guard — an identical consecutive tool plan (same tools, + # same serialized args) means the agent is spinning, not + # progressing. Reset on any change; fail once the streak holds. + plan = tuple(sorted( + (tc.name, ( + json.dumps(tc.arguments, sort_keys=True) + if isinstance(tc.arguments, (str, dict)) + else str(tc.arguments) + )) + for tc in tool_calls_this_turn + )) + if plan == prev_plan: + stall_streak += 1 + else: + prev_plan = plan + stall_streak = 0 + if stall_limit and stall_streak >= stall_limit: + result.status = AgentStatus.FAILED + result.error = ( + f"Agent stalled: identical tool plan repeated " + f"{stall_streak} consecutive turns — halting a spin." + ) + logger.warning(result.error) + break + # Check token budget if config.tool_token_budget and result.tokens_used >= config.tool_token_budget: result.status = AgentStatus.FAILED @@ -471,6 +582,8 @@ async def _bounded(tc): ), }) result.iterations += 1 + if on_turn_end: + on_turn_end() continue result.status = AgentStatus.COMPLETED @@ -607,30 +720,36 @@ async def _execute_tool_call( ) try: - # Map name to ToolType - tool_type = None - for tt, defn in TOOL_REGISTRY.items(): - if defn.name == tool_call.name: - tool_type = tt - break - - if tool_type is None: + # Resolve by LLM-facing name — covers built-ins AND MCP tools. + definition = resolve_tool_definition(tool_call.name, executor.router) + if definition is None: raise ValueError(f"Unknown tool: {tool_call.name}") - # Tier 0/1: Validate arguments + # Tier 0/1: Validate arguments (pydantic for built-ins, jsonschema for MCP) try: - validated_args = validate_args(tool_type, tool_call.arguments) - except ValidationError as ve: + raw = tool_call.arguments + if isinstance(raw, str): + raw = json.loads(raw) + if not isinstance(raw, dict): + raw = {} + validated_args = validate_call_args(tool_call.name, definition, raw) + except Exception as ve: tool_error = f"Invalid arguments for {tool_call.name}: {str(ve)}" logger.warning(tool_error) else: # Tier 2–4: Execute tool (permission/circuit-breaker handled inside) - tool_result = await executor.execute( - tool_type, **validated_args.model_dump() - ) + tool_result = await executor.execute(tool_call.name, **validated_args) tokens = tool_result.tool_call.tokens_used - if not tool_result.tool_call.success: + if tool_result.tool_call.status == ToolStatus.AWAITING_APPROVAL: + # Parked for human approval — not executed. Tell the model clearly + # so it stops retrying and waits for the user instead of treating + # this as a transient failure. + tool_error = ( + f"Tool '{tool_call.name}' is pending human approval — it was NOT " + "executed. Ask the user to approve it before retrying." + ) + elif not tool_result.tool_call.success: tool_error = tool_result.tool_call.error except Exception as e: diff --git a/src/harness/persistence/models.py b/src/harness/persistence/models.py index c779b54..a55dc43 100644 --- a/src/harness/persistence/models.py +++ b/src/harness/persistence/models.py @@ -209,6 +209,31 @@ class PendingQuestion(Base): answered_at = Column(DateTime, nullable=True) +class UserTask(Base): + """A to-do item created by the LLM via TaskCreate. + + Distinct from `Task`, which records agent-loop execution state. This table is + the user-facing checklist and is keyed by session so a task created on one + prompt is still pending on the next. + """ + __tablename__ = "user_tasks" + + id = Column(String(36), primary_key=True, index=True) + session_id = Column(String(36), nullable=False, index=True) + subject = Column(Text, nullable=False) + description = Column(Text, default="") + active_form = Column(Text, default="") + status = Column(String(50), default="pending", index=True) # pending, in_progress, completed + metadata_json = Column(JSON, default={}) + + created_at = Column(DateTime, default=datetime.now, index=True) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + __table_args__ = ( + Index('ix_usertask_session_status', 'session_id', 'status'), + ) + + class Analytics(Base): """System analytics and learning metrics.""" __tablename__ = "analytics" diff --git a/src/harness/plugins/__init__.py b/src/harness/plugins/__init__.py new file mode 100644 index 0000000..30e447f --- /dev/null +++ b/src/harness/plugins/__init__.py @@ -0,0 +1,6 @@ +"""Plugin marketplace support — install/uninstall bundles of agents, skills, and MCP servers.""" + +from .installer import PluginInstaller +from .models import PluginManifest, InstalledPlugin + +__all__ = ["PluginInstaller", "PluginManifest", "InstalledPlugin"] diff --git a/src/harness/plugins/downloader.py b/src/harness/plugins/downloader.py new file mode 100644 index 0000000..aff3933 --- /dev/null +++ b/src/harness/plugins/downloader.py @@ -0,0 +1,113 @@ +"""Secure download/extraction of remote plugin assets. + +All network access is HTTPS-only, runs with a hard timeout, uses +``subprocess`` list-args (never shell), and validates every extracted path +against path traversal before writing. +""" + +import subprocess +import tarfile +import tempfile +from pathlib import Path + +from harness.config import URLFetchError, http_get_string, is_url + +# Substrings that never belong in a remote-controlled path/URL segment. +_FORBIDDEN = ("..", "~", "\\", "\x00", "\n", "\r") +# Shell metacharacters — reject anything that could be reinterpreted if the +# value ever reached a command line (it never does, but defense in depth). +_SHELL_META = (";", "&", "|", "`", "$", ">", "<", '"', "'", "*", "?") + + +def validate_plugin_ref(ref: str) -> None: + """Reject obviously malicious plugin source strings. + + Raises :class:`ValueError` if the reference contains path traversal, + shell metacharacters, or control characters. Anything that survives this + guard is passed only as a single subprocess argument (list form), never + through a shell. + """ + if any(seg in ref for seg in _FORBIDDEN): + raise ValueError("Plugin source contains forbidden path characters") + if any(c in ref for c in _SHELL_META): + raise ValueError("Plugin source contains shell metacharacters") + + +def validate_https_url(url: str) -> None: + """Reject non-HTTPS or clearly malformed URLs early.""" + if not is_url(url): + raise URLFetchError(f"Unsupported URL scheme: {url.split(':', 1)[0]}://") + validate_plugin_ref(url) + + +def _safe_dest(dest: Path) -> Path: + """Resolve dest to an absolute path (no CWD-relative surprises).""" + return dest.expanduser().resolve() + + +def fetch_text(url: str, timeout: int = 10) -> str: + """Fetch a UTF-8 text file (e.g. marketplace.json) over HTTPS.""" + validate_https_url(url) + return http_get_string(url, timeout=timeout) + + +def git_clone(url: str, dest: Path, timeout: int = 60) -> None: + """Shallow-clone a git repo into ``dest`` (list-args, never shell).""" + validate_https_url(url) + dest = _safe_dest(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + cmd = ["git", "clone", "--depth", "1", "--quiet", "--", url, str(dest)] + try: + proc = subprocess.run(cmd, capture_output=True, timeout=timeout, text=True) + except subprocess.TimeoutExpired: + raise URLFetchError(f"git clone timed out after {timeout}s") from None + if proc.returncode != 0: + msg = (proc.stderr or "unknown error").strip().splitlines() + raise URLFetchError(f"git clone failed: {msg[-1] if msg else 'unknown error'}") + + +def download_tarball(url: str, dest: Path, timeout: int = 60) -> None: + """Download a .tar.gz over HTTPS and extract it into ``dest`` safely.""" + import io + import ssl + import urllib.request + + validate_https_url(url) + dest = _safe_dest(dest) + dest.mkdir(parents=True, exist_ok=True) + + ctx = ssl.create_default_context() + request = urllib.request.Request( + url, headers={"User-Agent": "harness-plugin-manager/1.0"} + ) + try: + with urllib.request.urlopen(request, timeout=timeout, context=ctx) as resp: + data = resp.read() + except (urllib.error.URLError, ssl.SSLError, TimeoutError, OSError) as exc: + raise URLFetchError( + f"Could not download archive ({type(exc).__name__})" + ) from exc + + _extract_tar_safe(io.BytesIO(data), dest) + + +def _extract_tar_safe(stream, dest: Path) -> None: + """Extract a tar stream, rejecting any path that escapes ``dest``.""" + dest = _safe_dest(dest) + with tarfile.open(fileobj=stream, mode="r:gz") as tar: + for member in tar.getmembers(): + target = (dest / member.name).resolve() + if not target.is_relative_to(dest): + raise URLFetchError( + f"Archive contains path traversal: {member.name!r}" + ) + if member.issym() or member.islnk(): + raise URLFetchError( + f"Archive contains a symlink: {member.name!r}" + ) + tar.extractall(dest, filter="data") + + +def temp_workdir() -> Path: + """A private temp dir for downloading/extracting before final install.""" + return Path(tempfile.mkdtemp(prefix="harness-plugin-")) \ No newline at end of file diff --git a/src/harness/plugins/installer.py b/src/harness/plugins/installer.py new file mode 100644 index 0000000..9f0d1f3 --- /dev/null +++ b/src/harness/plugins/installer.py @@ -0,0 +1,251 @@ +"""Plugin installer — namespace-aware install/uninstall of plugin bundles. + +Two install paths: + - ``install(source_dir)`` — from a local plugin bundle directory. + - ``install_from_marketplace(name, alias)`` — resolve a plugin's relative + ``source`` inside a locally cloned marketplace, then install. + +Assets are copied into ``~/.code/plugins/installed///`` +with ``agents/``, ``skills/``, and ``commands/`` subfolders — namespaced by the +marketplace alias (Claude Code convention) so plugins from different +marketplaces never collide in the raw global agents/skills dirs. MCP servers +are merged into settings.json ``mcpServers`` under a ``-`` +key so server names stay namespaced too. +""" + +import json +import shutil +from datetime import datetime +from pathlib import Path + +from harness.config import ( + _writable_settings, + save_settings_file, +) +from harness.plugins.marketplace import fetch_catalog +from harness.plugins.models import InstalledPlugin, PluginManifest +from harness.plugins.state import ( + load_state, + save_state, + installed_plugins_dir, +) + +# Manifest file names inside a plugin's source dir, in discovery order. +_MANIFEST_CANDIDATES = (".claude-plugin/plugin.json", "plugin.json") + + +def _locate_manifest(source_dir: Path) -> Path | None: + for rel in _MANIFEST_CANDIDATES: + candidate = source_dir / rel + if candidate.is_file(): + return candidate + return None + + +def _copy_md_files(src_dir: Path, dst_dir: Path) -> list[str]: + """Copy every ``*.md`` under src_dir (subdirs included) into dst_dir. + + Claude Code skills live under ``skills//SKILL.md``, so the whole + relative tree is preserved. Returns the copied relative paths. + """ + if not src_dir.is_dir(): + return [] + dst_dir.mkdir(parents=True, exist_ok=True) + copied: list[str] = [] + for path in sorted(src_dir.rglob("*.md")): + if not path.is_file(): + continue + rel = path.relative_to(src_dir) + target = dst_dir / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + copied.append(rel.as_posix()) + return copied + + +def _collect_mcp(manifest: PluginManifest, source_dir: Path) -> dict: + """MCP servers: plugin.json ``mcp.servers`` merged with repo-root ``.mcp.json``.""" + servers = dict(manifest.mcp.get("servers", {}) or {}) + mcp_json = source_dir / ".mcp.json" + if mcp_json.is_file(): + try: + data = json.loads(mcp_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = {} + servers.update(data.get("mcpServers", {}) or {}) + return servers + + +class PluginInstaller: + """Install, uninstall, and list plugin bundles (namespaced by marketplace).""" + + def install( + self, + source_dir: Path, + marketplace: str = "", + name: str | None = None, + ) -> InstalledPlugin: + """Install a bundle from a local plugin source directory. + + ``source_dir`` is the plugin's root: contains ``.claude-plugin/plugin.json`` + (or ``plugin.json``) plus ``agents/``, ``skills/``, ``commands/``. + Agents are auto-discovered from ``agents/*.md`` by convention. + + ``name`` optionally overrides the installed name (used by marketplace + installs to key the plugin by its catalog ``name``). Assets are copied + under ``installed///``. + """ + manifest_path = _locate_manifest(source_dir) + if manifest_path is None: + raise ValueError( + f"No plugin manifest in {source_dir} " + "(expected .claude-plugin/plugin.json)" + ) + manifest = PluginManifest.from_file(manifest_path) + namespace = marketplace or manifest.name + plugin_name = name or manifest.name + + state = load_state() + installed = state.setdefault("installed", {}) + if plugin_name in installed: + raise ValueError( + f"Plugin '{plugin_name}' is already installed " + f"(marketplace '{installed[plugin_name].get('marketplace', '')}'). " + "Run uninstall first." + ) + + dest_root = installed_plugins_dir() / namespace / plugin_name + dest_root.mkdir(parents=True, exist_ok=True) + + # 1. Agents — convention: agents/*.md (not declared in plugin.json). + agent_dir = source_dir / "agents" + agent_names = _copy_md_files(agent_dir, dest_root / "agents") + + # 2. Skills — each declared dir copied shallow. + skill_names: list[str] = [] + for rel in manifest.skills or [""]: + if not rel: + continue + skill_names.extend( + _copy_md_files(source_dir / rel, dest_root / "skills") + ) + + # 3. Commands — each declared dir copied shallow. + command_names: list[str] = [] + for rel in manifest.commands or []: + command_names.extend( + _copy_md_files(source_dir / rel, dest_root / "commands") + ) + + # 3b. Instructions + rules — convention dirs or manifest-declared dirs + # (like skills: "" resolves to the source root). + instruction_names: list[str] = [] + for rel in manifest.instructions or [""]: + if not rel: + continue + instruction_names.extend( + _copy_md_files(source_dir / rel, dest_root / "instructions") + ) + rule_names: list[str] = [] + for rel in manifest.rules or [""]: + if not rel: + continue + rule_names.extend( + _copy_md_files(source_dir / rel, dest_root / "rules") + ) + + # 4. MCP servers → settings.json, namespaced `{recipe}-{server}`. + mcp_servers = _collect_mcp(manifest, source_dir) + installed_servers: list[str] = [] + if mcp_servers: + data = _writable_settings() + servers = data.setdefault("mcpServers", {}) + for server_name, cfg in mcp_servers.items(): + key = f"{namespace}-{server_name}" + servers[key] = cfg + installed_servers.append(key) + save_settings_file() + + # 5. Record installation in state. + record = InstalledPlugin( + name=plugin_name, + version=manifest.version, + marketplace=namespace, + agent_names=agent_names, + skill_names=skill_names, + command_names=command_names, + instruction_names=instruction_names, + rule_names=rule_names, + mcp_server_names=installed_servers, + installed_at=datetime.now().isoformat(timespec="seconds"), + ) + installed[plugin_name] = record.to_dict() + save_state(state) + return record + + def install_from_marketplace( + self, name: str, marketplace_alias: str + ) -> InstalledPlugin: + """Install a plugin from a registered marketplace's local clone. + + The catalog is read from the local clone (no network), the plugin's + ``source`` is resolved relative to that clone, and ``install()`` copies + its assets with the marketplace alias as the namespace. + """ + catalog = fetch_catalog(marketplace_alias) + if catalog is None: + raise ValueError( + f"Marketplace '{marketplace_alias}' is not registered. " + "Run `harness plugin marketplace add ` first." + ) + + plugin = catalog.search(name) + if plugin is None: + available = ", ".join(p.name for p in catalog.plugins) or "(empty)" + raise ValueError( + f"Plugin '{name}' not found in marketplace " + f"'{marketplace_alias}'. Available plugins: {available}" + ) + + source_dir = catalog.resolve_source(plugin) + if not source_dir.is_dir(): + raise ValueError( + f"Source dir '{plugin.source}' for plugin '{name}' is missing " + f"inside marketplace '{marketplace_alias}' (clone may be stale; " + "re-run plugin marketplace add)." + ) + return self.install(source_dir, marketplace=marketplace_alias, name=plugin.name) + + def uninstall(self, name: str) -> bool: + """Remove a plugin by name. Returns False if not installed.""" + state = load_state() + installed = state.get("installed", {}) + if name not in installed: + return False + + record = InstalledPlugin.from_dict(installed[name]) + namespace = record.marketplace or record.name + + # Remove the namespaced install area. + shutil.rmtree( + installed_plugins_dir() / namespace / name, ignore_errors=True + ) + + # Remove namespaced MCP servers from settings.json. + if record.mcp_server_names: + data = _writable_settings() + servers = data.get("mcpServers") or {} + for sname in record.mcp_server_names: + servers.pop(sname, None) + save_settings_file() + + del installed[name] + save_state(state) + return True + + def list_installed(self) -> list[InstalledPlugin]: + state = load_state() + return [ + InstalledPlugin.from_dict(rec) + for rec in state.get("installed", {}).values() + ] \ No newline at end of file diff --git a/src/harness/plugins/loader.py b/src/harness/plugins/loader.py new file mode 100644 index 0000000..a1eeebc --- /dev/null +++ b/src/harness/plugins/loader.py @@ -0,0 +1,110 @@ +"""Startup loader — mounts installed plugins' agents/skills into the registries. + +Plugin agents/skills are NOT copied into the raw global ``~/.code/agents`` / +``~/.code/skills`` dirs. They live namespaced under +``~/.code/plugins/installed///`` and are mounted here as +*namespaced scan roots*, so they surface in the LLM roster as +``-`` — matching Claude Code's convention of prefixing each +plugin asset with its marketplace alias to avoid collisions. + +Called once at startup from ``HarnessOrchestrator.ensure_session``. +""" + +import logging +from pathlib import Path + +from harness.plugins.state import installed_plugins_dir, load_state + +logger = logging.getLogger(__name__) + + +def load_installed_plugins(agent_registry=None, skill_registry=None) -> int: + """Mount each installed plugin's ``agents/`` and ``skills/`` as namespaced roots. + + Pass the harness :class:`AgentRegistry`/`SkillRegistry` to surface plugin + agents and skills in the roster as ``-``. Dirs that + don't exist (e.g. a plugin without skills) are skipped. Returns the number + of plugins registered. + """ + installed = (load_state().get("installed") or {}).values() + mounted = 0 + for record in installed: + name = record.get("name") + if not name: + continue + namespace = record.get("marketplace") or name + plugin_dir = installed_plugins_dir() / namespace / name + if agent_registry is not None: + agent_registry.add_scope_root(plugin_dir / "agents", prefix=namespace) + if skill_registry is not None: + skill_registry.add_scope_root(plugin_dir / "skills", prefix=namespace) + mounted += 1 + logger.info("Plugin loader mounted %d installed plugin(s)", mounted) + return mounted + + +def _frontmatter_description(path: Path) -> str: + """Extract ``description:`` from a leading frontmatter block, else ``""``.""" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + if not text.startswith("---"): + return "" + for line in text.splitlines()[1:]: + if line == "---": + break + if line.lower().startswith("description:"): + return line.split(":", 1)[1].strip() + return "" + + +def collect_plugin_commands() -> list[dict]: + """Enumerate every installed plugin's ``commands/*.md`` files. + + Returns ``[{name, description, path, plugin}]`` where ``name`` is the file + stem (slash-command name) and ``plugin`` is the namespaced label + ``-``. Used to mount plugin commands in the UI palette. + """ + out: list[dict] = [] + for record in (load_state().get("installed") or {}).values(): + name = record.get("name") + if not name: + continue + namespace = record.get("marketplace") or name + commands_dir = installed_plugins_dir() / namespace / name / "commands" + if not commands_dir.is_dir(): + continue + for path in sorted(commands_dir.rglob("*.md")): + out.append( + { + "name": path.stem, + "description": _frontmatter_description(path), + "path": str(path), + "plugin": f"{namespace}-{name}", + } + ) + return out + + +def collect_plugin_catalog() -> list[dict]: + """Cheap per-plugin asset counts from the state records (no disk scan). + + Returns ``[{name, commands, instructions, rules}]`` for the orchestrator's + ```` roster block. + """ + out: list[dict] = [] + for record in (load_state().get("installed") or {}).values(): + rname = record.get("name") + if not rname: + continue + namespace = record.get("marketplace") or rname + out.append( + { + "name": f"{namespace}-{rname}", + "commands": len(record.get("command_names") or []), + "instructions": len(record.get("instruction_names") or []), + "rules": len(record.get("rule_names") or []), + } + ) + return out \ No newline at end of file diff --git a/src/harness/plugins/marketplace.py b/src/harness/plugins/marketplace.py new file mode 100644 index 0000000..28d1888 --- /dev/null +++ b/src/harness/plugins/marketplace.py @@ -0,0 +1,366 @@ +"""Marketplace registry — clone-local, source-relative plugin catalogs. + +A "marketplace" is a git repo that ships a catalog plus the plugins themselves. +``plugin marketplace add`` **clones the whole repo** into +``~/.code/plugins/marketplaces//``, where ``name`` is the marketplace's +own ``name`` from its ``.claude-plugin/marketplace.json`` (or an explicit +``--alias``). The catalog is discovered at ``.claude-plugin/marketplace.json`` +(falling back to the clone root). Catalog ``plugins`` entries reference a +*source directory* inside the clone via a relative ``source`` (e.g. ``"./"``), +so installs resolve against the local clone — no per-plugin network fetch. +""" + +import json +import re +import shutil +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + +from harness.plugins import downloader +from harness.plugins.state import ( + load_state, + save_state, + update_state, + marketplaces_dir, +) + +# Catalog file names, in discovery order (Claude Code convention first). +_CATALOG_CANDIDATES = (".claude-plugin/marketplace.json", "marketplace.json") + +# plugin.json location inside a plugin's source dir. +_PLUGIN_MANIFEST_CANDIDATES = (".claude-plugin/plugin.json", "plugin.json") + +# GitHub repo shorthand/full URL: owner/repo (no protocol) or https://github.com/... +_GITHUB_RE = re.compile(r"^(?:https://github\.com/)?([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$") + + +@dataclass +class MarketplaceRecord: + """A registered marketplace's persisted metadata (refers to a local clone).""" + name: str + url: str # clone source (repo URL or catalog URL) + path: str = "" # absolute local clone/catalog dir + catalog: str = "marketplace.json" # catalog path relative to `path` + added_at: str = "" + + @property + def catalog_path(self) -> Path | None: + """Absolute path to the marketplace.json catalog file, if it exists.""" + if not self.path: + return None + root = Path(self.path) + candidate = (root / self.catalog) if self.catalog else None + if candidate and candidate.is_file(): + return candidate + for rel in _CATALOG_CANDIDATES: + exist = root / rel + if exist.is_file(): + return exist + return None + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "url": self.url, + "path": self.path, + "catalog": self.catalog, + "added_at": self.added_at, + } + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "MarketplaceRecord": + return cls( + name=d.get("name", ""), + url=d.get("url", ""), + path=d.get("path", ""), + catalog=d.get("catalog", "marketplace.json"), + added_at=d.get("added_at", ""), + ) + + +@dataclass +class MarketplacePlugin: + """One plugin entry inside a marketplace.json catalog.""" + name: str + source: str = "./" # relative source dir inside the marketplace clone + version: str = "latest" + description: str = "" + + def to_dict(self) -> dict[str, Any]: + return {"name": self.name, "source": self.source, "version": self.version} + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "MarketplacePlugin": + return cls( + name=d.get("name", ""), + source=d.get("source", d.get("url", "./")), + version=d.get("version", "latest"), + description=d.get("description", ""), + ) + + +@dataclass +class MarketplaceCatalog: + """The parsed contents of a marketplace.json catalog.""" + source_url: str = "" + raw: dict[str, Any] = field(default_factory=dict) + plugins: list[MarketplacePlugin] = field(default_factory=list) + fetched_at: str = "" + catalog_path: Path | None = None # local file the catalog was read from + marketplace_alias: str = "" + root: Path | None = None # marketplace clone root; source is relative to it + + @property + def name(self) -> str: + return self.raw.get("name", "") or "" + + @classmethod + def validate(cls, raw: dict[str, Any]) -> None: + """Validate the catalog schema; raises ValueError on bad shape.""" + if not isinstance(raw, dict): + raise ValueError("marketplace.json must be a JSON object") + if not isinstance(raw.get("name"), str) or not raw["name"].strip(): + raise ValueError("marketplace.json is missing a 'name'") + plugins = raw.get("plugins") or [] + if not isinstance(plugins, list): + raise ValueError("marketplace.json 'plugins' must be a list") + for p in plugins: + if not isinstance(p, dict) or not isinstance(p.get("name"), str): + raise ValueError( + "marketplace.json contains a badly-shaped plugin entry" + ) + + @classmethod + def from_raw( + cls, + raw: dict[str, Any], + source_url: str = "", + catalog_path: Path | None = None, + marketplace_alias: str = "", + root: Path | None = None, + ) -> "MarketplaceCatalog": + cls.validate(raw) + plugins = [ + MarketplacePlugin.from_dict(p) for p in (raw.get("plugins") or []) + ] + return cls( + source_url=source_url, + raw=raw, + plugins=plugins, + fetched_at=datetime.now().isoformat(timespec="seconds"), + catalog_path=catalog_path, + marketplace_alias=marketplace_alias, + root=root, + ) + + def search(self, name: str) -> MarketplacePlugin | None: + """Return the plugin with the given name, or None if not present.""" + for p in self.plugins: + if p.name == name: + return p + return None + + def resolve_source(self, plugin: MarketplacePlugin) -> Path: + """Absolute source dir of a plugin inside the local marketplace clone. + + The plugin's ``source`` is relative to the marketplace clone root (the + dir containing ``.claude-plugin/``) — so ``"./"`` means the marketplace + root itself, matching Claude Code's ``source`` convention. + """ + if self.root is not None: + base = self.root + elif self.catalog_path is not None: + base = self.catalog_path.parent + else: + base = Path(self.source_url) + src = (plugin.source or "./").strip() or "./" + return Path(base).resolve() / src + + +# ── URL resolution helpers ──────────────────────────────────────────────── + +def _alias_from_source(source: str) -> str: + """Derive a sensible alias from a GitHub shorthand or a repo URL.""" + m = _GITHUB_RE.match(source.strip()) + if m: + return m.group(2) + parts = [s for s in source.strip("/").split("/") if s] + return parts[-1] or "marketplace" + + +def _github_url(source: str) -> str | None: + """Return canonical https://github.com/owner/repo if source is a repo.""" + m = _GITHUB_RE.match(source.strip()) + if m: + owner, repo = m.groups() + return f"https://github.com/{owner}/{repo}" + return None + + +def _sanitize(name: str) -> str: + """Lowercase and keep only [a-z0-9._-] — safe for a folder name.""" + return re.sub(r"[^a-zA-Z0-9_.-]", "-", name).strip(".-").lower() + + +def _discover_catalog(root: Path) -> Path | None: + """Return the first catalog file found under ``root``, or None.""" + for rel in _CATALOG_CANDIDATES: + candidate = root / rel + if candidate.is_file(): + return candidate + return None + + +# ── Public registry API ─────────────────────────────────────────────────── + +class MarketplaceRegistry: + """Reads/writes the ``marketplaces`` section of the plugin state file.""" + + def list(self) -> list[MarketplaceRecord]: + state = load_state() + return [ + MarketplaceRecord.from_dict(d) + for d in state.get("marketplaces", {}).values() + ] + + def lookup(self, alias: str) -> MarketplaceRecord | None: + state = load_state() + d = state.get("marketplaces", {}).get(alias) + return MarketplaceRecord.from_dict(d) if d else None + + def add(self, source: str, alias: str | None = None) -> MarketplaceRecord: + """Register a marketplace by cloning its repo and validating its catalog. + + The whole repo is cloned into ``marketplaces//``, where ``name`` + is the marketplace's own ``name`` from its ``marketplace.json`` catalog + (or an explicit ``--alias``), per Claude Code convention. Installs then + scan the local clone and resolve each plugin's ``source`` relative to it. + Direct ``marketplace.json`` URLs are downloaded and cached the same way. + """ + src = source.strip() + repo_url = _github_url(src) + guess = _sanitize(alias or _alias_from_source(src)) or "marketplace" + staging = marketplaces_dir() / f".pending-{guess}" + raw: dict | None = None + try: + if repo_url: + downloader.git_clone(repo_url, staging, timeout=120) + catalog_path = _discover_catalog(staging) + if catalog_path is None: + raise ValueError( + f"Cloned marketplace '{src}' has no catalog " + "(expected .claude-plugin/marketplace.json)" + ) + try: + raw = json.loads(catalog_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError( + f"Marketplace catalog is not readable JSON: {exc}" + ) from exc + else: + if not src.endswith(".json"): + raise ValueError( + "Unsupported marketplace source. Use a GitHub repo " + "(owner/repo or https://github.com/...) or a direct " + "marketplace.json URL." + ) + try: + raw = json.loads(downloader.fetch_text(src, timeout=10)) + except json.JSONDecodeError as exc: + raise ValueError( + f"Marketplace manifest is not valid JSON: {exc}" + ) from exc + + # Name the folder by the catalog's own name (or --alias). + MarketplaceCatalog.validate(raw) + final_alias = _sanitize(alias or raw["name"]) or guess + + state = load_state() + if final_alias in state.get("marketplaces", {}): + raise ValueError( + f"Marketplace '{final_alias}' is already registered. " + f"Run `harness plugin marketplace remove {final_alias}` first." + ) + + dest = marketplaces_dir() / final_alias + if repo_url: + shutil.rmtree(dest, ignore_errors=True) # orphaned leftover, if any + dest.parent.mkdir(parents=True, exist_ok=True) + staging.replace(dest) + catalog_path = dest / catalog_path.relative_to(staging) + else: + dest.mkdir(parents=True, exist_ok=True) + catalog_path = dest / "marketplace.json" + catalog_path.write_text(json.dumps(raw, indent=2), encoding="utf-8") + + catalog = MarketplaceCatalog.from_raw( + raw, + source_url=src, + catalog_path=catalog_path, + marketplace_alias=final_alias, + root=dest, + ) + + record = MarketplaceRecord( + name=final_alias, + url=src, + path=str(dest), + catalog=( + catalog_path.relative_to(dest).as_posix() + if catalog_path.parent != dest + else "marketplace.json" + ), + added_at=datetime.now().isoformat(timespec="seconds"), + ) + + def _add(state): + state["marketplaces"][final_alias] = record.to_dict() + return state + + update_state(_add) + return record + finally: + # staging is moved (or never created) on success; removed on failure. + shutil.rmtree(staging, ignore_errors=True) + + def remove(self, alias: str) -> bool: + """Unalias a marketplace. Returns False if not registered.""" + state = load_state() + if alias not in state.get("marketplaces", {}): + return False + del state["marketplaces"][alias] + save_state(state) + return True + + +def fetch_catalog(alias: str) -> MarketplaceCatalog | None: + """Return the validated catalog for a registered marketplace alias. + + Reads from the marketplace's local clone — no network — matching the + Claude Code convention that marketplaces are cloned locally. + """ + record = MarketplaceRegistry().lookup(alias) + if not record: + return None + path = record.catalog_path + if not path or not path.is_file(): + if not record.path: + raise ValueError( + f"Marketplace '{alias}' is not registered. " + "Run `harness plugin marketplace add ` first." + ) + raise ValueError(f"Marketplace '{alias}' clone is missing its catalog file") + try: + body = path.read_text(encoding="utf-8") + raw = json.loads(body) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Marketplace '{alias}' catalog is unreadable: {exc}") from exc + return MarketplaceCatalog.from_raw( + raw, + source_url=record.url, + catalog_path=path, + marketplace_alias=alias, + root=Path(record.path) if record.path else None, + ) \ No newline at end of file diff --git a/src/harness/plugins/models.py b/src/harness/plugins/models.py new file mode 100644 index 0000000..5672161 --- /dev/null +++ b/src/harness/plugins/models.py @@ -0,0 +1,119 @@ +"""Data models for plugin manifests and plugin records. + +Schema mirrors the Claude Code plugin convention: +- A marketplace.json catalog lives at ``/.claude-plugin/marketplace.json``. +- Catalog ``plugins`` entries reference a plugin *source directory* inside the + marketplace clone via a relative ``source`` (e.g. ``"./"``) — not a URL. +- Each plugin dir carries its own ``.claude-plugin/plugin.json``. +- Agents are auto-discovered from ``agents/*.md`` by convention (not declared). +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +import json + + +def _as_path_list(value: Any) -> list[str]: + """Normalize a manifest dir field (string path or list of paths) to a list. + + Claude Code plugin.json files declare skills/commands as either a single + string (``"skills": "./skills/"``) or a list; both must survive install. + """ + if value is None: + return [] + if isinstance(value, str): + return [value.strip()] if value.strip() else [] + return [str(v).strip() for v in value if str(v).strip()] + + +@dataclass +class PluginManifest: + """Describes a plugin: MCP servers + agents/skills/commands asset dirs. + + ``agents``/``skills``/``commands`` are relative paths resolved against the + plugin's source directory. Agents are convention-discovered, so a manifest + normally declares only skills/commands dirs; ``agents`` is populated at + load time from ``agents/``. + """ + name: str + version: str + description: str + mcp: dict[str, Any] = field(default_factory=dict) # {"servers": {name: config}} + agents: list[str] = field(default_factory=list) # relative paths to .md + skills: list[str] = field(default_factory=list) # relative dirs ("" = source root) + commands: list[str] = field(default_factory=list) # relative dirs + instructions: list[str] = field(default_factory=list) # relative dirs ("" = source root) + rules: list[str] = field(default_factory=list) # relative dirs ("" = source root) + + @classmethod + def from_file(cls, path: Path) -> "PluginManifest": + data = json.loads(path.read_text(encoding="utf-8")) + + # Claude Code manifests commonly declare MCP servers at the top level + # ("mcpServers") rather than nested under "mcp"; fold both forms in. + mcp = dict(data.get("mcp", {}) or {}) + servers = dict(mcp.get("servers", {}) or {}) + servers.update(data.get("mcpServers", {}) or {}) + mcp["servers"] = servers + + return cls( + name=data["name"], + version=data.get("version", "0.0.0"), + description=data.get("description", ""), + mcp=mcp, + agents=_as_path_list(data.get("agents")), + skills=_as_path_list(data.get("skills")), + commands=_as_path_list(data.get("commands")), + instructions=_as_path_list(data.get("instructions")), + rules=_as_path_list(data.get("rules")), + ) + + +@dataclass +class InstalledPlugin: + """Record of an installed plugin, persisted in the state file. + + A plugin is installed *namespaced*: its agents/skills/commands live under + ``installed///`` relative to the plugin dirs, and names + are surfaced to the LLM context prefixed with the marketplace alias. + """ + name: str + version: str + marketplace: str = "" # alias it came from + agent_names: list[str] = field(default_factory=list) # basenames installed + skill_names: list[str] = field(default_factory=list) + command_names: list[str] = field(default_factory=list) + instruction_names: list[str] = field(default_factory=list) + rule_names: list[str] = field(default_factory=list) + mcp_server_names: list[str] = field(default_factory=list) + installed_at: str = "" + + def to_dict(self) -> dict: + return { + "name": self.name, + "version": self.version, + "marketplace": self.marketplace, + "agent_names": self.agent_names, + "skill_names": self.skill_names, + "command_names": self.command_names, + "instruction_names": self.instruction_names, + "rule_names": self.rule_names, + "mcp_server_names": self.mcp_server_names, + "installed_at": self.installed_at, + } + + @classmethod + def from_dict(cls, d: dict) -> "InstalledPlugin": + return cls( + name=d["name"], + version=d.get("version", "0.0.0"), + marketplace=d.get("marketplace", ""), + agent_names=d.get("agent_names", []), + skill_names=d.get("skill_names", []), + command_names=d.get("command_names", []), + instruction_names=d.get("instruction_names", []), + rule_names=d.get("rule_names", []), + mcp_server_names=d.get("mcp_server_names", []), + installed_at=d.get("installed_at", ""), + ) \ No newline at end of file diff --git a/src/harness/plugins/state.py b/src/harness/plugins/state.py new file mode 100644 index 0000000..a19fd18 --- /dev/null +++ b/src/harness/plugins/state.py @@ -0,0 +1,99 @@ +"""Plugin state store — reads/writes ~/.code/plugins/installed_plugins.json. + +This is the single source of truth for registered marketplaces and installed +plugins. Writes are atomic (temp file + rename) so a crash mid-save never +corrupts the state file. +""" + +import json +import os +import tempfile +from pathlib import Path +from typing import Any, Callable + +_PLUGINS_DIR = Path.home() / ".code" / "plugins" +STATE_FILE = _PLUGINS_DIR / "installed_plugins.json" + + +def _fresh_empty_state() -> dict[str, Any]: + """A brand-new empty state. Never returns the shared constant, so callers + that mutate nested dicts (e.g. ``update_state`` mutators) can never + corrupt module-level state.""" + return {"marketplaces": {}, "installed": {}} + + +def state_path() -> Path: + """Path to the installed_plugins.json state file (creates the dir if needed).""" + _PLUGINS_DIR.mkdir(parents=True, exist_ok=True) + return STATE_FILE + + +def marketplaces_dir() -> Path: + """Directory holding the full local clones of registered marketplaces. + + ``plugin marketplace add`` clones the marketplace repo here, keyed by its + alias, so installs can scan the local clone (Claude Code convention). + """ + _PLUGINS_DIR.mkdir(parents=True, exist_ok=True) + return _PLUGINS_DIR / "marketplaces" + + +def installed_plugins_dir() -> Path: + """Directory each installed plugin is copied into, namespaced by marketplace. + + Layout: ``installed///`` containing its own + ``agents/``, ``skills/``, and ``commands/`` subfolders. Keeping plugins out + of the raw global agents/skills dirs and namespacing them by marketplace + prevents naming collisions between plugins. + """ + _PLUGINS_DIR.mkdir(parents=True, exist_ok=True) + return _PLUGINS_DIR / "installed" + + +def load_state() -> dict[str, Any]: + """Return the full state dict (marketplaces + installed). Never corrupts on read.""" + path = state_path() + if not path.exists(): + return _fresh_empty_state() + try: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return _fresh_empty_state() + data.setdefault("marketplaces", {}) + data.setdefault("installed", {}) + return data + except (json.JSONDecodeError, OSError): + # Reset empty state rather than crash — the CLI path never depends on + # a pre-existing good file (it recreates on demand). + return _fresh_empty_state() + + +def clear_state() -> None: + """Delete the state file. Used by tests and 'plugin uninstall --all'.""" + path = state_path() + if path.exists(): + path.unlink() + + +def save_state(state: dict[str, Any]) -> None: + """Atomically persist the full state dict to disk.""" + path = state_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") + try: + with open(fd, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + f.flush() + os.fsync(f.fileno()) + Path(tmp).replace(path) + except OSError: + Path(tmp).unlink(missing_ok=True) + raise + + +def update_state(mutator: Callable[[dict[str, Any]], dict[str, Any]]) -> dict[str, Any]: + """Load, apply ``mutator(state)``, and atomically save the result.""" + state = load_state() + state = mutator(state) + save_state(state) + return state \ No newline at end of file diff --git a/src/harness/registry/definitions.py b/src/harness/registry/definitions.py index 3c4059d..9b30367 100644 --- a/src/harness/registry/definitions.py +++ b/src/harness/registry/definitions.py @@ -21,25 +21,61 @@ class DefinitionMeta: class DefinitionRegistry: - """Scan frontmatter-only, lazy-load full body with mtime-invalidated cache.""" + """Scan frontmatter-only, lazy-load full body with mtime-invalidated cache. + + Scans the project agents/skills dir plus any namespaced scan roots added + via :meth:`add_scope_root` (e.g. marketplace plugins). Namespaced entries + are indexed under ``-`` so plugins from different marketplaces + never collide with the base roster or each other. + """ def __init__(self, dir_getter: Callable[[], Path], kind: str): self._dir_getter = dir_getter self.kind = kind self._index: Dict[str, DefinitionMeta] = {} self._body_cache: Dict[str, Tuple[float, str]] = {} + self._extra_roots: list[tuple[Path, str]] = [] + + def add_scope_root(self, directory: Path, prefix: str = "") -> None: + """Register an extra scan root (e.g. an installed plugin's agents/ dir). + + Entries are surfaced under ``{prefix}-{name}`` so a plugin from one + marketplace can never shadow a different ``architect`` from another. + A ``prefix`` of ``""`` surfaces entries under their bare name. + """ + self._extra_roots.append((directory, prefix)) + self.scan() def scan(self) -> None: - """(Re)build the lightweight index. Reads ONLY frontmatter bytes per file.""" - directory = self._dir_getter() - self._index.clear() - if not directory.exists(): - return + """(Re)build the lightweight index across all registered roots. - for path in sorted(directory.glob("*.md")): - meta = self._parse_frontmatter_only(path) - if meta is not None: - self._index[meta.name] = meta + The base dir is scanned flat (``*.md``); namespaced extra roots are + scanned recursively so nested skill files (``skills//SKILL.md``) + are also picked up. + """ + self._index.clear() + for directory, prefix in self._iter_roots(): + if not directory.exists(): + continue + pattern = "**/*.md" if prefix else "*.md" + for path in sorted(directory.glob(pattern)): + meta = self._parse_frontmatter_only(path) + if meta is None: + continue + name = f"{prefix}-{meta.name}" if prefix else meta.name + self._index[name] = DefinitionMeta( + name=name, + description=meta.description, + path=path, + mtime=meta.mtime, + tools=meta.tools, + model=meta.model, + ) + + def _iter_roots(self) -> list[tuple[Path, str]]: + roots = [(self._dir_getter(), "")] + roots.extend(self._extra_roots) + return roots def _parse_frontmatter_only(self, path: Path) -> Optional[DefinitionMeta]: """Reads line-by-line; stops at closing '---'. Body is never read here.""" diff --git a/src/harness/tools/definitions.py b/src/harness/tools/definitions.py index 3dabbc6..59a3da0 100644 --- a/src/harness/tools/definitions.py +++ b/src/harness/tools/definitions.py @@ -122,14 +122,30 @@ class MemorySearchArgs(BaseModel): limit: int = 3 +class PluginContextArgs(BaseModel): + """Arguments for plugin_context tool. + + Fetches installed plugin commands/instructions/rules content on demand. + ``plugin`` names a namespaced plugin (``-``) or bare + name; empty returns a catalog of everything available. + """ + kind: Literal["commands", "instructions", "rules", "all"] = "all" + plugin: str = "" + + @dataclass class ToolDefinition: - """Definition of a tool for LLM tool-calling.""" + """Definition of a tool for LLM tool-calling. + + Built-in tools define a pydantic ``args_model``; dynamically registered MCP + tools supply opaque JSON Schema instead (``json_schema``). Exactly one is set. + """ name: str tool_type: ToolType description: str - args_model: Type[BaseModel] - permission_kind: Literal["fs_read", "fs_write", "shell", "agent_spawn", "interaction", "skill", "task"] + args_model: Optional[Type[BaseModel]] = None + json_schema: Optional[dict] = None + permission_kind: Literal["fs_read", "fs_write", "shell", "agent_spawn", "interaction", "skill", "task"] = "interaction" # Static tool registry — one entry per tool that has an actual handler. @@ -858,9 +874,40 @@ class ToolDefinition: args_model=MemorySearchArgs, permission_kind="fs_read", ), + ToolType.PLUGIN_CONTEXT: ToolDefinition( + name="PluginContext", + tool_type=ToolType.PLUGIN_CONTEXT, + description="""Fetch installed plugin commands, instructions, or rules on demand. + +The harness loads plugin assets lazily, not eagerly — call this tool when a +task would benefit from a plugin's guidance (e.g. before following an +installed command, or when a plugin's instructions/rules may constrain your +work). Kinds: commands, instructions, rules, or all. + +- plugin="" returns a catalog of every installed plugin and the counts/types + of assets it ships. +- plugin= returns the full markdown content of the requested kind for + that plugin (commands/instructions/rules), so you can follow its guidance. + +Plugin names are namespaced ``-`` (e.g. +``context-mode-context-mode``). Prefer fetching on demand over assuming you +know a plugin's contents. +""", + args_model=PluginContextArgs, + permission_kind="fs_read", + ), } +def tool_definition_schema(definition: ToolDefinition) -> dict: + """Return the raw JSON Schema for a tool's arguments.""" + if definition.args_model is not None: + return definition.args_model.model_json_schema() + if definition.json_schema is not None: + return definition.json_schema.get("$ref", definition.json_schema) if isinstance(definition.json_schema, dict) and "$ref" in definition.json_schema else definition.json_schema + return {"type": "object"} + + def to_llm_tool_schema(definition: ToolDefinition) -> dict: """Convert a ToolDefinition to OpenAI/litellm tool schema format.""" return { @@ -868,7 +915,7 @@ def to_llm_tool_schema(definition: ToolDefinition) -> dict: "function": { "name": definition.name, "description": definition.description, - "parameters": definition.args_model.model_json_schema(), + "parameters": tool_definition_schema(definition), }, } @@ -877,6 +924,7 @@ def get_tools_payload(router) -> list[dict]: """Build tools payload for LLM, filtering to only registered handlers. For spawn_agent, dynamically appends available agent names+descriptions to the tool description. + Also appends any dynamically registered (MCP) tools carried on ``router.mcp_tools``. """ tools = [] for tool_type in router.handlers.keys(): @@ -885,9 +933,40 @@ def get_tools_payload(router) -> list[dict]: definition = TOOL_REGISTRY[tool_type] schema = to_llm_tool_schema(definition) tools.append(schema) + + # Dynamically registered tools (MCP) — keyed by name, already LLM-facing. + for name, definition in getattr(router, "mcp_tools", {}).items(): + tools.append(to_llm_tool_schema(definition)) return tools +def resolve_tool_definition(name: str, router=None) -> ToolDefinition | None: + """Resolve a tool definition by its LLM-facing name (built-in or MCP).""" + parts = name.split("__", 2) + if len(parts) == 3 and parts[0] == "mcp" and router is not None: + mcp_defs = getattr(router, "mcp_tools", {}) + return mcp_defs.get(name) + for tt, defn in TOOL_REGISTRY.items(): + if defn.name == name: + return defn + return None + + +def validate_call_args(name: str, definition: ToolDefinition, raw_args: dict) -> dict: + """Validate tool arguments against the definition's schema. + + Returns a plain dict. pydantic models are used for built-ins; ``jsonschema`` + validates opaque MCP JSON Schema. Raises on invalid input. + """ + if definition.args_model is not None: + return definition.args_model.model_validate(raw_args).model_dump() + if definition.json_schema is not None: + import jsonschema + jsonschema.validate(instance=raw_args, schema=definition.json_schema) + return raw_args + return raw_args + + def validate_args(tool_type: ToolType, raw_args: dict) -> BaseModel: """Validate and parse tool arguments against the tool's args schema. @@ -897,4 +976,6 @@ def validate_args(tool_type: ToolType, raw_args: dict) -> BaseModel: raise ValueError(f"Unknown tool type: {tool_type.value}") definition = TOOL_REGISTRY[tool_type] + if definition.args_model is None: + raise ValueError(f"Tool {tool_type.value} has no args model defined") return definition.args_model.model_validate(raw_args) diff --git a/src/harness/tools/executor.py b/src/harness/tools/executor.py index e6b01e3..85f6e7b 100644 --- a/src/harness/tools/executor.py +++ b/src/harness/tools/executor.py @@ -8,11 +8,27 @@ from .models import ToolCall, ToolType, ToolStatus, ToolResult from .router import ToolRouter +from .permissions import ApprovalRequired from harness.config import get_settings logger = structlog.get_logger(__name__) +def _tname(tool_type) -> str: + """String form of a tool identity — normalizes a ToolType enum to its value + (str(ToolType.BASH) is "ToolType.BASH", NOT "Bash" — that divergence made + session-grant keys never match the retry check).""" + return tool_type.value if isinstance(tool_type, ToolType) else str(tool_type) + + +# Decisions the approval callback returns once the human decides. The executor +# maps each to an outcome: approve-set → execute the call now; deny-set → fail +# the call; anything else (None, unexpected string) → park as AWAITING_APPROVAL +# so the loop can surface it again on retry. +_APPROVED_DECISIONS = {"approved", "approved_session", "persist"} +_DENIED_DECISIONS = {"denied", "denied_session"} + + class ToolExecutor: """Execute tools with retry, caching, and circuit breaker.""" @@ -36,6 +52,10 @@ def __init__( ToolType.BASH: {"max_retries": 3, "backoff": 0.5}, ToolType.GREP: {"max_retries": 2, "backoff": 0.5}, ToolType.GLOB: {"max_retries": 1, "backoff": 0.5}, + # A spawned agent is a whole long-running task, not a quick I/O op — + # never auto-retry one. A failed sub-agent is reported to the parent + # model, which decides adaptively instead of silently re-launching. + ToolType.SPAWN_AGENT: {"max_retries": 0, "backoff": 0.5}, } self.circuit_breaker_threshold = 5 self.circuit_breaker_reset_time = 60 @@ -44,23 +64,35 @@ def __init__( def _cache_key(self, tool_type: ToolType, **kwargs) -> str: """Generate cache key for tool call.""" - key_str = f"{tool_type.value}:{str(sorted(kwargs.items()))}" + key_str = f"{_tname(tool_type)}:{str(sorted(kwargs.items()))}" return hashlib.md5(key_str.encode()).hexdigest() - def _fingerprint_for_approval(self, tool_type: ToolType, kwargs: Dict[str, Any]) -> str: - """Generate a fingerprint for approval matching (tool+arg combo).""" - from harness.core.approval_policy import fingerprint_bash, fingerprint_file - if tool_type == ToolType.BASH: - return fingerprint_bash(kwargs.get("command", "")) - elif tool_type in (ToolType.WRITE, ToolType.EDIT, ToolType.READ): - return fingerprint_file(kwargs.get("path", "")) - else: - return "" - def _is_cached_valid(self, cached_time: datetime) -> bool: """Check if cached result is still valid.""" return datetime.now() - cached_time < self.cache_ttl + async def _surface_approval(self, tool_type, kwargs: Dict[str, Any], risk: str = "high") -> Optional[str]: + """Block on the human's approval decision for this tool call. + + Awaits the UI callback (the Y/N/A/S/P picker) and returns the decision + the human made: "approved", "approved_session", "persist", "denied", or + "denied_session". Returns None when no callback is wired (headless) or + the callback errored — the caller then parks the call as + AWAITING_APPROVAL so the loop can surface it again on retry. + """ + if not self.approval_callback: + return None + name = _tname(tool_type) # plain string — the grant keys must match this + try: + return await self.approval_callback( + action={"tool_type": name, "args": kwargs}, + tool=name, + risk_level=risk, + ) + except Exception as e: + logger.warning("Failed to surface approval request", tool=name, error=str(e)) + return None + async def execute( self, tool_type: ToolType, @@ -83,34 +115,63 @@ async def execute( needs_approval = True if needs_approval: - # Check if already granted (session or persisted) - from harness.core.approval_policy import is_granted_session - fingerprint = self._fingerprint_for_approval(tool_type, kwargs) + # Check the unified approval state (session grant / one-call grant / + # session denial) using the same fingerprints the UI grants with. + from harness.core.approval_policy import is_granted, is_denied + name = _tname(tool_type) + resource = kwargs.get("command") or kwargs.get("path") or "" - if not is_granted_session(tool_type.value, fingerprint): - # Not granted — return AWAITING_APPROVAL for the loop to park - logger.info( - "Tool requires approval, returning AWAITING_APPROVAL", - tool_type=tool_type.value, - ) + if is_denied(name, resource): + logger.info("Tool denied for this session", tool_type=name) return ToolResult( tool_call=ToolCall( tool_type=tool_type, args=kwargs, - status=ToolStatus.AWAITING_APPROVAL, + status=ToolStatus.FAILED, + error=f"Tool '{name}' was denied for this session.", ) ) + if not is_granted(name, resource): + # Not granted — BLOCK on the human's decision. Approved → + # fall through and execute; denied → fail; no UI → park so + # the loop can surface it again on retry. + logger.info( + "Tool requires approval, awaiting human decision", + tool_type=name, + ) + decision = await self._surface_approval(tool_type, kwargs, risk="high") + if decision in _DENIED_DECISIONS: + return ToolResult( + tool_call=ToolCall( + tool_type=tool_type, + args=kwargs, + status=ToolStatus.FAILED, + error=f"Tool '{name}' was denied by the user.", + ) + ) + if decision not in _APPROVED_DECISIONS: + # No UI / no decision — park for the loop to re-surface. + return ToolResult( + tool_call=ToolCall( + tool_type=tool_type, + args=kwargs, + status=ToolStatus.AWAITING_APPROVAL, + ) + ) + # Approved — the UI handler set the grant before returning, so + # the scoped router's gate lets this call run below. + cache_key = self._cache_key(tool_type, **kwargs) # Check circuit breaker (with time-based reset for half-open retry) if self.failed_attempts.get(tool_type, 0) >= self.circuit_breaker_threshold: opened_at = self.circuit_opened_at.get(tool_type) if opened_at and datetime.now() - opened_at > timedelta(seconds=self.circuit_breaker_reset_time): - logger.info(f"Circuit breaker half-open for {tool_type.value}, attempting reset") + logger.info(f"Circuit breaker half-open for {_tname(tool_type)}, attempting reset") self.reset_circuit_breaker(tool_type) else: - logger.warning(f"Circuit breaker open for {tool_type.value}") + logger.warning(f"Circuit breaker open for {_tname(tool_type)}") result = await self.router.call(tool_type, **kwargs) result.tool_call.error = "Circuit breaker open" return result @@ -119,7 +180,7 @@ async def execute( if cache_key in self.cache: cached_result, cached_time = self.cache[cache_key] if self._is_cached_valid(cached_time): - logger.info(f"Cache hit for {tool_type.value}") + logger.info(f"Cache hit for {_tname(tool_type)}") result = ToolResult( tool_call=ToolCall( tool_type=tool_type, @@ -142,10 +203,16 @@ async def execute( last_result = None for attempt in range(max_retries + 1): try: - # AskUserQuestion bypasses the executor's timeout — the UI handler - # (handle_ask_user_question) manages its own timeout using the - # user's ask_question_timeout_seconds setting (0 = forever). - if tool_type == ToolType.ASK_USER_QUESTION: + # AskUserQuestion and AgentSpawn bypass the executor's per-tool + # timeout. AskUserQuestion is governed by its own UI timeout; a + # spawned agent is a long-running task governed by ITS OWN wall- + # clock budget (AgentConfig.timeout_seconds). Binding it to the + # tool I/O timeout is what killed legitimate 10-20 min sub-agents + # and made the parent model re-launch a replacement. + if _tname(tool_type) in ( + _tname(ToolType.ASK_USER_QUESTION), + _tname(ToolType.SPAWN_AGENT), + ): result = await self.router.call(tool_type, **kwargs) else: result = await asyncio.wait_for( @@ -159,7 +226,7 @@ async def execute( # Cache successful result self.cache[cache_key] = (result.tool_call.result, datetime.now()) self.failed_attempts[tool_type] = 0 - logger.info(f"Success on attempt {attempt + 1}", tool=tool_type.value) + logger.info(f"Success on attempt {attempt + 1}", tool=_tname(tool_type)) return result last_result = result @@ -167,13 +234,59 @@ async def execute( if attempt < max_retries: wait_time = backoff * (2 ** attempt) logger.warning( - f"Retry {attempt + 1}/{max_retries} for {tool_type.value}", + f"Retry {attempt + 1}/{max_retries} for {_tname(tool_type)}", wait_time=wait_time, ) await asyncio.sleep(wait_time) + except ApprovalRequired: + # Permission gate wants human approval — BLOCK on the decision. + # Approved → the grant is now set, so re-calling runs through the + # gate; denied → fail; no UI → park for the loop to re-surface. + logger.info(f"Tool {_tname(tool_type)} requires human approval", tool_type=_tname(tool_type)) + decision = await self._surface_approval(tool_type, kwargs, risk="medium") + if decision in _DENIED_DECISIONS: + return ToolResult( + tool_call=ToolCall( + tool_type=tool_type, + args=kwargs, + status=ToolStatus.FAILED, + error=f"Tool '{_tname(tool_type)}' was denied by the user.", + ) + ) + if decision not in _APPROVED_DECISIONS: + # No UI / no decision — park for the loop to re-surface. + return ToolResult( + tool_call=ToolCall( + tool_type=tool_type, + args=kwargs, + status=ToolStatus.AWAITING_APPROVAL, + error=f"Tool '{_tname(tool_type)}' requires human approval — not executed.", + ) + ) + # Approved — the grant is set, so re-call executes through the gate. + try: + result = await self.router.call(tool_type, **kwargs) + except ApprovalRequired: + # Grant didn't stick (state wiped between surface and here) — park. + return ToolResult( + tool_call=ToolCall( + tool_type=tool_type, + args=kwargs, + status=ToolStatus.AWAITING_APPROVAL, + error=f"Tool '{_tname(tool_type)}' requires human approval — not executed.", + ) + ) + result.retry_count = attempt + result.total_retries = max_retries + if result.tool_call.success: + self.cache[cache_key] = (result.tool_call.result, datetime.now()) + self.failed_attempts[tool_type] = 0 + logger.info(f"Approved tool executed", tool=_tname(tool_type)) + return result + last_result = result except asyncio.TimeoutError: - logger.error(f"Tool call timed out after {self.tool_timeout_seconds}s: {tool_type.value}") + logger.error(f"Tool call timed out after {self.tool_timeout_seconds}s: {_tname(tool_type)}") last_result = ToolResult( tool_call=ToolCall( tool_type=tool_type, @@ -219,7 +332,7 @@ def reset_circuit_breaker(self, tool_type: Optional[ToolType] = None) -> None: """Reset circuit breaker for a tool or all tools.""" if tool_type: self.failed_attempts[tool_type] = 0 - logger.info(f"Circuit breaker reset for {tool_type.value}") + logger.info(f"Circuit breaker reset for {_tname(tool_type)}") else: self.failed_attempts.clear() logger.info("All circuit breakers reset") diff --git a/src/harness/tools/factory.py b/src/harness/tools/factory.py index 66973c7..754d1bf 100644 --- a/src/harness/tools/factory.py +++ b/src/harness/tools/factory.py @@ -4,23 +4,31 @@ from .models import ToolType from .router import ToolRouter -from .permissions import PermissionScope, PathGuard, CommandGuard +from .permissions import PermissionScope, PathGuard, CommandGuard, ApprovalRequired +from harness.core.approval_policy import is_granted, is_denied from . import handlers def _gate(scope: PermissionScope, tool_name: str, resource: str = "") -> bool: - """Check if tool is allowed and return whether approval is needed. - - Returns: - True if tool requires approval, False if allowed without approval. + """Gate a tool+resource against the scope before it runs. Raises: - PermissionError: If tool is denied. + PermissionError: the tool+resource is denied outright (config or a + session denial). + ApprovalRequired: the tool+resource needs human approval and has not yet + been granted; the executor parks the call as AWAITING_APPROVAL. """ allowed, mode = scope.check(tool_name, resource) if not allowed: raise PermissionError(f"{tool_name} is not allowed in this scope") - return mode == "requires_approval" + if mode == "requires_approval" and tool_name != "AskUserQuestion": + # Denied this session / exact call? Block outright. + if is_denied(tool_name, resource): + raise PermissionError(f"{tool_name} was denied for this session") + # Already approved (session or exact)? Then let it run — otherwise park it. + if not is_granted(tool_name, resource): + raise ApprovalRequired(tool_name) + return False @@ -30,6 +38,8 @@ def build_scoped_router( spawn_fn: Callable = None, parent_config: Any = None, ask_user_question_callback: Optional[Callable] = None, + mcp_manager: Any = None, + skill_registry: Any = None, ) -> ToolRouter: """Build a ToolRouter with permission-guarded handlers. @@ -146,26 +156,32 @@ async def ask_user_question_guarded(**kwargs: Any) -> str: # ── Skill — skill execution with permission check ─────────────────────── async def execute_skill_guarded(**kwargs: Any) -> str: _gate(scope, "Skill") - return await handlers.execute_skill(**kwargs) + return await handlers.execute_skill(**kwargs, skill_registry=skill_registry) router.register_handler(ToolType.SKILL, execute_skill_guarded) # ── Task management tools with permission check ───────────────────────── + # session_id comes from the agent config, never from the LLM — tasks must stay + # scoped to the session that created them so a later prompt still sees them. + task_session_id = "" + if parent_config is not None: + task_session_id = (getattr(parent_config, "context", None) or {}).get("session_id") or "" + async def task_create_guarded(**kwargs: Any) -> str: _gate(scope, "TaskCreate") - return await handlers.task_create(**kwargs) + return await handlers.task_create(**kwargs, session_id=task_session_id) router.register_handler(ToolType.TASK_CREATE, task_create_guarded) async def task_get_guarded(**kwargs: Any) -> str: _gate(scope, "TaskGet") - return await handlers.task_get(**kwargs) + return await handlers.task_get(**kwargs, session_id=task_session_id) router.register_handler(ToolType.TASK_GET, task_get_guarded) async def task_list_guarded(**kwargs: Any) -> str: _gate(scope, "TaskList") - return await handlers.task_list(**kwargs) + return await handlers.task_list(**kwargs, session_id=task_session_id) router.register_handler(ToolType.TASK_LIST, task_list_guarded) @@ -183,7 +199,7 @@ async def task_stop_guarded(**kwargs: Any) -> str: async def task_update_guarded(**kwargs: Any) -> str: _gate(scope, "TaskUpdate") - return await handlers.task_update(**kwargs) + return await handlers.task_update(**kwargs, session_id=task_session_id) router.register_handler(ToolType.TASK_UPDATE, task_update_guarded) @@ -193,4 +209,28 @@ async def memory_search_handler(**kwargs: Any) -> str: router.register_handler(ToolType.MEMORY_SEARCH, memory_search_handler) + # PLUGIN_CONTEXT — read-only (fetches installed plugin commands/instructions/ + # rules on demand), registered unconditionally like memory_search. + async def plugin_context_handler(**kwargs: Any) -> str: + return await handlers.plugin_context(**kwargs) + + router.register_handler(ToolType.PLUGIN_CONTEXT, plugin_context_handler) + + # ── Dynamically registered MCP tools ─────────────────────────────────── + # Each healthy server's tools are namespaced ``mcp__server__tool`` and gated by + # the same PermissionScope (so deny/ask rules target them by their namespaced + # name). A degraded server contributes no tools to this router. + if mcp_manager is not None: + for definition in mcp_manager.all_tools(): + full_name = definition.name + router.mcp_tools[full_name] = definition + + # Bind the tool name by default arg: a closure over the loop var would + # late-bind every handler to the last tool's name. + async def mcp_guarded(_name: str = full_name, **kwargs: Any) -> str: + _gate(scope, _name) + return await mcp_manager.call(_name, kwargs) + + router.register_handler(full_name, mcp_guarded) + return router diff --git a/src/harness/tools/handlers.py b/src/harness/tools/handlers.py index dfb5f59..8a66452 100644 --- a/src/harness/tools/handlers.py +++ b/src/harness/tools/handlers.py @@ -145,30 +145,54 @@ async def ask_user_question( ) -> str: """Ask the user a multiple-choice question. - Delegates to the approval/UI callback when available; otherwise returns - a structured response so the LLM can proceed on its own judgment. + This stub is reached only when the factory has NOT wired a live UI callback + (headless/test runs). Raising here is intentional: silently returning a fake + "pending" response would cause the model to believe the question was shown and + answered, producing invisible data corruption. """ - import json - payload = { - "questions": questions or [], - "multi_select": multi_select, - "preview": preview, - } - # ponytail: approval_callback wired by factory when available - return json.dumps({"asked": True, "payload": payload, "pending": True}) + raise RuntimeError( + "AskUserQuestion requires a live UI callback and cannot run headless. " + "Wire a UI callback via build_scoped_router() before calling this tool." + ) -async def execute_skill(skill: str, args: str = "") -> str: +async def execute_skill( + skill: str, args: str = "", skill_registry: Any = None +) -> str: """Execute a named skill. - Delegates to the skill_registry wired by the factory. - Without a registry, returns a structured error so the LLM adapts. + Loads the skill's body via the skill_registry wired by the factory and + returns it so the LLM can follow its SKILL.md instructions (the same way a + skill is "run" by name). Without a registry, returns a structured error so + the LLM adapts. """ import json - return json.dumps({"skill": skill, "args": args, "executed": False, "reason": "Skill registry not available in this scope"}) + if skill_registry is None: + return json.dumps({"skill": skill, "args": args, "executed": False, "reason": "Skill registry not available in this scope"}) + try: + body = skill_registry.get_full(skill) + except KeyError: + available = ", ".join(sorted(s.name for s in skill_registry.list_skills())) + return json.dumps({"skill": skill, "args": args, "executed": False, "reason": f"Unknown skill. Available: {available}"}) + return json.dumps({"skill": skill, "args": args, "executed": True, "body": body}) # ── Task management handlers ────────────────────────────────────────────── +# +# Tasks live in the `user_tasks` table keyed by session_id, so a task created on +# one prompt is still pending on the next and survives a restart. + +_VALID_TASK_STATUS = ("pending", "in_progress", "completed") + + +def _task_row_to_dict(row) -> dict: + return { + "id": row.id, + "subject": row.subject, + "description": row.description or "", + "activeForm": row.active_form or "", + "status": row.status, + } async def task_create( @@ -176,22 +200,58 @@ async def task_create( description: str = "", active_form: str = "", metadata: dict | None = None, + session_id: str = "", ) -> str: """Create a new task.""" import json - return json.dumps({"created": True, "subject": subject, "id": "pending"}) - - -async def task_get(task_id: str) -> str: + from uuid import uuid4 + from harness.persistence.database import get_session + from harness.persistence.models import UserTask + + task_id = uuid4().hex + async with get_session() as db: + db.add(UserTask( + id=task_id, + session_id=session_id, + subject=subject, + description=description, + active_form=active_form, + status="pending", + metadata_json=metadata or {}, + )) + await db.commit() + return json.dumps({"created": True, "id": task_id, "subject": subject, "status": "pending"}) + + +async def task_get(task_id: str, session_id: str = "") -> str: """Retrieve task details by ID.""" import json - return json.dumps({"task_id": task_id, "found": False, "reason": "Task manager not available in this scope"}) + from sqlalchemy import select + from harness.persistence.database import get_session + from harness.persistence.models import UserTask + + async with get_session() as db: + row = (await db.execute( + select(UserTask).where(UserTask.id == task_id) + )).scalar_one_or_none() + if row is None: + return json.dumps({"task_id": task_id, "found": False, "reason": "No such task"}) + return json.dumps({"found": True, **_task_row_to_dict(row)}) -async def task_list(status: str | None = None) -> str: +async def task_list(status: str | None = None, session_id: str = "") -> str: """List tasks, optionally filtered by status.""" import json - return json.dumps({"tasks": [], "filter": status}) + from sqlalchemy import select + from harness.persistence.database import get_session + from harness.persistence.models import UserTask + + query = select(UserTask).where(UserTask.session_id == session_id) + if status: + query = query.where(UserTask.status == status) + async with get_session() as db: + rows = (await db.execute(query.order_by(UserTask.created_at))).scalars().all() + return json.dumps({"tasks": [_task_row_to_dict(r) for r in rows], "filter": status}) async def task_output(task_id: str, block: bool = True, timeout: int = 60000) -> str: @@ -212,10 +272,38 @@ async def task_update( subject: str | None = None, description: str | None = None, metadata: dict | None = None, + session_id: str = "", ) -> str: """Update a task's status, details, or metadata.""" import json - return json.dumps({"task_id": task_id, "updated": True, "status": status}) + from sqlalchemy import select + from harness.persistence.database import get_session + from harness.persistence.models import UserTask + + if status is not None and status not in _VALID_TASK_STATUS: + return json.dumps({ + "task_id": task_id, + "updated": False, + "reason": f"status must be one of {list(_VALID_TASK_STATUS)}", + }) + + async with get_session() as db: + row = (await db.execute( + select(UserTask).where(UserTask.id == task_id) + )).scalar_one_or_none() + if row is None: + return json.dumps({"task_id": task_id, "updated": False, "reason": "No such task"}) + if status is not None: + row.status = status + if subject is not None: + row.subject = subject + if description is not None: + row.description = description + if metadata is not None: + row.metadata_json = metadata + await db.commit() + payload = _task_row_to_dict(row) + return json.dumps({"updated": True, **payload}) # ── Spawn agent handler factory ─────────────────────────────────────────── @@ -260,7 +348,10 @@ def _build_child_config( project_context=parent_config.project_context, is_orchestrator=False, agent_registry=None, - skill_registry=None, + # Children share the parent's skill registry so the Skill tool they are + # advertised (and the LLM can invoke on demand) actually resolves bodies + # instead of erroring "registry not available in this scope". + skill_registry=parent_config.skill_registry, permission_scope=child_scope, spawn_depth=parent_config.spawn_depth + 1, model=parent_config.model, @@ -327,7 +418,8 @@ async def spawn_agent( "note": "Running in background — results are not collected.", }) - # Sub-agents are strict executors — no roster, no skills, no re-delegation. + # Sub-agents are strict executors — no roster, no re-delegation. They do + # share the parent's skill registry, so skills remain callable on demand. result = await spawn_fn(child_config) # Structured return so the orchestrator ingests a capsule, not a transcript. @@ -469,3 +561,79 @@ async def memory_search(query: str, source: str = "all", limit: int = 3) -> str: return f"No results found for: {query}" return "".join(results) + + +# ── Plugin context (commands/instructions/rules on demand) ──────────────── + + +def _plugin_asset_files(pdir: Path, kind: str) -> list[Path]: + """Return sorted ``*.md`` files under a plugin's kind subdir (commands/instructions/rules).""" + sub = pdir / kind + if not sub.is_dir(): + return [] + return sorted(sub.rglob("*.md")) + + +async def plugin_context(kind: str = "all", plugin: str = "") -> str: + """Fetch installed plugin commands/instructions/rules content on demand. + + Plugins are loaded lazily — this tool is how the model pulls the content in + when a task actually needs it. With no ``plugin`` it returns a catalog of + every installed plugin and the assets each ships. With ``plugin`` + a kind + it returns the full markdown bodies so the model can follow the plugin's + guidance. + """ + import json + from harness.plugins.state import installed_plugins_dir, load_state + + installed = (load_state().get("installed") or {}) + if not installed: + return json.dumps({"plugins": [], "note": "No plugins installed"}) + + def _dir(rec: dict) -> tuple[str, Path]: + rec_name = rec.get("name", "") + namespace = rec.get("marketplace") or rec_name + return f"{namespace}-{rec_name}", installed_plugins_dir() / namespace / rec_name + + # Catalog mode: enumerate every installed plugin's assets. + if not plugin: + catalog = [] + for rec in installed.values(): + label, pdir = _dir(rec) + catalog.append({ + "plugin": label, + "commands": [f.name for f in _plugin_asset_files(pdir, "commands")], + "instructions": [f.name for f in _plugin_asset_files(pdir, "instructions")], + "rules": [f.name for f in _plugin_asset_files(pdir, "rules")], + }) + return json.dumps({"plugins": catalog}) + + # Resolve the target plugin dir from a namespaced or bare name. The name is + # matched against installed records only — never treated as a filesystem path. + target_dir = None + for rec in installed.values(): + label, pdir = _dir(rec) + if plugin in (label, rec.get("name", "")): + target_dir = pdir + break + if target_dir is None: + available = ", ".join(_dir(rec)[0] for rec in installed.values()) + return json.dumps({ + "plugin": plugin, "found": False, + "reason": f"Unknown plugin. Available: {available}. Omit `plugin` to see the catalog.", + }) + + if kind not in ("commands", "instructions", "rules", "all"): + return json.dumps({ + "plugin": plugin, "found": False, + "reason": "kind must be one of: commands, instructions, rules, all", + }) + + payload: dict = {"plugin": plugin, "found": True} + for k in (["commands", "instructions", "rules"] if kind == "all" else [kind]): + files = _plugin_asset_files(target_dir, k) + payload[k] = { + "files": [f.name for f in files], + "contents": [f.read_text(encoding="utf-8", errors="replace") for f in files], + } + return json.dumps(payload) diff --git a/src/harness/tools/mcp_manager.py b/src/harness/tools/mcp_manager.py new file mode 100644 index 0000000..dab764d --- /dev/null +++ b/src/harness/tools/mcp_manager.py @@ -0,0 +1,242 @@ +"""MCP (Model Context Protocol) tool provider. + +Lets third-party MCP servers contribute tools at runtime with no code change. +Each server's tools are namespaced ``mcp____`` so they never +collide with built-ins (or each other) and so permission rules can target them +by glob (e.g. ``deny: ["mcp__prod_db__*"]``). + +Supports two transports: ``stdio`` (command/args/env) and ``streamable-http`` +(url/headers), driven by an ``mcpServers`` block in settings.json. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import Any + +import structlog + +from harness.config import get_app_settings +from harness.tools.definitions import ToolDefinition +from harness.tools.models import ToolType + +logger = structlog.get_logger(__name__) + +NAMESPACE_PREFIX = "mcp__" +# A server that hasn't connected within this budget is marked degraded rather +# than blocking agent startup. One slow server never stalls the grid. +CONNECT_TIMEOUT_SECONDS = 10 + + +class MCPProvider: + """A single MCP server connection, owned by MCPManager.""" + + def __init__(self, name: str, config: dict[str, Any]) -> None: + self.name = name + self._config = config + self._namespace = f"{NAMESPACE_PREFIX}{name}__" + self._defs: dict[str, ToolDefinition] = {} # full tool name -> definition + self._degraded = False + self._last_error = "" + self._stack: contextlib.AsyncExitStack | None = None + self._session: Any = None + self._lock = asyncio.Lock() + + # ── lifecycle ────────────────────────────────────────────────────────── + async def start(self) -> None: + """Connect and cache tools. Any failure marks the server degraded.""" + try: + async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS): + await self._connect_and_list() + self._degraded = False + self._last_error = "" + logger.info("mcp server connected", server=self.name, tools=len(self._defs)) + except Exception as exc: # noqa: BLE001 — degrade, never crash the grid + self._degraded = True + self._last_error = str(exc) + await self._dispose() + logger.warning("mcp server degraded (offline)", name=self.name, error=str(exc)) + + async def stop(self) -> None: + await self._dispose() + + async def _dispose(self) -> None: + if self._stack is not None: + with contextlib.suppress(Exception): + await self._stack.aclose() + self._stack = None + self._session = None + + # ── data exposed to the grid ─────────────────────────────────────────── + @property + def degraded(self) -> bool: + return self._degraded + + @property + def error(self) -> str: + return self._last_error + + def tool_definitions(self) -> list[ToolDefinition]: + return list(self._defs.values()) + + async def call(self, local_name: str, args: dict[str, Any]) -> str: + """Invoke a tool on this server. Reconnects on a dropped session.""" + async with self._lock: + if self._session is None: + async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS): + await self._connect_and_list() + try: + result = await self._session.call_tool(local_name, arguments=args) + return _format_call_result(result) + except Exception as exc: # noqa: BLE001 — surface to caller + degrade + self._degraded = True + self._last_error = str(exc) + raise + + # ── internals ────────────────────────────────────────────────────────── + async def _connect_and_list(self) -> None: + """(Re)establish a session and refresh the cached tool list.""" + from mcp import ClientSession + + new_stack = contextlib.AsyncExitStack() + try: + if "url" in self._config: + read, write = await new_stack.enter_async_context(self._http_session()) + else: + read, write = await new_stack.enter_async_context(self._stdio_client()) + session = await new_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + + listed = await session.list_tools() + self._defs = { + f"{self._namespace}{tool.name}": ToolDefinition( + name=f"{self._namespace}{tool.name}", + tool_type=ToolType.READ, # dynamic identity; kind unused for MCP + description=tool.description or tool.name, + json_schema=tool.inputSchema or {}, + ) + for tool in listed.tools + } + # Swap in the new stack only after everything succeeded. + old, self._stack, self._session = self._stack, new_stack, session + if old is not None: + with contextlib.suppress(Exception): + await old.aclose() + except Exception: + with contextlib.suppress(Exception): + await new_stack.aclose() + raise + + @contextlib.asynccontextmanager + async def _stdio_client(self): + from mcp.client.stdio import StdioServerParameters, stdio_client + + command = self._config.get("command") + if not command: + raise ValueError(f"mcp server '{self.name}' needs a 'command'") + params = StdioServerParameters( + command=command, + args=self._config.get("args") or [], + env=self._config.get("env"), + ) + async with stdio_client(params) as streams: + yield streams # (read, write) + + @contextlib.asynccontextmanager + async def _http_session(self): + import httpx + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + + url = self._config["url"] + headers = self._config.get("headers") or {} + async with httpx.AsyncClient(headers=headers) as http_client: + async with streamable_http_client(url, http_client=http_client) as streams: + # streams = (read, write, get_session_id); unwrap to (read, write). + yield streams[:2] + + +def _format_call_result(result) -> str: + """Flatten an MCP call result into a plain string for the agent.""" + parts: list[str] = [] + for block in getattr(result, "content", []) or []: + text = getattr(block, "text", None) + if isinstance(text, str): + parts.append(text) + elif (st := getattr(block, "structuredContent", None)) is not None: + import json as _json + + parts.append(_json.dumps(st, default=str)) + body = "\n".join(p for p in parts if p).strip() + if getattr(result, "isError", False) or not body: + return body or f"[mcp tool error: {getattr(result, 'isError', False)}]" + return body + + +class MCPManager: + """Owns all configured MCP servers and exposes their tools to the grid.""" + + def __init__(self) -> None: + self._providers: dict[str, MCPProvider] = {} + self._started = False + + # ── lifecycle ────────────────────────────────────────────────────────── + @property + def started(self) -> bool: + return self._started + + async def load_from_settings(self) -> None: + """Read ``mcpServers`` from settings.json and register each server.""" + servers = get_app_settings().get("mcpServers", {}) or {} + for name, config in servers.items(): + if name in self._providers: + continue + self._providers[name] = MCPProvider(name, config) + self._started = True + + async def add(self, name: str, config: dict[str, Any]) -> MCPProvider: + """Dynamically add a server at runtime (hot-pluggable).""" + provider = MCPProvider(name, config) + self._providers[name] = provider + await provider.start() + return provider + + async def remove(self, name: str) -> None: + provider = self._providers.pop(name, None) + if provider is not None: + await provider.stop() + + async def start(self) -> None: + """Start every registered server concurrently; degraded ones are skipped.""" + if not self._providers: + return + await asyncio.gather(*(p.start() for p in self._providers.values())) + + async def stop(self) -> None: + await asyncio.gather(*(p.stop() for p in self._providers.values())) + + # ── grid-facing helpers ──────────────────────────────────────────────── + def healthy(self) -> list[MCPProvider]: + return [p for p in self._providers.values() if not p.degraded] + + def all_tools(self) -> list[ToolDefinition]: + """Tool definitions from every healthy server.""" + out: list[ToolDefinition] = [] + for p in self._providers.values(): + if not p.degraded: + out.extend(p.tool_definitions()) + return out + + def get(self, name: str) -> MCPProvider | None: + return self._providers.get(name) + + async def call(self, full_name: str, args: dict[str, Any]) -> str: + """Route ``mcp__server__tool`` by its namespace prefix.""" + parts = full_name.split("__", 2) + if len(parts) != 3 or parts[0] != "mcp": + raise ValueError(f"Not an MCP tool: {full_name}") + provider = self._providers.get(parts[1]) + if provider is None: + raise ValueError(f"unknown mcp server: {parts[1]}") + return await provider.call(parts[2], args) \ No newline at end of file diff --git a/src/harness/tools/models.py b/src/harness/tools/models.py index a933eaf..15da78f 100644 --- a/src/harness/tools/models.py +++ b/src/harness/tools/models.py @@ -6,18 +6,26 @@ from datetime import datetime -class ToolType(Enum): - """Supported tool types.""" +class ToolType(str, Enum): + """Built-in tool types. + + Subclasses str so members are interchangeable with the plain tool-name + strings used for dynamically registered (MCP/plugin) tools: routers, + registries and permission checks are all keyed by str. + + Values MUST equal the LLM-facing name in TOOL_REGISTRY, since that name is + what the model sends back and what permission rules match on. + """ READ = "Read" WRITE = "Write" EDIT = "Update" BASH = "Bash" - GREP = "Pattern" - GLOB = "Search" + GREP = "Grep" + GLOB = "Glob" GIT = "Git" HTTP = "HTTP" SPAWN_AGENT = "Agent" - ATTEMPT_COMPLETION = "Is_completion" + ATTEMPT_COMPLETION = "Completion" ASK_USER_QUESTION = "AskUserQuestion" SKILL = "Skill" TASK_CREATE = "TaskCreate" @@ -27,6 +35,7 @@ class ToolType(Enum): TASK_STOP = "TaskStop" TASK_UPDATE = "TaskUpdate" MEMORY_SEARCH = "MemorySearch" + PLUGIN_CONTEXT = "PluginContext" class ToolStatus(Enum): diff --git a/src/harness/tools/permissions.py b/src/harness/tools/permissions.py index 1f2c0ed..60f19e7 100644 --- a/src/harness/tools/permissions.py +++ b/src/harness/tools/permissions.py @@ -15,13 +15,28 @@ from harness.config import get_app_settings +class ApprovalRequired(Exception): + """Raised when a tool+resource needs human approval but has not been granted. + + The tool executor converts this into an AWAITING_APPROVAL result so the call + is never executed silently. + """ + + @dataclass class ToolPermission: - """Single tool's permission state.""" + """Single tool's permission state. + + ``patterns`` scopes the tool's mode: for ``ask`` they narrow which resources + prompt, for ``deny`` which resources are blocked. ``deny_patterns`` carries + path-scoped deny rules on an otherwise-allowed tool (e.g. Read allowed + everywhere except ``.env*`` / ``.git/*``). + """ tool: str mode: Literal["allow", "deny", "ask"] - patterns: list[str] = field(default_factory=list) # e.g., ["Read(.env*)", "Write(.git/*)"] + patterns: list[str] = field(default_factory=list) # scopes the mode + deny_patterns: list[str] = field(default_factory=list) # path-scoped deny on an allow/ask tool @dataclass @@ -52,18 +67,51 @@ def default_for_project(cls, project_root: Path) -> "PermissionScope": app_settings = get_app_settings() perm_config = app_settings.get("permissions", {}) - # Build tool permissions from allow/deny/ask lists - tools = {} - for tool in perm_config.get("allow", []): - tools[tool] = ToolPermission(tool=tool, mode="allow") - - for tool in perm_config.get("deny", []): - tools[tool] = ToolPermission(tool=tool, mode="deny") - + allow_list = perm_config.get("allow", []) + deny_list = perm_config.get("deny", []) + ask_list = perm_config.get("ask", []) patterns_config = perm_config.get("patterns", {}) - for tool in perm_config.get("ask", []): - patterns = patterns_config.get(tool, []) - tools[tool] = ToolPermission(tool=tool, mode="ask", patterns=patterns) + + # Build per-tool permissions with path scoping. Priority: deny > ask > + # allow — a tool in several lists keeps each concern, instead of the last + # list silently overwriting the others (which is what blanketed Read in + # deny and dropped its patterns). + tools: dict[str, ToolPermission] = {} + + # Deny is strongest: whole-tool, or path-scoped via `patterns`. + for tool in deny_list: + tools[tool] = ToolPermission( + tool=tool, mode="deny", patterns=list(patterns_config.get(tool, [])) + ) + + # Ask: prompt on matching paths, or always when the entry has no patterns. + # A tool already deny-scoped (e.g. Write on .git/*) keeps those hard deny + # paths while prompting for everything else. + for tool in ask_list: + existing = tools.get(tool) + if existing is not None and existing.mode == "deny": + tools[tool] = ToolPermission( + tool=tool, + mode="ask", + patterns=[], + deny_patterns=list(existing.patterns), + ) + else: + tools[tool] = ToolPermission( + tool=tool, mode="ask", patterns=list(patterns_config.get(tool, [])) + ) + + # Allow is weakest. An allowlisted tool that is also deny-scoped (e.g. + # Read on .env*/.git/*) keeps those deny paths (hard block) but is allowed + # everywhere else. + for tool in allow_list: + existing = tools.get(tool) + if existing is not None and existing.mode == "deny": + tools[tool] = ToolPermission( + tool=tool, mode="allow", deny_patterns=list(existing.patterns) + ) + else: + tools.setdefault(tool, ToolPermission(tool=tool, mode="allow")) # If no explicit config, default to allow common tools if not tools: @@ -83,44 +131,93 @@ def default_for_project(cls, project_root: Path) -> "PermissionScope": def check(self, tool: str, resource: str = "") -> tuple[bool, str | None]: """Check if tool+resource is allowed. + Rules are evaluated in priority order: + 1. deny — hard block, path-scoped by the tool's patterns (a deny entry + with no patterns blocks the tool everywhere). + 2. alwaysAsk — always requires approval. + 3. ask — requires approval on matching paths (or always when the entry + carries no patterns). + 4. allow — allowed without approval. + 5. default — third-party MCP tools ask; strict scope denies; else allowed. + Returns (allowed, mode) where mode is: - None: allowed without approval - "requires_approval": allowed but needs user approval - - PermissionError: denied (caller should raise) + - caller treats (False, None) as denied """ perm = self.tools.get(tool) - # Deny is absolute (checked before always_ask) + # 1. Deny first. Path-scoped deny blocks only matching resources. if perm and perm.mode == "deny": + if not perm.patterns or self._matches_patterns(resource, perm.patterns): + return False, None + elif perm and perm.deny_patterns and self._matches_patterns(resource, perm.deny_patterns): return False, None - # Always-ask tools require approval + # 2. Always-ask tools require approval if tool in self.always_ask: return True, "requires_approval" - if not perm: - if self.default_mode == "strict": - return False, None + # 3. Ask — prompt on matching paths (or always when unpatterned) + if perm and perm.mode == "ask": + if not perm.patterns or self._matches_patterns(resource, perm.patterns): + return True, "requires_approval" return True, None - if perm.mode == "ask": - if self._matches_patterns(resource, perm.patterns): - return True, "requires_approval" + # 4. Allow + if perm and perm.mode == "allow": return True, None + # 5. Default for tools with no explicit rule + if not perm: + # Third-party MCP tools default to ask (approval) unless the operator + # explicitly allowlists them (or the scope is permissive). + if tool.startswith("mcp__") and self.default_mode != "permissive": + return True, "requires_approval" + if self.default_mode == "strict": + return False, None + return True, None @staticmethod def _matches_patterns(resource: str, patterns: list[str]) -> bool: - """Check if resource matches any pattern (e.g., "Read(.env*)" → deny .env files).""" + """Check if resource matches any pattern. + + Accepts both "Tool(glob)" entries (e.g. "Read(.env*)") and the bare globs + used in settings.json's ``patterns`` block (e.g. ".env*", ".git/*"). + + Uses fnmatch semantics so ``*`` never crosses a path separator, preventing + patterns like ``.env*`` from matching ``/deep/nested/.env``. + """ + import fnmatch + from pathlib import PurePosixPath + for pattern in patterns: - # Parse "Tool(glob_pattern)" → extract glob and convert to regex + glob_part = pattern if "(" in pattern and ")" in pattern: - _, glob_part = pattern.split("(", 1) - glob_part = glob_part.rstrip(")") - regex = glob_part.replace("*", ".*").replace("?", ".") - if re.match(f"^{regex}$", resource): + # "Read(.env*)" → ".env*" + glob_part = pattern.split("(", 1)[1].rstrip(")") + + # Normalise separators so Windows paths work with posix-style globs. + norm_resource = resource.replace("\\", "/") + norm_glob = glob_part.replace("\\", "/") + + if "/" in norm_glob: + # Path-qualified glob (e.g. ".git/*", "src/**/*.py") — use + # PurePosixPath.match() which handles "**" and keeps "*" within a + # single segment. + try: + if PurePosixPath(norm_resource).match(norm_glob): + return True + except Exception: + pass + else: + # Bare filename glob (e.g. ".env*", "*.key") — match against the + # last path component only so it cannot cross directory boundaries. + basename = norm_resource.rsplit("/", 1)[-1] if "/" in norm_resource else norm_resource + if fnmatch.fnmatch(basename, norm_glob): return True + return False def without_agent_spawn(self) -> "PermissionScope": diff --git a/src/harness/tools/router.py b/src/harness/tools/router.py index f784f2d..8d7e6d5 100644 --- a/src/harness/tools/router.py +++ b/src/harness/tools/router.py @@ -6,6 +6,7 @@ import structlog from .models import ToolCall, ToolType, ToolStatus, ToolResult, ToolBudget +from .permissions import ApprovalRequired logger = structlog.get_logger(__name__) @@ -15,13 +16,15 @@ class ToolRouter: def __init__(self): self.budget = ToolBudget() - self.handlers: Dict[ToolType, Callable] = {} + self.handlers: Dict[str, Callable] = {} + # Dynamically registered (MCP) tool definitions, keyed by LLM name. + self.mcp_tools: Dict[str, Any] = {} self.call_history: list[ToolCall] = [] - def register_handler(self, tool_type: ToolType, handler: Callable) -> None: - """Register a tool handler.""" + def register_handler(self, tool_type: str, handler: Callable) -> None: + """Register a tool handler keyed by its LLM-facing name.""" self.handlers[tool_type] = handler - logger.info(f"Registered handler for {tool_type.value}") + logger.info(f"Registered handler for {getattr(tool_type, 'value', tool_type)}") async def call( self, @@ -41,29 +44,35 @@ async def call( if not self.budget.has_budget: raise RuntimeError("Token budget exhausted") - # Check handler exists + tool_name = tool_type if isinstance(tool_type, str) else getattr(tool_type, "value", str(tool_type)) + + # Unified dispatch: built-in and MCP tools (mcp__server__tool) are both + # registered in self.handlers by factory.build_scoped_router(). if tool_type not in self.handlers: - raise ValueError(f"Unknown tool: {tool_type.value}") + raise ValueError(f"Unknown tool: {tool_name}") - # Execute tool handler = self.handlers[tool_type] - logger.info(f"Calling {tool_type.value}", args=kwargs) - + logger.info(f"Calling {tool_name}", args=kwargs) result = await handler(**kwargs) tool_call.status = ToolStatus.SUCCESS tool_call.result = result tool_call.tokens_used = len(str(result).split()) + except ApprovalRequired: + # Not an execution failure — permission gate wants human approval. + # Re-raise so the executor parks the call as AWAITING_APPROVAL. + raise + except asyncio.TimeoutError: tool_call.status = ToolStatus.TIMEOUT tool_call.error = "Tool execution timed out" - logger.warning(f"Timeout for {tool_type.value}") + logger.warning(f"Timeout for {getattr(tool_type, 'value', tool_type)}") except Exception as e: tool_call.status = ToolStatus.FAILED tool_call.error = str(e) - logger.error(f"Tool failed: {tool_type.value}", error=str(e)) + logger.error(f"Tool failed: {getattr(tool_type, 'value', tool_type)}", error=str(e)) finally: tool_call.completed_at = datetime.now() diff --git a/src/harness/ui/renderers.py b/src/harness/ui/renderers.py index 4d57aa4..8086a08 100644 --- a/src/harness/ui/renderers.py +++ b/src/harness/ui/renderers.py @@ -7,6 +7,64 @@ from .markdown_text import render_markdown +def option_text(opt: dict, num: Any = "") -> str: + """Resolve an option's display text across the accepted key spellings. + + The tool schema documents `label`, older payloads use `title`, and models + sometimes emit `text`/`option`. Rendering and answer capture MUST share this + resolver — when they diverged, the picker displayed the choice while capture + returned "" and the model was told no input was given. + """ + return ( + opt.get("title") or opt.get("label") or opt.get("text") + or opt.get("option") or (f"Option {num}" if num != "" else "") + ) + + +# Approval picker options — single source of truth for rendering AND key dispatch. +# `key` is the single-key shortcut, `decision` is what terminal.py maps through +# _finish_approval() to the grant/deny policy. Order = display order; the first +# option is the initial ▸ focus, and the keybar shows its action live so the +# human always sees what Enter will do before pressing it. +APPROVAL_OPTIONS = [ + { + "key": "y", + "num": 1, + "label": "Allow once", + "detail": "Run this tool call one time", + "decision": "approved", + }, + { + "key": "a", + "num": 2, + "label": "Allow for session", + "detail": "Run it and don't ask again for this tool during this session", + "decision": "approved_session", + }, + { + "key": "p", + "num": 3, + "label": "Always allow", + "detail": "Remember the choice and save to project settings", + "decision": "persist", + }, + { + "key": "n", + "num": 4, + "label": "Deny once", + "detail": "Block this tool call", + "decision": "denied", + }, + { + "key": "s", + "num": 5, + "label": "Deny for session", + "detail": "Suppress further prompts for this tool during this session", + "decision": "denied_session", + }, +] + + def _format_args_compact(args: Optional[dict] = None, max_val: int = 40) -> str: """Format tool arguments as a compact display string.""" if not args: @@ -316,20 +374,18 @@ def render_processing_indicator( is_processing: bool, show_indicator: bool, tasks: Optional[list] = None, + awaiting_approval: bool = False, ) -> Text: - """Render * Blinking... + |_ + task list in processing area when tasks exist. + """Render * Blinking... + |_ + task list in processing area. - When tasks exist and is_processing: - - * Blinking... header toggles on/off with show_indicator (both blink together) - - |_ tree connector always visible - - Task checkboxes: □ pending, ■ running, ■ strikethrough done - When no tasks or not processing: returns empty Text. + The header renders whenever the system is processing — even with zero + tasks — so the running indicator reflects actual activity, not task-board + creation. The |_ tree and task checkboxes only render when tasks exist. + When not processing: returns empty Text. """ result = Text() if not is_processing: return result - if not tasks: - return result # Header: * Blinking... (both toggle with show_indicator for blink effect) if show_indicator: @@ -337,26 +393,29 @@ def render_processing_indicator( result.append("Blinking...", style=Styles.WORKING_BLINK) result.append("\n") - # Tree connector - result.append("|_\n", style=Styles.OPTION_DETAIL) - - # Task list - for task in tasks: - status = task.get("status", "pending") - subject = task.get("subject", task.get("name", "Untitled")) - if len(subject) > 55: - subject = subject[:55] + "…" - result.append(" ", style=Styles.OPTION_DETAIL) - if status == "completed": - result.append("■ ", style=Styles.TASK_BOX_DONE) - result.append(subject, style=Styles.TASK_TEXT_DONE) - elif status == "in_progress": - result.append("■ ", style=Styles.TASK_BOX_RUNNING) - result.append(subject, style=Styles.USER_TEXT) - else: - result.append("□ ", style=Styles.TASK_BOX_PENDING) - result.append(subject, style=Styles.TASK_META) - result.append("\n") + # Awaiting-approval banner — shown whenever the agent is parked on a picker. + if awaiting_approval: + result.append(" Awaiting approval...\n", style=Styles.OPTION_DETAIL) + + # Tree connector + task list only when tasks exist + if tasks: + result.append("|_\n", style=Styles.OPTION_DETAIL) + for task in tasks: + status = task.get("status", "pending") + subject = task.get("subject", task.get("name", "Untitled")) + if len(subject) > 55: + subject = subject[:55] + "…" + result.append(" ", style=Styles.OPTION_DETAIL) + if status == "completed": + result.append("■ ", style=Styles.TASK_BOX_DONE) + result.append(subject, style=Styles.TASK_TEXT_DONE) + elif status == "in_progress": + result.append("■ ", style=Styles.TASK_BOX_RUNNING) + result.append(subject, style=Styles.USER_TEXT) + else: + result.append("□ ", style=Styles.TASK_BOX_PENDING) + result.append(subject, style=Styles.TASK_META) + result.append("\n") return result @@ -539,7 +598,7 @@ def render_ask_user_questions(state: dict, width: int = 80) -> Text: result.append(" ", style=Styles.CURSOR_BLINK) num = opt.get("num", oi + 1) - title = opt.get("title") or opt.get("label") or opt.get("text") or opt.get("option") or f"Option {num}" + title = option_text(opt, num) cstyle = Styles.OPTION_FOCUS if (is_focused or is_selected) else Styles.OPTION_NORMAL result.append(f"{num}. ", style=Styles.TASK_BOX_PENDING) @@ -802,16 +861,55 @@ def render_task_board(tasks: list, goal: str = "", @staticmethod def render_permission_prompt(tool: str, command_str: str, risk: str, - description: str = "") -> Text: - """Render permission prompt as inline text.""" + description: str = "", focus_idx: int = 0, + width: int = 80) -> Text: + """Render the permission prompt as a vertical, selectable option list. + + Shares the AskUserQuestion picker's visual language (thick dividers, + a ▸ focus marker, aligned brackets) so approval feels like part of the + same UI. The focused option is highlighted and repeated in the keybar + ("▶ Allow once") so the human always sees what Enter will do — no hidden + default, no accidental approve. + """ + divider = "═" * min(width, 80) result = Text() - result.append(f" Tool: {tool}\n", style=Styles.USER_TEXT) - result.append(f" Command: {command_str[:80]}\n", style=Styles.AI) + result.append(divider + "\n", style=Styles.DIVIDER) + result.append(" 🔐 Permission required\n\n", style=Styles.SUBMIT_HEADING) + + # ── Tool / command / risk / summary ───────────────────────────── + result.append(" Tool ", style=Styles.USER_TEXT) + result.append(f"{tool}\n", style=Styles.AI) + if command_str: + result.append(" Command ", style=Styles.USER_TEXT) + result.append(f"{command_str[:80]}\n", style=Styles.AI) + risk_color = Styles.WORKING if risk in ("high", "critical") else Styles.TOOL_COUNT + result.append(" Risk ", style=Styles.USER_TEXT) + result.append(f"{risk}\n", style=risk_color) if description: result.append(f" {description}\n", style=Styles.AI) - risk_color = Styles.WORKING if risk in ("high", "critical") else Styles.TOOL_COUNT - result.append(f" Risk: {risk}\n", style=risk_color) - result.append("\n [Y] Yes [N] No [A] Always [S] Skip", style=Styles.USER_TEXT) + result.append("\n") + + # ── Vertical options with ▸ focus marker ──────────────────────── + n_opts = len(APPROVAL_OPTIONS) + for oi, opt in enumerate(APPROVAL_OPTIONS): + is_focused = oi == (focus_idx % n_opts) + label_style = Styles.OPTION_FOCUS if is_focused else Styles.OPTION_NORMAL + bracket_style = Styles.PICKER_FOCUS if is_focused else Styles.PICKER_NORMAL + detail_style = Styles.OPTION_DETAIL if is_focused else Styles.INPUT_PLACEHOLDER + + result.append("▸ " if is_focused else " ", style=Styles.CURSOR_BLINK) + result.append(f"{opt['num']}. ", style=Styles.TASK_BOX_PENDING) + result.append(f"{opt['label']}{' ' * (22 - len(opt['label']))}", + style=label_style) + result.append(f"[{opt['key'].upper()}]\n", style=bracket_style) + result.append(f" {opt['detail']}\n", style=detail_style) + + # ── Keybar: navigation hint + live Enter action ───────────────── + focused = APPROVAL_OPTIONS[focus_idx % n_opts] + result.append(" ↑↓ navigate Enter select ", style=Styles.KEYBAR_BG) + result.append(f"▶ {focused['label']} ", style=Styles.PICKER_FOCUS) + result.append("1-5 / letter quick-pick\n", style=Styles.KEYBAR_BG) + result.append(divider + "\n", style=Styles.DIVIDER) return result @staticmethod diff --git a/src/harness/ui/terminal.py b/src/harness/ui/terminal.py index ac7f24d..cb80a8c 100644 --- a/src/harness/ui/terminal.py +++ b/src/harness/ui/terminal.py @@ -6,6 +6,7 @@ import time from collections import OrderedDict from datetime import datetime, timedelta +from pathlib import Path from typing import Optional, TYPE_CHECKING from rich.layout import Layout from rich.live import Live @@ -18,11 +19,11 @@ from .state import UIState from .keybinds import KeybindMap, KeyCode from .input_handler import InputHandler, KeyEvent -from .command_palette import CommandPalette +from .command_palette import Command, CommandPalette from .command_actions import CommandActions from .stream_listener import StreamListener, LogEntry from .stream_aggregator import StreamAggregator -from .renderers import OutputRenderer +from .renderers import OutputRenderer, option_text, APPROVAL_OPTIONS from .claude_code_style import Styles if TYPE_CHECKING: @@ -41,6 +42,10 @@ ("╎", "#fbbf24"), # amber ] +# Letter shortcuts → approval option, hoisted so every keypress doesn't rebuild +# the dict. Single source of truth stays in renderers.APPROVAL_OPTIONS. +_APPROVAL_SHORTCUTS = {opt["key"]: opt for opt in APPROVAL_OPTIONS} + class TerminalUI: """Claude Code-style terminal UI orchestrator.""" @@ -83,6 +88,12 @@ def __init__(self, llm_client: Optional["LLMClient"] = None): self.input_handler = InputHandler(self.keybinds) self.command_palette = CommandPalette() + # Command palette interaction state (open/query/selection index). + # Drives the palette overlay rendered in the picker slot. Ctrl+K toggles. + self._palette_open = False + self._palette_query = "" + self._palette_index = 0 + # Phase 2C: Real-time streams self.stream_listener = StreamListener() self.stream_aggregator = StreamAggregator(self.stream_listener) @@ -127,6 +138,12 @@ def __init__(self, llm_client: Optional["LLMClient"] = None): # Pending approvals: list of ApprovalRequest rows from DB, polled each render self._pending_approvals: list = [] self._current_approval_idx: int = 0 + # Focus index within the current approval's vertical option list (↑↓/1-5/Enter). + self._approval_focus_idx: int = 0 + # approval_id → asyncio.Future for each live approval prompt. The executor + # blocks on handle_approval_request until the Y/N/A/S/P picker resolves + # this future with the human's decision string. + self._approval_futures: dict[str, asyncio.Future] = {} # Pending question interactive picker self._pending_question: Optional[dict] = None @@ -194,33 +211,114 @@ def request_approval(self): """Return the handle_approval_request coroutine for orchestrator wiring.""" return self.handle_approval_request - async def handle_approval_request(self, action: str, tool: str, risk_level: str = "medium") -> bool: - """Handle approval request from orchestrator. + async def handle_approval_request(self, action, tool: str, risk_level: str = "medium") -> Optional[str]: + """Block on the human's approval decision via the Y/N/A/S/P picker. - Returns True if approved, False otherwise. + Renders the approval in the picker area and WAITS — the executor's tool + call does not continue until the human presses Y/N/A/S/P (or the + configured approval_timeout_seconds elapses). Returns the decision the + executor maps to an outcome: + - "approved" / "approved_session" / "persist" → execute the call + - "denied" / "denied_session" → fail the call + - None (headless, no live UI) → park as AWAITING_APPROVAL SECURITY: Fails CLOSED — denies on any system error (never approve if safety check fails). """ - from harness.core.approval_manager import create_approval_request, approve_request + from uuid import uuid4 + from datetime import datetime + from harness.persistence.database import get_session + from harness.persistence.models import ApprovalRequest + from harness.config import get_settings import logging try: - req = await create_approval_request( - action=action, - tool=tool, + # A concurrent request for the SAME call (model retry before the human + # decides, or two agents racing) joins the in-flight future instead of + # stacking a duplicate picker prompt. + # + # Use a stable SHA-256 fingerprint of the action dict so that serialised + # and re-parsed copies of the same action compare equal, not just + # identical Python dict objects. + import hashlib + import json as _json + + def _action_fingerprint(a) -> str: + if isinstance(a, dict): + return hashlib.sha256( + _json.dumps(a, sort_keys=True, default=str).encode() + ).hexdigest() + return hashlib.sha256(str(a).encode()).hexdigest() + + incoming_fp = _action_fingerprint(action) + + for aid, fut in self._approval_futures.items(): + if fut.done(): + continue + for a in self._pending_approvals: + if getattr(a, "approval_id", None) == aid: + existing_fp = _action_fingerprint(getattr(a, "proposed_action", None)) + if existing_fp == incoming_fp: + return await self._wait_for_decision(fut, get_settings()) + + req = ApprovalRequest( + approval_id=uuid4().hex, + proposed_action=action, + status="pending", + idempotency_key=uuid4().hex, risk_level=risk_level, - status="pending" + summary=f"Execute tool: {tool}", + created_at=datetime.now(), ) + async with get_session() as db: + db.add(req) + await db.commit() + self._pending_approvals.append(req) + self._current_approval_idx = 0 + self._approval_focus_idx = 0 self._dirty = True - await approve_request(req.id, approved=True) - return True + future: asyncio.Future = asyncio.Future() + self._approval_futures[req.approval_id] = future + + return await self._wait_for_decision(future, get_settings()) except Exception as e: logging.error(f"SECURITY: Approval system failed for tool '{tool}': {str(e)}", exc_info=True) self.main_panel.add_error( f"⚠️ Approval system error for {tool}. Tool execution BLOCKED for safety." ) - return False # Fail closed: deny by default on any error + return "denied" # Fail closed: deny by default on any error + + async def _wait_for_decision(self, future: asyncio.Future, settings) -> str: + """Await the picker's decision, applying the timeout+action policy. + + On timeout the pending approval is resolved via approval_timeout_action + ("deny" default, "approve" opt-in) so the executor never hangs and never + double-prompts. + """ + timeout_seconds = settings.approval_timeout_seconds + try: + if timeout_seconds > 0: + decision = await asyncio.wait_for(future, timeout=timeout_seconds) + else: + decision = await future + return decision + except asyncio.TimeoutError: + approval = None + for aid, f in self._approval_futures.items(): + if f is future: + for a in self._pending_approvals: + if getattr(a, "approval_id", None) == aid: + approval = a + break + break + if approval is not None: + decision = ( + "approved" if settings.approval_timeout_action == "approve" + else "denied" + ) + await self._finish_approval(approval, decision) + return decision + return "denied" def _validate_questions(self, questions: Optional[list]) -> list: """Ensure questions structure is valid, return safe default if not.""" @@ -369,13 +467,19 @@ async def _handle_picker_key(self, key: str) -> bool: for qi in range(len(qs)): cv = state["custom_values"].get(qi, "").strip() if cv: - collected[str(qi)] = cv + answer = cv else: + answer = "" sel = state["selections"].get(qi) if sel is not None: opts = qs[qi].get("options", []) if 0 <= sel < len(opts): - collected[str(qi)] = opts[sel].get("title", "") + answer = option_text(opts[sel], sel + 1) + if answer: + collected[str(qi)] = { + "question": qs[qi].get("question", ""), + "answer": answer, + } state["answers"] = collected if state["future"] and not state["future"].done(): state["future"].set_result({"answers": collected}) @@ -456,6 +560,105 @@ async def _handle_picker_key(self, key: str) -> bool: # (old picker helpers removed — replaced by tab-based _handle_picker_key) + # ── Command palette (Ctrl+K) ────────────────────────────────────────── + + async def _handle_palette_key(self, key: str) -> None: + """Route a key to the open command palette.""" + if key in (KeyCode.ESCAPE.value, "\x1b"): + self._palette_open = False + self._dirty = True + return + if key in (KeyCode.ENTER.value, "\r", "\n"): + self._palette_open = False + results = self.command_palette.search(self._palette_query) + if results and 0 <= self._palette_index < len(results): + cmd = results[self._palette_index] + if cmd.handler: + await cmd.handler() + self._dirty = True + return + if key in (KeyCode.UP.value, "k"): + results = self.command_palette.search(self._palette_query) + if results: + self._palette_index = (self._palette_index - 1) % len(results) + self._dirty = True + return + if key in (KeyCode.DOWN.value, "j"): + results = self.command_palette.search(self._palette_query) + if results: + self._palette_index = (self._palette_index + 1) % len(results) + self._dirty = True + return + if key in (KeyCode.BACKSPACE.value, "\x7f", KeyCode.CTRL_H.value): + self._palette_query = self._palette_query[:-1] + self._palette_index = 0 + self._dirty = True + return + if key and len(key) == 1 and key.isprintable(): + self._palette_query += key + self._palette_index = 0 + self._dirty = True + + def _render_palette(self, width: int) -> Text: + """Render the command palette overlay: query line + filtered results.""" + results = self.command_palette.search(self._palette_query) + visible = results[:8] if results else [] + lines = [ + Text("Command Palette", style=Styles.HEADER_TITLE), + Text(f": {self._palette_query}▌", style=Styles.AI), + ] + if not visible: + lines.append(Text("No matching commands", style=Styles.TOOL_DOT_ERROR)) + else: + for i, cmd in enumerate(visible): + style = Styles.PICKER_FOCUS if i == self._palette_index else Styles.PICKER_NORMAL + prefix = "▸ " if i == self._palette_index else " " + desc = f" {cmd.description}" if cmd.description else "" + lines.append(Text(f"{prefix}{cmd.shortcut:<16}{desc}", style=style)) + return Text("\n".join(str(l) for l in lines)) + + def mount_plugin_commands(self, commands: list) -> int: + """Mount plugin slash-commands into the palette. + + Each ``{name, description, path, plugin}`` entry (from + ``harness.plugins.loader.collect_plugin_commands``) becomes a palette + command ``:`` whose handler reads the command body (COMMAND.md) + on demand and renders it as markdown. Returns the number mounted. + """ + mounted = 0 + for entry in commands or []: + name = entry.get("name", "") + if not name: + continue + path = Path(entry.get("path", "")) + plugin = entry.get("plugin", "") + description = entry.get("description", "") or f"Plugin command from {plugin}" + + async def handler(path=path, plugin=plugin): + try: + content = path.read_text(encoding="utf-8", errors="replace") + except OSError: + self.main_panel.add_error( + f"Failed to read plugin command from {plugin}: {path}" + ) + self._dirty = True + return + self.main_panel.add_text( + OutputRenderer.render_block("command", content, markdown=True) + ) + self._dirty = True + + self.command_palette.register( + Command( + name=f"{name} ({plugin})", + description=description, + shortcut=f":{name}", + handler=handler, + ) + ) + mounted += 1 + return mounted + # ── Task board helpers ───────────────────────────────────────────────── def _ensure_task_board(self, tasks: Optional[list] = None) -> None: @@ -473,6 +676,41 @@ def _ensure_task_board(self, tasks: Optional[list] = None) -> None: self._dirty = True self._refresh_task_board() + def _process_task_event(self, tool_name: str, data: dict) -> None: + """Update the task board from a task-management tool event. + + TaskCreate's REAL id comes from its result event (the call-start event + only carries the LLM's args, no id). TaskUpdate carries the real id in + its args and must update the entry TaskCreate made — keyed by that id so + the subject survives. Fabricating a board id on the TaskCreate call-start + event was the bug: later TaskUpdate (real UUID) missed the fabricated key + and created a bogus "Untitled / in_progress" entry. + """ + if tool_name == "TaskCreate": + result_raw = data.get("result") + if result_raw: + try: + parsed = json.loads(result_raw) + except (json.JSONDecodeError, TypeError): + parsed = {} + if isinstance(parsed, dict) and parsed.get("id"): + self._update_task({ + "task_id": parsed["id"], + "subject": parsed.get("subject", "Untitled"), + "status": parsed.get("status", "pending"), + "active_form": parsed.get("active_form", ""), + }) + elif tool_name == "TaskUpdate" and data.get("args"): + self._update_task(data["args"]) + elif tool_name == "TaskList" and data.get("result"): + try: + parsed = json.loads(data["result"]) + tasks = parsed.get("tasks", []) if isinstance(parsed, dict) else [] + if tasks: + self._ensure_task_board(tasks) + except (json.JSONDecodeError, TypeError, AttributeError): + pass + def _update_task(self, task_data: dict) -> None: """Update a task in the board from a TaskUpdate event.""" tid = task_data.get("task_id", "") @@ -628,7 +866,16 @@ async def on_quit(event: KeyEvent): self.state.shutdown() self.running = False + async def on_open_palette(event: KeyEvent): + """Toggle the command palette (Ctrl+K).""" + self._palette_open = not self._palette_open + if self._palette_open: + self._palette_query = "" + self._palette_index = 0 + self._dirty = True + # Register handlers + self.input_handler.register_handler("open_palette", on_open_palette) self.input_handler.register_handler("submit_input", on_submit_input) self.input_handler.register_handler("delete_char", on_delete_char) self.input_handler.register_handler("history_prev", on_history_prev) @@ -666,24 +913,8 @@ async def on_batch(entries: list[LogEntry]): # Populate task board from task management tool events. tool_name = data.get("tool", "") - if tool_name == "TaskCreate" and data.get("args"): - args = data["args"] - self._update_task({ - "task_id": f"t{len(self._task_board) + 1}", - "subject": args.get("subject", "Untitled"), - "status": args.get("status", "pending"), - "active_form": args.get("active_form", ""), - }) - elif tool_name == "TaskUpdate" and data.get("args"): - self._update_task(data["args"]) - elif tool_name == "TaskList" and data.get("result"): - try: - parsed = json.loads(data["result"]) - tasks = parsed.get("tasks", []) if isinstance(parsed, dict) else [] - if tasks: - self._ensure_task_board(tasks) - except (json.JSONDecodeError, TypeError, AttributeError): - pass + if tool_name in ("TaskCreate", "TaskUpdate", "TaskList"): + self._process_task_event(tool_name, data) # ---- Sub-agent lane: update agent tree ──────────────── if depth >= 1 and entry.source in ("tool", "agent_status", "agent"): @@ -698,7 +929,10 @@ async def on_batch(entries: list[LogEntry]): # Hidden tools (AskUserQuestion, TaskCreate, etc.) are # never rendered — they are interaction/task-management # tools whose results produce picker cards or task boards. + # Their ERRORS, however, must not be swallowed. if tool_name in self._hidden_tools: + if data.get("error"): + self.main_panel.add_error(f"{tool_name}: {data['error']}") continue # Each orchestrator tool renders as its own "o Read ..." line, # NOT a collapsed card. @@ -763,14 +997,18 @@ def _calc_processing_height(self) -> int: Mirrors render_processing_indicator output shape: - 1 line for header (* Blinking... or blank) - - 1 line for |_ tree connector + - 1 line for |_ tree connector (only when tasks exist) - 1 line per visible task - Returns 0 when no processing area is needed. + Returns 0 when not processing. While processing with no tasks the area is + still 1 line tall so the running indicator renders independently of the + task board. """ - if not self._is_processing or not self._task_board: + if not self._is_processing: return 0 - return 2 + len(self._task_board) + if self._task_board: + return 2 + len(self._task_board) + return 1 def render_layout(self) -> Layout: """Create responsive layout: header -> main -> processing_area -> picker -> input -> status. @@ -791,7 +1029,9 @@ def render_layout(self) -> Layout: # Picker overlay: dynamic height based on content when pending picker_height = 0 if self._pending_approvals: - picker_height = 8 + # Vertical picker: divider + header + tool/command/risk/desc (4) + + # blank + 5 options × 2 lines + keybar + divider ≈ 20-21 rows. + picker_height = min(21, max(9, height - 20)) elif self._pending_question: qs = self._pending_question.get("questions", []) tab = self._pending_question.get("current_tab_index", 0) @@ -804,6 +1044,8 @@ def render_layout(self) -> Layout: n_opts = len(qs[tab].get("options", [])) estimated = 9 + n_opts * 2 # active question picker_height = min(estimated, max(8, height - 20)) + elif self._palette_open: + picker_height = 12 # Dynamic heights: processing_area + picker processing_height = self._calc_processing_height() @@ -850,8 +1092,10 @@ def render_layout(self) -> Layout: # ── Render processing area (task board, blinking indicator) ─────── if processing_height > 0: tasks = list(self._task_board.values()) if self._task_board else None + awaiting = bool(self._pending_approvals) and self._is_processing proc = OutputRenderer.render_processing_indicator( - self._is_processing, self._show_indicator, tasks=tasks + self._is_processing, self._show_indicator, tasks=tasks, + awaiting_approval=awaiting, ) layout["processing_area"].update(proc) @@ -860,11 +1104,14 @@ def render_layout(self) -> Layout: if self._pending_approvals and self._current_approval_idx < len(self._pending_approvals): approval = self._pending_approvals[self._current_approval_idx] approval_action = getattr(approval, "proposed_action", None) or {} + _, command_str = self._approval_action(approval) picker_widget = OutputRenderer.render_permission_prompt( tool=approval_action.get("tool_type", "unknown"), - command_str=str(approval_action.get("args", {})), + command_str=command_str or str(approval_action.get("args", {})), risk=getattr(approval, "risk_level", "medium"), description=getattr(approval, "summary", "") or "", + focus_idx=self._approval_focus_idx, + width=width, ) layout["picker"].update(picker_widget) elif self._pending_question: @@ -872,6 +1119,8 @@ def render_layout(self) -> Layout: self._pending_question, width ) layout["picker"].update(picker_widget) + elif self._palette_open: + layout["picker"].update(self._render_palette(width)) # ── Render input area (pure input bar, always fixed at bottom) ──── layout["input"].update(self.input_bar.render()) @@ -882,51 +1131,31 @@ def render_layout(self) -> Layout: return layout - async def _apply_approval_decision(self, approval_id: str, decision: str) -> None: - """Record approval decision in DB (Y handler).""" - from harness.core.approval_manager import apply_decision - import logging - try: - if not approval_id: - return - await apply_decision(approval_id, decision, decided_by="user") - self._pending_approvals = [ - a for a in self._pending_approvals - if getattr(a, "approval_id", None) != approval_id - ] - self._current_approval_idx = 0 - except Exception as e: - logging.error(f"Failed to apply approval decision: {str(e)}", exc_info=True) - pass - - async def _prompt_rejection_reason(self, approval) -> None: - """Prompt for rejection reason (N handler).""" - from harness.core.approval_manager import apply_decision - import logging - if not approval: - return - try: - approval_id = getattr(approval, "approval_id", None) - if not approval_id: - logging.warning("Approval object missing approval_id") - return - await apply_decision(approval_id, "rejected", decided_by="user", notes="") - self._pending_approvals = [ - a for a in self._pending_approvals - if getattr(a, "approval_id", None) != approval_id - ] - self._current_approval_idx = 0 - except Exception as e: - logging.error(f"Failed to reject approval: {str(e)}", exc_info=True) - self._dirty = True - - async def _apply_approval_with_session_grant(self, approval, decision: str) -> None: - """Approve and grant for this session (A handler).""" + @staticmethod + def _approval_action(approval) -> tuple[str, str]: + """Return (tool_name, resource) from a pending approval's proposed_action.""" + action = getattr(approval, "proposed_action", None) or {} + if not isinstance(action, dict): + action = {} + tool_name = action.get("tool_type", "") or "" + args = action.get("args", {}) or {} + resource = args.get("command") or args.get("path") or "" + return str(tool_name), str(resource) + + async def _finish_approval(self, approval, decision: str) -> None: + """Record the human's decision and release the executor's blocked call. + + Applies the matching grant/deny policy (so the model's next call of the + same tool+resource is treated consistently) and resolves the approval + future the executor is awaiting. Every picker decision funnels through + here. SECURITY: Fails CLOSED — an unknown/errored decision becomes a deny. + """ from harness.core.approval_manager import apply_decision - from harness.core.approval_policy import grant_session, fingerprint_bash, fingerprint_file - from harness.tools.models import ToolType + from harness.core.approval_policy import ( + grant_once, grant_session, deny_once, deny_session, + persist_allow, coarse_fingerprint, + ) import logging - if not approval: return try: @@ -934,66 +1163,117 @@ async def _apply_approval_with_session_grant(self, approval, decision: str) -> N if not approval_id: logging.warning("Approval object missing approval_id") return - - await apply_decision(approval_id, decision, decided_by="user") - action = getattr(approval, "proposed_action", None) or {} - tool_name = action.get("tool_type", "") - args = action.get("args", {}) - - if "Bash" in tool_name: - fp = fingerprint_bash(args.get("command", "")) - elif any(x in tool_name for x in ["Read", "Write", "Edit"]): - fp = fingerprint_file(args.get("path", "")) + tool_name, resource = self._approval_action(approval) + + if decision in ("approved", "approved_session", "persist"): + await apply_decision(approval_id, "approved", decided_by="user") + if decision == "approved": + grant_once(tool_name, resource) + elif decision == "approved_session": + grant_session(tool_name, coarse_fingerprint(tool_name, resource)) + else: # persist + persist_allow(tool_name) + # Grant the session too so the current retry executes + # immediately (the persisted rule is picked up on the next + # scope build). + grant_session(tool_name, coarse_fingerprint(tool_name, resource)) + elif decision == "denied": + await apply_decision(approval_id, "rejected", decided_by="user", notes="") + deny_once(tool_name, resource) + elif decision == "denied_session": + await apply_decision(approval_id, "rejected", decided_by="user", notes="denied for session") + deny_session(tool_name, resource) else: - fp = "" + logging.warning(f"Unknown approval decision '{decision}', failing closed") + await apply_decision(approval_id, "rejected", decided_by="system", notes="unknown decision") + deny_once(tool_name, resource) + decision = "denied" - if fp: - grant_session(tool_name, fp) - - self._pending_approvals = [ - a for a in self._pending_approvals - if getattr(a, "approval_id", None) != approval_id - ] - self._current_approval_idx = 0 + self._resolve_approval(approval, decision) except Exception as e: - logging.error(f"Failed to apply session grant: {str(e)}", exc_info=True) - - async def _apply_approval_with_persisted_grant(self, approval, decision: str) -> None: - """Approve and save persisted rule (P handler).""" - from harness.core.approval_manager import apply_decision - from harness.core.approval_policy import grant_persisted, fingerprint_bash, fingerprint_file - import logging + logging.error(f"Failed to apply approval decision: {str(e)}", exc_info=True) + # Never leave the executor blocked on an unresolved future. + self._resolve_approval(approval, "denied") - if not approval: + def _resolve_approval(self, approval, decision: str) -> None: + """Resolve a pending approval's future and drop it from the picker list.""" + approval_id = getattr(approval, "approval_id", None) + if not approval_id: return - try: - approval_id = getattr(approval, "approval_id", None) - if not approval_id: - logging.warning("Approval object missing approval_id") - return - - await apply_decision(approval_id, decision, decided_by="user") - action = getattr(approval, "proposed_action", None) or {} - tool_name = action.get("tool_type", "") - args = action.get("args", {}) + self._pending_approvals = [ + a for a in self._pending_approvals + if getattr(a, "approval_id", None) != approval_id + ] + self._current_approval_idx = 0 + self._approval_focus_idx = 0 + future = self._approval_futures.get(approval_id) + if future is not None and not future.done(): + future.set_result(decision) + self._dirty = True - if "Bash" in tool_name: - fp = fingerprint_bash(args.get("command", "")) - elif any(x in tool_name for x in ["Read", "Write", "Edit"]): - fp = fingerprint_file(args.get("path", "")) - else: - fp = "" + async def _apply_approval_decision(self, approval, decision: str = "approved") -> None: + """Approve exactly this one call (Y handler). - if fp: - await grant_persisted(tool_name, fp, decision="allow") + The tool never ran when it parked as AWAITING_APPROVAL — the one-call + grant lets the model's retry of THIS call execute, while a different + call with the same tool still re-asks. + """ + await self._finish_approval(approval, "approved") - self._pending_approvals = [ - a for a in self._pending_approvals - if getattr(a, "approval_id", None) != approval_id - ] - self._current_approval_idx = 0 - except Exception as e: - logging.error(f"Failed to apply persisted grant: {str(e)}", exc_info=True) + async def _prompt_rejection_reason(self, approval) -> None: + """Deny exactly this one call (N handler).""" + await self._finish_approval(approval, "denied") + + async def _apply_approval_with_session_grant(self, approval, decision: str = "approved") -> None: + """Approve for this session (A handler) — same tool+fingerprint won't re-ask.""" + await self._finish_approval(approval, "approved_session") + + async def _apply_approval_with_session_deny(self, approval) -> None: + """Deny for this session (S handler) — suppress further prompts.""" + await self._finish_approval(approval, "denied_session") + + async def _apply_approval_with_persisted_grant(self, approval, decision: str = "approved") -> None: + """Approve and persist to the project .code/settings.json (P handler) so + future sessions in this project stop prompting for this tool.""" + await self._finish_approval(approval, "persist") + + # ── Approval picker key dispatch ──────────────────────────────────────── + + def _approval_picker_decision(self, key: str, n_opts: int) -> Optional[str]: + """Map one approval-picker key to a decision string. + + Returns the decision to resolve the pending approval with, or None when + the key only moved the ▸ focus (or was ignored). Extracted from the input + loop so the mapping is unit-testable and the loop stays a thin read loop. + + Key handling: + ↑/↓ or j/k → move focus (None, no decision) + 1-5 → select option by position + Y/A/P/N/S → select by shortcut letter + Enter → select the currently focused option (never a hidden + default; the renderer's keybar shows it live) + anything else → ignored (None) + """ + if key in (KeyCode.UP.value, "k"): + self._approval_focus_idx = (self._approval_focus_idx - 1) % n_opts + return None + if key in (KeyCode.DOWN.value, "j"): + self._approval_focus_idx = (self._approval_focus_idx + 1) % n_opts + return None + if key in "12345": + idx = int(key) - 1 + if 0 <= idx < n_opts: + self._approval_focus_idx = idx + return APPROVAL_OPTIONS[idx]["decision"] + return None + shortcut = _APPROVAL_SHORTCUTS.get(key.lower()) + if shortcut is not None: + return shortcut["decision"] + if key in (KeyCode.ENTER.value, "\r", "\n"): + return APPROVAL_OPTIONS[ + self._approval_focus_idx % len(APPROVAL_OPTIONS) + ]["decision"] + return None async def input_loop(self) -> None: """Main keyboard input loop.""" @@ -1004,30 +1284,16 @@ async def input_loop(self) -> None: await asyncio.sleep(0.01) continue - # Intercept keys for pending approvals + # Intercept keys for the vertical approval picker (all keys consumed + # while an approval is pending; the loop never falls through). if self._pending_approvals and self._current_approval_idx < len(self._pending_approvals): current_approval = self._pending_approvals[self._current_approval_idx] - if key.lower() == "y": - await self._apply_approval_decision(current_approval.approval_id, "approved") - self._dirty = True - await asyncio.sleep(0.001) - continue - elif key.lower() == "n": - await self._prompt_rejection_reason(current_approval) - self._dirty = True - await asyncio.sleep(0.001) - continue - elif key.lower() == "a": - await self._apply_approval_with_session_grant(current_approval, "approved") - self._dirty = True - await asyncio.sleep(0.001) - continue - elif key.lower() == "p": - await self._apply_approval_with_persisted_grant(current_approval, "approved") - self._dirty = True - await asyncio.sleep(0.001) - continue - # Other keys ignored during approval + decision = self._approval_picker_decision( + key, len(APPROVAL_OPTIONS) + ) + if decision is not None: + await self._finish_approval(current_approval, decision) + self._dirty = True await asyncio.sleep(0.001) continue @@ -1042,6 +1308,12 @@ async def input_loop(self) -> None: await asyncio.sleep(0.001) continue + # Route keys to the command palette when it is open. + if self._palette_open: + await self._handle_palette_key(key) + await asyncio.sleep(0.001) + continue + # Handle text input if self.input_handler.is_text_input(key): self.input_bar.add_char(key) @@ -1077,13 +1349,14 @@ async def display_loop(self) -> None: self._refresh_agent_tree() self._dirty = True - # Processing indicator + task board blink: toggle every 4 frames + # Processing indicator + task board blink: toggle every 4 frames. + # Always mark dirty while processing so the header blinks even + # with an empty task board (running state ≠ task-board presence). if self._is_processing: new_show = (self._spinner_frame // 4) % 2 == 0 if new_show != self._show_indicator: self._show_indicator = new_show - if self._task_board: - self._dirty = True + self._dirty = True # Task board: collapse 2s after all tasks complete self._age_completed_tasks() @@ -1577,11 +1850,24 @@ def append_chunk(chunk: str) -> None: self._text_dirty = True self._dirty = True + def end_turn() -> None: + """Close the open assistant block so the next turn starts its own. + + A verifier-rejected turn loops without any tool call in between, and + only tool calls otherwise reset the block — so without this the next + turn's text concatenates into the previous bullet. + """ + self._flush_active_text() + self._active_text_idx = None + self._active_text_raw = "" + try: # Preferred path: main agent with tools + delegation. if self.orchestrator: try: - result = await self.orchestrator.chat(prompt, on_text_delta=append_chunk) + result = await self.orchestrator.chat( + prompt, on_text_delta=append_chunk, on_turn_end=end_turn + ) if result and result.output and not self._active_text_raw.strip(): append_chunk(result.output) if result and not result.success and result.error: