diff --git a/docs/HARNESS_POWERSHELL.md b/docs/HARNESS_POWERSHELL.md index 3291bd86..be39f146 100644 --- a/docs/HARNESS_POWERSHELL.md +++ b/docs/HARNESS_POWERSHELL.md @@ -83,9 +83,11 @@ Slash commands (type `/help` in the console): | `/model [use ]` | show / select the local model | | `/skills`, `/tools`, `/connectors` | merged registry views | | `/github` | agentic GitHub status (read-only subprocess) | -| `/agent run\|confirm\|cancel` | stage, authorize, or discard a real-repo coding run | +| `/agent run\|plan\|read\|confirm\|cancel` | stage, refine, authorize, or discard a real-repo coding run | | `/agent status\|approve\|reject ` | read a run record, or decide a pending one | -| `/agent checks` | list the selectable verification profiles | +| `/agent plan [clear]` | load or clear a reviewed local `.md` / `.txt` plan for the staged run | +| `/agent read ` | declare an existing cloned-repo file for coder context (`clear` removes all) | +| `/agent checks [profile ...]` | list or choose allow-listed verification profiles for the staged run | | `/harness` | harness optimizer runs | | `/tokens` | per-session token tally | | `/status` | server status | @@ -112,15 +114,20 @@ worth knowing before you treat either as an off switch: 1. `/agent run claude/ ` stages a proposal and prints it. Nothing is sent. -2. `/agent confirm ` authorizes it. This is the request that clones the +2. Optionally, `/agent plan` opens the browser's native picker for a reviewed + local plan. The console retains only the selected text, never a server-side + path; reselect the file after editing it. `/agent read ` + declares existing clone content the coder may see, and `/agent checks pytest ruff` + replaces the default profile list. These values stay staged until confirmation. +3. `/agent confirm ` authorizes it. This is the request that clones the repo, asks the local model for a patch, and runs the selected verification profile against the result. **It blocks for up to 15 minutes** — the run record is written only when the run ends, so there is no intermediate progress to poll for, and the run id first exists in that response. -3. On success the run stops *before committing* and reports +4. On success the run stops *before committing* and reports `status: pending_decision`. `/agent approve ` is what actually commits; `/agent reject ` discards the clone. Neither pushes. -4. Escalating past the local commit is two further, separate decisions — +5. Escalating past the local commit is two further, separate decisions — deliberately not folded into approve, and each its own route: `/agent push ` puts the branch on origin, and `/agent publish ` opens a draft PR. **Both refuse on a shipped @@ -128,7 +135,7 @@ worth knowing before you treat either as an off switch: `false`) and publish needs `agentic/writer.py`'s `EXECUTION_ENABLED`, a hardcoded `False` no config file can flip. Arming either is the filed checklist in `docs/agentic/GITHUB_WRITE_ENABLEMENT.md`, not a toggle. -5. `/agent discard ` reclaims the clone. It is the only step that frees +6. `/agent discard ` reclaims the clone. It is the only step that frees disk: an approved run keeps its clone on purpose (push and publish still need it) and nothing reclaims it automatically, so a console session that only ever approves accumulates one full repository clone per run. diff --git a/harness/schemas.py b/harness/schemas.py index 01f54bb8..a2a94ad1 100644 --- a/harness/schemas.py +++ b/harness/schemas.py @@ -9,9 +9,10 @@ from typing import Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from harness.agent_policy import BRANCH_NAME_RE, DEFAULT_CHECK_PROFILE +from utils.repo_paths import canonical_repo_relative_path _MAX_MESSAGE_LEN = 32768 _MAX_TITLE_LEN = 200 @@ -21,9 +22,45 @@ _MAX_COMMIT_MESSAGE_LEN = 500 _MAX_BRANCH_LEN = 88 # longest allowlisted prefix + '/' + topic (1+79) _MAX_CHECK_PROFILES = 8 +_MAX_READ_FILES = 8 +_MAX_READ_FILE_LEN = 1024 +# The agentic planner caps its generated body at 6,000 characters, then adds a +# fixed truncation marker. Keep enough room for that legitimate reviewed output +# without turning the browser request into an unbounded prompt transport. +_MAX_PLAN_CHARS = 6_100 _MAX_ITERATIONS_CEILING = 10 +_READ_PATH_ERR = ( + "read_files must contain non-empty bounded repo-relative paths " + "without NUL bytes, traversal, or absolute/drive forms" +) + + +def _one_safe_read_path(raw: object) -> str: + # One entry → canonical form, or ValueError (jail-aligned). + if not isinstance(raw, str) or len(raw) > _MAX_READ_FILE_LEN: + raise ValueError(_READ_PATH_ERR) + canonical = canonical_repo_relative_path(raw) + if canonical is None: + raise ValueError(_READ_PATH_ERR) + return canonical + + +def _canonicalize_read_paths(read_paths: list[str]) -> list[str]: + # Shared by AgentRunRequest: reject jail-unsafe names, dedupe canonical forms. + if len(read_paths) > _MAX_READ_FILES: + raise ValueError(f"read_files allows at most {_MAX_READ_FILES} paths") + cleaned: list[str] = [] + seen: set[str] = set() + for raw in read_paths: + canonical = _one_safe_read_path(raw) + if canonical not in seen: + seen.add(canonical) + cleaned.append(canonical) + return cleaned + + class _ForbidModel(BaseModel, extra="forbid"): """Shared base: reject unexpected request fields.""" @@ -81,6 +118,13 @@ class AgentRunRequest(_ForbidModel): min_length=1, max_length=_MAX_CHECK_PROFILES, ) + # Browser clients supply plan TEXT selected from their own filesystem, not + # a server-side path. The shim materializes that text briefly for the CLI's + # existing --plan-file scanner and deletes it on every exit path. + plan: str | None = Field(default=None, min_length=1, max_length=_MAX_PLAN_CHARS) + # This is a declared list, never a browse/read API: the CLI resolves each + # path only inside the fresh jailed clone before showing it to the coder. + read_files: list[str] = Field(default_factory=list, max_length=_MAX_READ_FILES) # ge=1 rather than gt=0 so 0 is a validation error, not a silently-dropped # value: run_agentic_op gates --max-iterations on truthiness, so 0 would # fall through to the CLI default of 3 rather than doing what it says. @@ -101,6 +145,19 @@ def _one_target_only(self) -> AgentRunRequest: raise ValueError("pass at most one of pr / issue") return self + @field_validator("plan") + @classmethod + def _plan_is_not_blank(cls, plan_text: str | None) -> str | None: + if plan_text is not None and not plan_text.strip(): + raise ValueError("plan must not be blank") + return plan_text + + @field_validator("read_files") + @classmethod + def _read_files_are_safe_repo_relative(cls, read_paths: list[str]) -> list[str]: + # Align with the clone jail so confirm never 422s on staged junk. + return _canonicalize_read_paths(read_paths) + class AgentDecisionRequest(_ForbidModel): """Approve (commit) or reject (discard) one pending run. diff --git a/harness/server.py b/harness/server.py index 6c846ee1..58d0fa22 100644 --- a/harness/server.py +++ b/harness/server.py @@ -732,6 +732,8 @@ def agent_run(req: AgentRunRequest) -> dict: reason=req.reason, confirm=req.confirm, max_iterations=req.max_iterations, + plan=req.plan, + read_files=req.read_files, pr=req.pr, issue=req.issue, ), diff --git a/static/harness.html b/static/harness.html index f2da8417..c1d7db27 100644 --- a/static/harness.html +++ b/static/harness.html @@ -197,6 +197,7 @@ +