Skip to content
19 changes: 13 additions & 6 deletions docs/HARNESS_POWERSHELL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ Slash commands (type `/help` in the console):
| `/model [use <name>]` | 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 <id>` | 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 <repo-relative-path>` | 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 |
Expand All @@ -112,23 +114,28 @@ worth knowing before you treat either as an off switch:

1. `/agent run claude/<topic> <what the agent should do>` stages a proposal and
prints it. Nothing is sent.
2. `/agent confirm <reason>` 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 <repo-relative-path>`
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 <reason>` 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 <id>` is what actually commits;
`/agent reject <id>` 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 <id>` puts the branch on origin, and
`/agent publish <id> <why>` opens a draft PR. **Both refuse on a shipped
checkout:** push needs `deepagent_github.allow_git_write_tools` (ships
`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 <id>` reclaims the clone. It is the only step that frees
6. `/agent discard <id>` 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.
Expand Down
59 changes: 58 additions & 1 deletion harness/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions harness/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
160 changes: 146 additions & 14 deletions static/harness.html
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,37 @@
</div>
</div>
</div>
<input id="agentPlanFile" type="file" accept=".md,.txt,text/markdown,text/plain" hidden>

<script>
'use strict';
/* Model output and registry data are DATA, never HTML: everything rendered
through textContent or the table() builder (createElement + textContent). */
const $ = id => document.getElementById(id);
const stream = $('stream'), input = $('input'), sendBtn = $('send'), apiKeyInput = $('apiKey');
const agentPlanInput = $('agentPlanFile');
const MAX_AGENT_PLAN_CHARS = 6100;
const MAX_AGENT_PLAN_BYTES = 25000;
/* Mirrors harness.schemas._MAX_READ_FILES and the clone-jail path rules in
utils.repo_paths.canonical_repo_relative_path (keep in sync via console contract tests). */
const MAX_AGENT_READ_FILES = 8;
const MAX_AGENT_READ_FILE_LEN = 1024;
function isSafeRepoRelativePath(raw) {
if (typeof raw !== 'string' || !raw || raw.length > MAX_AGENT_READ_FILE_LEN || raw.indexOf('\0') !== -1) return false;
const normalized = raw.replace(/\\/g, '/');
if (normalized.charAt(0) === '/' || normalized.charAt(0) === '-') return false;
if (/^[A-Za-z]:/.test(raw) || normalized.indexOf('://') !== -1) return false;
const parts = normalized.split('/').filter(part => part !== '' && part !== '.');
if (!parts.length) return false;
for (let i = 0; i < parts.length; i++) {
if (parts[i] === '..' || parts[i].indexOf(':') !== -1) return false;
}
return true;
}
function canonicalRepoRelativePath(raw) {
if (!isSafeRepoRelativePath(raw)) return null;
return raw.replace(/\\/g, '/').split('/').filter(part => part !== '' && part !== '.').join('/');
}
/* Substituted server-side into the meta tag at serve time (harness/server.py's
console() route) -- the literal placeholder below never reaches a browser. */
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
Expand Down Expand Up @@ -366,9 +390,8 @@
['/tools', 'tool registry'],
['/connectors', 'connector catalog'],
['/github', 'agentic GitHub status'],
['/agent run|confirm|cancel', 'stage and start a real-repo coding run'],
['/agent run|plan|read|checks|confirm|cancel', 'stage and start a real-repo coding run'],
['/agent status|approve|reject <id>', 'inspect or decide a pending run'],
['/agent checks', 'available verification profiles'],
['/harness', 'harness optimizer runs'],
['/tokens', 'token tally'],
['/status', 'server status'],
Expand Down Expand Up @@ -410,6 +433,47 @@
}
return r.parsed;
}
function showPendingAgentRun() {
if (!pendingAgentRun) return;
sys(table([
['branch', pendingAgentRun.branch],
['instruction', pendingAgentRun.instruction],
['commit message', pendingAgentRun.commit_message],
['plan', pendingAgentRun.plan ? 'loaded (' + pendingAgentRun.plan.length + ' chars)' : 'none'],
['read files', pendingAgentRun.read_files.join(', ') || 'none'],
['checks', pendingAgentRun.checks.join(', ')],
], ['field', 'value']));
}

agentPlanInput.addEventListener('change', () => {
const file = agentPlanInput.files && agentPlanInput.files[0];
const stagedRun = pendingAgentRun;
agentPlanInput.value = '';
if (!file || !stagedRun) return;
if (file.size > MAX_AGENT_PLAN_BYTES) {
sys('plan file is too large; keep reviewed plans below ' + MAX_AGENT_PLAN_CHARS + ' characters');
return;
}
const reader = new FileReader();
reader.onerror = () => sys('could not read the selected plan file');
reader.onload = () => {
if (pendingAgentRun !== stagedRun) {
sys('staged run changed before the plan loaded; select it again for the current run');
return;
}
const plan = typeof reader.result === 'string' ? reader.result : '';
if (!plan.trim()) { sys('plan file is empty'); return; }
if (plan.length > MAX_AGENT_PLAN_CHARS) {
sys('plan is too long; keep reviewed plans below ' + MAX_AGENT_PLAN_CHARS + ' characters');
return;
}
pendingAgentRun.plan = plan;
sys('reviewed plan loaded; reselect it after any local edit.\n' + plan);
showPendingAgentRun();
};
reader.readAsText(file);
});

function showAgentRecord(rec) {
sys(table([
['run id', rec.run_id],
Expand Down Expand Up @@ -537,7 +601,21 @@
const sub = rest[0] || 'help';
if (sub === 'checks') {
const r = await api('/api/agent/checks');
sys(table(r.profiles.map(p => [p.name, p.description]), ['profile', 'what it runs']));
if (rest.length === 1) {
sys(table(r.profiles.map(p => [p.name, p.description]), ['profile', 'what it runs']));
} else if (!pendingAgentRun) {
sys('nothing staged — start with /agent run before selecting checks');
} else {
const requested = rest.slice(1).join(' ').split(/[\s,]+/).filter(Boolean);
const available = new Set(r.profiles.map(p => p.name));
const unknown = requested.filter(name => !available.has(name));
if (unknown.length) {
sys('unknown profile(s): ' + unknown.join(', ') + '; run /agent checks to list valid names');
} else {
pendingAgentRun.checks = requested;
showPendingAgentRun();
}
}
} else if (sub === 'run') {
const branch = rest[1] || '';
const instruction = rest.slice(2).join(' ').trim();
Expand All @@ -550,28 +628,73 @@
instruction: instruction,
commit_message: instruction.slice(0, 72),
checks: ['pytest'],
plan: null,
read_files: [],
};
sys(table([
['branch', pendingAgentRun.branch],
['instruction', pendingAgentRun.instruction],
['commit message', pendingAgentRun.commit_message],
['checks', pendingAgentRun.checks.join(', ')],
], ['field', 'value']));
sys('nothing has run yet. Authorize with: /agent confirm <why you are doing this>');
showPendingAgentRun();
sys('nothing has run yet. Optionally use /agent plan, /agent read, or /agent checks <profile ...>; then authorize with: /agent confirm <why you are doing this>');
} else if (sub === 'plan') {
if (!pendingAgentRun) { sys('nothing staged — start with /agent run'); break; }
if (rest[1] === 'clear' && rest.length === 2) {
pendingAgentRun.plan = null;
sys('staged plan cleared');
showPendingAgentRun();
} else if (rest.length > 1) {
sys('usage: /agent plan (or /agent plan clear)');
} else {
agentPlanInput.click();
}
} else if (sub === 'read') {
if (!pendingAgentRun) { sys('nothing staged — start with /agent run'); break; }
const path = rest.slice(1).join(' ').trim();
if (path === 'clear') {
pendingAgentRun.read_files = [];
sys('declared read files cleared');
} else if (!path) {
sys('usage: /agent read <repo-relative-path> (or /agent read clear)');
} else {
const canonical = canonicalRepoRelativePath(path);
if (!canonical) {
sys('refused read path (must be repo-relative: no absolute, .., drive, or flag-like names): ' + path);
} else if (pendingAgentRun.read_files.includes(canonical)) {
sys('read path is already declared: ' + canonical);
} else if (pendingAgentRun.read_files.length >= MAX_AGENT_READ_FILES) {
sys('at most ' + MAX_AGENT_READ_FILES + ' read paths can be staged; drop one with /agent read clear or confirm with the current list');
} else {
pendingAgentRun.read_files.push(canonical);
sys('declared read path: ' + canonical);
}
}
showPendingAgentRun();
} else if (sub === 'cancel') {
pendingAgentRun = null;
sys('staged run discarded');
} else if (sub === 'confirm') {
if (!pendingAgentRun) { sys('nothing staged — start with /agent run'); break; }
const why = rest.slice(1).join(' ').trim();
if (!why) { sys('a reason is required: /agent confirm <why you are doing this>'); break; }
const body = Object.assign({}, pendingAgentRun, { reason: why, confirm: true });
pendingAgentRun = null;
/* Keep the staged proposal until the server accepts the run. Clearing
first made a 422 (e.g. over-cap read_files) destroy the instruction,
plan, checks, and paths with no recovery short of re-staging. */
const stagedRun = pendingAgentRun;
const body = Object.assign({}, stagedRun, { reason: why, confirm: true });
sendBtn.disabled = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block keyboard confirmations while a run is in flight

While the potentially 15-minute confirmation request is running, this disables only the button and deliberately leaves pendingAgentRun populated. The input's existing Enter handler calls onSend() without checking sendBtn.disabled, so entering /agent confirm ... again launches a second concurrent clone/model/verification run for the same staged proposal; use an explicit in-flight guard or disable the input path as well.

Useful? React with 👍 / 👎.

sys('cloning, planning, patching and verifying — this blocks for up to 15 minutes…');
try {
const rec = agentRecord(await api('/api/agent/run', 'POST', body));
if (rec) showAgentRecord(rec);
/* agentRecord() returns null (not a throw) on ok=false or an
unparsed response, so only clear the staged run once a real
record confirms the server actually accepted it. */
if (rec) {
if (pendingAgentRun === stagedRun) pendingAgentRun = null;
showAgentRecord(rec);
}
} catch (err) {
if (pendingAgentRun === null || pendingAgentRun === stagedRun) {
pendingAgentRun = stagedRun;
}
sys('confirm failed — staged run kept. Fix the request or /agent cancel. (' + err.message + ')');
showPendingAgentRun();
} finally {
sendBtn.disabled = false;
}
Expand Down Expand Up @@ -607,6 +730,10 @@
} else {
sys(table([
['/agent run claude/<topic> <task>', 'stage a run (sends nothing)'],
['/agent plan [clear]', 'load or clear a reviewed local plan for the staged run'],
['/agent read <repo-relative-path>', 'declare an existing file for the staged coder context'],
['/agent read clear', 'clear declared read paths'],
['/agent checks [profile ...]', 'list or choose allow-listed verification profiles'],
['/agent confirm <reason>', 'authorize and start the staged run'],
['/agent cancel', 'discard the staged run'],
['/agent status <run id>', 'read a run record'],
Expand All @@ -615,7 +742,6 @@
['/agent push <run id>', 'push an approved branch to origin (disarmed by default)'],
['/agent publish <run id> <why>', 'open a draft PR for a pushed run (disarmed by default)'],
['/agent discard <run id>', 'reclaim the clone of a decided run from disk'],
['/agent checks', 'list verification profiles'],
], ['command', 'action']));
}
break;
Expand Down Expand Up @@ -683,6 +809,12 @@
}

async function onSend() {
/* sendBtn.disabled is the console's existing in-flight signal (see
sendChat and /agent confirm above); the Enter-key handler below calls
onSend() directly and bypasses the button's own disabled state, so a
second Enter during a blocking /agent confirm launched a concurrent
clone/model/verification run for the same staged proposal. */
if (sendBtn.disabled) return;
const text = input.value.trim();
if (!text) return;
input.value = '';
Expand Down
Loading
Loading