Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 da050ef23a docs(contributing): add contribution volume and batching guidance
Documents expectations for keeping contribution flow sustainable during the
stabilization phase: one issue per bug (no bundled multi-finding audits), keep
only a few items open at a time, and hold AI-assisted contributions to the same
per-item verification bar. Complements the existing issue-approved gate, which
already covers PR-to-issue linkage and scope; the gap was submission volume.
2026-07-21 11:25:29 +05:30
KRATOSandKRATOS 3967d49d64 feat(tui): permission prompt takes free-text feedback inline (#780)
* feat(tui): let the permission prompt take free-text feedback inline

"No, and tell Zero what to do differently" now opens an inline input on the
permission card instead of silently cancelling: type an instruction, Enter sends
it, Esc returns to the option list. The text is delivered as a Deny decision whose
Reason is the typed instruction, so the agent surfaces it via deniedPermissionResult
as the tool result and the model adjusts in the SAME turn — where the old cancel
returned an error and aborted the run. Empty input + Enter falls back to a plain
cancel (the prior meaning).

No agent-side change: PermissionDecision.Reason already carries free text to the
model. The UI mirrors the ask_user "type your own answer" pattern — the feedback
field reuses the shared composer input and the card renders it in the transcript
body.

Also: a focused permission prompt now suppresses the composer (and idle
hints/plan panel), keeping only the status line, exactly like the ask_user modal.
Its keys are inert anyway, so a "describe a task for zero…" box below the card was
misleading — and in feedback mode the shared input would otherwise echo in two
places.

Tests cover open / submit-as-Deny-with-text / empty-cancels / Esc-returns, the
composer suppression in both modes, and that the feedback text renders exactly
once.

* fix(tui): preserve the composer draft across permission feedback (#780 review)

anand + CodeRabbit (Major): entering feedback mode cleared the shared composer
input via SetValue(""), and neither submit nor cancel restored it — so a user
with a half-typed or queued next-turn message who chose "tell Zero what to do
differently" lost that draft.

Snapshot the composer value into pendingPermissionPrompt.savedDraft when feedback
mode opens, and restore it on BOTH exits (submit and Esc-cancel). Only the
feedback text is consumed, delivered via the decision Reason. Adds regression
tests for both paths; mutation-verified against the SetValue("") regression.

* fix(tui): guard permission-feedback backspace and clicks (#780 review)

Vasanthdev2004 review, all on the permission path:

1. Real bug: Backspace on an empty feedback field dropped a staged attachment.
   The keyBackspace case runs before the typing branch and, on an empty composer
   (feedback mode clears it), fell to removeLastAttachment — silently dropping a
   staged image/doc that savedDraft does not restore. Route Backspace to the
   shared input while typing so it is a plain edit of the feedback text.

2. Defence-in-depth: the mouse click resolver in handleTranscriptSelectionMouse
   resolved a clicked permission option (Allow included) with no typing check. It
   is safe today only because renderFocusedPermissionPrompt returns nil clickable
   offsets in feedback mode — a single early-return in a function other PRs also
   edit. Add an explicit !typing guard on the click path so the safety is stated,
   not emergent.

3. Test hardening (also raised by CodeRabbit): assert len(got) == 1 before
   indexing got[0] in the draft-preservation test, so a regression fails cleanly
   instead of panicking.

Tests: TestPermissionFeedbackBackspaceKeepsStagedAttachment (mutation-verified
against the missing guard) and TestPermissionFeedbackRendersNoClickableOptionsWhileTyping.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 23:29:10 +05:30
KRATOSandKRATOS 89bdc6719a fix(tools): read_file recovers a backwards line range instead of erroring (#779)
* fix(tools): read_file recovers a backwards line range instead of erroring

renderReadFileRange hard-failed when end_line < start_line, costing the caller a
whole retry for an arithmetic slip. It was also inconsistent: start_line past EOF
returns a friendly note and end_line past EOF is auto-clamped to the last line —
only a backwards range hard-errored.

Recover it the same way: clamp end_line UP to start_line (read just that line) and
surface the adjustment in the output so it is never silent. Valid ranges
(end_line >= start_line) are byte-for-byte unchanged.

The change is in the argument-validation phase, before any file I/O, so there is
no runtime cost; if anything it saves the agent a wasted retry turn.

Tests: TestReadFileToolRecoversBackwardsRange (recovery + note + reads only
start_line, mutation-verified against the old hard error) and
TestReadFileToolValidRangeUnchangedByRecovery (valid ranges carry no note and read
the identical window).

* test(tools): assert full valid-range output in read_file recovery test (#779 review)

anand + CodeRabbit: TestReadFileToolValidRangeUnchangedByRecovery claimed
byte-for-byte preservation but only checked substrings, so an altered
header/separator or a stray out-of-range line would still pass. Compare the
whole result.Output against the exact expected rendering.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 22:57:25 +05:30
KRATOSandKRATOS 722bb31216 fix(tui): stop the permission card clashing on cool themes (#778)
The focused permission prompt was the only prompt card in the TUI that used
brand/warning colours for its interior, and both read badly on cool themes such
as dracula:

1. The selected option ("Yes, proceed") was filled with zeroTheme.badge — the
   brand chip meant for short labels (" 0 ", " ASK ", " SPEC REVIEW "), a
   full-brightness accent fill. Every other selectable list in the TUI (model
   picker, provider list, / palette) highlights its selected row with onSel/selBg
   instead. The permission card was the outlier, painting a saturated accent slab
   across the row and skipping the card tint every other line composed onto.

2. The card BODY was tinted with permBg, an amber-family wash intended to signal
   a warning surface. It works on the warm default palette but renders as a
   brown-yellow slab over cool themes, and it made the permission card the only
   prompt whose body carries a fill — ask_user, spec review and plan all use a
   transparent body (lipgloss.NewStyle()).

Fix both to match the rest of the TUI: the selected row uses onSel(ink) like
every other picker, and the body drops its permBg fill for a transparent one.
Warning identity is unchanged — the amber PERMISSION badge and the amber-mixed
border still mark it as a permission gate; only the clashing interior fills go.

TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip asserts against
palette entries (not hardcoded hex, so it survives a theme retune): the selected
row carries selBg and not the brand accent, the body carries no permBg wash, and
the PERMISSION badge keeps its amber fill so the card still reads as a warning.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 22:39:50 +05:30
KRATOSandKRATOS a21a052a4a feat(sandbox): disable the sandbox via config (#687) (#746)
* feat(sandbox): disable the sandbox via config (#687)

Add a sandbox.enabled field (`"sandbox": {"enabled": false}`). ModeDisabled
already short-circuits the engine but had no config surface.

- SandboxConfig.Enabled *bool (pointer distinguishes an explicit false from an
  omitted key).
- mergeConfig honors it (global config / CLI); applyConfiguredSandboxPolicy maps
  enabled:false to ModeDisabled.
- mergeProjectConfig intentionally does NOT merge it: a cloned repo must not be
  able to disable the sandbox that constrains it — same posture as
  AdditionalWriteRoots and the Network tighten-only rule.

* fix(config): honor sandbox.enabled from the CLI override path (#687 review)

applyOverrides copied BlockUnixSockets/MonitorDenials but not the new Enabled
tri-state pointer, so a CLI/programmatic Overrides{Sandbox:{Enabled:&false}} was
dropped — contradicting the 'global config / CLI can disable' contract. Preserve
the pointer in applyOverrides (runs after all config sources, so an explicit
override wins) and add a resolver regression test: an explicit false override
disables, and an explicit true override wins over a lower-precedence config false.

* fix(config): keep sandbox.enabled out of the provider-command path (#687 review)

mergeConfig now copies Sandbox.Enabled, and it is shared by two callers: the
global-config merge (trusted) and the provider-command merge (not). A provider
command is an arbitrary executable named in config, and LoadProviderCommand
parses its stdout into a full FileConfig -- so one returning a valid provider
plus {"sandbox":{"enabled":false}} could disable the sandbox meant to constrain
it, despite the setting being documented as global-config/CLI only. Confirmed
reproducible in both directions before the fix.

Clear Sandbox.Enabled from the provider-command config before merging, mirroring
the carve-out mergeProjectConfig already applies for the same reason. Done at the
call site rather than inside mergeConfig because that helper must keep honouring
the setting for the trusted global-config merge.

Adds TestResolveSandboxEnabledIgnoredFromProviderCommand covering both
directions (cannot disable, cannot enable) and asserting the provider itself is
still applied, so the guard cannot be satisfied by dropping the command wholesale.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 21:07:04 +05:30
KRATOSandKRATOS f079b90f82 fix(sandbox): AST second opinion for interactive-command bypasses (#473) (#745)
* fix(sandbox): AST second opinion for interactive-command bypasses (#473)

The interactive-command guard split shell commands with a hand-written parser
(splitShellSegments), which mis-handles unusual quoting, command substitution,
subshells, and newline separators — an interactive program hidden by one slips
through and the agent hangs until the per-command timeout.

Add astCommandFields(): mvdan.cc/sh/v3/syntax (already used by analyzer.go) to
re-extract the real simple commands, then apply the SAME per-program checks the
regex path uses (interactivePrograms + hasNonInteractiveFlag). This only ADDS
detections and classifies every program exactly as before — ssh with a trailing
command, python -c, etc. stay allowed — while catching the splitter's bypasses.
Unparseable input (Windows cmd.exe, obfuscation) yields no commands and falls
through to the regex path; the guard never hard-blocks on a parse error.

* fix(sandbox): AST pass runs the full pipeline + guards dynamic programs (#473 review)

Address review on #745:
- The AST second opinion now applies the complete detection pipeline via a new
  inspectCommandFields helper (multi-word interactive segments, sh -c payload
  recursion, then the per-program lookup), so parser-hidden bypasses like
  { git rebase -i HEAD~1; } and { sh -c 'vim file.txt'; } are caught, not just
  bare interactive programs.
- astCommandFields now skips a CallExpr whose program word is not a static
  literal (isLiteralWord), so a substitution concatenated with a literal
  ($(printf foo)vim -> foovim) can't fabricate an interactive program name.

Regression tests added for both boundaries. (The un-braced $(printf foo)vim
false positive is a separate, pre-existing hand-parser behavior — present on
main before this PR — and is out of scope here.)

* fix(sandbox): require literal words for the whole AST call (#473 review)

astCommandFields guarded only the program word, then ran every argument
through wordText, which keeps literal/quoted parts and silently drops
CmdSubst/ParamExp. A dynamic ARGUMENT was therefore reconstructed lossily:
`git $(printf foo)rebase -i HEAD~1` runs as `git foorebase -i` (a
non-existent, non-interactive subcommand) but collapsed to `git rebase -i`
and was blocked -- a false positive on a command the user never wrote, and
new on this branch (the base correctly reports not-interactive).

Extend the literalness guard to every word in the call: if any word carries
an expansion, skip that AST command rather than classify a reconstruction
whose runtime value is unknowable. Skipping is the safe direction -- the
hand-written passes still run first, and a miss falls through to the normal
permission prompt instead of hard-blocking.

Genuine detection is unchanged: `git rebase -i HEAD~1` and the #473 bypass
cases still classify exactly as before.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 21:01:55 +05:30
KRATOSandKRATOS b30c3971b4 fix(tui): model rows labelled by id when the description is prose; keep the sidebar under the / palette (#775)
* fix(tui): label model rows by id when the description is prose

The model picker (provider wizard AND onboarding, which share displayLabel)
labelled each row with the provider's Description, falling back to the ID only
when the description was "generic". That is right when Description holds a short
display name -- "Grok 4.3" for x-ai/grok-4.3, which is what the field is for and
why the row prefers it over an ugly raw ID.

Providers do not all honour that. Aggregators commonly return a prose blurb
instead ("Fast DeepSeek model for efficient chat, coding help, and agent
loops"), which fails as a label twice: it overflows the row, and because such
blurbs are written per model FAMILY rather than per model, several DIFFERENT
models collapse to byte-identical rows. Observed in a live picker with two
identical "Open-weight GPT model for self-hosted reasoning..." rows and two
identical "Kimi multimodal agent model..." rows -- the distinguishing ID was
visible only in the detail line of whichever row happened to be highlighted, so
the list could not be used to pick between them.

providerWizardGenericModelDescription cannot catch these: it rejects vague
descriptions ("live model", "catalog default"), whereas these are specific, just
shared and sentence-shaped. So gate on shape instead -- a display name is short
and few-worded; anything longer is prose and the ID (unique by construction)
makes the better label. Friendly names are still preferred where providers
supply them, so the existing behaviour is unchanged for well-behaved catalogs.

* fix(tui): keep the sidebar open under the / command palette

sidebarAvailable grouped suggestionsActive() with the full-screen overlays
(setup, wizards, pickers), whose stated rationale is that they "take over the
chat column and render at full width". That is not true of the `/` palette:
suggestionOverlay draws it through centerRenderedBlock at
minInt(width, suggestionPaletteMaxWidth), a centred box capped at 76 cells
floating over the chat column.

Treating it as full-screen collapsed the whole layout on every `/`: the sidebar
disappeared, the plan panel dropped out of it and re-rendered inline above the
composer, and the transcript re-wrapped to full width -- a jarring reflow for a
popup, and worst mid-run with a live plan on screen.

It cannot overlap the sidebar either: twoColumnTranscriptView renders the
overlay with width = chatColumnWidth, so the palette is capped at
min(chatColumn, 76) and centred inside the chat column. Mouse hit-testing is
unaffected because sidebarLineAtMouse carries its own suggestionsActive() guard,
so clicks still reach the palette rather than the sidebar rows beneath it.

The genuinely full-width overlays keep suppressing the sidebar, pinned by the
new test alongside the palette case.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 20:58:11 +05:30
Vasanth T da9fb50f54 fix(tools): give write_stdin's invalid-session errors the same recovery guidance (#749 follow-up) (#768)
* fix(tools): give write_stdin's invalid-session errors the same recovery guidance

Follow-up to #749 (write_stdin session-id probing) / #702.

write_stdin had three "no valid live session" error paths with three different
messages and signatures: a missing session_id returned "session_id is required",
a zero/negative/non-integer one returned "session_id must be at least 1", and
only a valid-but-unknown id got the rich UnknownExecSessionError recovery guidance
("do not guess or probe; start a session with exec_command, or use edit_file/
write_file"). So a model that called write_stdin with session_id 0 (the common
"I have no id" placeholder) got a terse error with no way forward, and naming the
minimum nudged it toward trying 1, 2, 3... the exact id-probing #749 suppresses.
The three signatures also meant a model moving 0 to 1 to 2 reset the
repeated-failure streak on each class of mistake.

Route the missing / non-integer / <1 cases through the same UnknownExecSessionError
message. Now every "no live session" mistake gives the same recovery guidance and,
because that message is id-invariant, shares one repeated-failure signature, so any
mix of missing/zero/probing accumulates toward the halt.

Updates TestWriteStdinRequiresPositiveSessionID to cover missing/zero/negative/
non-integer and assert the recovery message. Guardrail signature/halt tests
unchanged.

* test(tools): cover the explicit session_id: nil case for write_stdin
2026-07-20 13:59:11 +05:30
KRATOSandKRATOS 684901165d fix(sandbox): don't auto-allow shell when re-entrancy skips wrapping (#727) (#744)
The ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND markers are user-controlled
at an unsandboxed process boundary. Policy could auto-allow an ordinary shell
command (backend reports a native wrapping sandbox) while the runner, seeing the
same markers, returned an unwrapped pass-through plan — the command then ran
directly on the host with no isolation and no prompt.

shellSandboxActive now mirrors the runner: when IsAlreadySandboxed() (the markers
are present, so wrapping is skipped) the sandbox is not the boundary for this
command, so it reports inactive and the shell command takes the normal approval
path instead of auto-allowing. Immediate mitigation per the issue; authenticated
launch provenance is the longer-term fix.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 11:56:36 +05:30
Vasanth T e1975c1b39 fix(perfbench): grant write tools so mutating tasks measure real edits (#763)
* fix(perfbench): grant write tools so mutating tasks measure real edits

The turn benchmark invoked `zero exec` in its default read-only posture,
which exposes no write or shell tools (no edit_file/apply_patch/
write_file/exec_command/bash). So the mutating classes (edit/fix/
refactor) could never apply a change: a run either ground to the turn
ceiling hunting for an edit tool or reported a no-tool blocker, and the
only tasks that 'passed' were ones whose oracle grep matched the stamped
.zero-answer.txt narration rather than a real edit.

This invalidated every mutating-class number in both baselines. A live
trace of fix-07 (a one-line admin->user prefix change) showed the model
calling only read_file/grep/lsp_navigate/update_plan and then stating it
had no file-writing tool. Adding --skip-permissions-unsafe exposes the
full tool set; each task already runs in an isolated throwaway fixture
copy, so there is nothing to protect. With the flag, edit-01/fix-01/
fix-07/refactor-01 go from failing (or false-passing) to 4/4 real
passes, and tool_execution rises from ~0.4% to ~29% of wall as the write
tools are actually exercised.

Adds TestBuildTurnExecArgsGrantsWriteTools pinning the flag as a
correctness contract.

* fix(perfbench): use --auto member and let the oracle judge incomplete exits

Reviewer feedback on the write-tools grant: --skip-permissions-unsafe is
broader than the benchmark needs. It also drops the workspace, network,
and destructive-command guards and auto-retries sandbox-blocked commands
unsandboxed. Switch to --auto member, which grants exactly the write +
sandboxed-shell tools the mutating classes need while keeping those
safeguards.

member-auto's shell is sandboxed, though, so on a host without sandbox
setup the agent's own self-verification (go test/build) can't run and the
turn exits INCOMPLETE even after a correct edit. The runner used to treat
any nonzero exit as failure before consulting the oracle, which would
false-fail those correct edits. Make the oracle authoritative for
oracle-bearing tasks: the stamped test/grep against the actual fixture
files is ground truth, so a correct edit passes regardless of the exit
code. Latency-only tasks have no oracle, so a nonzero exit still fails
them.

Verified with a 4-task glm-5.2 smoke (edit-01/fix-01/fix-07/refactor-01):
4/4 real passes under --auto member. Updates the exec-args test to assert
--auto member is present and --skip-permissions-unsafe is absent, and adds
TestOracleAuthoritativeOnIncompleteExit and
TestNonzeroExitStillFailsLatencyOnly.

* fix(perfbench): reject fixtureless tasks so the write-tools grant can't escape isolation

CodeRabbit and gnanam both flagged the same structural gap: buildTurnExecArgs
grants the write + sandboxed-shell tools to every invocation, but the fixture
isolation was conditional (a task with no workspaceFixture ran in the caller's
cwd). So the grant and the guard could drift apart, and a fixtureless task would
run an agent with write/shell tools in the caller's real repo dir.

Make the isolation a checked precondition: NewTurnExecRunner now rejects a task
with no workspaceFixture before launching zero exec, failing loudly rather than
silently running in cwd. Every shipped task already declares a fixture, so this
only fires on a malformed or newly added one.

Adds TestTurnRunnerRejectsFixturelessTask (rejection is pre-launch: it returns
the fixture error even with a nonexistent binary).

* fix(perfbench): defer to the oracle only for INCOMPLETE (exit 4), not every nonzero exit

anand's follow-up: the previous commit cleared VerifyErr for any nonzero
run_end whenever a VerificationCommand was present, which would also convert
a crash (1), usage error (2), provider failure (3), or interruption (130)
into a passing result whenever a partial edit happened to satisfy the oracle.

Gate the oracle override on exitCode == exitIncomplete (4): the completion
gate's "work maybe done but not signaled" case, the only one where a correct
edit can legitimately exit nonzero (its sandboxed self-verify couldn't run
under --auto member). Every other nonzero exit stays authoritative, so a
partial edit can't launder a crashed or interrupted run into a pass.

The 4-task glm-5.2 smoke stays 4/4 (those tasks exit INCOMPLETE, not some
other code). Adds TestNonIncompleteExitStaysAuthoritative (a correct edit that
reports exit 1 must still fail) and mirrors internal/cli.exitIncomplete as a
local execExitIncomplete constant.

* test(perfbench): pin the exit-4 oracle deferral from both sides; drop dead fixture branch

Follow-ups from a self-review of the exit-4 gating:

- Add TestIncompleteExitStillFailsWhenOracleFails: an INCOMPLETE (exit 4) run
  that did NO work must still FAIL, because deferring to the oracle still means
  the oracle is consulted. The sibling TestOracleAuthoritativeOnIncompleteExit
  applies a correct edit and would pass either way, so without this a regression
  that turned exit 4 into a blanket pass would go unnoticed.
- Broaden TestNonIncompleteExitStaysAuthoritative to a table over exit 1/3/130
  (crash/provider/interrupt), not just 1, so every named launder-risk code is
  covered.
- Tighten the NewTurnExecRunner doc: the verdict gate is the VerificationCommand
  (which runs the stamped OracleTest via go test), not OracleTest on its own.
- Drop the now-dead "if WorkspaceFixture != empty" branch: the fixtureless guard
  added earlier already guarantees it is set.

No behavior change; the 4-task glm-5.2 smoke stays 4/4.
2026-07-20 11:34:20 +05:30
KRATOSandKRATOS 8c6d3022bd feat(tui): add /undo as an alias for /rewind (#698) (#747)
* feat(tui): add /undo as an alias for /rewind (#698)

/undo was unregistered and fell through to commandUnknown. Add it to the
/rewind command's aliases (the same mechanism /quit, /find, /mcp-status use), so
it dispatches to the existing rewind handler with args intact.

* test(tui): cover the /undo numeric-sequence form (#698 review)

Add a parseCommand("/undo 123") assertion so both the latest and numeric
<sequence> forms of the /rewind contract are exercised through the alias.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 11:32:44 +05:30
KRATOSandKRATOS 974fc036c2 fix(oauth): refuse redirects on credential POSTs (#729) (#741)
Token exchange/refresh and the device authorization/poll POSTs validated only
the initial endpoint, then let the http.Client follow a 307/308 that replays the
form body (code, PKCE verifier, refresh_token, client_secret) to an unvalidated
origin.

Add withoutRedirects(): a shallow client copy whose CheckRedirect refuses every
redirect (ErrUnsafeRedirect). Applied at PostToken (covers ExchangeCode, Refresh,
and MCP which delegate to it), RequestDeviceCode, and pollDeviceOnce. The caller's
client is never mutated; legitimate token endpoints that return 200 are unaffected.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 08:19:37 +05:30
PierrunoYT 4cbd144d11 fix(providers): stop "provider not found" for env-derived profiles (#716)
* fix(providers): stop "provider not found" for env-derived profiles

* fix(providers): address env profile review feedback

* fix(providers): keep env JSON response schemas stable
2026-07-20 10:23:44 +08:00
euxaristiaandClaude Fable 5 c0b6b9e5c9 docs(readme): note govulncheck/golangci-lint install to GOPATH/bin (#711)
* docs(readme): note govulncheck/golangci-lint install to GOPATH/bin

The installed binaries land in $GOPATH/bin (default ~/go/bin), which must
be on PATH to run govulncheck directly. Document the export snippet so the
lint/security tooling from the contributor setup actually resolves.

* docs(readme): say "them" instead of singling out govulncheck

golangci-lint installs to the same $GOPATH/bin as govulncheck, so the
PATH note applies to both, not just the one named.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs(readme): honor GOBIN and make the PATH snippet idempotent

go install writes to GOBIN when set, with GOPATH/bin as the fallback, and
rerunning the old snippet appended the same directory repeatedly.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-20 10:21:54 +08:00
PierrunoYT d74ceb1127 fix(tui): cache settled alt-screen transcript (#647)
* fix(tui): cache settled alt-screen transcript

* fix(tui): invalidate settled alt-screen cache on in-place status row updates

setDoctorStatusRow, setSandboxSetupStatusRow, and setCompactStatusRow all
mutate an already-flushed transcript row in place (m.transcript[i] = row)
without invalidating the alt-screen settled cache. Since none of frontier,
width, or row count change on such an update, settleTranscript's rebuild
guard never fires and the View fast path serves the stale cached snapshot -
e.g. the doctor connectivity spinner freezes on its first frame.

Zero altScreenSettledWidth on the in-place path, mirroring the existing
pattern in toggleTranscriptRow, so the next settle rebuilds the cache.

Addresses PR #647 review feedback from Vasanthdev2004.

* fix(tui): invalidate settled transcript cache

* fix(tui): invalidate settled file selection cards
2026-07-20 10:20:46 +08:00
Vasanth T fbf85984f6 fix(agent): stop write_stdin session_id probing thrash (#702) (#749)
A model that calls write_stdin with no live session probed sequential
session_ids (1, 2, 3, …), each failing, and the run ground on for a long
time before halting. The repeated-failure guard keys on a normalized,
80-char-truncated signature of the tool error, and the old message
"Unknown exec session_id %d" put the varying id inside that prefix, so
every probe produced a fresh signature and the 6x-same-error halt never
fired.

- Rewrite the unknown-session error so stable recovery guidance leads and
  the id trails past the signature window: probing now collapses to one
  signature and trips the halt, and the model is told how to recover
  (start an exec_command, or use the file-edit tools).
- Pin session_id's schema Minimum to 1 so the advertised schema matches
  the runtime floor (intArg min 1).
- system_prompt: state that session_id is only ever an id returned by a
  still-running exec_command and must never be guessed or probed.

Tests pin the id-invariance of the signature and that distinct-id probing
now halts at the guard's stop threshold, plus the schema floor and the
recovery text. The TUI "Sent input" label on a failed write_stdin
(issue criterion 4) is a separate cosmetic concern in a shared hot path
and is left as a follow-up.
2026-07-19 22:13:35 +05:30
Anandan 9242b9e037 Add transcript-grounded structured task state (#761)
* Add transcript-grounded structured task state

Project existing plan, tool-result, changed-file, verification, and completion events into a deterministic per-run snapshot.

Emit content-free aggregate snapshots to traces. Carry bounded objective context through compaction and ground completion checks only when the projection matches the transcript; mismatches retain transcript-derived behavior and drop stale carried state.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check

* Preserve task objective across repeated compaction

Keep the immutable run objective when plan calls have been elided, while admitting mutable task fields only when plan parity still matches the transcript.

Make plan parsing match update_plan rejection semantics, coalesce and cap task-state trace events, rename parity to its plan-only scope, use explicit nilable dependencies, and remove confirmable objective fingerprints from traces.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check
2026-07-19 21:38:58 +05:30
48 changed files with 2223 additions and 121 deletions

No files matched your search

+22
View File
@@ -67,6 +67,28 @@ An approved issue means only that the specific issue has been accepted for work.
It does not approve unrelated future work, future pull requests, or future issues
from the same author. Each issue is reviewed case by case.
## Contribution Volume and Batching
To keep triage and review sustainable while the project stabilizes, please keep
your contribution flow to a size the core team can realistically work through:
- One issue per bug. File each defect as its own issue with its own
reproduction. Please do not bundle several unrelated findings into a single
"audit" issue; separate issues are far easier to triage, approve, and close
individually.
- Keep only a few items open at a time. If you already have several issues or
pull requests awaiting review, let those move through the queue before opening
more. A large simultaneous burst from one contributor slows review for
everyone, even when each item is valid.
- AI-assisted contributions are welcome, held to the same bar. Automated audits
and generated patches are fine, but each issue and pull request must be
individually verified, scoped, and reproducible before you open it. Please do
not post bulk machine-generated findings unfiltered.
These apply to all contributors and are about review bandwidth, not the value of
any single report. Maintainers may ask you to consolidate, split, or hold
submissions to keep the queue manageable.
## Pull Request Requirements
Before opening a community pull request, make sure all of the following are true:
+9
View File
@@ -384,6 +384,15 @@ go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
go install golang.org/x/vuln/cmd/[email protected]
```
The installed binaries land in `$GOBIN` when it is set, otherwise in
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
run them directly. If it isn't, add it:
```bash
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
```
### Cross-Compile Examples
```bash
+31
View File
@@ -169,6 +169,37 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
}
}
// The whole point of #702's fix: the unknown-session error's normalized
// signature must not vary with the session id, so a model probing ids 1, 2,
// 3, … produces one repeated signature the failure guard can count. If the id
// leaked into the first 80 normalized chars, each probe would reset the streak
// and the halt would never fire.
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
a := errorSignature(tools.UnknownExecSessionError(1))
b := errorSignature(tools.UnknownExecSessionError(999999))
if a != b {
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
}
}
// End to end: with an id-invariant signature, probing a different unknown id
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
// before the fix it never would.
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
var state guardState
var stoppedAt int
for i := 1; i <= toolFailureStopAt; i++ {
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureStopAt {
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
}
}
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
var state guardState
toolOnly := zeroruntime.CollectedStream{
+3
View File
@@ -100,6 +100,9 @@ work.
- Use exec_command with `tty: true` for interactive terminal-style commands that
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/edit_file/apply_patch for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
+6
View File
@@ -939,6 +939,12 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
// the default policy.
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
// An explicit `"sandbox": {"enabled": false}` disables the sandbox: every
// request is allowed and commands run unwrapped. Only reachable from global
// config / CLI — project config never sets Enabled (see mergeProjectConfig).
if cfg.Enabled != nil && !*cfg.Enabled {
policy.Mode = sandbox.ModeDisabled
}
if network := strings.TrimSpace(cfg.Network); network != "" {
switch sandbox.NetworkMode(network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
+159
View File
@@ -53,6 +53,22 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// SetActiveProvider only ever matches profiles persisted in config.json
// (see config.ProviderPersisted), but a provider can be visible in
// `zero providers list`/the TUI picker purely because Resolve()
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
// without ever writing a row to disk. Without this check, switching to
// that provider by name always fails with a confusing "not found" even
// though it is genuinely usable this session (issue #707).
persisted, err := config.ProviderPersisted(configPath, options.name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if !persisted {
if exit, handled := reportUnpersistedProviderUse(stdout, stderr, deps, options, configPath); handled {
return exit
}
}
cfg, err := config.SetActiveProvider(configPath, options.name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -352,6 +368,22 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps
return writeAppError(stderr, err.Error(), exitCrash)
}
name := options.names[0]
// RemoveProvider only ever matches profiles persisted in config.json (see
// config.ProviderPersisted), but a provider can be visible in
// `zero providers list`/the TUI picker purely because Resolve()
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
// without ever writing a row to disk. Without this check, deleting that
// provider by name always fails with a confusing "not found" even though
// it is genuinely visible/usable this session (issue #707).
persisted, err := config.ProviderPersisted(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if !persisted {
if exit, handled := reportUnpersistedProviderRemove(stdout, stderr, deps, name, options.json, configPath); handled {
return exit
}
}
cfg, err := config.RemoveProvider(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -428,6 +460,16 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
oldName := options.names[0]
persisted, err := config.ProviderPersisted(configPath, oldName)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if !persisted {
if exit, handled := reportUnpersistedProviderRename(stdout, stderr, deps, oldName, options.json, configPath); handled {
return exit
}
}
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -447,3 +489,120 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
}
return exitSuccess
}
// providerResolvedByName reports whether name matches a provider in a
// resolved provider list — used to tell a genuinely unknown name (a typo)
// apart from a real, env-derived provider that just has no config.json row.
func providerResolvedByName(providers []config.ProviderProfile, name string) bool {
name = strings.TrimSpace(name)
for _, provider := range providers {
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
return true
}
}
return false
}
// reportUnpersistedProviderUse handles `zero providers use <name>` for a
// provider that is not persisted in config.json. If it's not resolvable at
// all (an unknown/misspelled name), it returns handled=false so the caller
// falls through to SetActiveProvider's real "not found" error. If it IS
// resolvable — an env-derived profile (e.g. ambient OPENAI_API_KEY) —
// SetActiveProvider would only ever fail "not found" against it, so this
// reports the situation plainly instead of that confusing error (issue
// #707).
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
// resolveCommandCenterConfig already wrote its own error to stderr;
// stop here instead of letting the caller try SetActiveProvider too.
return exitCode, true
}
if !providerResolvedByName(resolved.Providers, options.name) {
return exitCode, false
}
message := fmt.Sprintf(
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to switch to.\nIt is available whenever its environment variable is set, but is only active when selected (for example via ZERO_PROVIDER); unset its environment variable to stop Zero from detecting it automatically.",
options.name,
)
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"activeProvider": resolved.ActiveProvider,
"configPath": configPath,
"persisted": false,
"message": message,
}); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
if _, err := fmt.Fprintln(stdout, message); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
// reportUnpersistedProviderRemove handles `zero providers remove <name>` for
// a provider that is not persisted in config.json, mirroring the TUI
// provider manager's delete handling (internal/tui/provider_manager.go). If
// name isn't resolvable at all, it returns handled=false so the caller falls
// through to RemoveProvider's real "not found" error.
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode, true
}
if !providerResolvedByName(resolved.Providers, name) {
return exitCode, false
}
message := fmt.Sprintf(
"Provider %q is not saved in config.json (likely set via an environment variable) — nothing to remove there.\nUnset its environment variable to stop Zero from detecting it automatically.",
name,
)
if jsonOutput {
if err := writePrettyJSON(stdout, map[string]any{
"removed": "",
"keyRemoved": false,
"activeProvider": resolved.ActiveProvider,
"configPath": configPath,
"persisted": false,
"message": message,
}); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
if _, err := fmt.Fprintln(stdout, message); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode, true
}
if !providerResolvedByName(resolved.Providers, name) {
return exitCode, false
}
message := fmt.Sprintf(
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to rename.",
name,
)
if jsonOutput {
if err := writePrettyJSON(stdout, map[string]any{
"renamed": nil,
"configPath": configPath,
"persisted": false,
"message": message,
}); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
if _, err := fmt.Fprintln(stdout, message); err != nil {
return exitCrash, true
}
return exitSuccess, true
}
+105
View File
@@ -75,6 +75,111 @@ func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) {
}
}
func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte("{"), 0o600); err != nil {
t.Fatalf("write malformed config: %v", err)
}
exitCode := runWithDeps([]string{"providers", "use", "openai"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitCrash {
t.Fatalf("exit code = %d, want %d", exitCode, exitCrash)
}
if !strings.Contains(stderr.String(), "invalid config JSON") {
t.Fatalf("stderr = %q, want malformed-config error", stderr.String())
}
}
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
ConfigPath string `json:"configPath"`
Persisted bool `json:"persisted"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.ConfigPath != configPath || payload.Persisted {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Removed string `json:"removed"`
KeyRemoved bool `json:"keyRemoved"`
Persisted bool `json:"persisted"`
ConfigPath string `json:"configPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Removed != "" || payload.KeyRemoved || payload.Persisted || payload.ConfigPath != configPath {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "no saved profile to rename") {
t.Fatalf("stdout = %q, want unpersisted explanation", stdout.String())
}
}
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Renamed *struct{} `json:"renamed"`
ConfigPath string `json:"configPath"`
Persisted bool `json:"persisted"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Renamed != nil || payload.ConfigPath != configPath || payload.Persisted {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
cases := []struct {
name string
+15
View File
@@ -1001,3 +1001,18 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key
}
return ""
}
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
disabled := false
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
}
// Omitted (nil) and explicit true keep the default enforcing mode.
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
t.Fatal("omitted enabled must not disable the sandbox")
}
enabled := true
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
t.Fatal("enabled:true must not disable the sandbox")
}
}
+20
View File
@@ -93,6 +93,16 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
if err != nil {
return ResolvedConfig{}, err
}
// Sandbox.Enabled is NOT accepted from a provider command, for the same
// reason project config cannot set it (see mergeProjectConfig): only
// global config and the CLI may turn the sandbox off. A provider command
// is an arbitrary executable named in config, and LoadProviderCommand
// parses its stdout into a full FileConfig — so without this, a command
// returning a valid provider plus {"sandbox":{"enabled":false}} would
// disable the very sandbox meant to constrain what it can do. Cleared
// here rather than in mergeConfig because that helper is shared with the
// trusted global-config merge, which must keep honouring the setting.
commandConfig.Sandbox.Enabled = nil
mergeConfig(&cfg, commandConfig)
}
@@ -216,6 +226,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP, true)
if src.Sandbox.Enabled != nil {
dst.Sandbox.Enabled = src.Sandbox.Enabled
}
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
@@ -273,6 +286,10 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
return err
}
// Sandbox.Enabled is intentionally NOT merged from project config: a cloned
// repo's .zero/config.json must not be able to disable the sandbox that
// constrains it. Only global config and CLI can turn the sandbox off.
//
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
@@ -688,6 +705,9 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
}
if overrides.Sandbox.Enabled != nil {
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
}
if overrides.Sandbox.BlockUnixSockets {
cfg.Sandbox.BlockUnixSockets = true
}
+79
View File
@@ -1744,6 +1744,85 @@ func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
}
}
func TestResolveSandboxEnabledUserConfigDisables(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
t.Fatalf("user config enabled:false must resolve to a false pointer, got %v", resolved.Sandbox.Enabled)
}
}
func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) {
// Security: a cloned repo's project config must NOT be able to disable the
// sandbox that constrains it. Only global config / CLI can turn it off.
userPath := writeConfig(t, `{}`)
projectPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled != nil {
t.Fatalf("project config enabled:false must be ignored, got %v — a repo could disable the sandbox", *resolved.Sandbox.Enabled)
}
}
func TestResolveSandboxEnabledIgnoredFromProviderCommand(t *testing.T) {
// Security: a provider command is an arbitrary executable named in config, and
// its stdout is parsed into a full FileConfig — so it must NOT be able to reach
// Sandbox.Enabled, in either direction. Same rule as project config: only global
// config / CLI may turn the sandbox off.
for _, tc := range []struct {
name string
value string
}{
{name: "cannot disable", value: "false"},
{name: "cannot enable", value: "true"},
} {
t.Run(tc.name, func(t *testing.T) {
command := writeCommand(t, commandScript{
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"sandbox":{"enabled":` + tc.value + `}}`,
})
resolved, err := Resolve(ResolveOptions{ProviderCommand: command, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
// The provider itself must still be applied — only the sandbox toggle is dropped.
if resolved.ActiveProvider != "cmd" {
t.Fatalf("ActiveProvider = %q, want cmd (provider command must still apply)", resolved.ActiveProvider)
}
if resolved.Sandbox.Enabled != nil {
t.Fatalf("provider command set sandbox.enabled=%s, got %v — a provider command could toggle the sandbox", tc.value, *resolved.Sandbox.Enabled)
}
})
}
}
func TestResolveSandboxEnabledCLIOverride(t *testing.T) {
// A CLI/programmatic override (applied after every config source) must be able
// to disable the sandbox, even with no config file.
disabled := false
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &disabled}}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
t.Fatalf("CLI override enabled:false must disable the sandbox, got %v", resolved.Sandbox.Enabled)
}
// An explicit true override must win over a lower-precedence global-config false.
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
enabled := true
resolved2, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &enabled}}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved2.Sandbox.Enabled == nil || !*resolved2.Sandbox.Enabled {
t.Fatalf("CLI override enabled:true must win over config false, got %v", resolved2.Sandbox.Enabled)
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
+6
View File
@@ -62,6 +62,12 @@ func HasProviderProfile(profile ProviderProfile) bool {
}
type SandboxConfig struct {
// Enabled turns the sandbox off when set to false (`"sandbox": {"enabled":
// false}`). A pointer distinguishes an explicit false (disable) from an
// omitted key (keep the default: enabled). Honored from the GLOBAL user
// config and CLI only — deliberately NOT project config, so a cloned repo
// cannot disable the sandbox that constrains it. nil / true keep enforcement.
Enabled *bool `json:"enabled,omitempty"`
// Network controls whether shell commands classified as network-touching
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
// Empty keeps the built-in default (deny). Without this knob the engine's
+27
View File
@@ -177,6 +177,33 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// ProviderPersisted reports whether a provider profile named name actually has
// a row in the config file at path. A provider can appear in the resolved/
// in-memory provider list without ever being written to config.json — e.g.
// applyProviderEnv synthesizes an "openai" profile purely from an ambient
// OPENAI_API_KEY environment variable on every Resolve() call, without ever
// persisting it. RemoveProvider/SetActiveProvider/SetProviderModel only ever
// look at what's on disk, so a caller offering to mutate a provider by name
// should check this first: "not on disk" needs different handling (nothing to
// persist/remove there) than a name that doesn't exist anywhere at all.
func ProviderPersisted(path string, name string) (bool, error) {
path = strings.TrimSpace(path)
name = strings.TrimSpace(name)
if path == "" || name == "" {
return false, nil
}
cfg, err := loadConfigFile(path)
if err != nil {
return false, err
}
for _, provider := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
return true, nil
}
}
return false, nil
}
// RemoveProvider deletes the named provider profile from the config at path.
// When the removed profile was active, activeProvider hands off to the first
// remaining provider (or clears when none remain) so the config never points at
+6 -6
View File
@@ -49,9 +49,9 @@ func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now
if err := validateTokenEndpoint(endpoint); err != nil {
return DeviceAuth{}, err
}
if client == nil {
client = http.DefaultClient
}
// Refuse redirects so a device-authorization redirect can't replay the
// client_id/client_secret POST body to an unvalidated origin.
client = withoutRedirects(client)
if now == nil {
now = time.Now
}
@@ -157,9 +157,9 @@ func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, device
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
return Token{}, err
}
if client == nil {
client = http.DefaultClient
}
// Refuse redirects so a poll redirect can't replay the device_code/secret
// POST body to an unvalidated origin.
client = withoutRedirects(client)
form := url.Values{}
form.Set("grant_type", deviceGrantType)
form.Set("device_code", deviceCode)
+25
View File
@@ -2,6 +2,7 @@ package oauth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -28,6 +29,30 @@ func TestRequestDeviceCodeSendsClientSecret(t *testing.T) {
}
}
func TestRequestDeviceCodeRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
_, err := RequestDeviceCode(context.Background(), http.DefaultClient,
Config{ClientID: "c", ClientSecret: "shh", DeviceAuthorizationEndpoint: endpoint}, nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("RequestDeviceCode err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — client_secret replayed", n)
}
}
func TestPollDeviceOnceRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusPermanentRedirect) // 308
_, err := pollDeviceOnce(context.Background(), http.DefaultClient,
Config{ClientID: "c", ClientSecret: "shh", TokenEndpoint: endpoint}, "device-code", nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("pollDeviceOnce err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — device_code/secret replayed", n)
}
}
func TestRequestDeviceCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WXYZ-1234","verification_uri":"https://x/dev","verification_uri_complete":"https://x/dev?c=WXYZ","expires_in":600,"interval":3}`))
+20 -3
View File
@@ -116,6 +116,23 @@ func validateTokenEndpoint(endpoint string) error {
return ValidateEndpointURL(endpoint)
}
// withoutRedirects returns a shallow copy of client whose redirect policy
// refuses to follow ANY redirect. A credential-bearing OAuth POST (token
// exchange/refresh, device authorization, device-token poll) must never let a
// 307/308 replay its form body — codes, PKCE verifiers, refresh tokens, client
// secrets — to a target that was never endpoint-validated. The caller's client
// is left unmodified.
func withoutRedirects(client *http.Client) *http.Client {
if client == nil {
client = http.DefaultClient
}
copied := *client
copied.CheckRedirect = func(*http.Request, []*http.Request) error {
return ErrUnsafeRedirect
}
return &copied
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
@@ -176,9 +193,9 @@ func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, f
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
return Token{}, err
}
if client == nil {
client = http.DefaultClient
}
// Refuse redirects so a 307/308 from the token endpoint can't replay this
// credential-bearing POST body to another (unvalidated) origin.
client = withoutRedirects(client)
if now == nil {
now = time.Now
}
+60
View File
@@ -5,10 +5,12 @@ import (
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
@@ -126,6 +128,64 @@ func TestBuildAuthorizationURLRejectsInsecureEndpoint(t *testing.T) {
}
}
// redirectTrap starts an "attacker" server that counts any hit, and a credential
// endpoint that redirects to it with the given status. It returns the endpoint
// URL and a func reporting how many times the attacker was contacted — a
// non-zero count means the credential-bearing POST body was replayed (#729).
func redirectTrap(t *testing.T, status int) (endpoint string, attackerHits func() int32) {
t.Helper()
var hits int32
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
_, _ = io.WriteString(w, `{"access_token":"leaked","refresh_token":"leaked"}`)
}))
t.Cleanup(attacker.Close)
endp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, attacker.URL, status)
}))
t.Cleanup(endp.Close)
return endp.URL, func() int32 { return atomic.LoadInt32(&hits) }
}
func TestPostTokenRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", "the-code")
form.Set("client_secret", "shh")
_, err := PostToken(context.Background(), http.DefaultClient, endpoint, form, Token{}, nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("PostToken err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — credential body was replayed", n)
}
}
func TestExchangeCodeRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusPermanentRedirect) // 308
_, err := ExchangeCode(context.Background(), http.DefaultClient,
Config{ClientID: "c", TokenEndpoint: endpoint}, "the-code", "verifier", "http://127.0.0.1/cb", nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("ExchangeCode err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — code/verifier replayed", n)
}
}
func TestRefreshRefusesRedirect(t *testing.T) {
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
_, err := Refresh(context.Background(), http.DefaultClient,
Config{ClientID: "c", TokenEndpoint: endpoint}, Token{RefreshToken: "rt"}, nil)
if !errors.Is(err, ErrUnsafeRedirect) {
t.Fatalf("Refresh err = %v, want ErrUnsafeRedirect", err)
}
if n := hits(); n != 0 {
t.Fatalf("attacker received %d request(s) — refresh_token replayed", n)
}
}
func TestValidateTokenEndpoint(t *testing.T) {
cases := map[string]bool{
"https://auth.example.com/token": true,
+5
View File
@@ -90,6 +90,11 @@ var (
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
// non-https, non-loopback endpoint.
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
// ErrUnsafeRedirect is returned when a credential-bearing OAuth POST (token
// exchange/refresh, device authorization, or device-token poll) is redirected.
// Following it would replay the form body — codes, PKCE verifiers, refresh
// tokens, client secrets — to a target that never passed endpoint validation.
ErrUnsafeRedirect = errors.New("oauth: refusing to follow a redirect from a credential endpoint")
// ErrNoRefreshToken is returned when a refresh is attempted without one.
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
+72 -17
View File
@@ -578,28 +578,54 @@ func WriteTurnBenchJSON(w io.Writer, result TurnBenchResult) error {
return encoder.Encode(result)
}
// execExitIncomplete mirrors internal/cli.exitIncomplete (the `zero exec` exit
// code, 4) for a headless run the completion gate marked INCOMPLETE: the run
// stopped without a completion signal (e.g. it couldn't self-verify because its
// sandboxed shell was unavailable under --auto member), which is distinct from a
// crash (1), usage error (2), provider failure (3), or interruption (130). It is
// the ONLY nonzero exit an oracle-bearing task may defer to the oracle for; every
// other nonzero exit stays authoritative. Kept as a local literal so perfbench
// doesn't import the cli package; must stay in sync with that constant.
const execExitIncomplete = 4
// NewTurnExecRunner builds the production turn-benchmark runner: it invokes
// headless `zero exec` with stream-json output AND `--trace <tmpfile>`, then
// parses the emitted NDJSON trace into a *trace.TurnTrace. binary is the path to
// the `zero` binary; extraArgs are appended to every invocation. Pass/fail is
// decided from the stream-json run_end exit code (and the task's
// VerificationCommand when present), exactly like NewExecRunner.
// decided by the task's oracle when it has one. The oracle that gates the verdict
// is the VerificationCommand (which compiles and runs the stamped OracleTest via
// `go test`, plus any greps against the fixture); it is ground truth, so an
// oracle-bearing task that applied the right edit but exited INCOMPLETE (exit 4,
// its sandboxed self-verification couldn't run) still passes. Any OTHER nonzero
// exit (crash/usage/provider/interrupt) stays authoritative and fails even an
// oracle-bearing task. A latency-only task has no VerificationCommand, so any
// nonzero exit is its only signal and fails it.
func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
return func(ctx context.Context, task BenchTask, rc RunContext) TurnTaskOutcome {
// buildTurnExecArgs grants the write + sandboxed-shell tool set to EVERY
// invocation, so a task MUST run inside an isolated fixture copy — never the
// caller's cwd. The grant is unconditional, so the isolation can't be
// optional: a fixtureless task would turn an agent with write/shell tools
// loose in the caller's working directory (their real repo). Enforce the
// invariant instead of trusting it — reject a fixtureless task up front,
// before anything is launched, so the grant and the guard can't drift apart.
// Every shipped task declares a fixture, so this only fires on a malformed or
// newly added task, and it fails loudly rather than silently.
if strings.TrimSpace(task.WorkspaceFixture) == "" {
return TurnTaskOutcome{Err: errors.New("benchmark task has no workspaceFixture: the run grants write/shell tools and must execute in an isolated fixture copy, not the caller's cwd")}
}
// Isolate the workspace: copy the fixture into a fresh temp dir so a
// mutating task (edit/fix/refactor) can't dirty the shared, checked-in
// fixture or bleed into a later iteration of the same task. When no
// fixture is configured the agent runs in the caller's cwd as before.
if fixture := strings.TrimSpace(task.WorkspaceFixture); fixture != "" {
copyDir, parent, cerr := copyFixture(fixture)
if cerr != nil {
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
}
// Clean the whole unique parent (which owns copyDir) so the
// per-invocation scratch dir never leaks.
defer os.RemoveAll(parent)
task.WorkspaceFixture = copyDir
// fixture or bleed into a later iteration of the same task. The guard above
// guarantees WorkspaceFixture is set, so this always runs.
copyDir, parent, cerr := copyFixture(strings.TrimSpace(task.WorkspaceFixture))
if cerr != nil {
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
}
// Clean the whole unique parent (which owns copyDir) so the per-invocation
// scratch dir never leaks.
defer os.RemoveAll(parent)
task.WorkspaceFixture = copyDir
traceFile, err := os.CreateTemp("", "zero-turn-trace-*.ndjson")
if err != nil {
@@ -656,10 +682,23 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
}
}
// A nonzero agent exit already decided failure; don't run verification or
// mark the task passed.
// Decide what a nonzero exit means. We defer to the oracle for exactly ONE
// case: an oracle-bearing task that exited INCOMPLETE (exit 4). That is the
// completion gate downgrading a run whose edits may be correct but that
// couldn't emit a completion signal (e.g. its sandboxed self-verify couldn't
// run under --auto member) — the oracle below (the compiled test / grep run
// against the actual fixture files) is ground truth, so if the edit really
// landed it passes, and if the run abandoned the task the oracle fails it.
// Every OTHER nonzero exit stays authoritative: a crash (1), usage error
// (2), provider failure (3), or interruption (130) is a genuine failure, so
// a partial edit that happens to satisfy the oracle can't launder it into a
// pass. A latency-only task has no oracle to defer to, so any nonzero exit
// fails it.
if outcome.VerifyErr != "" {
return outcome
if len(task.VerificationCommand) == 0 || exitCode != execExitIncomplete {
return outcome
}
outcome.VerifyErr = ""
}
// Stamp the compiler-backed oracle and capture the agent's final answer
// BEFORE running the verification command. Both write into the fixture
@@ -790,7 +829,23 @@ func stampOracleAndAnswer(task BenchTask, outBuf []byte) error {
}
func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArgs []string) []string {
args := []string{"exec", "--output-format", "stream-json", "--trace", tracePath}
// --auto member is REQUIRED, not optional: without a permission grant `zero
// exec` runs in its default read-only posture, which exposes no write or shell
// tools (no edit_file/apply_patch/write_file/exec_command/bash). The mutating
// task classes (edit/fix/refactor) then cannot apply any change, so every run
// grinds to the turn ceiling or reports a no-tool blocker, and the only tasks
// that "pass" are ones whose oracle grep matches the stamped answer text rather
// than a real edit. member-auto grants the write + sandboxed-shell tools the
// benchmark needs while keeping the workspace/network/destructive safeguards, so
// it is preferred over the broader --skip-permissions-unsafe (which also drops
// those guards and auto-retries blocked commands unsandboxed). Note the shell it
// grants is sandboxed: on a host without sandbox setup the agent's own
// self-verification (go test/build) can't run and the turn exits INCOMPLETE even
// after a correct edit — the runner treats the stamped oracle, not that exit
// code, as ground truth for oracle-bearing tasks (see NewTurnExecRunner), so a
// correct edit still passes. Each task also runs in an isolated, throwaway
// fixture copy, so the granted tools have nothing outside the task to harm.
args := []string{"exec", "--auto", "member", "--output-format", "stream-json", "--trace", tracePath}
if model := strings.TrimSpace(rc.Model); model != "" {
args = append(args, "--model", model)
}
+138
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -750,6 +751,92 @@ echo '{"type":"run_end","exitCode":0}'
}
}
// TestOracleAuthoritativeOnIncompleteExit is the companion to the --auto member
// switch: under member-auto the agent's shell is sandboxed, so on a host without
// sandbox setup its self-verification (go test/build) can't run and the turn
// exits INCOMPLETE (exit 4) even after a correct edit. For an ORACLE-bearing task
// the runner must treat the stamped oracle — not that INCOMPLETE exit — as ground
// truth: here the stub applies the real edit-01 rename but reports exitCode 4, and
// the task must still pass because the fixture is correct. This defers ONLY for
// exit 4; TestNonIncompleteExitStaysAuthoritative pins the other side.
func TestOracleAuthoritativeOnIncompleteExit(t *testing.T) {
task := loadBaselineTask(t, "edit-01")
outcome := runTurnStub(t, task, `sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
echo '{"type":"run_end","exitCode":4}'
`)
if outcome.Err != nil {
t.Fatalf("incomplete-exit with a correct edit should pass, got harness error: %v", outcome.Err)
}
if !outcome.Passed {
t.Fatalf("a correct edit that exited INCOMPLETE (exit 4) must still pass its oracle: %+v", outcome)
}
if strings.TrimSpace(outcome.VerifyErr) != "" {
t.Fatalf("a passing oracle must clear the exit-code VerifyErr, got %q", outcome.VerifyErr)
}
}
// TestNonIncompleteExitStaysAuthoritative is the guard the oracle-authoritative
// change MUST NOT weaken: only INCOMPLETE (exit 4) defers to the oracle. A crash
// (1), provider failure (3), or interruption (130) is a genuine failure, so it
// stays authoritative even for an oracle-bearing task — otherwise a partial edit
// that happens to satisfy the oracle would launder a crashed or interrupted run
// into a pass. Each case applies the CORRECT edit-01 rename (so the oracle WOULD
// pass) but reports the failing exit code; the task must still fail, and the
// failure must be attributed to the exit code, not the oracle.
func TestNonIncompleteExitStaysAuthoritative(t *testing.T) {
for _, code := range []int{1, 3, 130} {
t.Run(fmt.Sprintf("exit%d", code), func(t *testing.T) {
task := loadBaselineTask(t, "edit-01")
outcome := runTurnStub(t, task, fmt.Sprintf(`sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
echo '{"type":"run_end","exitCode":%d}'
`, code))
if outcome.Err != nil {
t.Fatalf("a nonzero exit should be a task fail, not a harness error: %v", outcome.Err)
}
if outcome.Passed {
t.Fatalf("a run that exited %d must not pass even when the edit satisfies the oracle: %+v", code, outcome)
}
if want := fmt.Sprintf("exit code %d", code); !strings.Contains(outcome.VerifyErr, want) {
t.Fatalf("a non-INCOMPLETE nonzero exit must stay authoritative and surface its code, got VerifyErr=%q", outcome.VerifyErr)
}
})
}
}
// TestIncompleteExitStillFailsWhenOracleFails pins the OTHER half of the exit-4
// deferral: deferring to the oracle on INCOMPLETE must still MEAN the oracle is
// consulted, not a blanket pass. Here the stub does no real work and exits
// INCOMPLETE (4); the edit-01 rename never happened, so the oracle fails and the
// task fails. Without this, a regression that turned exit 4 into an unconditional
// pass would slip through, because its sibling TestOracleAuthoritativeOnIncomplete-
// Exit applies a CORRECT edit and would pass either way.
func TestIncompleteExitStillFailsWhenOracleFails(t *testing.T) {
task := loadBaselineTask(t, "edit-01")
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":4}'
`)
assertVerifyFailed(t, "incomplete exit with no edit applied", outcome)
}
// TestNonzeroExitStillFailsLatencyOnly is the guard on the other side of that
// change: a latency-only task has no oracle to appeal to, so the exit code is
// the ONLY correctness signal and a nonzero exit must still fail it. Without
// this, dropping the exit-code gate for oracle tasks could be misread as
// dropping it everywhere.
func TestNonzeroExitStillFailsLatencyOnly(t *testing.T) {
task := loadBaselineTask(t, "longproc-01")
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":4}'
`)
if outcome.Err != nil {
t.Fatalf("latency-only nonzero exit should be a verify fail, not a harness error: %v", outcome.Err)
}
if outcome.Passed {
t.Fatalf("a latency-only task that exited nonzero must not pass: %+v", outcome)
}
if strings.TrimSpace(outcome.VerifyErr) == "" {
t.Fatalf("a latency-only nonzero exit must surface a VerifyErr, got none: %+v", outcome)
}
}
// --- Satisfiable tests: the oracle PASSES the right thing (real fix applied) ---
// TestStampedOraclePassesWhenRefactorHappened proves the refactor-01 oracle is
@@ -1192,6 +1279,57 @@ func TestBuildTurnExecArgsIncludesExecProfile(t *testing.T) {
}
}
// Every benchmark invocation MUST grant the write/shell tool set via --auto
// member. Without a permission grant the agent runs read-only and cannot apply
// any edit, so the mutating classes measure nothing but oracle/answer
// contamination. member-auto is the RIGHT grant: it exposes the write +
// sandboxed-shell tools the benchmark needs while keeping the workspace/network/
// destructive safeguards, so the args must NOT reach for the broader
// --skip-permissions-unsafe (which drops those guards). This is a correctness
// contract, not a preference.
func TestBuildTurnExecArgsGrantsWriteTools(t *testing.T) {
args := buildTurnExecArgs(BenchTask{ID: "t", Prompt: "edit the file"}, RunContext{Model: "m"}, "trace.ndjson", nil)
autoMember := false
for i := 0; i < len(args)-1; i++ {
if args[i] == "--auto" && args[i+1] == "member" {
autoMember = true
break
}
}
if !autoMember {
t.Fatalf("benchmark exec args must include --auto member so the agent can apply edits, got %v", args)
}
for _, arg := range args {
if arg == "--skip-permissions-unsafe" {
t.Fatalf("benchmark exec args must NOT use --skip-permissions-unsafe (member-auto keeps the sandbox/network guards), got %v", args)
}
}
if args[len(args)-1] != "edit the file" {
t.Fatalf("prompt must stay the last argument, got %v", args)
}
}
// TestTurnRunnerRejectsFixturelessTask is the other half of the write-tools
// contract: because buildTurnExecArgs grants the write + sandboxed-shell tool
// set to every invocation, a task with no fixture would run an agent with those
// tools in the caller's cwd. The runner must reject such a task BEFORE launching
// zero exec. Passing a binary path that does not exist proves the rejection is
// pre-launch — the outcome carries the fixture error, not a spawn error — so this
// runs on every OS (it never reaches the POSIX-only exec stub).
func TestTurnRunnerRejectsFixturelessTask(t *testing.T) {
task := BenchTask{ID: "no-fixture", Prompt: "do a thing"} // no WorkspaceFixture set
outcome := NewTurnExecRunner(filepath.Join(t.TempDir(), "does-not-exist-zero"))(context.Background(), task, RunContext{Model: "m"})
if outcome.Err == nil {
t.Fatalf("a fixtureless task must be rejected before launch, got %+v", outcome)
}
if !strings.Contains(outcome.Err.Error(), "workspaceFixture") {
t.Fatalf("rejection must name the missing workspaceFixture, got: %v", outcome.Err)
}
if outcome.Passed {
t.Fatalf("a rejected task must not pass, got %+v", outcome)
}
}
// The configured profile must reach the runner's RunContext and be stamped
// into the result, so a profile A/B report is self-describing. The boundary
// canonicalizes (case/whitespace) so equivalent postures always carry the same
+70
View File
@@ -65,6 +65,76 @@ func AnalyzeCommand(script string) AnalysisResult {
return result
}
// astCommandFields parses command with the shell parser and returns each simple
// command as its literal field slice (program + args as text), resolving the
// real command positions across quoting, command substitution, subshells, and
// newline separators — the constructs the hand-written splitter in
// safe_command.go mis-handles (issue #473). It returns nil when the command
// cannot be parsed (e.g. a Windows cmd.exe string), so callers fall through to
// the regex path rather than hard-blocking.
func astCommandFields(command string) [][]string {
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
if err != nil {
return nil
}
var commands [][]string
syntax.Walk(file, func(node syntax.Node) bool {
call, ok := node.(*syntax.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
// EVERY word must be a static literal, not just the program name.
// wordText keeps only the literal/quoted parts of a word and silently
// drops expansions, so a dynamic word anywhere in the call reconstructs
// to something the shell will never run: `$(printf foo)vim` (runs as
// `foovim`) collapses to "vim", and `git $(printf foo)rebase -i` (runs
// as `git foorebase -i`, non-interactive) collapses to `git rebase -i`
// and fabricates an interactive match. Since the runtime value of an
// expansion is unknowable here, skip the whole call rather than classify
// a lossy reconstruction. Skipping is the safe direction: the
// hand-written passes above already ran, and a missed detection falls
// through to the normal permission prompt instead of hard-blocking a
// command the user never wrote.
for _, word := range call.Args {
if !isLiteralWord(word) {
return true
}
}
fields := make([]string, 0, len(call.Args))
for _, word := range call.Args {
fields = append(fields, wordText(word))
}
commands = append(commands, fields)
return true
})
return commands
}
// isLiteralWord reports whether every part of word is a static literal (bare or
// quoted). A word containing a command substitution, parameter/arithmetic
// expansion, process substitution, etc. is dynamic — its runtime value is
// unknown, so its wordText (a partial literal) must not be trusted as a program
// name.
func isLiteralWord(word *syntax.Word) bool {
if word == nil {
return false
}
for _, part := range word.Parts {
switch typed := part.(type) {
case *syntax.Lit, *syntax.SglQuoted:
case *syntax.DblQuoted:
for _, inner := range typed.Parts {
if _, ok := inner.(*syntax.Lit); !ok {
return false
}
}
default:
return false
}
}
return true
}
// analyzeInto parses script and folds its interactive/destructive/network usage
// into result, sharing seen so program names are de-duplicated across recursion.
func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, depth int) {
+34
View File
@@ -93,6 +93,40 @@ func TestSandboxedBashAutoAllowDoesNotAffectNonShell(t *testing.T) {
}
}
// Forged re-entrancy markers (ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND at
// an unsandboxed process boundary) must NOT let a shell command combine an
// auto-allow decision with an unwrapped pass-through plan (issue #727). The
// runner skips wrapping when the markers are present, so the policy must stop
// auto-allowing on the basis of an "active sandbox" — the command takes the
// normal prompt instead. Without the markers the identical setup auto-allows
// (TestSandboxedBashAutoAllowedWhenSandboxActive), so this is the before/after.
func TestForgedReentrancyMarkersDoNotAutoAllowUnwrappedShell(t *testing.T) {
t.Setenv(EnvSandboxed, "1")
t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap))
root := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: root,
Policy: DefaultPolicy(),
Backend: nativeWrappingBackend,
})
// Policy side: the shell command must NOT be auto-allowed.
decision := engine.Evaluate(context.Background(), bashRequest())
if decision.Action != ActionPrompt || decision.AutoAllowed {
t.Fatalf("forged markers must not auto-allow the shell command, got %#v", decision)
}
// Planning side: the same command is an unwrapped pass-through. Together with
// the prompt above, this proves auto-allow and unwrapped can no longer combine.
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "echo hi"}, Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if plan.Wrapped {
t.Fatalf("re-entrancy plan must be an unwrapped pass-through, got wrapped=%v", plan.Wrapped)
}
}
// TestShellSandboxActive reports correctly across backends and policy modes.
func TestShellSandboxActive(t *testing.T) {
root := t.TempDir()
+9
View File
@@ -232,6 +232,15 @@ func (engine *Engine) shellSandboxActive(policy Policy) bool {
if policy.Mode == ModeDisabled {
return false
}
// Re-entrancy skips wrapping: when the process carries the sandbox markers,
// the runner returns a pass-through (unwrapped) plan, so the native sandbox
// is NOT the boundary for THIS command. Because the markers are ambient and
// forgeable at an unsandboxed process boundary (issue #727), auto-allowing a
// shell command on that basis would run it unwrapped with no prompt. Mirror
// the runner: report inactive so the command takes the normal approval path.
if IsAlreadySandboxed() {
return false
}
backend := engine.backend
if !(backend.Available && backend.Executable != "" && backend.CommandWrapping && backend.NativeIsolation) {
return false
+56
View File
@@ -219,9 +219,65 @@ func DetectInteractiveCommand(command string, goos string) InteractiveCommandRes
}
}
// AST second opinion (issue #473): the hand-written segment split above
// mis-handles interactive programs hidden by unusual quoting, command
// substitution, subshells, or newline separators. Re-extract the real simple
// commands from the parsed shell tree and apply the SAME full pipeline
// (interactive segments, sh -c payload recursion, per-program lookup), so a
// bypass is caught while every program stays classified exactly as before
// (ssh with a trailing command, python -c, etc. remain allowed). A command
// the parser cannot handle (Windows cmd.exe, obfuscation) yields no commands
// and falls through unchanged — the guard never hard-blocks on a parse error.
for _, fields := range astCommandFields(command) {
if result, ok := inspectCommandFields(fields, goos); ok {
return result
}
}
return InteractiveCommandResult{}
}
// inspectCommandFields applies the full interactive-detection pipeline to one
// already-split command's fields: the multi-word interactive segments, then the
// `sh -c <payload>` recursion, then the per-program lookup with its
// non-interactive suppressions (hasNonInteractiveFlag covers REPL flags and
// trailing-command clients like ssh). It mirrors the hand-written passes above
// so an AST-extracted command is classified identically to a plainly-split one.
func inspectCommandFields(fields []string, goos string) (InteractiveCommandResult, bool) {
body := strings.ToLower(commandBody(fields))
for _, seg := range interactiveSegments {
if body == seg.match || strings.HasPrefix(body, seg.match+" ") {
suggestion := seg.suggestion
if goos == "windows" && seg.windowsSuggestion != "" {
suggestion = seg.windowsSuggestion
}
return InteractiveCommandResult{Interactive: true, Command: seg.command, Reason: seg.reason, Suggestion: suggestion}, true
}
}
first := firstProgram(fields)
if first == "" {
return InteractiveCommandResult{}, false
}
if payload := shellDashCPayload(first, fields); payload != "" {
if inner := DetectInteractiveCommand(payload, goos); inner.Interactive {
return inner, true
}
return InteractiveCommandResult{}, false
}
program, ok := interactivePrograms[first]
if !ok || (program.windowsOnly && goos != "windows") {
return InteractiveCommandResult{}, false
}
if hasNonInteractiveFlag(first, fields) {
return InteractiveCommandResult{}, false
}
suggestion := program.suggestion
if goos == "windows" && program.windowsSuggestion != "" {
suggestion = program.windowsSuggestion
}
return InteractiveCommandResult{Interactive: true, Command: first, Reason: program.reason, Suggestion: suggestion}, true
}
// wrapperPrograms are launcher prefixes that precede the real program. After
// one of these we keep scanning for the actual executable.
var wrapperPrograms = map[string]bool{
+84
View File
@@ -406,3 +406,87 @@ func TestDetectInteractiveMongoEvalAndFullPaths(t *testing.T) {
}
}
}
// The hand-written segment splitter misses interactive programs hidden by
// constructs only a real shell parser resolves (a newline separator collapsed
// to a space; a brace group that shifts the real command position). The AST
// second opinion must catch them (issue #473).
func TestDetectInteractiveCommandCatchesParserBypasses(t *testing.T) {
for _, cmd := range []string{
"echo hi\nvim file.txt", // newline separator collapsed to a space by the regex path
"{ vim file.txt; }", // brace group hides the real program position
} {
got := DetectInteractiveCommand(cmd, "linux")
if !got.Interactive {
t.Errorf("DetectInteractiveCommand(%q) = not interactive, want caught (parser bypass)", cmd)
continue
}
if got.Command != "vim" {
t.Errorf("DetectInteractiveCommand(%q).Command = %q, want vim", cmd, got.Command)
}
}
}
// The AST second opinion must not flag an interactive program NAME that appears
// only inside a quoted argument (not a real command position) — the parser
// distinguishes program from argument, so this must stay non-interactive.
func TestDetectInteractiveCommandNoFalsePositiveOnQuotedArgument(t *testing.T) {
got := DetectInteractiveCommand(`echo "please run vim later"`, "linux")
if got.Interactive {
t.Fatalf("interactive name inside a quoted argument must not be flagged, got %#v", got)
}
}
// The AST second opinion must run the FULL pipeline — multi-word interactive
// segments and sh -c payload recursion, not just the per-program lookup — so a
// grouped/nested bypass is caught; and it must not fabricate a program name from
// a command substitution concatenated with a literal (issue #473, review).
func TestDetectInteractiveCommandASTPipelineBoundaries(t *testing.T) {
interactive := []struct{ cmd, wantCmd string }{
{"{ git rebase -i HEAD~1; }", "git rebase -i"}, // multi-word segment in a brace group
{"{ sh -c 'vim file.txt'; }", "vim"}, // sh -c payload in a brace group
}
for _, tc := range interactive {
got := DetectInteractiveCommand(tc.cmd, "linux")
if !got.Interactive || got.Command != tc.wantCmd {
t.Errorf("DetectInteractiveCommand(%q) = %+v, want interactive Command=%q", tc.cmd, got, tc.wantCmd)
}
}
}
// The AST pass must not fabricate a program name from a dynamic (non-literal)
// program word: `$(printf '%s' foo)vim` runs as `foovim`, not vim. Tested at the
// extractor so the hand-written pass (which pre-empts DetectInteractiveCommand
// with its own, separate handling of that construct) does not mask the guard.
func TestAstCommandFieldsSkipsDynamicProgram(t *testing.T) {
for _, fields := range astCommandFields("$(printf '%s' foo)vim file.txt") {
if firstProgram(fields) == "vim" {
t.Fatalf("astCommandFields fabricated a vim command from a substitution: %v", fields)
}
}
}
// The literalness guard covers every word, not just the program name. A dynamic
// ARGUMENT is dropped by wordText the same way a dynamic program word is, so
// `git $(printf foo)rebase -i HEAD~1` (which runs as `git foorebase -i`, a
// non-existent and non-interactive subcommand) would otherwise be reconstructed
// as `git rebase -i` and blocked — a false positive on a command the user never
// wrote. Asserted through DetectInteractiveCommand as well as the extractor,
// because the whole point is what the caller ends up blocking.
func TestAstCommandFieldsSkipsDynamicArgument(t *testing.T) {
const dynamic = "{ git $(printf foo)rebase -i HEAD~1 ; }"
for _, fields := range astCommandFields(dynamic) {
if firstProgram(fields) == "git" {
t.Fatalf("astCommandFields reconstructed a git command from a dynamic argument: %v", fields)
}
}
if got := DetectInteractiveCommand(dynamic, "linux"); got.Interactive {
t.Errorf("DetectInteractiveCommand(%q) = %+v, want not interactive", dynamic, got)
}
// The genuine form must still be detected — the guard skips lossy
// reconstructions, it does not weaken real detection.
const genuine = "{ git rebase -i HEAD~1 ; }"
if got := DetectInteractiveCommand(genuine, "linux"); !got.Interactive || got.Command != "git rebase -i" {
t.Errorf("DetectInteractiveCommand(%q) = %+v, want interactive Command=%q", genuine, got, "git rebase -i")
}
}
+28 -8
View File
@@ -707,6 +707,18 @@ type writeStdinTool struct {
manager *execSessionManager
}
// UnknownExecSessionError is the result returned when write_stdin targets a
// session_id with no live exec session. The stable recovery guidance leads and
// the numeric id trails on purpose: the agent's repeated-failure guard keys on
// a normalized, truncated prefix of the error string, so keeping the id out of
// that prefix makes a model probing ids 1, 2, 3, … produce ONE signature that
// finally trips the halt instead of a fresh signature per id — while also
// telling the model how to actually recover. See TestUnknownExecSessionErrorSignatureIsIDInvariant
// in internal/agent, which pins this invariant against the real guard.
func UnknownExecSessionError(sessionID int) string {
return fmt.Sprintf("Error: write_stdin needs a session_id returned by a still-running exec_command; do not guess or probe session ids. Start a process with exec_command, or use write_file/edit_file/apply_patch for file changes. (no live session %d)", sessionID)
}
func (writeStdinTool) outputCategory(map[string]any) outputCategory {
return outputCategoryProcess
}
@@ -722,7 +734,7 @@ func NewWriteStdinTool(manager *execSessionManager) Tool {
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"session_id": {Type: "integer", Description: "Identifier of the running unified exec session."},
"session_id": {Type: "integer", Description: "Identifier of a running unified exec session, as returned by exec_command. Never guess or probe ids.", Minimum: intPtr(1)},
"chars": {Type: "string", Description: "Bytes to write to stdin. Defaults to empty, which polls without writing.", Default: ""},
"yield_time_ms": {Type: "integer", Description: "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.", Default: defaultPollYieldTimeMS, Minimum: intPtr(1), Maximum: intPtr(maxPollYieldTimeMS)},
"max_output_tokens": {Type: "integer", Description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.", Default: defaultMaxOutputTokens, Minimum: intPtr(1), Maximum: intPtr(maxExecOutputTokenRequest)},
@@ -763,12 +775,20 @@ func (tool writeStdinTool) Run(ctx context.Context, args map[string]any) Result
}
func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]any, _ RunOptions) Result {
if value, ok := args["session_id"]; !ok || value == nil {
return errorResult("Error: Invalid arguments for write_stdin: session_id is required")
}
sessionID, err := intArg(args, "session_id", 0, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
// A missing, non-integer, or < 1 session_id all mean the same thing: the model
// has no live session to write to. Route them to the SAME recovery guidance the
// no-live-session case uses (UnknownExecSessionError) instead of a terse
// "session_id must be at least 1". The terse error gives no way forward and, by
// naming the minimum, nudges the model to try 1, 2, 3... — the exact id-probing
// #749 works to suppress. The recovery message instead tells it to start a
// session or edit files directly, and because that message is id-invariant, the
// missing/zero/non-integer entry points and id-probing collapse to ONE
// repeated-failure signature, so any mix of them accumulates toward the halt
// rather than resetting the streak on each class of mistake.
value, present := args["session_id"]
sessionID, sessionErr := intArg(args, "session_id", 0, 1, 0)
if !present || value == nil || sessionErr != nil {
return errorResult(UnknownExecSessionError(sessionID))
}
chars, err := stringArgWithEmpty(args, "chars", "", false, true)
if err != nil {
@@ -784,7 +804,7 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
}
session, ok := tool.manager.get(sessionID)
if !ok {
return errorResult(fmt.Sprintf("Error: Unknown exec session_id %d.", sessionID))
return errorResult(UnknownExecSessionError(sessionID))
}
session.touch()
interrupted := false
+46 -13
View File
@@ -881,7 +881,7 @@ func TestRegistryHonorsWriteStdinArgumentPermission(t *testing.T) {
registry.Register(NewWriteStdinTool(newExecSessionManager()))
poll := registry.Run(context.Background(), WriteStdinToolName, map[string]any{"session_id": 9999})
if poll.Status != StatusError || !strings.Contains(poll.Output, "Unknown exec session_id") {
if poll.Status != StatusError || !strings.Contains(poll.Output, "still-running exec_command") {
t.Fatalf("empty poll should reach tool without permission prompt, got status=%s output=%q", poll.Status, poll.Output)
}
@@ -901,24 +901,57 @@ func TestWriteStdinReportsUnknownSession(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("status = %s, want error", result.Status)
}
if !strings.Contains(result.Output, "Unknown exec session_id 1234") {
t.Fatalf("unexpected output: %q", result.Output)
// The message must lead with recovery guidance and still identify the id.
if !strings.Contains(result.Output, "still-running exec_command") {
t.Fatalf("unknown-session error must carry recovery guidance, got: %q", result.Output)
}
if !strings.Contains(result.Output, "1234") {
t.Fatalf("unknown-session error must name the offending id, got: %q", result.Output)
}
}
// The runtime rejects session_id < 1 (intArg min 1); the advertised schema must
// say the same so a model sees the constraint before it probes id 0.
func TestWriteStdinSchemaPinsSessionIDMinimum(t *testing.T) {
schema := NewWriteStdinTool(nil).(writeStdinTool).parameters
prop, ok := schema.Properties["session_id"]
if !ok {
t.Fatal("session_id property missing from write_stdin schema")
}
if prop.Minimum == nil || *prop.Minimum != 1 {
t.Fatalf("session_id schema Minimum = %v, want 1 to match the runtime floor", prop.Minimum)
}
}
// A missing, zero, negative, or non-integer session_id all mean the model has no
// live session, so write_stdin returns the SAME recovery guidance the no-live-
// session case uses (start a session with exec_command, or edit files directly),
// not a terse "session_id must be at least 1" that gives no way forward and names
// a minimum that nudges the model to probe ids 1, 2, 3... Sharing one id-invariant
// message also keeps a single repeated-failure signature, so any mix of these
// mistakes accumulates toward the halt instead of resetting the streak (#749).
func TestWriteStdinRequiresPositiveSessionID(t *testing.T) {
tool := NewWriteStdinTool(newExecSessionManager())
for _, args := range []map[string]any{
{},
{"session_id": 0},
want := UnknownExecSessionError(0)
for name, args := range map[string]map[string]any{
"missing": {},
"nil": {"session_id": nil},
"zero": {"session_id": 0},
"negative": {"session_id": -3},
"non-integer": {"session_id": "abc"},
} {
result := tool.Run(context.Background(), args)
if result.Status != StatusError {
t.Fatalf("Run(%#v) status = %s, want error", args, result.Status)
}
if !strings.Contains(result.Output, "Invalid arguments for write_stdin") {
t.Fatalf("Run(%#v) output = %q, want invalid arguments", args, result.Output)
}
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), args)
if result.Status != StatusError {
t.Fatalf("status = %s, want error", result.Status)
}
if result.Output != want {
t.Fatalf("output = %q,\n want the recovery message %q", result.Output, want)
}
if strings.Contains(result.Output, "must be at least 1") {
t.Fatalf("still returns the terse minimum error: %q", result.Output)
}
})
}
}
+58
View File
@@ -829,3 +829,61 @@ func TestGlobSkipsWorkspaceExcludedDirectoriesAndBinaryFiles(t *testing.T) {
t.Fatalf("expected only keep.txt, got:\n%s", res.Output)
}
}
// A backwards range (end_line < start_line) is a caller-arithmetic slip, not a
// fatal error: read_file recovers by reading just start_line and surfaces a note
// explaining the adjustment, matching how an end_line past EOF is auto-clamped.
// It must never hard-error the way it used to.
func TestReadFileToolRecoversBackwardsRange(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta\nepsilon")
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
"path": "notes.txt",
"start_line": 3,
"end_line": 2, // backwards
})
if result.Status != StatusOK {
t.Fatalf("backwards range must recover, not error; got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "must be greater than or equal to start_line") {
t.Fatalf("backwards range still hard-errors: %q", result.Output)
}
for _, want := range []string{
"end_line 2 was before start_line 3", // the note
"only line 3 was read",
"3 | gamma",
} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected %q in output, got %q", want, result.Output)
}
}
// It read exactly start_line, nothing adjacent.
if strings.Contains(result.Output, "beta") || strings.Contains(result.Output, "delta") {
t.Fatalf("recovery read the wrong lines: %q", result.Output)
}
}
// A valid range with end_line >= start_line is byte-for-byte unchanged: the
// recovery path must not touch normal reads (no stray note, exact same window).
func TestReadFileToolValidRangeUnchangedByRecovery(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta\nepsilon")
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
"path": "notes.txt",
"start_line": 2,
"end_line": 4,
})
if result.Status != StatusOK {
t.Fatalf("valid range failed: %s: %s", result.Status, result.Output)
}
// Byte-for-byte: header, blank separator, the exact selected lines, and no
// recovery note or trailing content. An altered header/separator or a stray
// out-of-range line would fail this where a substring check would not.
const want = "File: notes.txt (lines 2-4 of 5)\n\n2 | beta\n3 | gamma\n4 | delta"
if result.Output != want {
t.Fatalf("valid-range output changed by the recovery path:\n got: %q\nwant: %q", result.Output, want)
}
}
+17 -2
View File
@@ -106,8 +106,18 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
if endLine == 0 || endLine > total {
endLine = total
}
// A backwards range (end_line < start_line) is a caller slip, not a fatal
// error. Recover the way an end_line past EOF is already recovered a few lines
// up (clamped down to the last line): clamp end_line UP to start_line and read
// just that one line. Erroring here instead cost the caller a whole retry for
// an off-by-arithmetic mistake, and it was inconsistent — start_line past EOF
// and end_line past EOF both recover gracefully; only this case hard-failed.
// The recovery is surfaced in the output (rangeNote) so it is never silent: the
// caller sees it read a different range than it asked for and why.
rangeNote := ""
if endLine < startLine {
return errorResult("Error: Invalid arguments for read_file: end_line must be greater than or equal to start_line")
rangeNote = fmt.Sprintf("(note: end_line %d was before start_line %d, so only line %d was read — pass end_line >= start_line to read a wider range)", endLine, startLine, startLine)
endLine = startLine
}
truncated := false
@@ -135,7 +145,12 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
}
budgetedOutput.WriteString(header)
budgetedOutput.WriteString("\n\n")
budgetedOutput.WriteString("\n")
if rangeNote != "" {
budgetedOutput.WriteString(rangeNote)
budgetedOutput.WriteString("\n")
}
budgetedOutput.WriteString("\n")
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
return errorResult("Error reading file " + relativePath + ": " + err.Error())
}
+14
View File
@@ -214,6 +214,12 @@ func (m model) setDoctorStatusRow(text string) model {
for i := len(m.transcript) - 1; i >= 0; i-- {
if m.transcript[i].id == doctorStatusRowID {
m.transcript[i] = row
// Settled alt-screen rows are cached across frames; force a rebuild
// so an in-place update (e.g. the spinner tick) is reflected
// immediately instead of serving the stale cached snapshot.
if i < m.flushed {
m.altScreenSettledWidth = 0
}
return m
}
}
@@ -691,6 +697,14 @@ func (m model) persistSelectedModel(profile config.ProviderProfile) (bool, error
if model == "" {
return false, nil
}
persisted, err := config.ProviderPersisted(path, name)
if err != nil {
return false, err
}
if !persisted {
// Env-derived providers have no config.json row to update.
return false, nil
}
if _, err := config.SetProviderModel(path, name, model); err != nil {
return false, err
}
+1
View File
@@ -261,6 +261,7 @@ var commandDefinitions = []commandDefinition{
},
{
name: "/rewind",
aliases: []string{"/undo"},
usage: "/rewind [latest|<sequence>]",
group: commandGroupSession,
description: "Restore workspace files to a checkpoint and truncate the session.",
+25
View File
@@ -985,6 +985,31 @@ func TestParseBackgroundTerminalCommands(t *testing.T) {
}
}
// /undo is an alias for /rewind (issue #698): previously it fell through to
// commandUnknown.
func TestUndoAliasesRewind(t *testing.T) {
rewind, ok := resolveCommand("/rewind")
if !ok {
t.Fatal("expected /rewind to resolve")
}
alias, ok := resolveCommand("/undo")
if !ok || alias.kind != rewind.kind {
t.Fatalf("expected /undo to alias /rewind, got ok=%v command=%#v", ok, alias)
}
// End-to-end: a typed /undo dispatches to the rewind handler with args intact,
// for both the "latest" and numeric-<sequence> forms of the /rewind contract.
if parsed := parseCommand("/undo latest"); parsed.kind != commandRewind || parsed.text != "latest" {
t.Fatalf("parseCommand(%q) = %#v, want commandRewind with text=latest", "/undo latest", parsed)
}
if parsed := parseCommand("/undo 123"); parsed.kind != commandRewind || parsed.text != "123" {
t.Fatalf("parseCommand(%q) = %#v, want commandRewind with text=123", "/undo 123", parsed)
}
// Guard: without the alias it would be commandUnknown.
if parsed := parseCommand("/undo"); parsed.kind == commandUnknown {
t.Fatal("/undo resolved to commandUnknown — the alias is not wired")
}
}
func TestCommandSelectionRequiresInputFromUsage(t *testing.T) {
cases := []struct {
name string
+19 -2
View File
@@ -348,12 +348,29 @@ func (m model) lastRowIndexForFile(path string) int {
return -1
}
func (m *model) setSelectedFile(path string) {
if m.selectedFile == path {
return
}
oldPath := m.selectedFile
m.selectedFile = path
for i := 0; i < m.flushed && i < len(m.transcript); i++ {
for _, changedPath := range m.transcript[i].changedFiles {
if changedPath == oldPath || changedPath == path {
m.altScreenSettledWidth = 0
return
}
}
}
}
// selectFile marks path as the selected file and scrolls the transcript so its
// most recent edit card is in view; the card tint comes from the renderers
// reading selectedFile (rowTouchesSelectedFile).
func (m model) selectFile(path string) model {
m.selectedFile = path
if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok {
rowIndex := m.lastRowIndexForFile(path)
m.setSelectedFile(path)
if offset, ok := m.scrollOffsetForTranscriptRow(rowIndex); ok {
m.chatScrollOffset = offset
if offset == 0 {
m.chatBodyLines = 0
+61 -9
View File
@@ -69,13 +69,6 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool {
// of the unflushed tail is rendered once and queued for scrollback. It returns
// the (possibly nil) print command for the queued batch.
func (m model) settleTranscript() (model, tea.Cmd) {
// In alt-screen mode there is no native scrollback surface for tea.Println:
// Bubble Tea drops that output by design. Keep rows in the managed view so
// the chat behaves like a fullscreen app and cannot reveal prior shell
// history by scrolling.
if m.altScreen {
return m, nil
}
// Never freeze history at the pre-WindowSizeMsg default width: the first
// real WindowSizeMsg arrives before any user-visible content settles, and
// flushing earlier would hard-wrap startup rows at 96 cols forever.
@@ -87,10 +80,14 @@ func (m model) settleTranscript() (model, tea.Cmd) {
// frontier themselves; this is a safety net).
m.flushed = len(m.transcript)
}
rc := buildRowContext(m.transcript)
oldFlushed := m.flushed
// An unsettled row blocks the frontier, so every cross-row dependency needed
// to decide or render the tail (call/result and prompt/decision pairs) also
// lives in the tail. Avoid rescanning the settled prefix on every tick.
rc := buildRowContext(m.transcript[m.flushed:])
width := chatWidth(m.width)
batch := []string{}
previousKind, havePreviousKind := previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
previousKind, havePreviousKind := m.flushedPreviousKind, m.flushedHavePreviousKind
for m.flushed < len(m.transcript) {
row := m.transcript[m.flushed]
if !m.settledRow(row, rc) {
@@ -100,6 +97,15 @@ func (m model) settleTranscript() (model, tea.Cmd) {
if row.kind == rowWelcome || rc.skip(row) {
continue
}
// Bubble Tea intentionally discards tea.Println in alt-screen mode. The
// bookkeeping still advances; the settled prefix is retained in the body
// item cache rebuilt below.
if m.altScreen {
m.flushedAny = true
previousKind = row.kind
havePreviousKind = true
continue
}
rendered := m.renderRowMode(row, width, rc, true)
if rendered == "" {
continue
@@ -115,6 +121,15 @@ func (m model) settleTranscript() (model, tea.Cmd) {
previousKind = row.kind
havePreviousKind = true
}
m.flushedPreviousKind = previousKind
m.flushedHavePreviousKind = havePreviousKind
if m.altScreen {
bodyWidth := m.chatColumnWidth()
if m.flushed != oldFlushed || m.altScreenSettledWidth != bodyWidth || m.altScreenSettledFrontier != m.flushed {
m.rebuildAltScreenSettledItems(bodyWidth)
}
return m, nil
}
if len(batch) > 0 {
m.flushQueue = append(m.flushQueue, strings.Join(batch, "\n"))
}
@@ -139,6 +154,11 @@ func (m model) drainFlushQueue() (model, tea.Cmd) {
// surface ended.
func (m *model) resetFlushFrontier(divider string) {
m.flushed = 0
m.flushedPreviousKind = rowWelcome
m.flushedHavePreviousKind = false
m.altScreenSettledItems = nil
m.altScreenSettledWidth = 0
m.altScreenSettledFrontier = 0
// Renumbers every row's bodyY from the top (used by /clear, /resume, /rewind,
// /compact); a transcript-hover target's bodyY would otherwise risk
// coincidentally matching an unrelated row in the rebuilt transcript.
@@ -147,3 +167,35 @@ func (m *model) resetFlushFrontier(divider string) {
m.flushQueue = append(m.flushQueue, zeroTheme.faint.Render(divider))
}
}
// rebuildAltScreenSettledItems snapshots the stable prefix once when the
// frontier (or width) changes. View then reuses these item descriptors and only
// constructs descriptors/context for the live tail.
func (m *model) rebuildAltScreenSettledItems(width int) {
if !m.altScreen || m.flushed == 0 {
m.altScreenSettledItems = nil
m.altScreenSettledWidth = width
m.altScreenSettledFrontier = m.flushed
return
}
snapshot := *m
snapshot.transcript = m.transcript[:m.flushed]
snapshot.flushed = 0
snapshot.flushedAny = false
snapshot.flushedHavePreviousKind = false
snapshot.altScreenSettledItems = nil
snapshot.altScreenSettledWidth = 0
snapshot.altScreenSettledFrontier = 0
snapshot.pending = false
snapshot.pendingSpecReview = nil
snapshot.fileView = fileViewState{}
built := snapshot.transcriptBodyItems(width, "", false)
// Keep scratch capacity for the live tail. transcriptBodyItems can then
// append transient rows without copying the entire settled prefix. The cache
// length never includes those scratch entries, and the next frame overwrites
// them on the single Bubble Tea render goroutine.
m.altScreenSettledItems = make([]transcriptBodyItem, len(built), len(built)+256)
copy(m.altScreenSettledItems, built)
m.altScreenSettledWidth = width
m.altScreenSettledFrontier = m.flushed
}
+147 -4
View File
@@ -36,7 +36,7 @@ func TestSettledRowsAdvanceFrontierAndLeaveLiveView(t *testing.T) {
}
}
func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) {
func TestAltScreenAdvancesFrontierAndCachesSettledRows(t *testing.T) {
m := newModel(context.Background(), Options{AltScreen: true})
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
m = updated.(model)
@@ -47,12 +47,155 @@ func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) {
if cmd != nil {
t.Fatal("alt-screen mode should not print rows into native scrollback")
}
if next.flushed != 0 {
t.Fatalf("alt-screen mode should keep the flush frontier unchanged, got %d", next.flushed)
if next.flushed != len(next.transcript) {
t.Fatalf("alt-screen mode should advance the frontier to %d, got %d", len(next.transcript), next.flushed)
}
view := viewString(next.View())
if !strings.Contains(view, "hello there") || !strings.Contains(view, "noted") {
t.Fatalf("settled rows should remain in the managed alt-screen view, got %q", view)
t.Fatalf("cached settled rows should remain in the managed alt-screen view, got %q", view)
}
}
func TestAltScreenSettledCacheRebuildsAfterRowToggle(t *testing.T) {
m := newModel(context.Background(), Options{AltScreen: true})
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
m = updated.(model)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowReasoning, text: "private thought"})
m, _ = m.settleTranscript()
m = m.toggleTranscriptRow(len(m.transcript) - 1)
m, _ = m.settleTranscript()
if !m.transcript[len(m.transcript)-1].expanded {
t.Fatal("expected reasoning row to be expanded")
}
if m.altScreenSettledWidth == 0 || len(m.altScreenSettledItems) == 0 {
t.Fatal("expected invalidated settled cache to be rebuilt")
}
}
func TestAltScreenSettledCacheRebuildsAfterDoctorStatusUpdate(t *testing.T) {
m := newModel(context.Background(), Options{AltScreen: true})
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
m = updated.(model)
m = m.setDoctorStatusRow("frame 0")
m, _ = m.settleTranscript()
if m.altScreenSettledWidth == 0 {
t.Fatal("precondition: settled cache should be populated")
}
// An in-place update to an already-settled doctor row (e.g. a spinner tick)
// must invalidate the settled cache, just like toggleTranscriptRow does for
// expand/collapse, otherwise the alt-screen view keeps serving the stale
// snapshot forever.
m = m.setDoctorStatusRow("frame 1")
if m.altScreenSettledWidth != 0 {
t.Fatal("expected in-place doctor status update to invalidate the settled cache")
}
m, _ = m.settleTranscript()
view := viewString(m.View())
if !strings.Contains(view, "frame 1") {
t.Fatalf("expected updated doctor status text in view, got %q", view)
}
if strings.Contains(view, "frame 0") {
t.Fatalf("stale doctor status text must not remain in view, got %q", view)
}
}
func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
m := filesPanelTestModel()
m.altScreen = true
m.width = 80
m.height = 30
m, _ = m.settleTranscript()
if m.altScreenSettledWidth == 0 {
t.Fatal("precondition: settled cache should be populated")
}
m = m.selectFile("internal/tui/sidebar.go")
if m.altScreenSettledWidth != 0 {
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
}
m, _ = m.settleTranscript()
selectedView := viewString(m.View())
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
if m.selectedFile != "" {
t.Fatalf("Esc should clear the selected file, got %q", m.selectedFile)
}
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
t.Fatal("clearing a selected file should rebuild the invalidated cache")
}
if clearedView := viewString(m.View()); clearedView == selectedView {
t.Fatal("clearing a selected file should remove the settled row's selection tint")
}
}
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
const path = "internal/tui/sidebar.go"
m := model{
transcript: []transcriptRow{
{kind: rowToolResult, changedFiles: []string{path}},
{kind: rowToolResult, changedFiles: []string{path}},
},
flushed: 1,
altScreenSettledWidth: 80,
}
m.setSelectedFile(path)
if m.altScreenSettledWidth != 0 {
t.Fatal("selecting a file with an older settled card must invalidate the cache")
}
m.altScreenSettledWidth = 80
m.setSelectedFile("")
if m.altScreenSettledWidth != 0 {
t.Fatal("clearing a file with an older settled card must invalidate the cache")
}
}
func TestAltScreenRepeatedStatusCollapseRewindsSettledFrontier(t *testing.T) {
m := newModel(context.Background(), Options{AltScreen: true})
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
m = updated.(model)
m.pending = true
m.activeRunID = 7
m.transcript = appendTranscriptRow(m.transcript,
transcriptRow{kind: rowToolCall, id: "old", tool: "swarm_status", runID: 7},
)
m.transcript = appendTranscriptRow(m.transcript,
transcriptRow{kind: rowToolResult, id: "old", tool: "swarm_status", status: tools.StatusOK, detail: "1 running", runID: 7},
)
m.transcript = appendTranscriptRow(m.transcript,
transcriptRow{kind: rowToolCall, id: "new", tool: "swarm_status", runID: 7},
)
m, _ = m.settleTranscript()
if m.flushed != len(m.transcript)-1 {
t.Fatalf("precondition: frontier=%d, want %d", m.flushed, len(m.transcript)-1)
}
updated, _ = m.Update(agentRowMsg{
runID: 7,
row: transcriptRow{
kind: rowToolResult, id: "new", tool: "swarm_status", status: tools.StatusError, detail: "1 running", runID: 7,
},
})
m = updated.(model)
if len(m.transcript) != 3 {
t.Fatalf("repeated status should collapse to welcome plus the new call/result pair, got %d rows", len(m.transcript))
}
if m.flushed != len(m.transcript) {
t.Fatalf("collapsed replacement should settle through %d rows, got frontier %d", len(m.transcript), m.flushed)
}
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
t.Fatal("collapsed replacement should rebuild the invalidated settled cache")
}
cachedView := viewString(m.View())
rebuilt := m
rebuilt.altScreenSettledWidth = 0
rebuilt, _ = rebuilt.settleTranscript()
if rebuiltView := viewString(rebuilt.View()); rebuiltView != cachedView {
t.Fatal("collapsed replacement cache is stale compared with a forced rebuild")
}
}
+80 -12
View File
@@ -312,16 +312,23 @@ type model struct {
chatBodyLines int
// Flush-frontier state (see flush.go). In inline mode, transcript[:flushed]
// is already in native scrollback; in alt-screen mode this frontier stays
// idle so history cannot reveal prior shell output.
// is already in native scrollback. Alt-screen mode advances the same
// frontier, but keeps the settled prefix as cached body items so fullscreen
// scrolling still exposes the complete transcript without rebuilding it on
// every frame.
// flushedAny gates the first turn-separator blank line; flushQueue/
// printInFlight serialize ordered scrollback prints; headerPrinted records
// the one-time inline title-bar print at startup.
flushed int
flushedAny bool
flushQueue []string
printInFlight bool
headerPrinted bool
flushed int
flushedAny bool
flushedPreviousKind rowKind
flushedHavePreviousKind bool
flushQueue []string
printInFlight bool
headerPrinted bool
altScreenSettledItems []transcriptBodyItem
altScreenSettledWidth int
altScreenSettledFrontier int
// Composer input history (shell-style ↑/↓ recall of submitted inputs).
// lastPrompt is the verbatim text of the most recent submitted prompt, so
@@ -401,8 +408,9 @@ type model struct {
// mouseReleased, when true, forces terminal mouse capture OFF so the user can
// drag-select and copy text natively (Ctrl+E toggles it). App mouse features
// (clickable suggestions, right-click paste, transcript select) pause while on.
mouseReleased bool
transcriptSelection transcriptSelectionState
mouseReleased bool
transcriptSelection transcriptSelectionState
transcriptInteraction *transcriptRenderInteraction
// hover identifies the single clickable row (if any) currently under the
// mouse cursor with no button pressed, so it renders in a distinct style —
// the visual cue that it's clickable. Requires AllMotion mouse reporting
@@ -681,6 +689,17 @@ type pendingPermissionPrompt struct {
// resting approval choice. Moved by ↑/↓/Tab; confirmed by Enter or a click.
// Hotkeys resolve the matching request-provided option directly.
cursor int
// typing is true once the user chose "tell Zero what to do differently": the
// card replaces its option list with a free-text field (sharing the composer
// input, like the ask_user questionnaire). Submitting sends a Deny decision
// whose Reason is the typed text, so the model reads it as the tool result and
// adjusts course in the same turn instead of the run being cancelled.
typing bool
// savedDraft holds whatever was in the shared composer input when feedback
// mode was entered. The field is cleared for typing and restored on both
// submit and cancel, so a half-typed or queued next-turn message survives the
// detour (permissionRequestMsg, unlike ask_user, does not clear the composer).
savedDraft string
}
// askUserRequestMsg is the TUI-loop equivalent of permissionRequestMsg: the
@@ -841,6 +860,7 @@ func newModel(ctx context.Context, options Options) model {
usageTracker: usageTracker,
transcript: initialTranscript(),
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
transcriptInteraction: &transcriptRenderInteraction{},
prService: prService,
prState: prService.GetState(),
input: input,
@@ -1430,6 +1450,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.pendingAskUser != nil {
return m.escapeAskUser()
}
// Esc in the permission feedback field steps back to the option list
// rather than resolving, so a stray keystroke is recoverable.
if m.pendingPermission != nil && m.pendingPermission.typing {
return m.cancelPermissionTyping()
}
if m.pendingSpecReview != nil {
m.burstCount = 0
return m.cancelSpecReview()
@@ -1472,7 +1497,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// selection is a passive highlight, so Esc dropping it is cheap and
// expected (mirrors how editors clear selection on Esc).
if m.selectedFile != "" {
m.selectedFile = ""
m.setSelectedFile("")
return m, nil
}
if m.hasQueuedMessage() {
@@ -1650,6 +1675,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case keyBackspace(msg):
// In permission feedback mode Backspace is a plain edit of the feedback
// text. This case runs before the typing branch below and, on an empty
// field (feedback mode clears the composer), would otherwise fall to the
// removeLastAttachment path and silently drop a staged image/doc that
// savedDraft does not restore. Route it to the shared input instead.
if m.pendingPermission != nil && m.pendingPermission.typing {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
@@ -1845,6 +1880,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleSpecReviewKey(msg)
}
if m.pendingPermission != nil {
// Feedback mode: a printable keystroke (and editing keys like
// backspace) types into the shared composer input, mirroring the
// ask_user free-text path above. Enter/Esc/↑/↓ were already handled
// earlier in this switch; the remaining keys reach the input here.
if m.pendingPermission.typing {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
m.burstCount = 0
return m.handlePermissionKey(msg)
}
@@ -2469,7 +2513,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// Collapse a repeated swarm status/collect card so re-checks don't flood
// the chat with identical blocks.
beforeCollapse := len(m.transcript)
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
if removed := beforeCollapse - len(m.transcript); removed > 0 {
m.flushed = max(0, m.flushed-removed)
m.altScreenSettledWidth = 0
}
m.transcript = appendTranscriptRow(m.transcript, msg.row)
m = m.captureStepWork(msg.row)
// A finished command tool may have mutated files git can see but no
@@ -2770,6 +2819,16 @@ func (m model) footerView(width int) string {
footer.WriteString(m.statusLine(width))
return footer.String()
}
// A focused permission prompt owns the keyboard: its options (and the feedback
// field) consume every key, so the composer is inert. Suppress it and the idle
// hints/plan panel like the ask_user modal above, keeping only the status line.
// The card itself renders in the transcript body. This also keeps the shared
// input from echoing in two places once "tell Zero what to do differently"
// opens the on-card feedback field.
if m.pendingPermission != nil {
footer.WriteString(m.statusLine(width))
return footer.String()
}
// Pinned plan panel: sits directly above the composer so it stays visible
// while the transcript scrolls underneath (a streaming turn no longer pushes
// the plan off-screen). Budgeted to at most a third of the screen height; a
@@ -3928,13 +3987,22 @@ func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := strings.ToLower(msg.String())
for _, option := range permissionOptions(m.pendingPermission.request) {
if option.hotkey == key {
return m.resolvePermission(option.choice)
return m.choosePermissionOption(option.choice)
}
}
return m, nil
}
func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cmd) {
return m.resolvePermissionWithReason(decision, permissionDecisionReason(decision))
}
// resolvePermissionWithReason resolves the pending prompt with an explicit reason
// string. It backs both the fixed-label choices (reason = permissionDecisionReason)
// and the free-text "tell Zero what to do differently" path, where the reason is
// the user's typed instruction and the action is Deny so the agent surfaces it as
// the tool result and keeps going.
func (m model) resolvePermissionWithReason(decision permissionDecision, reason string) (tea.Model, tea.Cmd) {
pending := m.pendingPermission
if pending == nil {
return m, nil
@@ -3943,7 +4011,7 @@ func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cm
if pending.decide != nil {
pending.decide(agent.PermissionDecision{
Action: decision,
Reason: permissionDecisionReason(decision),
Reason: reason,
})
}
m.pendingPermission = nil
+63 -3
View File
@@ -1,6 +1,8 @@
package tui
import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
@@ -74,7 +76,9 @@ func clampPermissionCursor(cursor int, request agent.PermissionRequest) int {
// the ends. A no-op when no permission prompt is pending. The cursor lives on the
// pending prompt (a pointer), mirroring how the picker's selection moves.
func (m model) movePermissionCursor(delta int) model {
if m.pendingPermission == nil {
if m.pendingPermission == nil || m.pendingPermission.typing {
// While typing feedback the arrow/Tab keys belong to the text field, not
// the option list.
return m
}
n := len(permissionOptions(m.pendingPermission.request))
@@ -87,11 +91,67 @@ func (m model) movePermissionCursor(delta int) model {
}
// confirmPermissionCursor resolves the currently highlighted option. It is the
// Enter-key counterpart to the a/y/d hotkeys and a mouse click.
// Enter-key counterpart to the a/y/d hotkeys and a mouse click. Confirming the
// "tell Zero what to do differently" choice opens the inline feedback field
// instead of resolving immediately.
func (m model) confirmPermissionCursor() (tea.Model, tea.Cmd) {
if m.pendingPermission == nil {
return m, nil
}
if m.pendingPermission.typing {
return m.submitPermissionFeedback()
}
option := permissionOptions(m.pendingPermission.request)[clampPermissionCursor(m.pendingPermission.cursor, m.pendingPermission.request)]
return m.resolvePermission(option.choice)
return m.choosePermissionOption(option.choice)
}
// choosePermissionOption applies a chosen decision. The cancel choice (the
// "tell Zero what to do differently" row and its [n] hotkey) opens the inline
// feedback field rather than aborting the run; every other choice resolves
// immediately as before.
func (m model) choosePermissionOption(choice permissionDecision) (tea.Model, tea.Cmd) {
if m.pendingPermission == nil {
return m, nil
}
if choice == permissionDecisionCancel {
m.pendingPermission.typing = true
// Preserve whatever the user had drafted/queued in the composer so it is
// restored when they leave feedback mode (submit or cancel).
m.pendingPermission.savedDraft = m.input.Value()
m.input.SetValue("")
return m, nil
}
return m.resolvePermission(choice)
}
// submitPermissionFeedback ends the feedback field. Non-empty text is sent as a
// Deny decision whose Reason is the text: the agent surfaces that as the tool
// result (deniedPermissionResult) so the model reads the instruction and adjusts
// in the same turn, rather than the run being cancelled. Empty text falls back to
// a plain cancel, matching the option's prior behaviour.
func (m model) submitPermissionFeedback() (tea.Model, tea.Cmd) {
if m.pendingPermission == nil {
return m, nil
}
feedback := strings.TrimSpace(m.input.Value())
// Restore the composer draft the user had before entering feedback mode; the
// feedback text itself is delivered via the decision Reason, not the composer.
m.input.SetValue(m.pendingPermission.savedDraft)
m.pendingPermission.typing = false
if feedback == "" {
return m.resolvePermission(permissionDecisionCancel)
}
return m.resolvePermissionWithReason(permissionDecisionDeny, feedback)
}
// cancelPermissionTyping returns from the feedback field to the option list
// without resolving, so Esc is a safe "I didn't mean to type" back-out.
func (m model) cancelPermissionTyping() (tea.Model, tea.Cmd) {
if m.pendingPermission == nil || !m.pendingPermission.typing {
return m, nil
}
m.pendingPermission.typing = false
m.input.SetValue(m.pendingPermission.savedDraft)
m.pendingPermission.savedDraft = ""
return m, nil
}
+254 -16
View File
@@ -2,6 +2,7 @@ package tui
import (
"context"
"fmt"
"strings"
"testing"
@@ -10,6 +11,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func pendingPermissionModel(t *testing.T, decide func(agent.PermissionDecision)) model {
@@ -72,7 +74,7 @@ func TestPermissionOptionsExposeApprovalCancelWhenSupplied(t *testing.T) {
t.Fatalf("cancel option = %#v, want cancel on n", options[3])
}
card, _ := renderFocusedPermissionPrompt(request, 3, 80)
card, _ := renderFocusedPermissionPrompt(request, 3, false, "", 80)
got := plainRender(t, card)
for _, want := range []string{"continue without running it", "[d]", "tell Zero what to do differently", "[n]"} {
if !strings.Contains(got, want) {
@@ -95,7 +97,7 @@ func TestPermissionOptionsExposeCommandPrefixApproval(t *testing.T) {
if len(options) != 3 || options[1].choice != permissionDecisionAllowPrefix || options[1].hotkey != "p" {
t.Fatalf("prefix option = %#v, want p hotkey in supplied order", options)
}
card, _ := renderFocusedPermissionPrompt(request, 1, 100)
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 100)
got := plainRender(t, card)
for _, want := range []string{"allow `git status` in this session", "[p]"} {
if !strings.Contains(got, want) {
@@ -114,7 +116,7 @@ func TestPermissionPromptMapsEscalatedSandboxReason(t *testing.T) {
agent.PermissionDecisionDeny,
},
}
card, _ := renderFocusedPermissionPrompt(request, 0, 96)
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 96)
got := plainRender(t, card)
if !strings.Contains(got, "This command needs to run outside the sandbox.") {
t.Fatalf("permission card = %q, missing user-facing sandbox reason", got)
@@ -139,7 +141,7 @@ func TestPermissionOptionsExposePersistentCommandPrefixApproval(t *testing.T) {
if len(options) != 4 || options[2].choice != permissionDecisionAlwaysAllowPrefix || options[2].hotkey != "y" {
t.Fatalf("persistent prefix option = %#v, want y hotkey in supplied order", options)
}
card, _ := renderFocusedPermissionPrompt(request, 2, 100)
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 100)
got := plainRender(t, card)
for _, want := range []string{"always allow `git status`", "[y]"} {
if !strings.Contains(got, want) {
@@ -157,7 +159,7 @@ func TestPermissionOptionsCanExposePatchCancelWithoutRecoverableDeny(t *testing.
agent.PermissionDecisionCancel,
},
}
card, _ := renderFocusedPermissionPrompt(request, 2, 80)
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 80)
got := plainRender(t, card)
if !strings.Contains(got, "tell Zero what to do differently") || !strings.Contains(got, "[n]") {
t.Fatalf("permission card = %q, missing cancel option", got)
@@ -180,7 +182,7 @@ func TestRequestPermissionsPromptUsesGrantLabelsAndEscDenies(t *testing.T) {
agent.PermissionDecisionDeny,
},
}
card, _ := renderFocusedPermissionPrompt(request, 1, 96)
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 96)
got := plainRender(t, card)
for _, want := range []string{
"Grant requested permissions?",
@@ -255,7 +257,9 @@ func TestPermissionHotkeysStillResolveDirectly(t *testing.T) {
}
}
func TestPermissionCancelHotkeyResolvesDirectly(t *testing.T) {
// feedbackRequest is a bash prompt whose decision set includes Cancel, so the
// "tell Zero what to do differently" row (and its [n] hotkey) is present.
func feedbackRequest() agent.PermissionRequest {
request := testPromptPermissionRequest()
request.ToolName = "bash"
request.AvailableDecisions = []agent.PermissionDecisionAction{
@@ -263,21 +267,87 @@ func TestPermissionCancelHotkeyResolvesDirectly(t *testing.T) {
agent.PermissionDecisionDeny,
agent.PermissionDecisionCancel,
}
got := []permissionDecision{}
m := pendingPermissionModelWithRequest(t, request, func(d agent.PermissionDecision) {
got = append(got, permissionDecision(d.Action))
return request
}
// The [n] "tell Zero what to do differently" hotkey no longer resolves cancel
// immediately: it opens the inline feedback field and sends nothing yet.
func TestPermissionTellDifferentlyOpensFeedbackField(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
if _, cmd := m.Update(testKeyText("n")); cmd != nil {
t.Fatal("'n' should resolve synchronously")
next, _ := m.Update(testKeyText("n"))
nm := next.(model)
if nm.pendingPermission == nil || !nm.pendingPermission.typing {
t.Fatalf("'n' should open the feedback field, got pending=%#v", nm.pendingPermission)
}
if len(got) != 1 || got[0] != permissionDecisionCancel {
t.Fatalf("'n' should resolve cancel directly, got %#v", got)
if len(got) != 0 {
t.Fatalf("'n' must not resolve anything yet, got %#v", got)
}
}
// Typing an instruction and pressing Enter sends a Deny whose Reason is the
// text, so the agent surfaces it to the model as the tool result.
func TestPermissionFeedbackSubmitSendsDenyWithText(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
next, _ := m.Update(testKeyText("n"))
nm := typeRunes(t, next.(model), "use apply_patch instead")
after, _ := nm.Update(testKey(tea.KeyEnter))
am := after.(model)
if len(got) != 1 {
t.Fatalf("Enter should resolve exactly one decision, got %#v", got)
}
if got[0].Action != agent.PermissionDecisionDeny {
t.Fatalf("feedback should resolve as Deny, got %s", got[0].Action)
}
if got[0].Reason != "use apply_patch instead" {
t.Fatalf("Deny reason should carry the typed text, got %q", got[0].Reason)
}
if am.pendingPermission != nil {
t.Fatal("prompt should be dismissed after submitting feedback")
}
}
// Enter with an empty field falls back to a plain cancel (the option's prior
// meaning), so opening the field and changing your mind is safe.
func TestPermissionFeedbackEmptySubmitCancels(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
next, _ := m.Update(testKeyText("n"))
after, _ := next.(model).Update(testKey(tea.KeyEnter))
_ = after
if len(got) != 1 || got[0].Action != agent.PermissionDecisionCancel {
t.Fatalf("empty feedback should resolve Cancel, got %#v", got)
}
}
// Esc in the feedback field returns to the option list without resolving.
func TestPermissionFeedbackEscReturnsToOptions(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
next, _ := m.Update(testKeyText("n"))
after, _ := typeRunes(t, next.(model), "half a thought").Update(testKey(tea.KeyEsc))
am := after.(model)
if am.pendingPermission == nil || am.pendingPermission.typing {
t.Fatalf("Esc should return to the option list, got pending=%#v", am.pendingPermission)
}
if len(got) != 0 {
t.Fatalf("Esc must not resolve anything, got %#v", got)
}
}
func TestPermissionRenderEmitsHighlightedClickableOffsets(t *testing.T) {
request := agent.PermissionRequest{ToolName: "write_file", AvailableDecisions: testAllPermissionDecisions()}
card, offsets := renderFocusedPermissionPrompt(request, 2, 60) // cursor on future approval
card, offsets := renderFocusedPermissionPrompt(request, 2, false, "", 60) // cursor on future approval
if len(offsets) != len(permissionOptions(request)) {
t.Fatalf("offsets = %d, want %d", len(offsets), len(permissionOptions(request)))
}
@@ -298,7 +368,7 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
Scope: "example.com",
AvailableDecisions: testAllPermissionDecisions(),
}
card, _ := renderFocusedPermissionPrompt(request, 1, 72)
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 72)
got := plainRender(t, card)
for _, want := range []string{"target: example.com", "allow this host for this conversation", "[s]", "allow this host in the future", "[y]"} {
if !strings.Contains(got, want) {
@@ -376,3 +446,171 @@ func TestShiftDownComposerGuard(t *testing.T) {
t.Fatalf("Shift+Down with non-empty composer scrolled offset to %d, want 3 (unchanged)", got)
}
}
// The highlighted permission option must use the selected-row tint, not the
// brand chip. zeroTheme.badge is the accent-filled chip for short labels
// (" 0 ", " ASK ", " SPEC REVIEW "); using it for a whole row painted a
// full-brightness accent slab across a card whose palette is deliberately amber
// (warning), and skipped the card tint every other line composes onto. selBg is
// the tint tuned for a highlighted row against a panel, and onSel is what every
// other selectable list in the TUI uses.
func TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip(t *testing.T) {
request := agent.PermissionRequest{ToolName: "exec_command", SideEffect: "shell"}
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 70)
var selected string
for _, line := range strings.Split(card, "\n") {
if strings.Contains(ansiPattern.ReplaceAllString(line, ""), "▸ ") {
selected = line
break
}
}
if selected == "" {
t.Fatal("no highlighted option row rendered")
}
accentBg := backgroundCode(darkPalette.accent)
selBg := backgroundCode(darkPalette.selBg)
if strings.Contains(selected, accentBg) {
t.Errorf("selected row is filled with the brand accent %s (zeroTheme.badge); want the selection tint:\n%q", darkPalette.accent, selected)
}
if !strings.Contains(selected, selBg) {
t.Errorf("selected row should carry the selection tint %s:\n%q", darkPalette.selBg, selected)
}
// The PERMISSION chip keeps its amber fill — the card must still read as a
// warning surface, so this fix must not flatten it.
if !strings.Contains(card, backgroundCode(darkPalette.amber)) {
t.Errorf("PERMISSION badge lost its amber fill:\n%q", card)
}
// The card BODY carries no warm permBg wash any more: it matches the other
// prompt cards (ask_user, spec) whose bodies are transparent. Warning identity
// comes from the amber badge + border, not a full-body tint that clashes on
// cool themes.
if strings.Contains(card, backgroundCode(darkPalette.permBg)) {
t.Errorf("permission card body still tinted with permBg %s; want a transparent body:\n%q", darkPalette.permBg, card)
}
}
// backgroundCode renders the SGR truecolor background sequence for a #rrggbb
// palette entry, so assertions compare against the palette rather than
// hardcoded numbers that drift when a theme is retuned.
func backgroundCode(hex string) string {
var r, g, b int
fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
return fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
}
// A focused permission prompt suppresses the composer: keys drive the card, so a
// "describe a task for zero…" box below it is inert and misleading — and once the
// feedback field is open, the shared input must not echo in both places.
func TestComposerSuppressedDuringPermissionPrompt(t *testing.T) {
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
m.width, m.height = 96, 30
view := plainRender(t, m.View())
if strings.Contains(view, "describe a task for zero") {
t.Errorf("composer must be hidden while a permission prompt is focused:\n%s", view)
}
// While typing feedback, the text lives on the card, not in a second box.
next, _ := m.Update(testKeyText("n"))
typed := typeRunes(t, next.(model), "use apply_patch")
tv := plainRender(t, typed.View())
if strings.Contains(tv, "describe a task for zero") {
t.Errorf("composer must stay hidden in feedback mode:\n%s", tv)
}
if strings.Count(tv, "use apply_patch") != 1 {
t.Errorf("feedback text should appear exactly once (on the card), got %d occurrences:\n%s", strings.Count(tv, "use apply_patch"), tv)
}
}
// Entering feedback mode must not lose a composer draft (a half-typed or queued
// next-turn message). The draft is restored whether feedback is submitted or
// cancelled; only the feedback text (delivered via the decision Reason) is
// consumed.
func TestPermissionFeedbackPreservesComposerDraftOnSubmit(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
m.input.SetValue("my queued next message")
next, _ := m.Update(testKeyText("n"))
nm := next.(model)
if nm.input.Value() != "" {
t.Fatalf("feedback field should start empty, got %q", nm.input.Value())
}
nm = typeRunes(t, nm, "use apply_patch")
after, _ := nm.Update(testKey(tea.KeyEnter))
am := after.(model)
if len(got) != 1 {
t.Fatalf("expected exactly one permission decision, got %#v", got)
}
if got[0].Reason != "use apply_patch" {
t.Fatalf("feedback text lost: %q", got[0].Reason)
}
if am.input.Value() != "my queued next message" {
t.Fatalf("composer draft not restored after submit, got %q", am.input.Value())
}
}
func TestPermissionFeedbackPreservesComposerDraftOnCancel(t *testing.T) {
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
m.input.SetValue("my queued next message")
next, _ := m.Update(testKeyText("n"))
nm := typeRunes(t, next.(model), "half a thought")
after, _ := nm.Update(testKey(tea.KeyEsc))
am := after.(model)
if am.pendingPermission == nil || am.pendingPermission.typing {
t.Fatal("Esc should return to the option list")
}
if am.input.Value() != "my queued next message" {
t.Fatalf("composer draft not restored after cancel, got %q", am.input.Value())
}
}
// Backspace on an empty permission feedback field must edit the feedback text,
// never drop a staged attachment. The keyBackspace case runs before the typing
// branch and, on an empty composer, would otherwise remove the last staged
// image/doc — which savedDraft does not restore.
func TestPermissionFeedbackBackspaceKeepsStagedAttachment(t *testing.T) {
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
m.pendingImages = []zeroruntime.ImageBlock{{MediaType: "image/png"}}
m.pendingImageLabels = []string{"diagram.png"}
next, _ := m.Update(testKeyText("n")) // open feedback field (clears composer text)
after, _ := next.(model).Update(testKey(tea.KeyBackspace))
am := after.(model)
if len(am.pendingImageLabels) != 1 || len(am.pendingImages) != 1 {
t.Fatalf("Backspace in feedback mode dropped the staged attachment: labels=%v imgs=%d", am.pendingImageLabels, len(am.pendingImages))
}
if !am.pendingPermission.typing {
t.Fatal("Backspace should stay in the feedback field, not exit it")
}
}
// No permission option row is clickable while the feedback field is open: the
// renderer registers zero clickable offsets in typing mode, so a stray click
// (Allow included) has no row to land on. This is the primary safety; the
// !typing guard on the click resolver in handleTranscriptSelectionMouse is the
// explicit second layer for if this early-return is ever refactored away.
func TestPermissionFeedbackRendersNoClickableOptionsWhileTyping(t *testing.T) {
request := feedbackRequest()
// Option mode: rows are clickable.
_, optOffsets := renderFocusedPermissionPrompt(request, 0, false, "", 80)
if len(optOffsets) == 0 {
t.Fatal("precondition: option rows should be clickable in option mode")
}
// Feedback mode: no clickable rows.
_, typingOffsets := renderFocusedPermissionPrompt(request, 0, true, "some feedback", 80)
if typingOffsets != nil {
t.Fatalf("feedback mode must register no clickable option offsets, got %#v", typingOffsets)
}
}
+34 -5
View File
@@ -347,22 +347,42 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) {
wizard.manageStatus = "No user config path — cannot delete."
return m, nil
}
cfg, err := config.RemoveProvider(m.userConfigPath, name)
persisted, err := config.ProviderPersisted(m.userConfigPath, name)
if err != nil {
wizard.manageStatus = "Delete failed: " + err.Error()
return m, nil
}
var notes []string
var activeAfter string
var cleanup tea.Cmd
if persisted {
cfg, err := config.RemoveProvider(m.userConfigPath, name)
if err != nil {
wizard.manageStatus = "Delete failed: " + err.Error()
return m, nil
}
activeAfter = cfg.ActiveProvider
notes = []string{"Deleted " + name + "."}
cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile)
} else {
// Env-derived providers have no persisted profile or credential to
// delete. Keep this path session-only.
notes = []string{
"Removed " + name + " from this session.",
"It wasn't saved in config.json (likely set via an environment variable) — unset it to stop Zero from detecting it automatically.",
}
}
// Surgical removal — see saveManagerEdit for why the raw cfg.Providers list
// must not replace the resolved/filtered savedProviders wholesale.
m.savedProviders = removeSavedProvider(m.savedProviders, name)
notes := []string{"Deleted " + name + "."}
if strings.EqualFold(strings.TrimSpace(m.providerName), strings.TrimSpace(name)) {
notes = append(notes, "This session keeps running on it until you switch.")
} else if active := strings.TrimSpace(cfg.ActiveProvider); active != "" && !strings.EqualFold(active, name) {
notes = append(notes, "Active provider: "+active+".")
} else if activeAfter != "" && !strings.EqualFold(activeAfter, name) {
notes = append(notes, "Active provider: "+activeAfter+".")
}
cleanup := providerManagerCleanupCmd(m.userConfigPath, row.profile)
if len(m.savedProviders) == 0 {
m.providerWizard = nil
@@ -570,6 +590,15 @@ func (m model) saveManagerEdit() (model, tea.Cmd) {
return m, nil
}
oldName := strings.TrimSpace(wizard.editOriginal.Name)
persisted, err := config.ProviderPersisted(m.userConfigPath, oldName)
if err != nil {
wizard.err = err.Error()
return m, nil
}
if !persisted {
wizard.err = "provider " + oldName + " is not saved in config.json, so there is no saved profile to edit"
return m, nil
}
newName := strings.TrimSpace(wizard.editDraft.Name)
if newName == "" {
wizard.err = "name cannot be empty"
+42 -5
View File
@@ -1959,12 +1959,49 @@ func (wizard *providerWizardState) filteredModels() []providerWizardModel {
return models
}
func (model providerWizardModel) displayLabel() string {
description := strings.TrimSpace(model.Description)
if description != "" && !providerWizardGenericModelDescription(description) {
return description
// A model Description is only a good row label when it reads like a display
// NAME — "Grok 4.3" for x-ai/grok-4.3 — which is what the field is for, and why
// the row prefers it over an ugly raw ID.
//
// Providers do not all honour that. Aggregators commonly return a prose blurb
// instead ("Fast DeepSeek model for efficient chat, coding help, and agent
// loops"), which fails as a label twice over: it overflows the row, and because
// such blurbs are written per model FAMILY rather than per model, several
// DIFFERENT models collapse to byte-identical rows. A picker then offers two
// visually identical entries with the distinguishing ID visible only in the
// detail line of whichever one is highlighted.
//
// providerWizardGenericModelDescription cannot catch these: it rejects
// descriptions that are vague ("live model", "catalog default"), whereas these
// are specific — just shared and sentence-shaped. So the test is shape, not
// wording: a name is short and few-worded; anything longer is prose, and the ID
// (unique by construction) makes the better label.
const (
providerWizardMaxModelNameChars = 28
providerWizardMaxModelNameWords = 4
)
func providerWizardDescriptionIsDisplayName(description string) bool {
trimmed := strings.TrimSpace(description)
if trimmed == "" || providerWizardGenericModelDescription(trimmed) {
return false
}
return model.ID
if len(trimmed) > providerWizardMaxModelNameChars {
return false
}
return len(strings.Fields(trimmed)) <= providerWizardMaxModelNameWords
}
func (model providerWizardModel) displayLabel() string {
if providerWizardDescriptionIsDisplayName(model.Description) {
return strings.TrimSpace(model.Description)
}
if id := strings.TrimSpace(model.ID); id != "" {
return id
}
// ID-less sentinel rows built in currentModel ("model name required",
// "no matching models") would otherwise render blank.
return strings.TrimSpace(model.Description)
}
func (model providerWizardModel) secondaryText() string {
+34
View File
@@ -1787,3 +1787,37 @@ func providerWizardIDs(descriptors []providercatalog.Descriptor) []string {
}
return ids
}
// A provider that returns prose blurbs instead of display names must not
// collapse distinct models into identical rows. Reproduces the reported picker
// where two "Open-weight GPT model..." and two "Kimi multimodal agent model..."
// rows were indistinguishable, with the ID visible only for the highlighted one.
func TestProviderWizardModelRowsStayDistinctWithProseDescriptions(t *testing.T) {
wizard := &providerWizardState{
step: providerWizardStepModel,
modelSource: "models.dev",
models: []providerWizardModel{
{ID: "gpt-oss-120b", Description: "Open-weight GPT model for self-hosted reasoning and instruction-following workloads"},
{ID: "gpt-oss-20b", Description: "Open-weight GPT model for self-hosted reasoning and instruction-following workloads"},
{ID: "kimi-k2-thinking", Description: "Kimi multimodal agent model for visual understanding, coding, and planning"},
{ID: "kimi-k2-instruct", Description: "Kimi multimodal agent model for visual understanding, coding, and planning"},
{ID: "x-ai/grok-4.3", Description: "Grok 4.3"},
},
}
view := plainRender(t, strings.Join(wizard.renderModelStep(96), "\n"))
// every prose-described model is identified by its unique ID
for _, id := range []string{"gpt-oss-120b", "gpt-oss-20b", "kimi-k2-thinking", "kimi-k2-instruct"} {
if !strings.Contains(view, id) {
t.Errorf("model %q is not identifiable in the list:\n%s", id, view)
}
}
// a genuine display name is still preferred over the raw id
if !strings.Contains(view, "Grok 4.3") {
t.Errorf("friendly display name was lost:\n%s", view)
}
// the prose blurb must not be a row label
if strings.Contains(view, "❯ Open-weight GPT model") {
t.Errorf("prose blurb still used as a row label:\n%s", view)
}
}
+35 -4
View File
@@ -66,6 +66,11 @@ func rcKey(runID int, id string) string {
}
func buildRowContext(rows []transcriptRow) rowContext {
if len(rows) == 0 {
// Nil maps are safe for all rowContext lookups. This is the steady-state
// frontier-at-tail path, so avoid allocating maps on every View.
return rowContext{}
}
rc := rowContext{
resolved: map[string]bool{},
hints: map[string]string{},
@@ -1102,12 +1107,19 @@ func wrapDetailBlock(detail string, width int) string {
// renderFocusedPermissionPrompt draws the modal permission card and reports the
// card-relative Y offset of each option line (in permissionOptions order) so the
// caller can register those lines as clickable.
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, width int) (string, []int) {
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, typing bool, feedback string, width int) (string, []int) {
name := strings.TrimSpace(request.ToolName)
if name == "" {
name = "tool"
}
fill := zeroTheme.onPerm
// The card body carries no background fill, matching every other prompt card
// (ask_user, spec review, plan) — see the lipgloss.NewStyle() fills below. The
// permission card used to tint its whole body with permBg, an amber-family
// wash that reads as a warm slab on cool themes (e.g. a brown-yellow box over
// dracula's purples) and made it the one outlier. The amber PERMISSION badge
// and the amber-mixed border still carry the "this is a permission gate"
// signal; the body no longer clashes with the surrounding theme.
fill := func(style lipgloss.Style) lipgloss.Style { return style }
top := zeroTheme.permBadge.Render(" PERMISSION ")
@@ -1129,6 +1141,17 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
lines = append(lines, "")
// Feedback mode: the option list is replaced by a free-text field, like the
// ask_user "type your own answer" surface. What is typed is sent to the model
// as the denial reason, so it reads the instruction and adjusts.
if typing {
lines = append(lines, fill(zeroTheme.muted).Render("Tell Zero what to do differently:"))
lines = append(lines, zeroTheme.userPrompt.Render("❯ ")+fill(zeroTheme.ink).Render(feedback)+fill(zeroTheme.accent).Render("▌"))
lines = append(lines, "")
lines = append(lines, fill(zeroTheme.faint).Render("enter · send to Zero esc · back to options"))
return styledBlockFill(width, lines, zeroTheme.permBorder, zeroTheme.permBg), nil
}
// Each option is its own line so a click anywhere on that row selects it (no
// per-column hit-testing). The highlighted row gets a ▸ marker and a reverse
// label; the rest stay quiet. styledBlockFill prepends exactly one top-border
@@ -1142,8 +1165,16 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
hotkey := fill(zeroTheme.faint).Render(" [" + option.hotkey + "]")
optionLabel := permissionOptionLabel(option, request)
if index == cursor {
// onSel, not badge. zeroTheme.badge is the brand chip (" 0 ", " ASK ",
// " SPEC REVIEW ") — a full-brightness accent fill meant for short
// labels. Using it for a selected ROW painted a bright accent slab
// across the permission card, fighting the card's amber warning palette
// and ignoring the card tint every other line composes onto. selBg is
// the tint tuned for exactly this job ("separates from the panel while
// ink label contrast stays ~9.4:1"), and onSel is what every other
// selectable list in the TUI uses for its highlighted row.
marker := fill(zeroTheme.accent).Render("▸ ")
label := zeroTheme.badge.Render(" " + optionLabel + " ")
label := zeroTheme.onSel(zeroTheme.ink).Bold(true).Render(" " + optionLabel + " ")
lines = append(lines, marker+label+hotkey)
} else {
label := fill(zeroTheme.ink).Render(optionLabel)
@@ -1158,7 +1189,7 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
}
lines = append(lines, fill(zeroTheme.faint).Render(footer))
return styledBlockFill(width, lines, zeroTheme.permBorder, zeroTheme.permBg), offsets
return styledBlockFill(width, lines, zeroTheme.permBorder, lipgloss.NewStyle()), offsets
}
func permissionScopeLine(request agent.PermissionRequest, scope string) string {
+1 -1
View File
@@ -1659,7 +1659,7 @@ func TestFocusedPermissionCardShowsBadgeAndKeys(t *testing.T) {
Risk: sandbox.Risk{Level: sandbox.RiskMedium},
AvailableDecisions: testAllPermissionDecisions(),
}
card, offsets := renderFocusedPermissionPrompt(request, 0, 80)
card, offsets := renderFocusedPermissionPrompt(request, 0, false, "", 80)
got := plainRender(t, card)
for _, want := range []string{"PERMISSION", "edit_file", "writes internal/agent/exec.go", "Yes, proceed", "[a]", "this session", "[s]", "don't ask again", "[y]", "continue without running it", "[d]", "[esc]"} {
if !strings.Contains(got, want) {
+6
View File
@@ -111,6 +111,12 @@ func (m model) setSandboxSetupStatusRow(text string) model {
for i := len(m.transcript) - 1; i >= 0; i-- {
if m.transcript[i].id == sandboxSetupStatusRowID {
m.transcript[i] = row
// Settled alt-screen rows are cached across frames; force a rebuild
// so an in-place update is reflected immediately instead of serving
// the stale cached snapshot.
if i < m.flushed {
m.altScreenSettledWidth = 0
}
return m
}
}
+6
View File
@@ -976,6 +976,12 @@ func (m model) setCompactStatusRow(text string) model {
for i := len(m.transcript) - 1; i >= 0; i-- {
if m.transcript[i].id == compactStatusRowID {
m.transcript[i] = row
// Settled alt-screen rows are cached across frames; force a rebuild
// so an in-place update (e.g. the compact spinner tick) is reflected
// immediately instead of serving the stale cached snapshot.
if i < m.flushed {
m.altScreenSettledWidth = 0
}
return m
}
}
+19 -5
View File
@@ -94,12 +94,26 @@ func (m model) sidebarAvailable() bool {
if widthTier(m.width) < tierMedium {
return false
}
// Full-screen overlays (setup, wizards, pickers, the empty-state suggestion
// list) take over the chat column and render at full width; suppress the
// second column while any is active so their geometry and mouse hit-testing
// stay full-width as before.
// Full-screen overlays (setup, wizards, pickers) take over the chat column and
// render at full width; suppress the second column while any is active so
// their geometry and mouse hit-testing stay full-width as before.
//
// The `/` command palette is deliberately NOT in this list. It is not a
// full-screen overlay: suggestionOverlay draws it via centerRenderedBlock at
// minInt(width, suggestionPaletteMaxWidth) — a centred box capped at 76 cells
// — floating over the chat column rather than replacing it. Suppressing the
// sidebar for it collapsed the whole layout on every keystroke of `/`: the
// plan panel dropped out of the sidebar and re-rendered inline at the bottom
// and the transcript re-wrapped to full width, which is at its most jarring
// mid-run with a live plan on screen. Since the sidebar already requires the
// medium tier (>= 80 cells) the palette is always strictly narrower than the
// chat column here, so the two never contend for the same cells.
//
// Mouse hit-testing is unaffected: sidebarLineAtMouse carries its own
// suggestionsActive() guard, so clicks still go to the palette and not to the
// sidebar rows underneath it.
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
m.mcpManager != nil || m.picker != nil {
return false
}
// Home/welcome screen: stay single-column until there's real conversation, so
+55
View File
@@ -631,3 +631,58 @@ func TestTwoColumnTranscriptViewWidth(t *testing.T) {
func stripSidebar(lines []string) string {
return ansiPattern.ReplaceAllString(strings.Join(lines, "\n"), "")
}
// The `/` command palette must NOT collapse the sidebar. It is a centred box
// capped at suggestionPaletteMaxWidth floating over the chat column, not a
// full-screen overlay — suppressing the second column for it dropped the plan
// out of the sidebar and re-rendered it inline at the bottom on every `/`,
// which is at its most disruptive mid-run with a live plan on screen. The
// genuinely full-width overlays must still suppress it.
func TestSidebarSurvivesCommandPalette(t *testing.T) {
base := func() model {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
m.headerPrinted = true
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
return m
}
m := base()
if !m.sidebarActive() {
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
}
// `/` palette open: sidebar stays, so the plan keeps its home and the layout
// does not reflow.
m.suggestions = []commandSuggestion{{Name: "/model", Desc: "Pick a model."}, {Name: "/plan", Desc: "Show planning mode status."}}
if !m.suggestionsActive() {
t.Fatal("precondition: suggestions should be active")
}
if !m.sidebarActive() {
t.Error("command palette must not collapse the sidebar")
}
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Errorf("plan must stay in the sidebar, not fall back to the pinned panel:\n%s", got)
}
// The palette never contends for the sidebar's cells: the two-column path
// renders it at width = chatColumnWidth, so it is centred inside the chat
// column and cannot overlap the sidebar.
chatW := m.chatColumnWidth()
for _, line := range strings.Split(plainRender(t, m.suggestionOverlay(chatW)), "\n") {
if w := lipgloss.Width(line); w > chatW {
t.Errorf("palette line is %d wide, wider than the chat column %d — it would overlap the sidebar", w, chatW)
}
}
// Clicks still belong to the palette, not the sidebar rows beneath it.
if _, ok := m.sidebarLineAtMouse(tea.MouseClickMsg{Button: tea.MouseLeft}); ok {
t.Error("sidebar must not take mouse hits while the palette is open")
}
// A genuinely full-width overlay still suppresses the sidebar.
full := base()
full.picker = &commandPicker{}
if full.sidebarActive() {
t.Error("a full-screen picker must still collapse the sidebar")
}
}
@@ -0,0 +1,38 @@
package tui
import (
"fmt"
"testing"
)
func benchmarkIssue561Model(turns int) model {
m := transcriptViewTestModel()
m.altScreen = true
for i := 0; i < turns; i++ {
m.transcript = append(m.transcript,
transcriptRow{kind: rowUser, text: fmt.Sprintf("question %d", i)},
transcriptRow{kind: rowAssistant, text: fmt.Sprintf("answer %d", i), final: true},
)
}
m, _ = m.settleTranscript()
return m
}
func BenchmarkIssue561SettledAltScreen(b *testing.B) {
m := benchmarkIssue561Model(5000)
width := m.chatColumnWidth()
if m.altScreenSettledFrontier != m.flushed || m.altScreenSettledWidth != width {
b.Fatalf(
"settled cache miss before benchmark: frontier=%d flushed=%d width=%d wantWidth=%d",
m.altScreenSettledFrontier,
m.flushed,
m.altScreenSettledWidth,
width,
)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = m.transcriptBodyItems(width, "", false)
}
}
+79 -6
View File
@@ -4,6 +4,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
@@ -38,6 +39,29 @@ type transcriptSelectionState struct {
cursor transcriptSelectionPoint
}
// transcriptRenderInteraction lets settled-item render closures read the
// current selection and hover state without rebuilding the cached descriptors.
// The mutex keeps the shared cache state safe even if a renderer inspects a
// model copy concurrently with an update.
type transcriptRenderInteraction struct {
mu sync.Mutex
selection transcriptSelectionState
hover hoverTarget
}
func (s *transcriptRenderInteraction) set(selection transcriptSelectionState, hover hoverTarget) {
s.mu.Lock()
s.selection = selection
s.hover = hover
s.mu.Unlock()
}
func (s *transcriptRenderInteraction) get() (transcriptSelectionState, hoverTarget) {
s.mu.Lock()
defer s.mu.Unlock()
return s.selection, s.hover
}
type transcriptSelectableLine struct {
bodyY int
rowIndex int
@@ -167,6 +191,9 @@ func shiftSelectableX(lines []transcriptSelectableLine, gutter int) []transcript
// so the highlight is computed in the same shifted coordinate the mouse maps to
// and lands exactly where the user selected (instead of gutter cells off).
func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcriptSelectableLine, gutter int, startBodyY int) transcriptBodyRenderedItem {
if m.transcriptInteraction != nil {
m.transcriptSelection, m.hover = m.transcriptInteraction.get()
}
lines := padTranscriptBodyLines(viewLines(rendered), gutter)
shifted := shiftSelectableX(selectable, gutter)
if m.transcriptSelection.active {
@@ -211,6 +238,21 @@ func (m model) renderHoverHighlight(rendered string, selectable []transcriptSele
}
func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem {
if m.transcriptInteraction != nil {
m.transcriptInteraction.set(m.transcriptSelection, m.hover)
}
// Steady-state alt-screen frames (notably the idle cursor blink) can reuse
// the settled list directly. Keeping this fast path outside the closure-heavy
// builder also prevents the large model receiver from escaping to the heap.
if m.altScreen && !detailed && !m.fileView.active && !m.pending && m.pendingSpecReview == nil &&
m.flushedAny && m.flushed == len(m.transcript) &&
m.altScreenSettledWidth == width && m.altScreenSettledFrontier == m.flushed {
return m.altScreenSettledItems
}
return m.buildTranscriptBodyItems(width, emptyOverlay, detailed)
}
func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem {
// File drill-in: the chat column's body swaps to the viewed file's
// diff/content. Swapping HERE (the single source every consumer reads) keeps
// the viewport, scroll engine, renderer, and mouse hit-tests consistent.
@@ -239,7 +281,6 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool
items = append(items, transcriptBlockBodyItem(transcriptBodyItemEmpty, -1, m.emptyState(width)))
}
} else {
rc := buildRowContext(m.transcript)
shownAny := false
// The detailed view shows the full transcript from index 0, not
// the managed region after m.flushed.
@@ -247,11 +288,32 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool
if detailed {
startIdx = 0
}
useSettledCache := m.altScreen && !detailed &&
m.altScreenSettledWidth == width &&
m.altScreenSettledFrontier == m.flushed
if useSettledCache {
// The cache reserves scratch capacity for live-tail descriptors, avoiding
// an O(history) slice copy on every streaming/spinner frame.
items = m.altScreenSettledItems[:len(m.altScreenSettledItems)]
} else if m.altScreen && !detailed {
// A directly-constructed model (not yet passed through Update), or an
// explicitly invalidated cache, must still render complete history.
// The next settle rebuilds the cache for subsequent frames.
startIdx = 0
}
// Dependencies for an unflushed row cannot live wholly before the
// frontier: such a row would have blocked the frontier. Build context only
// for the live tail instead of rescanning settled history each frame.
rc := buildRowContext(m.transcript[startIdx:])
renderRowFn := transcriptRowDispatchFn(m.renderTranscriptRow)
if detailed {
renderRowFn = transcriptRowDispatchFn(m.renderTranscriptDetailedRow)
}
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc)
if startIdx > 0 {
previousKind, havePreviousKind = m.flushedPreviousKind, m.flushedHavePreviousKind
} else {
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc)
}
specialistSummaryEmitted := false
for index := startIdx; index < len(m.transcript); index++ {
row := m.transcript[index]
@@ -392,7 +454,7 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool
rowIndex: -1,
heightCacheStable: false, // the highlight changes with the cursor
render: func(startBodyY int) transcriptBodyRenderedItem {
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, width)
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, perm.typing, m.input.Value(), width)
options := permissionOptions(perm.request)
selectable := make([]transcriptSelectableLine, 0, len(offsets))
for index, offset := range offsets {
@@ -1326,7 +1388,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
// opens/switches the file view.
if path, ok := m.fileRowAtMouse(msg); ok {
if m.fileView.active || m.selectedFile == path {
m.selectedFile = path
m.setSelectedFile(path)
return m.openFileView(path), nil, true
}
return m.selectFile(path), nil, true
@@ -1339,8 +1401,14 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
}
return m, nil, false
}
if line.permOption {
// A left-click on a permission-popup option resolves it directly.
if line.permOption && !(m.pendingPermission != nil && m.pendingPermission.typing) {
// A left-click on a permission-popup option resolves it directly. The
// typing guard is defence-in-depth: renderFocusedPermissionPrompt already
// returns nil offsets in feedback mode, so no option row is registered as
// clickable then — but that single early-return is the only thing keeping
// a stray click (Allow included) off the decision path, and it lives in a
// function other PRs also edit. Guarding here makes the safety explicit
// rather than emergent.
next, cmd := m.resolvePermission(line.permChoice)
return next.(model), cmd, true
}
@@ -1442,6 +1510,11 @@ func (m model) toggleTranscriptRow(rowIndex int) model {
switch m.transcript[rowIndex].kind {
case rowReasoning, rowToolResult:
m.transcript[rowIndex].expanded = !m.transcript[rowIndex].expanded
// Settled alt-screen rows are cached across frames; force a rebuild so an
// expand/collapse interaction is reflected immediately.
if rowIndex < m.flushed {
m.altScreenSettledWidth = 0
}
}
return m
}