From f967288066af59fc27f7971047c92f6cc4c92fed Mon Sep 17 00:00:00 2001 From: CyClaw Agent Date: Wed, 5 Aug 2026 00:53:16 -0400 Subject: [PATCH 1/9] fix(harness): show candidate diff before approval --- static/harness.html | 8 ++++++-- tests/test_harness_console_contract.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/static/harness.html b/static/harness.html index a4992ac0..f2da8417 100644 --- a/static/harness.html +++ b/static/harness.html @@ -423,8 +423,12 @@ ['pull request', rec.pr_url || '—'], ['error', rec.error || '—'], ], ['field', 'value'])); - if (rec.status === 'pending_decision') - sys('awaiting your decision — /agent approve ' + rec.run_id + ' or /agent reject ' + rec.run_id); + if (rec.status === 'pending_decision' && rec.diff) { + sys('candidate diff\n' + rec.diff); + sys('diff shown above — /agent approve ' + rec.run_id + ' or /agent reject ' + rec.run_id); + } else if (rec.status === 'pending_decision') { + sys('diff not loaded — inspect with /agent status ' + rec.run_id + ' before deciding'); + } // Each escalation past approve is its own decision, so the console offers // the next one rather than performing it. Both are disarmed on a shipped // checkout (allow_git_write_tools / EXECUTION_ENABLED) — the prompt is the diff --git a/tests/test_harness_console_contract.py b/tests/test_harness_console_contract.py index 95a71e35..0cbc94b6 100644 --- a/tests/test_harness_console_contract.py +++ b/tests/test_harness_console_contract.py @@ -196,3 +196,15 @@ def test_session_id_is_url_encoded_on_every_interpolated_path(): "static/harness.html interpolates a session id into a URL without " f"encodeURIComponent(): {sorted(set(interpolations))}" ) + + +def test_pending_agent_diff_is_shown_before_approval(): + """The browser's human gate must display the backend-rendered diff first.""" + html = _HARNESS_HTML.read_text(encoding="utf-8") + start = html.index("function showAgentRecord(rec)") + end = html.index("/* ── slash commands ── */", start) + body = html[start:end] + + assert "'candidate diff\\n' + rec.diff" in body + assert body.index("rec.diff") < body.index("/agent approve ") + assert "diff not loaded — inspect with /agent status " in body From f9402c3e5f90d3a65c086576a265329985551ced Mon Sep 17 00:00:00 2001 From: CyClaw Agent Date: Wed, 5 Aug 2026 01:55:19 -0400 Subject: [PATCH 2/9] feat(harness): stage reviewed browser agent inputs --- docs/HARNESS_POWERSHELL.md | 19 +++-- harness/schemas.py | 30 ++++++- harness/server.py | 2 + static/harness.html | 106 ++++++++++++++++++++++--- tests/test_harness_agent_routes.py | 37 ++++++++- tests/test_harness_console_contract.py | 23 ++++++ tests/test_ops_runner.py | 42 +++++++++- utils/ops_runner.py | 36 +++++++-- 8 files changed, 264 insertions(+), 31 deletions(-) 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..f09007bf 100644 --- a/harness/schemas.py +++ b/harness/schemas.py @@ -9,7 +9,7 @@ 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 @@ -21,6 +21,12 @@ _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 @@ -81,6 +87,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 +114,21 @@ 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, value: str | None) -> str | None: + if value is not None and not value.strip(): + raise ValueError("plan must not be blank") + return value + + @field_validator("read_files") + @classmethod + def _read_files_are_bounded_text(cls, values: list[str]) -> list[str]: + for path in values: + if not path or len(path) > _MAX_READ_FILE_LEN or "\x00" in path: + raise ValueError("read_files must contain non-empty bounded paths without NUL bytes") + return values + class AgentDecisionRequest(_ForbidModel): """Approve (commit) or reject (discard) one pending run. diff --git a/harness/server.py b/harness/server.py index 2facf7f9..b3622c6d 100644 --- a/harness/server.py +++ b/harness/server.py @@ -724,6 +724,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..067bfc90 100644 --- a/static/harness.html +++ b/static/harness.html @@ -197,6 +197,7 @@ +